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