Skip to main content

lib/live_admin.ex

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

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

  @options_schema [
    components: [
      type: :non_empty_keyword_list,
      doc: "Overrides portions of the UI with custom LiveComponent modules.",
      type_doc: "list of modules implementing LiveComponent overrides of LiveAdmin views",
      keys: [
        nav: [type: :atom],
        home: [type: :atom],
        session: [type: :atom],
        create: [type: :atom],
        edit: [type: :atom],
        index: [type: :atom],
        show: [type: :atom]
      ]
    ],
    ecto_repo: [
      type: :atom,
      doc: "Required. Must be set at the application or scope level.",
      type_doc: "Ecto Repo used to query resource"
    ],
    query_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      doc:
        "Customizes how records are fetched. Receives the resource and search term and should return an Ecto queryable. Useful for adding preloads or custom search logic. When not set, uses the schema with built-in search.",
      type_doc: "`t:func_ref/0` returning an Ecto queryable"
    ],
    render_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      doc:
        "Customizes how field values are displayed. Receives the record, field name, and session. Should return a string or `Phoenix.HTML.Safe` value to render HTML. When not set, uses built-in type-based rendering.",
      type_doc: "`t:func_ref/0` used to convert field values to strings when rendering"
    ],
    delete_with: [
      type: {:or, [{:in, [false]}, :atom, {:tuple, [:atom, :atom]}]},
      doc:
        "Customizes how records are deleted. Can be set to `false` to disable. When not set, uses `Repo.delete`.",
      type_doc: "`t:func_ref/0` or `false`"
    ],
    create_with: [
      type: {:or, [{:in, [false]}, :atom, {:tuple, [:atom, :atom]}]},
      doc:
        "Customizes how records are created. Can be set to `false` to disable. When not set, builds a changeset that casts all fields with no validations and calls `Repo.insert`.",
      type_doc: "`t:func_ref/0` or `false`"
    ],
    update_with: [
      type: {:or, [{:in, [false]}, :atom, {:tuple, [:atom, :atom]}]},
      doc:
        "Customizes how records are updated. Can be set to `false` to disable. When not set, builds a changeset that casts all fields with no validations and calls `Repo.update`.",
      type_doc: "`t:func_ref/0` or `false`"
    ],
    validate_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      doc:
        "Customizes how changesets are validated in create/update forms. When not set, no additional validation is applied.",
      type_doc: "`t:func_ref/0`"
    ],
    label_with: [
      type: {:or, [:atom, {:tuple, [:atom, :atom]}]},
      doc: "Customizes how records are identified in the UI. When not set, uses the primary key.",
      type_doc: "`t:func_ref/0`"
    ],
    title_with: [
      type: {:or, [:string, {:tuple, [:atom, :atom]}]},
      doc:
        "Customizes the heading displayed for a resource. When not set, uses the schema module name.",
      type_doc: "string literal or `t:func_ref/0`"
    ],
    hidden_fields: [
      type: {:list, :atom},
      doc:
        "Specifies fields to hide from all views. When not set at the resource level, falls back to scope or global config (default: `[]`).",
      type_doc: "`t:field_list/0`"
    ],
    immutable_fields: [
      type: {:list, :atom},
      doc:
        "Specifies fields to disable in create/update forms. When not set at the resource level, falls back to scope or global config (default: `[]`).",
      type_doc: "`t:field_list/0`"
    ],
    actions: [
      type:
        {:list,
         {:or,
          [
            :atom,
            {:tuple, [:atom, :atom]},
            {:tuple, [:atom, :atom, :integer]},
            {:tuple,
             [:atom, {:or, [{:tuple, [:atom, :atom]}, {:tuple, [:atom, :atom, :integer]}]}]}
          ]}},
      doc:
        "Defines functions that operate on a specific record. When not set at the resource level, falls back to scope or global config (default: `[]`).",
      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]}]}]}
          ]}},
      doc:
        "Defines functions that operate on a resource as a whole. When not set at the resource level, falls back to scope or global config (default: `[]`).",
      type_doc: "`t:func_list/0` taking a query, 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, config, function_type, function)
      when function_type in [:tasks, :actions] and is_atom(function) do
    with result = {_, m, f, _} <-
           extract_function_from_config(resource, config, function_type, function),
         docs when is_map(docs) <- extract_function_docs(m, f) do
      Tuple.insert_at(result, tuple_size(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.get(config, key))

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

    key
  end

  def announce(message, type, session),
    do: LiveAdmin.PubSub.broadcast(session.id, {:announce, %{message: message, type: type}})

  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 ->
          if is_struct(segment) && Phoenix.Param.impl_for(segment) do
            Phoenix.Param.to_param(segment)
          else
            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)
        |> String.replace(~r/(?<=[a-z])(?=[A-Z])/, " ")

      {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

  @default_function_arity 2
  defp extract_function_from_config(resource, session, function_type, function) do
    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_function_arity}
      {m, f, a} -> f == function && {f, m, f, a}
      {m, f} -> f == function && {f, m, f, @default_function_arity}
      name -> name == function && {name, resource, name, @default_function_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

  def safe_render(val) when is_list(val), do: inspect(val, pretty: true)

  def safe_render(val) do
    to_string(val)
  rescue
    _ -> inspect(val, pretty: true)
  end
end