lib/live_admin/components/resource/form.ex

defmodule LiveAdmin.Components.Container.Form do
  use Phoenix.LiveComponent
  use Phoenix.HTML

  import LiveAdmin.ErrorHelpers
  import LiveAdmin, only: [associated_resource: 3, route_with_params: 4]

  alias __MODULE__.{ArrayInput, MapInput, SearchSelect}
  alias LiveAdmin.Resource

  @supported_primitive_types [
    :string,
    :boolean,
    :date,
    :integer,
    :naive_datetime,
    :utc_datetime,
    :id,
    :binary_id,
    :float
  ]

  @impl true
  def update(assigns = %{record: record}, socket) do
    socket =
      socket
      |> assign(assigns)
      |> assign(:enabled, false)
      |> assign(:changeset, Resource.change(assigns.resource, record))

    {:ok, socket}
  end

  @impl true
  def update(assigns, socket) do
    socket =
      socket
      |> assign(assigns)
      |> assign(:enabled, false)
      |> assign(:changeset, Resource.change(assigns.resource))

    {:ok, socket}
  end

  @impl true
  def render(assigns = %{record: nil}) do
    ~H"""
    <div>No record found</div>
    """
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div id="form-page" phx-hook="FormPage">
      <.form
        :let={f}
        for={@changeset}
        as={:params}
        phx-change="validate"
        phx-submit={@action}
        phx-target={@myself}
        class="resource__form"
      >
        <%= for {field, type, opts} <- Resource.fields(@resource) do %>
          <.field
            field={field}
            type={type}
            form={f}
            immutable={Keyword.get(opts, :immutable, false)}
            resource={@resource}
            resources={@resources}
            form_ref={@myself}
            session={@session}
            prefix={@prefix}
            repo={@repo}
          />
        <% end %>
        <div class="form__actions">
          <%= submit("Save",
            class: "resource__action#{if !@enabled, do: "--disabled", else: "--btn"}",
            disabled: !@enabled
          ) %>
        </div>
      </.form>
    </div>
    """
  end

  @impl true
  def handle_event("after_create", _, socket) do
    {:noreply,
     push_redirect(socket,
       to:
         route_with_params(socket.assigns.base_path, socket.assigns.key, [],
           prefix: socket.assigns.prefix
         )
     )}
  end

  @impl true
  def handle_event(
        "validate",
        %{"field" => field, "value" => value},
        socket = %{assigns: %{resource: resource, changeset: changeset, session: session}}
      ) do
    changeset = validate(resource, changeset, Map.put(changeset.params, field, value), session)

    {:noreply,
     assign(socket,
       changeset: changeset,
       enabled: enabled?(changeset, socket.assigns.action, resource)
     )}
  end

  @impl true
  def handle_event(
        "validate",
        %{"params" => params},
        socket = %{assigns: %{resource: resource, changeset: changeset, session: session}}
      ) do
    changeset = validate(resource, changeset, params, session)

    {:noreply,
     assign(socket,
       changeset: changeset,
       enabled: enabled?(changeset, socket.assigns.action, resource)
     )}
  end

  @impl true
  def handle_event(
        "create",
        %{"params" => params},
        %{assigns: %{resource: resource, session: session, repo: repo}} = socket
      ) do
    socket =
      case Resource.create(resource, params, session, repo) do
        {:ok, _} -> push_event(socket, "create", %{})
        {:error, changeset} -> assign(socket, changeset: changeset)
      end

    {:noreply, socket}
  end

  @impl true
  def handle_event(
        "update",
        %{"params" => params},
        %{assigns: %{resource: resource, session: session, record: record}} = socket
      ) do
    socket =
      Resource.update(record, resource, params, session)
      |> case do
        {:ok, _} ->
          socket
          |> push_event("success", %{msg: "Changes successfully saved"})
          |> assign(:enabled, false)

        {:error, changeset} ->
          assign(socket, changeset: changeset)
      end

    {:noreply, socket}
  end

  def handle_event("add_embed", %{"field" => field}, socket = %{assigns: %{changeset: changeset}}) do
    field_name = String.to_existing_atom(field)

    socket =
      update(socket, :changeset, fn changeset ->
        existing =
          Ecto.Changeset.get_change(changeset, field_name) ||
            Ecto.Changeset.get_field(changeset, field_name)

        new_value =
          socket.assigns.resource.__live_admin_config__(:schema).__schema__(:embed, field_name).cardinality
          |> case do
            :many -> (existing || []) ++ [%{}]
            :one -> %{}
          end

        Ecto.Changeset.put_embed(changeset, field_name, new_value)
      end)

    {:noreply,
     assign(
       socket,
       :enabled,
       enabled?(changeset, socket.assigns.action, socket.assigns.resource)
     )}
  end

  def handle_event(
        "remove_embed",
        %{"field" => field, "idx" => idx},
        socket = %{assigns: %{changeset: changeset}}
      ) do
    field_name = String.to_existing_atom(field)
    index = String.to_integer(idx)

    socket =
      update(socket, :changeset, fn changeset ->
        existing =
          (Ecto.Changeset.get_change(changeset, field_name) ||
             Ecto.Changeset.get_field(changeset, field_name, []))
          |> Enum.filter(fn
            %{action: action} when action != :insert -> false
            _ -> true
          end)

        Ecto.Changeset.put_embed(changeset, field_name, List.delete_at(existing, index))
      end)

    {:noreply,
     assign(
       socket,
       :enabled,
       enabled?(changeset, socket.assigns.action, socket.assigns.resource)
     )}
  end

  def handle_event(
        "remove_embed",
        %{"field" => field},
        socket = %{assigns: %{changeset: changeset}}
      ) do
    field_name = String.to_existing_atom(field)

    socket =
      update(socket, :changeset, fn changeset ->
        Ecto.Changeset.put_embed(changeset, field_name, nil)
      end)

    {:noreply,
     assign(
       socket,
       :enabled,
       enabled?(changeset, socket.assigns.action, socket.assigns.resource)
     )}
  end

  defp field(assigns) do
    ~H"""
    <div class={"field__group#{if @immutable, do: "--disabled"} field__#{field_class(@type)}"}>
      <%= label(@form, @field, class: "field__label") %>
      <%= if supported_type?(@type) do %>
        <.input
          form={@form}
          type={@type}
          field={@field}
          disabled={@immutable}
          resource={@resource}
          resources={@resources}
          form_ref={@form_ref}
          session={@session}
          prefix={@prefix}
          repo={@repo}
        />
      <% else %>
        <%= textarea(@form, @field,
          rows: 1,
          disabled: true,
          value: @form |> input_value(@field) |> inspect()
        ) %>
      <% end %>
      <%= error_tag(@form, @field) %>
    </div>
    """
  end

  defp input(assigns = %{type: {_, Ecto.Embedded, meta}}) do
    assigns = assign(assigns, :meta, meta)

    ~H"""
    <div class="embed__group">
      <%= unless @disabled do %>
        <%= hidden_input(@form, @field, value: "delete") %>
        <%= unless input_value(@form, @field) == nil do %>
          <%= for fp <- inputs_for(@form, @field) do %>
            <div class="embed__item">
              <div>
                <a
                  class="button__remove"
                  phx-click="remove_embed"
                  phx-value-field={@field}
                  phx-value-idx={fp.index}
                  phx-target={@form_ref}
                />
              </div>
              <div>
                <%= for {field, type, _} <- fields_for_embed(@type) do %>
                  <.field
                    field={field}
                    type={type}
                    form={fp}
                    immutable={false}
                    resource={@resource}
                    resources={@resources}
                    form_ref={@form_ref}
                    session={@session}
                    prefix={@prefix}
                    repo={@repo}
                  />
                <% end %>
              </div>
            </div>
          <% end %>
        <% end %>
        <%= if @meta.cardinality == :many || input_value(@form, @field) == nil do %>
          <div class="form__actions">
            <a
              href="#"
              phx-click="add_embed"
              phx-value-field={@field}
              phx-target={@form_ref}
              class="resource__action--btn"
            >
              New
            </a>
          </div>
        <% end %>
      <% else %>
        <pre><%= @form |> input_value(@field) |> inspect() %></pre>
      <% end %>
    </div>
    """
  end

  defp input(assigns = %{type: id}) when id in [:id, :binary_id] do
    assigns =
      assign(
        assigns,
        :associated_resource,
        associated_resource(
          assigns.resource.__live_admin_config__(:schema),
          assigns.field,
          assigns.resources
        )
      )

    ~H"""
    <%= if @associated_resource do %>
      <%= unless @form.data |> Ecto.primary_key() |> Keyword.keys() |> Enum.member?(@field) do %>
        <.live_component
          module={SearchSelect}
          id={input_id(@form, @field)}
          form={@form}
          field={@field}
          disabled={@disabled}
          resource={@associated_resource}
          form_ref={@form_ref}
          session={@session}
          handle_select="validate"
          prefix={@prefix}
          repo={@repo}
        />
      <% else %>
        <div class="form__number">
          <%= number_input(@form, @field, disabled: @disabled) %>
        </div>
      <% end %>
    <% else %>
      <%= textarea(@form, @field, rows: 1, disabled: @disabled) %>
    <% end %>
    """
  end

  defp input(assigns = %{type: {:array, :string}}) do
    ~H"""
    <.live_component
      module={ArrayInput}
      id={input_id(@form, @field)}
      form={@form}
      field={@field}
      disabled={@disabled}
      form_ref={@form_ref}
    />
    """
  end

  defp input(assigns = %{type: :map}) do
    ~H"""
    <.live_component
      module={MapInput}
      id={input_id(@form, @field)}
      form={@form}
      field={@field}
      disabled={@disabled}
      form_ref={@form_ref}
    />
    """
  end

  defp input(assigns = %{type: :string}) do
    ~H"""
    <%= textarea(@form, @field, rows: 1, disabled: @disabled) %>
    """
  end

  defp input(assigns = %{type: :boolean}) do
    ~H"""
    <div class="form__checkbox">
      <%= checkbox(@form, @field, disabled: @disabled) %>
    </div>
    """
  end

  defp input(assigns = %{type: :date}) do
    ~H"""
    <%= date_input(@form, @field, disabled: @disabled) %>
    """
  end

  defp input(assigns = %{type: number}) when number in [:integer, :id] do
    ~H"""
    <div class="form__number">
      <%= number_input(@form, @field, disabled: @disabled) %>
    </div>
    """
  end

  defp input(assigns = %{type: :float}) do
    ~H"""
    <div class="form__number">
      <%= number_input(@form, @field, disabled: @disabled, step: "any") %>
    </div>
    """
  end

  defp input(assigns = %{type: type}) when type in [:naive_datetime, :utc_datetime] do
    ~H"""
    <div class="form__time">
      <%= datetime_local_input(@form, @field, disabled: @disabled) %>
    </div>
    """
  end

  defp input(assigns = %{type: {_, Ecto.Enum, %{mappings: mappings}}}) do
    assigns = assign(assigns, :mappings, mappings)

    ~H"""
    <%= select(@form, @field, [nil | Keyword.keys(@mappings)], disabled: @disabled) %>
    """
  end

  defp input(assigns = %{type: {:array, {_, Ecto.Enum, %{mappings: mappings}}}}) do
    assigns = assign(assigns, :mappings, mappings)

    ~H"""
    <div class="checkbox__group">
      <%= hidden_input(@form, @field, name: input_name(@form, @field) <> "[]", value: nil) %>
      <%= for option <- Keyword.keys(@mappings) do %>
        <%= checkbox(@form, @field,
          name: input_name(@form, @field) <> "[]",
          checked_value: option,
          value: @form |> input_value(@field) |> Kernel.||([]) |> Enum.find(&(&1 == option)),
          unchecked_value: "",
          hidden_input: false,
          disabled: @disabled,
          id: input_id(@form, @field) <> to_string(option)
        ) %>
        <label for={input_id(@form, @field) <> to_string(option)}>
          <%= option %>
        </label>
      <% end %>
    </div>
    """
  end

  defp fields_for_embed({_, _, %{related: schema}}),
    do: Enum.map(schema.__schema__(:fields), &{&1, schema.__schema__(:type, &1), []})

  defp validate(resource, changeset, params, session) do
    resource
    |> Resource.change(changeset.data, params)
    |> Resource.validate(resource, session)
  end

  defp field_class(type) when type in @supported_primitive_types, do: to_string(type)
  defp field_class(:map), do: "map"
  defp field_class({:array, _}), do: "array"
  defp field_class({_, Ecto.Embedded, _}), do: "embed"
  defp field_class({_, Ecto.Enum, _}), do: "enum"
  defp field_class(_), do: "other"

  defp supported_type?(type) when type in @supported_primitive_types, do: true
  defp supported_type?(:map), do: true
  defp supported_type?({:array, _}), do: true
  defp supported_type?({_, Ecto.Embedded, _}), do: true
  defp supported_type?({_, Ecto.Enum, _}), do: true
  defp supported_type?(_), do: false

  def enabled?(changeset, action, resource) do
    resource.__live_admin_config__(:"#{action}_with") != false && Enum.empty?(changeset.errors)
  end
end