lib/exzeitable.ex

defmodule Exzeitable do
  @moduledoc """
  # Exzeitable. Check README for usage instructions.
  """

  @doc "Expands into the gigantic monstrosity that is Exzeitable"
  defmacro __using__(opts) do
    alias Exzeitable.{Database, Params}

    search_string =
      opts
      |> Params.set_fields()
      |> Database.tsvector_string()

    # coveralls-ignore-stop

    quote do
      use Phoenix.LiveView
      use Phoenix.HTML
      import Ecto.Query
      alias Phoenix.LiveView.Helpers
      alias Exzeitable.{Database, Filter, Format, HTML, Params, Validation}
      @callback render(map) :: {:ok, iolist}
      @type socket :: Phoenix.LiveView.Socket.t()

      @doc """
      Convenience helper so LiveView doesn't have to be called directly

      ## Example

      ```
      <%= YourAppWeb.Live.Site.live_table(@conn, query: @query) %>
      ```

      """
      defdelegate build_table(assigns), to: HTML, as: :build

      @spec live_table(Plug.Conn.t(), keyword) :: {:safe, iolist}
      def live_table(conn, opts \\ []) do
        Phoenix.Component.live_render(conn, __MODULE__,
          # Live component ID
          id: Keyword.get(unquote(opts), :id, 1),
          session: %{"params" => Params.new(opts, unquote(opts), __MODULE__)}
        )
      end

      ###########################
      ######## CALLBACKS ########
      ###########################

      @doc "Initial setup on page load"
      @spec mount(:not_mounted_at_router | map, map, socket) :: {:ok, socket}
      def mount(:not_mounted_at_router, assigns, socket) do
        assigns = Map.new(assigns, fn {k, v} -> {String.to_atom(k), v} end)

        socket =
          socket
          |> assign(assigns)
          |> maybe_get_records()
          |> maybe_set_refresh()

        {:ok, socket}
      end

      def mount(_map, assigns, socket) do
        assigns = %{params: Params.new([], unquote(opts), __MODULE__)}

        socket =
          socket
          |> assign(assigns)
          |> maybe_get_records()
          |> maybe_set_refresh()

        {:ok, socket}
      end

      @doc "Clicking the hide button hides the column"
      @spec handle_event(String.t(), map, socket) :: {:noreply, socket}
      def handle_event("hide_column", %{"column" => column}, socket) do
        %{assigns: %{params: %Params{fields: fields}}} = socket
        fields = Kernel.put_in(fields, [String.to_existing_atom(column), :hidden], true)

        {:noreply, assign_params(socket, :fields, fields)}
      end

      @doc "Clicking the show button shows the column"
      def handle_event("show_column", %{"column" => column}, socket) do
        %{assigns: %{params: %Params{fields: fields}}} = socket
        fields = Kernel.put_in(fields, [String.to_existing_atom(column), :hidden], false)

        {:noreply, assign_params(socket, :fields, fields)}
      end

      @doc "Hide all the show buttons"
      def handle_event("hide_buttons", _, socket) do
        {:noreply, assign_params(socket, :show_field_buttons, false)}
      end

      @doc "Show all the show buttons"
      def handle_event("show_buttons", _, socket) do
        {:noreply, assign_params(socket, :show_field_buttons, true)}
      end

      @doc "Changes page when pagination buttons are clicked"
      def handle_event("change_page", %{"page" => page}, %{assigns: %{params: params}} = socket) do
        new_params = Map.put(params, :page, String.to_integer(page))

        socket
        |> assign_params(:page, new_params.page)
        |> assign_params(:list, Database.get_records(new_params))
        |> then(&{:noreply, &1})
      end

      @doc "Clicking the sort button sorts the column"
      def handle_event(
            "sort_column",
            %{"column" => column},
            %{assigns: %{params: params}} = socket
          ) do
        column = String.to_existing_atom(column)

        new_order =
          case params.order do
            [asc: ^column] -> [desc: column]
            _ -> [asc: column]
          end

        new_params = Map.merge(params, %{order: new_order, page: 1})

        socket
        |> assign_params(:page, 1)
        |> assign_params(:order, new_order)
        |> assign_params(:list, Database.get_records(new_params))
        |> then(&{:noreply, &1})
      end

      @doc "Typing into the search box... searches. Crazy, right?"
      def handle_event("search", %{"search" => %{"search" => search}}, socket) do
        socket
        |> assign_params(:search, search)
        |> assign_params(:page, 1)
        |> maybe_get_records()
        |> then(&{:noreply, &1})
      end

      @doc "Refresh periodically grabs new records from the database"
      def handle_info(:refresh, socket) do
        {:noreply, maybe_get_records(socket)}
      end

      defp maybe_get_records(%{assigns: %{params: params}} = socket) do
        if connected?(socket) do
          socket
          |> assign_params(:list, Database.get_records(params))
          |> assign_params(:count, Database.get_record_count(params))
        else
          socket
          |> assign_params(:list, [])
          |> assign_params(:count, 0)
        end
      end

      defp maybe_set_refresh(%{socket: %{assigns: %{refresh: refresh}}} = socket)
           when is_integer(refresh) do
        with true <- connected?(socket),
             {:ok, _tref} <- :timer.send_interval(refresh, self(), :refresh) do
          socket
        else
          _ -> socket
        end
      end

      defp maybe_set_refresh(socket) do
        socket
      end

      defp assign_params(%{assigns: %{params: params}} = socket, key, value) do
        params
        |> Map.put(key, value)
        |> then(&assign(socket, :params, &1))
      end

      # Need to unquote the search string because string interpolation is not allowed.
      @spec do_search(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
      def do_search(query, search) do
        where(
          query,
          fragment(
            unquote(search_string),
            ^Database.prefix_search(search)
          )
        )
      end
    end
  end
end