defmodule LiveAdmin.Resource do
@moduledoc """
API for managing Ecto schemas and their individual record instances used internally by LiveAdmin.
> #### `use LiveAdmin.Resource` {: .info}
> This is required in any module that should act as a LiveAdmin Resource.
> If the module is not an Ecto schema, then the `:schema` option must be passed.
> Using this module will create a __live_admin_config__ module variable and 2 functions
> to query it, __live_admin_config__/0 and __live_admin_config__/1. The former returns the entire
> config while the latter will return a key if it exists, otherwise it will fallback
> to either a global config for that key, or the key's default value.
To customize UI behavior, the following options may also be used:
* `title_with` - a binary, or MFA that returns a binary, used to identify the resource
* `label_with` - a binary, or MFA that returns a binary, used to identify records
* `list_with` - an atom or MFA that identifies the function that implements listing the resource
* `create_with` - an atom or MFA that identifies the function that implements creating the resource (set to false to disable create)
* `update_with` - an atom or MFA that identifies the function that implements updating a record (set to false to disable update)
* `delete_with` - an atom or MFA that identifies the function that implements deleting a record (set to false to disable delete)
* `validate_with` - an atom or MFA that identifies the function that implements validating a changed record
* `render_with` - an atom or MFA that identifies the function that implements table field rendering logic
* `hidden_fields` - a list of fields that should not be displayed in the UI
* `immutable_fields` - a list of fields that should not be editable in forms
* `actions` - list of atoms or MFAs that identify a function that operates on a record
* `tasks` - list atoms or MFAs that identify a function that operates on a resource
* `components` - keyword list of component module overrides for specific views (`:list`, `:new`, `:edit`, `:home`, `:nav`, `:session`, `:view`)
* `ecto_repo` - Ecto repo to use when building queries for this resource
"""
import Ecto.Query
import LiveAdmin, only: [record_label: 2, parent_associations: 1]
alias Ecto.Changeset
alias PhoenixHTMLHelpers.{Format, Tag}
@doc false
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
@__live_admin_config__ Keyword.put_new(opts, :schema, __MODULE__)
def __live_admin_config__, do: @__live_admin_config__
def __live_admin_config__(key) do
@__live_admin_config__
|> Keyword.get(key, Application.get_env(:live_admin, key))
|> case do
false -> false
nil -> LiveAdmin.Resource.default_config_value(key)
config -> config
end
end
end
end
def render(record, field, resource, assoc_resource, session) do
:render_with
|> resource.__live_admin_config__()
|> case do
nil ->
if assoc_resource do
record_label(
Map.fetch!(record, get_assoc_name!(resource.__live_admin_config__(:schema), field)),
elem(assoc_resource, 1)
)
else
record
|> Map.fetch!(field)
|> render_field()
end
{m, f, a} ->
apply(m, f, [record, field, session] ++ a)
f when is_atom(f) ->
apply(resource, f, [record, field, session])
end
end
def all(ids, resource, prefix, repo) do
resource.__live_admin_config__(:schema)
|> where([s], s.id in ^ids)
|> repo.all(prefix: prefix)
end
def find!(id, resource, prefix, repo) do
find(id, resource, prefix, repo) ||
raise(Ecto.NoResultsError, queryable: resource.__live_admin_config__(:schema))
end
def find(id, resource, prefix, repo) do
resource.__live_admin_config__(:schema)
|> preload(^preloads(resource))
|> repo.get(id, prefix: prefix)
end
def delete(record, resource, session, repo) do
:delete_with
|> resource.__live_admin_config__()
|> case do
nil ->
repo.delete(record)
{mod, func_name, args} ->
apply(mod, func_name, [record, session] ++ args)
name when is_atom(name) ->
apply(resource, name, [record, session])
end
end
def list(resource, opts, session, repo) do
:list_with
|> resource.__live_admin_config__()
|> case do
nil ->
build_list(resource, opts, repo)
{mod, func_name, args} ->
apply(mod, func_name, [resource, opts, session] ++ args)
name when is_atom(name) ->
apply(resource, name, [opts, session])
end
end
def change(resource, record \\ nil, params \\ %{})
def change(resource, record, params) when is_struct(record) do
build_changeset(record, resource, params)
end
def change(resource, nil, params) do
:schema
|> resource.__live_admin_config__()
|> struct(%{})
|> build_changeset(resource, params)
end
def create(resource, params, session, repo) do
:create_with
|> resource.__live_admin_config__()
|> case do
nil ->
resource
|> change(nil, params)
|> repo.insert(prefix: session.prefix)
{mod, func_name, args} ->
apply(mod, func_name, [params, session] ++ args)
name when is_atom(name) ->
apply(resource, name, [params, session])
end
end
def update(record, resource, params, session) do
:update_with
|> resource.__live_admin_config__()
|> case do
nil ->
resource
|> change(record, params)
|> resource.__live_admin_config__(:ecto_repo).update()
{mod, func_name, args} ->
apply(mod, func_name, [record, params, session] ++ args)
name when is_atom(name) ->
apply(resource, name, [record, params, session])
end
end
def validate(changeset, resource, session) do
:validate_with
|> resource.__live_admin_config__()
|> case do
nil -> changeset
{mod, func_name, args} -> apply(mod, func_name, [changeset, session] ++ args)
name when is_atom(name) -> apply(resource, name, [changeset, session])
end
|> Map.put(:action, :validate)
end
def fields(resource) do
schema = resource.__live_admin_config__(:schema)
Enum.flat_map(schema.__schema__(:fields), fn field_name ->
:hidden_fields
|> resource.__live_admin_config__()
|> Enum.member?(field_name)
|> case do
false ->
[
{field_name, schema.__schema__(:type, field_name),
[
immutable:
Enum.member?(resource.__live_admin_config__(:immutable_fields) || [], field_name)
]}
]
true ->
[]
end
end)
end
def default_config_value(key) when key in [:actions, :tasks, :components, :hidden_fields],
do: []
def default_config_value(:label_with), do: :id
def default_config_value(_), do: nil
defp build_list(resource, opts, repo) do
opts =
opts
|> Enum.into(%{})
|> Map.put_new(:page, 1)
|> Map.put_new(:sort_dir, :asc)
|> Map.put_new(:sort_attr, :id)
query =
:schema
|> resource.__live_admin_config__()
|> limit(10)
|> offset(^((opts[:page] - 1) * 10))
|> order_by(^[{opts[:sort_dir], opts[:sort_attr]}])
|> preload(^preloads(resource))
query =
opts
|> Enum.reduce(query, fn
{:search, q}, query when byte_size(q) > 0 ->
apply_search(query, q, fields(resource))
_, query ->
query
end)
{
repo.all(query, prefix: opts[:prefix]),
repo.aggregate(
query |> exclude(:limit) |> exclude(:offset),
:count,
prefix: opts[:prefix]
)
}
end
defp apply_search(query, q, fields) do
q
|> String.split(~r{[^\s]*:}, include_captures: true, trim: true)
|> case do
[q] ->
matcher = if String.contains?(q, "%"), do: q, else: "%#{q}%"
Enum.reduce(fields, query, fn {field_name, _, _}, query ->
or_where(
query,
[r],
like(
fragment("LOWER(CAST(? AS text))", field(r, ^field_name)),
^String.downcase(matcher)
)
)
end)
field_queries ->
field_queries
|> Enum.map(&String.trim/1)
|> Enum.chunk_every(2)
|> Enum.reduce(query, fn
[field_key, q], query ->
fields
|> Enum.find_value(fn {field_name, _, _} ->
if "#{field_name}:" == field_key, do: field_name
end)
|> case do
nil ->
query
field_name ->
or_where(
query,
[r],
ilike(fragment("CAST(? AS text)", field(r, ^field_name)), ^"%#{q}%")
)
end
_, query ->
query
end)
end
end
defp build_changeset(record = %schema{}, resource, params) do
resource
|> case do
:embed ->
Enum.map(schema.__schema__(:fields), fn field_name ->
{field_name, schema.__schema__(:type, field_name), []}
end)
resource ->
fields(resource)
end
|> Enum.reduce(Changeset.cast(record, params, []), fn
{field_name, {_, Ecto.Embedded, %{cardinality: :many}}, _}, changeset ->
Changeset.cast_embed(changeset, field_name,
with: fn embed, params -> build_changeset(embed, :embed, params) end,
sort_param: LiveAdmin.View.sort_param_name(field_name),
drop_param: LiveAdmin.View.drop_param_name(field_name)
)
{field_name, {_, Ecto.Embedded, %{cardinality: :one}}, _}, changeset ->
if Map.get(params, to_string(field_name)) == "" do
Changeset.put_change(changeset, field_name, nil)
else
Changeset.cast_embed(changeset, field_name,
with: fn embed, params -> build_changeset(embed, :embed, params) end
)
end
{field_name, type, opts}, changeset ->
unless Keyword.get(opts, :immutable, false) do
changeset = Changeset.cast(changeset, params, [field_name])
if type == :map do
Changeset.update_change(changeset, field_name, &parse_map_param/1)
else
changeset
end
else
changeset
end
end)
end
defp parse_map_param(param = %{}) do
param
|> Enum.sort_by(fn {idx, _} -> idx end)
|> Map.new(fn {_, %{"key" => key, "value" => value}} -> {key, value} end)
end
defp parse_map_param(param), do: param
defp preloads(resource) do
:preload
|> resource.__live_admin_config__()
|> case do
nil ->
resource.__live_admin_config__(:schema)
|> parent_associations()
|> Enum.map(& &1.field)
{m, f, a} ->
apply(m, f, [resource | a])
preloads when is_list(preloads) ->
preloads
end
end
defp get_assoc_name!(schema, fk) do
Enum.find(schema.__schema__(:associations), fn assoc_name ->
fk == schema.__schema__(:association, assoc_name).owner_key
end)
end
defp render_field(val = %{}), do: Tag.content_tag(:pre, inspect(val, pretty: true))
defp render_field(val) when is_list(val), do: Enum.map(val, &render_field/1)
defp render_field(val) when is_binary(val), do: Format.text_to_html(val)
defp render_field(val), do: inspect(val)
end