lib/live_admin.ex

defmodule LiveAdmin do
  @moduledoc docout: [LiveAdmin.READMECompiler]

  @type mod_func :: {module(), :atom}
  @type func_ref :: :atom | mod_func() | :mfa
  @type func_list :: [func_ref] | keyword(func_ref)
  @type field_list :: [:atom]

  @options_schema [
    components: [
      type: :non_empty_keyword_list,
      type_doc: "list of modules implementing LiveComponent overrides of LiveAdmin views",
      keys: [
        nav: [type: :atom],
        home: [type: :atom],
        session: [type: :atom],
        new: [type: :atom],
        edit: [type: :atom],
        list: [type: :atom],
        view: [type: :atom]
      ]
    ],
    ecto_repo: [
      type: :atom,
      type_doc: "Ecto Repo used to query resource"
    ],
    list_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      type_doc:
        "`t:func_ref/0` returning `{records, count}` used to fetch records in LiveAdmin :list component"
    ],
    render_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      type_doc:
        "`t:func_ref/0` used to convert field values to string in LiveAdmin :list component"
    ],
    delete_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      type_doc: "`t:func_ref/0` or `false` to disable deleting records"
    ],
    create_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      type_doc: "`t:func_ref/0` or `false` to disable creating records"
    ],
    update_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      type_doc: "`t:func_ref/0` or `false` to disable updating records"
    ],
    validate_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      type_doc:
        "`t:func_ref/0` used to validate create/update changesets in LiveAdmin :form component"
    ],
    label_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      type_doc:
        "`t:func_ref/0` used to convert (association) record to string in LiveAdmin SearchSelect component"
    ],
    title_with: [
      type: {:or, [:string, {:tuple, [:atom, :atom]}]},
      type_doc: "string literal or MFA returning a string used to render LiveAdmin UI heading"
    ],
    hidden_fields: [
      type: {:list, :atom},
      type_doc: "`t:field_list/0` to be hidden from LiveView"
    ],
    immutable_fields: [
      type: {:list, :atom},
      type_doc: "`t:field_list/0` to be disabled in LiveAdmin :form component"
    ],
    actions: [
      type:
        {:list,
         {:or,
          [
            :atom,
            {:tuple, [:atom, :atom]},
            {:tuple, [:atom, :atom, :integer]},
            {:tuple,
             [:atom, {:or, [{:tuple, [:atom, :atom]}, {:tuple, [:atom, :atom, :integer]}]}]}
          ]}},
      type_doc: "`t:func_list/0` taking a record, LiveAdmin session struct, and any extra args"
    ],
    tasks: [
      type:
        {:list,
         {:or,
          [
            :atom,
            {:tuple, [:atom, :atom]},
            {:tuple, [:atom, :atom, :integer]},
            {:tuple,
             [:atom, {:or, [{:tuple, [:atom, :atom]}, {:tuple, [:atom, :atom, :integer]}]}]}
          ]}},
      type_doc: "`t:func_list/0` taking a LiveAdmin session and any extra args"
    ]
  ]
  @doc """
  Defines [NimbleOptions](https://hexdocs.pm/nimble_options/NimbleOptions.html) schema for configuration that can be set at all levels (resource, scope, and application).

  Used internally to validate configuration in apps using LiveAdmin.

  Supported options:
  #{@options_schema |> NimbleOptions.new!() |> NimbleOptions.docs()}
  """
  def base_configs_schema, do: @options_schema

  def fetch_function(resource, session, function_type, function)
      when function_type in [:tasks, :actions] and is_atom(function) do
    with result = {_, m, f, _} <-
           extract_function_from_config(resource, session, function_type, function),
         docs when is_map(docs) <- extract_function_docs(m, f) do
      Tuple.append(result, docs)
    end
  end

  def fetch_config(resource, :components, config),
    do:
      Keyword.merge(
        Keyword.fetch!(config, :components),
        Keyword.get(resource.__live_admin_config__(), :components, [])
      )

  def fetch_config(resource, key, config),
    do: Keyword.get(resource.__live_admin_config__, key) || Keyword.fetch!(config, key)

  def primary_key!(resource) do
    [key] = Keyword.fetch!(resource.__live_admin_config__(), :schema).__schema__(:primary_key)

    key
  end

  def route_with_params(assigns, parts \\ []) do
    resource_path = parts[:resource_path] || assigns.key

    encoded_params =
      parts
      |> Keyword.get(:params, %{})
      |> Enum.into(%{})
      |> then(fn params ->
        if assigns[:prefix] do
          Map.put_new(params, :prefix, assigns[:prefix])
        else
          params
        end
      end)
      |> Enum.into(%{})
      |> Enum.flat_map(fn
        {_, nil} -> []
        {:sort_attr, val} -> [{:"sort-attr", val}]
        {:sort_dir, val} -> [{:"sort-dir", val}]
        {:search, val} -> [{:s, val}]
        pair -> [pair]
      end)
      |> Enum.into(%{})
      |> case do
        params when map_size(params) > 0 -> "?" <> Plug.Conn.Query.encode(params)
        _ -> ""
      end

    segments =
      Enum.map(
        parts[:segments] || [],
        fn segment ->
          cond do
            is_struct(segment) && Phoenix.Param.impl_for(segment) ->
              Phoenix.Param.to_param(segment)

            true ->
              to_string(segment)
          end
        end
      )

    Path.join([assigns.base_path, resource_path] ++ segments) <> encoded_params
  end

  def session_store,
    do: Application.get_env(:live_admin, :session_store, __MODULE__.Session.Agent)

  def associated_resource(schema, field_name, resources, part \\ nil) do
    with %{related: assoc_schema} <-
           schema |> parent_associations() |> Enum.find(&(&1.owner_key == field_name)),
         config when not is_nil(config) <-
           Enum.find(resources, fn {_, resource} ->
             Keyword.fetch!(resource.__live_admin_config__, :schema) == assoc_schema
           end) do
      case part do
        nil -> config
        :key -> elem(config, 0)
        :resource -> elem(config, 1)
      end
    else
      _ -> nil
    end
  end

  def parent_associations(schema) do
    Enum.flat_map(schema.__schema__(:associations), fn assoc_name ->
      case schema.__schema__(:association, assoc_name) do
        assoc = %{relationship: :parent} -> [assoc]
        _ -> []
      end
    end)
  end

  def resource_title(resource, session) do
    resource
    |> fetch_config(:title_with, session)
    |> case do
      nil ->
        resource.__live_admin_config__()
        |> Keyword.fetch!(:schema)
        |> Module.split()
        |> Enum.at(-1)

      {m, f} ->
        apply(m, f, [])

      title when is_binary(title) ->
        title
    end
  end

  def record_label(nil, _, _), do: nil

  def record_label(record, resource, config) do
    resource
    |> fetch_config(:label_with, config)
    |> case do
      nil -> Map.fetch!(record, LiveAdmin.primary_key!(resource))
      {m, f} -> apply(m, f, [record])
      label when is_atom(label) -> Map.fetch!(record, label)
    end
  end

  def use_i18n?, do: gettext_backend() != LiveAdmin.Gettext

  def trans(string, opts \\ []) do
    args =
      [gettext_backend(), string]
      |> then(fn base_args ->
        if opts[:inter], do: base_args ++ [opts[:inter]], else: base_args
      end)

    apply(Gettext, :gettext, args)
  end

  def gettext_backend, do: Application.get_env(:live_admin, :gettext_backend, LiveAdmin.Gettext)

  def resources(router, base_path) do
    router
    |> Phoenix.Router.routes()
    |> Enum.flat_map(fn
      %{metadata: %{base_path: ^base_path, resource: resource}} -> [resource]
      _ -> []
    end)
  end

  defp extract_function_from_config(resource, session, function_type, function) do
    default_arity = if function_type == :tasks, do: 1, else: 2

    resource
    |> LiveAdmin.fetch_config(function_type, session)
    |> Enum.find_value(:error, fn
      {name, {m, f, a}} -> name == function && {name, m, f, a}
      {name, {m, f}} -> name == function && {name, m, f, default_arity}
      {m, f, a} -> f == function && {f, m, f, a}
      {m, f} -> f == function && {f, m, f, default_arity}
      name -> name == function && {name, resource, name, default_arity}
    end)
  end

  def extract_function_docs(module, function) do
    with {_, _, _, _, _, _, module_docs} <- Code.fetch_docs(module),
         function_docs <-
           Enum.find_value(module_docs, %{}, fn {{_, name, _}, _, _, docs, _} ->
             name == function && is_map(docs) && docs
           end) do
      function_docs
    else
      {:error, _} -> %{}
    end
  end
end