lib/live_admin/components/resource/index.ex

defmodule LiveAdmin.Components.Container.Index do
  use Phoenix.LiveComponent
  use PhoenixHTMLHelpers

  import LiveAdmin,
    only: [
      route_with_params: 1,
      route_with_params: 2,
      trans: 1,
      trans: 2
    ]

  import LiveAdmin.Components
  import LiveAdmin.View, only: [get_function_keys: 3]

  alias LiveAdmin.Resource
  alias Phoenix.LiveView.JS

  @impl true
  def update(assigns, socket) do
    socket =
      socket
      |> assign(assigns)
      |> assign_async(
        [:records],
        fn ->
          {:ok,
           %{
             records:
               Resource.list(
                 assigns.resource,
                 Map.take(assigns, [:prefix, :sort_attr, :sort_dir, :page, :search]),
                 assigns.session,
                 assigns.repo,
                 assigns.config
               )
           }}
        end,
        reset: true
      )

    {:ok, socket}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div id={@id} class="view__container" phx-hook="IndexPage" phx-target={@myself}>
      <div class="list__search">
        <div class="flex border-2 rounded-lg">
          <form phx-change={JS.push("search", target: @myself, page_loading: true)}>
            <input
              type="text"
              placeholder={"#{trans("Search")}..."}
              name="query"
              onkeydown="return event.key != 'Enter'"
              value={@search}
              phx-debounce="500"
            />
          </form>
          <button
            phx-click="search"
            phx-value-query=""
            phx-target={@myself}
            class="flex items-center justify-center px-2 border-l"
          >
            <svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
              <path d="M16.32 14.9l5.39 5.4a1 1 0 0 1-1.42 1.4l-5.38-5.38a8 8 0 1 1 1.41-1.41zM10 16a6 6 0 1 0 0-12 6 6 0 0 0 0 12z" />
            </svg>
          </button>
        </div>
      </div>
      <div class="table__wrapper">
        <table class="resource__table">
          <thead>
            <tr>
              <th class="resource__header">
                <input
                  type="checkbox"
                  id="select-all"
                  class="resource__select"
                  phx-click={JS.dispatch("live_admin:toggle_select")}
                />
              </th>
              <%= for {field, _, _} <- Resource.fields(@resource, @config) do %>
                <th class="resource__header" title={field}>
                  <.link
                    patch={
                      route_with_params(
                        assigns,
                        params:
                          list_link_params(assigns,
                            sort_attr: field,
                            sort_dir:
                              if(field == @sort_attr,
                                do: Enum.find([:asc, :desc], &(&1 != @sort_dir)),
                                else: @sort_dir
                              )
                          )
                      )
                    }
                    class={"header__link#{if field == @sort_attr, do: "--#{[asc: :up, desc: :down][@sort_dir]}"}"}
                  >
                    <%= trans(humanize(field)) %>
                  </.link>
                </th>
              <% end %>
            </tr>
          </thead>
          <tbody>
            <%= if !@records.loading do %>
              <%= for record <- @records.result |> elem(0) do %>
                <tr class="resource__row">
                  <td>
                    <div class="cell__contents">
                      <input
                        type="checkbox"
                        class="resource__select"
                        data-record-key={Map.fetch!(record, LiveAdmin.primary_key!(@resource))}
                        phx-click={JS.dispatch("live_admin:toggle_select")}
                      />
                    </div>
                  </td>
                  <%= for {field, type, _} <- Resource.fields(@resource, @config) do %>
                    <% assoc_resource =
                      LiveAdmin.associated_resource(
                        LiveAdmin.fetch_config(@resource, :schema, @config),
                        field,
                        @resources
                      ) %>
                    <td class={"resource__cell resource__cell--#{type_to_css_class(type)}"}>
                      <div class="cell__contents">
                        <%= Resource.render(
                          record,
                          field,
                          @resource,
                          assoc_resource,
                          @session,
                          @config
                        ) %>
                      </div>
                      <div class="cell__icons">
                        <div class="cell__copy" data-message="Copied cell contents to clipboard">
                          <svg
                            xmlns="http://www.w3.org/2000/svg"
                            viewBox="0 0 24 24"
                            fill="currentColor"
                          >
                            <path d="M 4 2 C 2.895 2 2 2.895 2 4 L 2 18 L 4 18 L 4 4 L 18 4 L 18 2 L 4 2 z M 8 6 C 6.895 6 6 6.895 6 8 L 6 20 C 6 21.105 6.895 22 8 22 L 20 22 C 21.105 22 22 21.105 22 20 L 22 8 C 22 6.895 21.105 6 20 6 L 8 6 z M 8 8 L 20 8 L 20 20 L 8 20 L 8 8 z" />
                          </svg>
                        </div>
                        <%= if record |> Ecto.primary_key() |> Keyword.keys() |> Enum.member?(field) || (assoc_resource && Map.fetch!(record, field)) do %>
                          <a
                            class="cell__link"
                            href={
                              if assoc_resource,
                                do:
                                  route_with_params(assigns,
                                    resource_path: elem(assoc_resource, 0),
                                    segments: [Map.fetch!(record, field)]
                                  ),
                                else: route_with_params(assigns, segments: [record])
                            }
                            target="_blank"
                          >
                            <svg
                              xmlns="http://www.w3.org/2000/svg"
                              fill="none"
                              viewBox="0 0 24 24"
                              stroke-width="1.5"
                              stroke="currentColor"
                              class="w-6 h-6"
                            >
                              <path
                                stroke-linecap="round"
                                stroke-linejoin="round"
                                d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
                              />
                            </svg>
                          </a>
                        <% end %>
                      </div>
                    </td>
                  <% end %>
                </tr>
              <% end %>
            <% else %>
              <tr>
                <td class="p-10">
                  <div class="spinner" />
                </td>
              </tr>
            <% end %>
          </tbody>
          <tfoot>
            <tr id="footer-nav">
              <%= if @records.ok? do %>
                <td class="w-full" colspan={@resource |> Resource.fields(@config) |> Enum.count()}>
                  <div class="table__actions">
                    <%= if @page > 1 do %>
                      <.link
                        patch={
                          route_with_params(
                            assigns,
                            params: list_link_params(assigns, page: @page - 1)
                          )
                        }
                        class="resource__action--btn"
                      >
                        <%= trans("Prev") %>
                      </.link>
                    <% else %>
                      <span class="resource__action--disabled">
                        <%= trans("Prev") %>
                      </span>
                    <% end %>
                    <%= if @page < (@records.result |> elem(1)) / 10 do %>
                      <.link
                        patch={
                          route_with_params(
                            assigns,
                            params: list_link_params(assigns, page: @page + 1)
                          )
                        }
                        class="resource__action--btn"
                      >
                        <%= trans("Next") %>
                      </.link>
                    <% else %>
                      <span class="resource__action--disabled">
                        <%= trans("Next") %>
                      </span>
                    <% end %>
                  </div>
                </td>
                <td class="text-right p-2">
                  <%= trans("%{count} total rows", inter: [count: elem(@records.result, 1)]) %>
                </td>
              <% end %>
            </tr>
            <tr id="footer-select" class="hidden">
              <td colspan={@resource |> Resource.fields(@config) |> Enum.count()}>
                <div class="table__actions">
                  <%= if LiveAdmin.fetch_config(@resource, :delete_with, @config) != false do %>
                    <button
                      class="resource__action--danger"
                      data-action="delete"
                      phx-click={JS.dispatch("live_admin:action")}
                      data-confirm="Are you sure?"
                    >
                      <%= trans("Delete") %>
                    </button>
                  <% end %>
                  <.dropdown
                    :let={action}
                    orientation={:up}
                    label={trans("Run action")}
                    items={get_function_keys(@resource, @config, :actions)}
                    disabled={Enum.empty?(LiveAdmin.fetch_config(@resource, :actions, @config))}
                  >
                    <.action_control action={action} session={@session} resource={@resource} />
                  </.dropdown>
                </div>
              </td>
            </tr>
          </tfoot>
        </table>
      </div>
    </div>
    """
  end

  @impl true
  def handle_event("task", params = %{"name" => task}, socket) do
    {_, m, f, _, _} =
      LiveAdmin.fetch_function(
        socket.assigns.resource,
        socket.assigns.session,
        :tasks,
        String.to_existing_atom(task)
      )

    socket =
      case apply(m, f, [socket.assigns.session | Map.get(params, "args", [])]) do
        {:ok, result} ->
          socket
          |> put_flash(
            :info,
            trans("%{task} succeeded: %{result}",
              inter: [
                task: task,
                result: result
              ]
            )
          )
          |> push_navigate(to: route_with_params(socket.assigns))

        {:error, error} ->
          push_event(socket, "error", %{
            msg:
              trans("%{task} failed: %{error}",
                inter: [
                  task: task,
                  error: error
                ]
              )
          })
      end

    {:noreply, socket}
  end

  @impl true
  def handle_event("search", %{"query" => query}, socket = %{assigns: assigns}) do
    {:noreply,
     push_patch(socket,
       to:
         route_with_params(socket.assigns,
           params:
             assigns
             |> Map.take([:prefix, :sort_dir, :sort_attr, :page])
             |> Map.put(:search, query)
         )
     )}
  end

  @impl true
  def handle_event(
        "delete",
        %{"ids" => ids},
        %{
          assigns: %{
            resource: resource,
            session: session,
            prefix: prefix,
            repo: repo
          }
        } = socket
      ) do
    results =
      ids
      |> Resource.all(resource, prefix, repo)
      |> Enum.map(fn record ->
        Task.Supervisor.async(LiveAdmin.Task.Supervisor, fn ->
          Resource.delete(record, resource, session, socket.assigns.repo, socket.assigns.config)
        end)
      end)
      |> Task.await_many()

    socket =
      socket
      |> put_flash(
        :info,
        trans("Deleted %{count} records", inter: [count: Enum.count(results)])
      )
      |> push_navigate(to: route_with_params(socket.assigns))

    {:noreply, socket}
  end

  @impl true
  def handle_event(
        "action",
        params = %{"name" => action, "ids" => ids},
        socket = %{assigns: %{resource: resource, prefix: prefix, repo: repo}}
      ) do
    records = Resource.all(ids, resource, prefix, repo)

    results =
      records
      |> Enum.map(fn record ->
        Task.Supervisor.async(LiveAdmin.Task.Supervisor, fn ->
          {_, m, f, _, _} =
            LiveAdmin.fetch_function(
              socket.assigns.resource,
              socket.assigns.session,
              :actions,
              String.to_existing_atom(action)
            )

          apply(m, f, [record, socket.assigns.session] ++ Map.get(params, "args", []))
        end)
      end)
      |> Task.await_many()

    socket =
      socket
      |> put_flash(
        :info,
        trans("Action completed on %{count} records: %{action}",
          inter: [count: Enum.count(results), action: action]
        )
      )
      |> push_navigate(to: route_with_params(socket.assigns))

    {:noreply, socket}
  end

  defp list_link_params(assigns, params) do
    assigns
    |> Map.take([:search, :page, :sort_attr, :sort_dir, :prefix])
    |> Enum.into([])
    |> Keyword.merge(params)
  end

  defp type_to_css_class({_, type, _}), do: type_to_css_class(type)
  defp type_to_css_class({:array, {_, type, _}}), do: {:array, type} |> type_to_css_class()
  defp type_to_css_class({:array, type}), do: "array.#{type}" |> type_to_css_class()

  defp type_to_css_class(type),
    do: type |> to_string() |> Phoenix.Naming.underscore() |> String.replace("/", "_")
end