Current section

Files

Jump to
ash_admin lib ash_admin components resource form.ex
Raw

lib/ash_admin/components/resource/form.ex

defmodule AshAdmin.Components.Resource.Form do
@moduledoc false
use Phoenix.LiveComponent
import AshAdmin.Helpers
import AshAdmin.CoreComponents
require Logger
attr :resource, :any, required: true
attr :domain, :any, required: true
attr :record, :any, default: nil
attr :type, :atom, default: nil
attr :actor, :any, default: nil
attr :tenant, :any, default: nil
attr :authorizing, :boolean, default: false
attr :action, :any, required: true
attr :table, :any, required: true
attr :tables, :any, required: true
attr :prefix, :any, required: true
attr :url_path, :any, required: true
attr :params, :any, required: true
attr :polymorphic_actions, :any, required: true
def mount(socket) do
{:ok,
socket
|> assign_new(:load_errors, fn -> %{} end)
|> assign_new(:loaded, fn -> %{} end)
|> assign_new(:uploaded_files, fn -> %{} end)
|> assign(:params, %{})}
end
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign(:typeahead_options, [])
|> assign_form()
|> allow_uploading_form_arguments()
|> assign(:initialized, true)}
end
def render(assigns) do
~H"""
<div class="md:pt-10 sm:mt-0 bg-gray-300 min-h-screen">
<div class="md:grid md:grid-cols-3 md:gap-6 md:mx-16 md:mt-10">
<div class="mt-5 md:mt-0 md:col-span-2">
{render_form(assigns)}
</div>
</div>
<div :if={@type != :create} class="md:grid md:grid-cols-3 md:gap-6 md:mx-16 md:mt-10">
<div class="mt-5 md:mt-0 md:col-span-2">
{AshAdmin.Components.Resource.Show.render_show(
assigns,
@record,
@resource,
"Original Record",
false
)}
</div>
</div>
</div>
"""
end
defp all_errors(form) do
form
|> AshPhoenix.Form.errors(for_path: :all)
|> Enum.flat_map(fn {path, errors} ->
Enum.map(errors, fn {field, message} ->
path = List.wrap(path)
case Enum.reject(path ++ List.wrap(field), &is_nil/1) do
[] ->
{nil, message}
items ->
{Enum.join(items, "."), message}
end
end)
end)
end
defp render_form(assigns) do
~H"""
<div class="shadow-lg overflow-hidden sm:rounded-md bg-white">
<div :if={@form.source.submitted_once?} class="ml-4 mt-4 text-red-500">
<ul>
<li :for={{field, message} <- all_errors(@form)}>
<span :if={field}>
{field}:
</span>
<span>
{message}
</span>
</li>
</ul>
</div>
<h1 class="text-lg mt-2 ml-4">
{String.capitalize(to_string(@action.type))} {AshAdmin.Resource.name(@resource)}
</h1>
<div class="flex justify-between col-span-6 mr-4 mt-2 overflow-auto px-4">
<AshAdmin.Components.Resource.SelectTable.table
resource={@resource}
action={@action}
on_change="change_table"
polymorphic_actions={@polymorphic_actions}
target={@myself}
table={@table}
tables={@tables}
/>
<.form
:let={form}
as={:action}
for={to_form(%{}, as: :action)}
phx-change="change_action"
phx-target={@myself}
id="_action_form"
>
<label for="action">Action</label>
<.input
type="select"
field={form[:action]}
disabled={Enum.count(actions(@resource, @type)) <= 1}
options={actions(@resource, @type)}
value={to_string(@action.name)}
/>
</.form>
</div>
<div class="px-4 py-5 sm:p-6">
<.form
:let={form}
for={@form}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
autocomplete={false}
id="form"
>
<.input
:for={kv <- form.hidden}
name={form.name <> "[#{elem(kv, 0)}]"}
value={elem(kv, 1)}
type="hidden"
/>
{render_attributes(assigns, @resource, @action, form)}
<div class="px-4 py-3 text-right sm:px-6">
<button
type="submit"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{save_button_text(@type)}
</button>
</div>
</.form>
</div>
</div>
"""
end
defp save_button_text(:update), do: "Save"
defp save_button_text(type), do: type |> to_string() |> String.capitalize()
def render_attributes(
assigns,
resource,
action,
form,
exactly \\ nil,
skip \\ []
) do
assigns =
assign(assigns,
resource: resource,
action: action,
form: form,
exactly: exactly,
skip: skip
)
~H"""
<% {attributes, flags, bottom_attributes, relationship_args} =
attributes(@resource, @action, @exactly) %>
<div class="grid grid-cols-6 gap-6">
<div
:for={attribute <- Enum.reject(attributes, &(is_nil(@exactly) && &1.name in @skip))}
class={
classes([
"col-span-6",
"sm:col-span-full": markdown?(@resource, attribute),
"sm:col-span-2": short_text?(@resource, attribute),
"sm:col-span-3": !(long_text?(@resource, attribute) || markdown?(@resource, attribute))
])
}
>
<div>
<label
class="block text-sm font-medium text-gray-700"
for={@form.name <> "[#{attribute.name}]"}
>
{to_name(attribute)}
</label>
{render_attribute_input(assigns, attribute, @form)}
<.error_tag
:for={{error, vars} <- Keyword.get_values(@form.errors || [], attribute.name)}
:if={!Ash.Type.embedded_type?(attribute.type)}
>
{replace_vars(error, vars)}
</.error_tag>
</div>
</div>
</div>
<div :if={!Enum.empty?(flags)} class="hidden sm:block" aria-hidden="true">
<div class="py-5">
<div class="border-t border-gray-200" />
</div>
</div>
<div :if={!Enum.empty?(flags)} class="grid grid-cols-6 gap-6">
<div
:for={attribute <- flags}
class={
classes([
"col-span-6",
"sm:col-span-2": short_text?(@resource, attribute),
"sm:col-span-3": !long_text?(@resource, attribute)
])
}
>
<label
class="block text-sm font-medium text-gray-700"
for={@form.name <> "[#{attribute.name}]"}
>
{to_name(attribute.name)}
</label>
{render_attribute_input(assigns, attribute, @form)}
<.error_tag
:for={{error, vars} <- Keyword.get_values(@form.errors || [], attribute.name)}
:if={!Ash.Type.embedded_type?(attribute.type)}
>
{replace_vars(error, vars)}
</.error_tag>
</div>
</div>
<div :if={!Enum.empty?(bottom_attributes)} class="hidden sm:block" aria-hidden="true">
<div class="py-5">
<div class="border-t border-gray-200" />
</div>
</div>
<div :if={!Enum.empty?(bottom_attributes)} class="grid grid-cols-6 gap-6">
<div
:for={attribute <- bottom_attributes}
class={
classes([
"col-span-6",
"sm:col-span-2": short_text?(@resource, attribute),
"sm:col-span-3":
!(long_text?(@resource, attribute) || Ash.Type.embedded_type?(attribute.type))
])
}
>
<label
class="block text-sm font-medium text-gray-700"
for={@form.name <> "[#{attribute.name}]"}
>
{to_name(attribute.name)}
</label>
{render_attribute_input(assigns, attribute, @form)}
<.error_tag
:for={{error, vars} <- Keyword.get_values(@form.errors || [], attribute.name)}
:if={!Ash.Type.embedded_type?(attribute.type)}
>
{replace_vars(error, vars)}
</.error_tag>
</div>
</div>
<div :for={{relationship, argument, opts} <- relationship_args}>
<%= if relationship not in @skip and argument.name not in @skip do %>
<label
class="block text-sm font-medium text-gray-700"
for={@form.name <> "[#{argument.name}]"}
>
{to_name(argument.name)}
</label>
{render_relationship_input(
assigns,
Ash.Resource.Info.relationship(@form.source.resource, relationship),
@form,
argument,
opts
)}
<% end %>
</div>
"""
end
@spec error_tag(any()) :: Phoenix.LiveView.Rendered.t()
def error_tag(assigns) do
~H"""
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
{render_slot(@inner_block)}
</p>
"""
end
defp replace_vars(string, vars) do
vars =
if is_map(vars) do
vars
else
List.wrap(vars)
end
Enum.reduce(vars, string, fn {key, value}, acc ->
if String.contains?(acc, "%{#{key}}") do
String.replace(acc, "%{#{key}}", to_string(value))
else
acc
end
end)
end
defp render_relationship_input(
assigns,
relationship,
form,
argument,
opts
) do
key =
opts[:value_is_key] ||
relationship.destination
|> Ash.Resource.Info.primary_key()
|> case do
[key] ->
key
_ ->
nil
end
{hidden?, exactly_fields} =
if map_type?(argument.type) || !key do
{true, nil}
else
{false, [key]}
end
assigns =
assign(assigns,
relationship: relationship,
form: form,
argument: argument,
opts: opts,
key: key,
hidden: hidden?,
exactly_fields: exactly_fields,
is_relationship_form: true
)
~H"""
<div :if={!must_load?(@opts) || loaded?(@form.source.source, @relationship.name)}>
<.inputs_for :let={inner_form} field={@form[@argument.name]}>
<div :if={@form.source.submitted_once?} class="ml-4 mt-4 text-red-500">
<ul>
<li :for={{field, message} <- AshPhoenix.Form.errors(inner_form.source)}>
<span :if={field}>
{to_name(field)}:
</span>
<span>
{message}
</span>
</li>
</ul>
</div>
<.input
:for={kv <- inner_form.hidden}
:if={@hidden}
name={inner_form.name <> "[#{elem(kv, 0)}]"}
value={elem(kv, 1)}
type="hidden"
/>
<%= if inner_form.source.form_keys[:_join] do %>
<.inputs_for :let={join_form} field={inner_form[:_join]}>
<.input
:for={kv <- join_form.hidden}
:if={@hidden}
name={inner_form.name <> "[#{elem(kv, 0)}]"}
value={elem(kv, 1)}
type="hidden"
/>
{render_attributes(
assigns,
@relationship.through,
join_action(@relationship.through, join_form, inner_form.source.form_keys[:_join]),
join_form,
@exactly_fields || inner_form.source.form_keys[:_join][:create_fields],
skip_through_related(@exactly_fields, @relationship)
)}
</.inputs_for>
<% end %>
{render_attributes(
assigns,
inner_form.source.resource,
inner_form.source.source.action,
inner_form,
@exactly_fields || relationship_fields(inner_form),
skip_related(@exactly_fields, @relationship)
)}
<button
:if={can_remove_related?(inner_form, @opts)}
type="button"
phx-click="remove_form"
phx-target={@myself}
phx-value-path={inner_form.name}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-minus" class="h-4 w-4 text-gray-500" />
</button>
</.inputs_for>
<button
:if={can_add_related?(@form, :read_action, @argument)}
type="button"
phx-click="add_form"
phx-target={@myself}
phx-value-path={@form.name <> "[#{@argument.name}]"}
phx-value-type="lookup"
phx-value-cardinality={to_string(@relationship.cardinality)}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-magnifying-glass-circle" class="h-4 w-4 text-gray-500" />
</button>
<button
:if={can_add_related?(@form, :create_action, @argument)}
type="button"
phx-click="add_form"
phx-target={@myself}
phx-value-path={@form.name <> "[#{@argument.name}]"}
phx-value-type="create"
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-plus" class="h-4 w-4 text-gray-500" />
</button>
<button
:if={
@form.source.form_keys[@argument.name][:read_form] &&
!relationship_set?(@form.source.source, @relationship.name, @argument.name)
}
type="button"
phx-click="add_form"
phx-target={@myself}
phx-value-path={@form.name <> "[#{@argument.name}]"}
phx-value-type="lookup"
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-plus" class="h-4 w-4 text-gray-500" />
</button>
</div>
<div :if={must_load?(@opts) && !loaded?(@form.source.source, @relationship.name)}>
<button
phx-click="load"
phx-target={@myself}
phx-value-path={@form.name}
phx-value-relationship={@relationship.name}
type="button"
class="flex py-2 ml-4 px-4 mt-2 bg-indigo-600 text-white border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
Load
</button>
<div :if={is_exception(@load_errors[@relationship.name])}>
{Exception.message(@load_errors[@relationship.name])}
</div>
<div :if={@load_errors[@relationship.name] && !is_exception(@load_errors[@relationship.name])}>
{inspect(@load_errors[@relationship.name])}
</div>
</div>
"""
end
defp join_action(through, join_form, opts) do
name =
case join_form.source.type do
:create ->
opts[:create_action]
:update ->
opts[:update_action]
:destroy ->
opts[:destroy_action]
:read ->
opts[:read_action]
end
Ash.Resource.Info.action(through, name)
end
defp can_add_related?(form, action, argument) do
if form.source.form_keys[argument.name][action] do
case argument.type do
{:array, _} ->
true
_ ->
form.source.forms[argument.name] in [[], nil]
end
else
false
end
end
defp relationship_fields(inner_form) do
if inner_form.source.type == :read do
AshPhoenix.Form.attributes(inner_form.source)
|> Enum.concat(AshPhoenix.Form.arguments(inner_form.source))
|> Enum.map(& &1.name)
|> Enum.concat(Keyword.keys(Keyword.drop(inner_form.source.form_keys, [:_join, :_update])))
|> Enum.concat(Ash.Resource.Info.primary_key(inner_form.source.resource))
else
AshPhoenix.Form.attributes(inner_form.source)
|> Enum.concat(AshPhoenix.Form.arguments(inner_form.source))
|> Enum.map(& &1.name)
|> Enum.concat(Keyword.keys(Keyword.drop(inner_form.source.form_keys, [:_join, :_update])))
end
end
defp skip_related(nil, relationship) do
case relationship.type do
:belongs_to ->
[]
_ ->
[relationship.destination_attribute]
end
end
defp skip_related(_, _) do
[]
end
defp must_load?(opts) do
Ash.Changeset.ManagedRelationshipHelpers.must_load?(opts)
end
defp skip_through_related(nil, relationship) do
[
relationship.source_attribute_on_join_resource,
relationship.destination_attribute_on_join_resource
]
end
defp skip_through_related(_, _) do
[]
end
defp loaded?(%{action_type: :create}, _), do: true
defp loaded?(%{data: record}, relationship) do
case Map.get(record, relationship) do
%Ash.NotLoaded{} -> false
_ -> true
end
end
defp relationship_set?(%{action_type: :create} = changeset, relationship, id) do
changeset.relationships
|> Kernel.||(%{})
|> Map.get(relationship, [])
|> Enum.any?(fn {manage, opts} ->
if opts[:meta][:id] == id do
manage not in [[], nil]
end
end)
end
defp relationship_set?(changeset, relationship, id) do
changeset.relationships
|> Kernel.||(%{})
|> Map.get(relationship, [])
|> Enum.any?(fn {manage, opts} ->
if opts[:meta][:id] == id do
manage not in [[], nil]
end
end)
end
defp can_remove_related?(inner_form, opts) do
if inner_form.source.type in [:create, :read] do
true
else
Ash.Changeset.ManagedRelationshipHelpers.could_handle_missing?(opts)
end
end
defp short_text?(resource, attribute) do
case AshAdmin.Resource.field(resource, attribute.name) do
%{type: :short_text} ->
true
_ ->
false
end
end
defp long_text?(resource, attribute) do
case AshAdmin.Resource.field(resource, attribute.name) do
%{type: :long_text} ->
true
_ ->
unwrap_type(attribute.type) == Ash.Type.Map
end
end
defp markdown?(resource, attribute) do
case AshAdmin.Resource.field(resource, attribute.name) do
%{type: :markdown} ->
true
_ ->
false
end
end
defp unwrap_type({:array, type}), do: unwrap_type(type)
defp unwrap_type(type), do: type
def render_attribute_input(
assigns,
attribute,
form,
value \\ nil,
name \\ nil,
id \\ nil,
union_type \\ nil
)
def render_attribute_input(
assigns,
%{type: Ash.Type.Date} = attribute,
form,
value,
name,
id,
_
) do
assigns = assign(assigns, form: form, value: value, name: name, attribute: attribute, id: id)
~H"""
<.input
type="date"
value={value(@value, @form, @attribute)}
name={@name || @form.name <> "[#{@attribute.name}]"}
id={@id || @form.id <> "_#{@attribute.name}"}
/>
"""
end
def render_attribute_input(assigns, %{type: type} = attribute, form, value, name, id, _)
when type in [Ash.Type.UtcDatetime, Ash.Type.UtcDatetimeUsec, Ash.Type.DateTime] do
assigns = assign(assigns, form: form, value: value, name: name, attribute: attribute, id: id)
~H"""
<.input
type="datetime-local"
value={value(@value, @form, @attribute)}
name={@name || @form.name <> "[#{@attribute.name}]"}
id={@id || @form.id <> "_#{@attribute.name}"}
/>
"""
end
def render_attribute_input(
assigns,
%{
type: Ash.Type.Boolean,
allow_nil?: false
} = attribute,
form,
value,
name,
id,
_
) do
assigns = assign(assigns, attribute: attribute, form: form, value: value, name: name, id: id)
~H"""
<.input
type="checkbox"
value={value(@value, @form, @attribute)}
name={@name || @form.name <> "[#{@attribute.name}]"}
id={@id || @form.id <> "_#{@attribute.name}"}
/>
"""
end
def render_attribute_input(
assigns,
%{
type: Ash.Type.Boolean
} = attribute,
form,
value,
name,
id,
_
) do
assigns = assign(assigns, attribute: attribute, form: form, value: value, name: name, id: id)
~H"""
<.input
type="select"
id={@id || @form.id <> "_#{@attribute.name}"}
name={@name || @form.name <> "[#{@attribute.name}]"}
options={[True: "true", False: "false"]}
value={value(@value, @form, @attribute, "true")}
/>
"""
end
def render_attribute_input(
assigns,
%{
type: Ash.Type.File
} = attribute,
form,
value,
name,
id,
_
) do
upload_key = upload_key(form, attribute)
assigns =
assign(assigns,
attribute: attribute,
form: form,
value: value,
name: name,
id: id,
upload_key: upload_key,
upload: assigns[:uploads][upload_key],
uploaded_file: Map.get(assigns.uploaded_files, upload_key)
)
~H"""
<%= if @uploaded_file do %>
<div class="flex items-center justify-between mt-2 w-full rounded-lg border border-zinc-300 text-zinc-900 text-sm overflow-hidden">
<span class="px-2 py-2.5">{Path.basename(@uploaded_file.source)}</span>
<button
type="button"
phx-click="remove_upload"
phx-target={@myself}
phx-value-upload-key={@upload_key}
class="px-3 py-2.5 bg-gray-100 hover:bg-gray-200"
>
<.icon name="hero-minus" class="h-4 w-4 text-gray-500" />
</button>
</div>
<% else %>
<div phx-drop-target={@upload.ref}>
<label for={@id || @upload_key} class="sr-only">Choose File</label>
<.live_file_input
id={@id || @upload_key}
upload={@upload}
class="mt-2 block w-full rounded-lg border border-zinc-300 active:border-zinc-400 text-zinc-900 text-sm file:border-0 file:text-sm file:bg-gray-200 file:me-4 file:py-2.5 file:px-4 focus:outline-none focus:border-zinc-400 target:border-zinc-400 cursor-pointer file:cursor-pointer"
/>
<%= if length(@upload.entries) > 0 do %>
<div class="w-full bg-gray-200 rounded-full h-1.5 mb-1 mt-1">
<div
class="bg-indigo-600 h-1.5 rounded-full"
data-progress={hd(@upload.entries).progress}
style={"width: #{hd(@upload.entries).progress}%"}
>
</div>
</div>
<p
:for={err <- upload_errors(@upload, hd(@upload.entries))}
class="mb-3 flex gap-3 text-sm leading-6 text-rose-600"
>
{error_to_string(err)}
</p>
<% end %>
</div>
<% end %>
<p :for={err <- upload_errors(@upload)} class="alert alert-danger">
{error_to_string(err)}
</p>
"""
end
def render_attribute_input(assigns, %{type: Ash.Type.Binary}, _form, _value, _name, _id, _) do
~H"""
<span class="italic">(binary fields cannot be edited)</span>
"""
end
def render_attribute_input(
assigns,
%{
type: type,
default: default
} = attribute,
form,
value,
name,
id,
_
)
when type in [Ash.Type.CiString, Ash.Type.String, Ash.Type.UUID, Ash.Type.Atom] do
attribute =
case form.source do
%{type: :read} -> Map.put(attribute, :related_resource, assigns[:resource])
_ -> attribute
end
assigns =
assign(assigns,
attribute: attribute,
form: form,
value: value,
type: type,
name: name,
default: default,
id: id
)
~H"""
<%= cond do %>
<% @type == Ash.Type.Atom && @attribute.constraints[:one_of] -> %>
<.input
type="select"
id={@id || @form.id <> "_#{@attribute.name}"}
options={Enum.map(@attribute.constraints[:one_of] || [], &{to_name(&1), &1})}
value={value(@value, @form, @attribute, default_atom_list_value(@attribute))}
prompt={allow_nil_option(@attribute, @value)}
name={@name || @form.name <> "[#{@attribute.name}]"}
/>
<% markdown?(@resource, @attribute) -> %>
<div
phx-hook="MarkdownEditor"
id={if @id, do: @id <> "_container", else: @form.id <> "_#{@attribute.name}_container"}
phx-update="ignore"
data-target-id={@form.id <> "_#{@attribute.name}"}
class="prose max-w-none"
>
<textarea
id={@id || @id || @form.id <> "_#{@attribute.name}"}
class="prose max-w-none"
name={@name || @form.name <> "[#{@attribute.name}]"}
><%= value(@value, @form, @attribute) || "" %></textarea>
</div>
<% long_text?(@resource, @attribute) -> %>
<textarea
id={@id || @form.id <> "_#{@attribute.name}"}
name={@name || @form.name <> "[#{@attribute.name}]"}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md resize-y"
phx-hook="MaintainAttrs"
data-attrs="style"
placeholder={placeholder(@default)}
><%= value(@value, @form, @attribute) %></textarea>
<% short_text?(@resource, @attribute) -> %>
<.input
type={text_input_type(@resource, @attribute)}
id={@id || @form.id <> "_#{@attribute.name}"}
value={value(@value, @form, @attribute)}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
name={@name || @form.name <> "[#{@attribute.name}]"}
placeholder={placeholder(@default)}
/>
<% is_map(@attribute) and Map.has_key?(@attribute, :related_resource) && AshAdmin.Resource.label_field(@attribute.related_resource) -> %>
<.live_component
module={AshAdmin.Components.Resource.RelationshipField}
id={@id || "#{@form.name}-#{@attribute.name}"}
value={value(@value, @form, @attribute)}
tenant={@tenant}
actor={@actor}
authorizing={@authorizing}
resource={@attribute.related_resource}
form={@form}
attribute={@attribute}
/>
<% true -> %>
<.input
type={text_input_type(@resource, @attribute)}
placeholder={placeholder(@default)}
id={@id || @form.id <> "_#{@attribute.name}"}
value={value(@value, @form, @attribute)}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
name={@name || @form.name <> "[#{@attribute.name}]"}
/>
<% end %>
"""
end
def render_attribute_input(
assigns,
%{type: number, default: default} = attribute,
form,
value,
name,
id,
_
)
when number in [Ash.Type.Integer, Ash.Type.Float, Ash.Type.Decimal] do
assigns =
assign(assigns,
attribute: attribute,
form: form,
value: value,
name: name,
default: default,
id: id
)
~H"""
<.input
type="number"
id={@id || @form.id <> "_#{@attribute.name}"}
value={value(@value, @form, @attribute)}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
name={@name || @form.name <> "[#{@attribute.name}]"}
placeholder={placeholder(@default)}
/>
"""
end
def render_attribute_input(
assigns,
%{type: {:array, Ash.Type.Map}} = attribute,
form,
value,
name,
id,
_
) do
render_attribute_input(assigns, %{attribute | type: Ash.Type.Map}, form, value, name, id)
end
def render_attribute_input(assigns, %{type: Ash.Type.Map} = attribute, form, value, name, id, _) do
encoded = Jason.encode!(value(value, form, attribute))
assigns =
assign(assigns,
attribute: attribute,
form: form,
value: value,
name: name,
encoded: encoded,
id: id
)
~H"""
<div>
<div
phx-hook="JsonEditor"
phx-update="ignore"
data-input-id={@form.id <> "_#{@attribute.name}"}
id={if @id, do: @id <> "_json", else: @form.id <> "_#{@attribute.name}_json"}
/>
<.input
type="hidden"
phx-hook="JsonEditorSource"
data-editor-id={@form.id <> "_#{@attribute.name}_json"}
value={@encoded}
name={@name || @form.name <> "[#{@attribute.name}]"}
id={@id || @form.id <> "_#{@attribute.name}"}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</div>
"""
rescue
_ ->
~H"""
<.input
type="text"
disabled
value="..."
name={@name || @form.name <> "[#{@attribute.name}]"}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
"""
end
def render_attribute_input(
assigns,
%{type: Ash.Type.Union} = attribute,
form,
value,
name,
id,
_
) do
union_type_name =
if name do
name <> "[_new_union_type]"
else
form.name <> "[#{attribute.name}][_new_union_type]"
end
actual_union_type_name =
case value do
{:list_value, %{params: %{"_new_union_type" => union_type}}} ->
case Enum.find(attribute.constraints[:types], fn {type_name, _} ->
to_string(type_name) == union_type
end) do
{match, _} -> match
_ -> elem(Enum.at(attribute.constraints[:types], 0), 0)
end
{:list_value, %{params: %{"_union_type" => union_type}}} ->
case Enum.find(attribute.constraints[:types], fn {type_name, _} ->
to_string(type_name) == union_type
end) do
{match, _} -> match
_ -> elem(Enum.at(attribute.constraints[:types], 0), 0)
end
{:list_value,
%AshPhoenix.Form{
resource: AshPhoenix.Form.WrappedValue,
data: %{value: %Ash.Union{type: type}}
}} ->
type
_ ->
with %Phoenix.HTML.FormField{value: [%Phoenix.HTML.Form{} = attr_form]} <-
form[attribute.name],
union_type <- non_nil_form_field(attr_form, [:_new_union_type, :_union_type]),
{match, _} <-
Enum.find(attribute.constraints[:types], fn {type_name, _} ->
to_string(type_name) == to_string(union_type)
end) do
match
else
_ ->
elem(Enum.at(attribute.constraints[:types], 0), 0)
end
end
actual_union_type =
Keyword.get(attribute.constraints[:types], actual_union_type_name)[:type] || Ash.Type.String
actual_union_constraints =
Keyword.get(attribute.constraints[:types], actual_union_type_name)[:constraints] || []
{name, id} =
if Ash.Type.embedded_type?(actual_union_type) do
{name, id}
else
name =
if name do
name <> "[value]"
else
form.name <> "[#{attribute.name}][value]"
end
id =
if id do
id <> "_value"
else
form.id <> "_#{attribute.name}_value"
end
{name, id}
end
union_type_id = "#{id}_#{attribute.name}_union_type"
assigns =
assign(
assigns,
attribute: attribute,
form: form,
value: value,
name: name,
id: id,
union_type_id: union_type_id,
possible_types: Keyword.keys(attribute.constraints[:types]),
actual_union_type: actual_union_type,
actual_union_constraints: actual_union_constraints,
union_type_name: union_type_name,
actual_union_type_name: actual_union_type_name,
actual_union_value: value(value, form, attribute, attribute.default)
)
~H"""
<div class={if !is_nil(@actual_union_value), do: "border", else: ""}>
<label
:if={!is_nil(@actual_union_value)}
class="block text-sm font-medium text-gray-700"
for={@union_type_name}
>
Type
</label>
<div class="w-full">
<.input
:if={not (is_nil(@actual_union_value) && map_type?(@actual_union_type))}
phx-change="union-type-changed"
id={@union_type_id}
name={@union_type_name}
type="select"
value={@actual_union_type_name}
options={@possible_types}
field={@form[:_union_type]}
/>
{render_attribute_input(
assigns,
%{@attribute | type: @actual_union_type, constraints: @actual_union_constraints},
@form,
@actual_union_value,
@name,
@id,
@actual_union_type_name
)}
</div>
</div>
"""
end
def render_attribute_input(assigns, attribute, form, value, name, id, union_type) do
if Ash.Type.NewType.new_type?(attribute.type) do
constraints = Ash.Type.NewType.constraints(attribute.type, attribute.constraints)
type = Ash.Type.NewType.subtype_of(attribute.type)
attribute = %{attribute | type: type, constraints: constraints}
render_attribute_input(assigns, attribute, form, value, name, id)
else
assigns =
assign(assigns,
attribute: attribute,
form: form,
value: value,
name: name,
union_type: union_type,
id: id
)
~H"""
<%= cond do %>
<% match?({:array, {:array, _}}, @attribute.type) -> %>
{render_fallback_attribute(assigns, @form, @attribute, @value, @name, @id, @union_type)}
<% match?({:array, _}, @attribute.type) && Ash.Type.embedded_type?(@attribute.type) -> %>
<.inputs_for :let={inner_form} field={@form[@attribute.name]}>
<.input
:for={kv <- inner_form.hidden}
name={inner_form.name <> "[#{elem(kv, 0)}]"}
value={elem(kv, 1)}
type="hidden"
/>
<button
type="button"
phx-click="remove_form"
phx-target={@myself}
phx-value-path={inner_form.name}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-minus" class="h-4 w-4 text-gray-500" />
</button>
{render_attributes(
assigns,
inner_form.source.resource,
inner_form.source.source.action,
%{
inner_form
| id: nested_form_id(@id, @form.id, @attribute.name, inner_form),
name: nested_form_name(@name, @form.name, @attribute.name, inner_form)
}
)}
</.inputs_for>
<button
:if={can_append_embed?(@form.source, @attribute.name, @attribute.type)}
type="button"
phx-click="add_form"
phx-target={@myself}
phx-value-pkey={embedded_type_pkey(@attribute.type)}
phx-value-union-type={@union_type}
phx-value-path={@form.name <> "[#{@attribute.name}]"}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-plus" class="h-4 w-4 text-gray-500" />
</button>
<% Ash.Type.embedded_type?(@attribute.type) && match?([%AshPhoenix.Form{} | _], @value) -> %>
<%= for inner_form <- Enum.map(@value, &to_form/1) do %>
<.input
:for={kv <- inner_form.hidden}
name={inner_form.name <> "[#{elem(kv, 0)}]"}
value={elem(kv, 1)}
type="hidden"
/>
<button
type="button"
phx-click="remove_form"
phx-target={@myself}
phx-value-path={inner_form.name}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-minus" class="h-4 w-4 text-gray-500" />
</button>
{render_attributes(
assigns,
inner_form.source.resource,
inner_form.source.source.action,
%{
inner_form
| id: @id || @form.id <> "_#{@attribute.name}",
name: @name || @form.name <> "[#{@attribute.name}]"
}
)}
<% end %>
<button
:if={can_append_embed?(@form.source, @attribute.name, @attribute.type)}
type="button"
phx-click="add_form"
phx-target={@myself}
phx-value-union-type={@union_type}
phx-value-pkey={embedded_type_pkey(@attribute.type)}
phx-value-path={@form.name <> "[#{@attribute.name}]"}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-plus" class="h-4 w-4 text-gray-500" />
</button>
<% Ash.Type.embedded_type?(@attribute.type) && match?(%AshPhoenix.Form{}, @value) -> %>
<% inner_form = to_form(@value) %>
<.input
:for={kv <- inner_form.hidden}
name={inner_form.name <> "[#{elem(kv, 0)}]"}
value={elem(kv, 1)}
type="hidden"
/>
<button
type="button"
phx-click="remove_form"
phx-target={@myself}
phx-value-path={inner_form.name}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-minus" class="h-4 w-4 text-gray-500" />
</button>
{render_attributes(
assigns,
inner_form.source.resource,
inner_form.source.source.action,
%{
inner_form
| id: @id || @form.id <> "_#{@attribute.name}",
name: @name || @form.name <> "[#{@attribute.name}]"
}
)}
<% Ash.Type.embedded_type?(@attribute.type) && match?(%{source: %AshPhoenix.FilterForm.Arguments{}}, @form) -> %>
{"AshPhoenix.FilterForm doesn't support embedded yet"}
<% Ash.Type.embedded_type?(@attribute.type) -> %>
<.inputs_for :let={inner_form} field={@form[@attribute.name]}>
<.input
:for={kv <- inner_form.hidden}
name={inner_form.name <> "[#{elem(kv, 0)}]"}
value={elem(kv, 1)}
type="hidden"
/>
<button
type="button"
phx-click="remove_form"
phx-target={@myself}
phx-value-path={inner_form.name}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-minus" class="h-4 w-4 text-gray-500" />
</button>
{render_attributes(
assigns,
inner_form.source.resource,
inner_form.source.source.action,
%{
inner_form
| id: @id || @form.id <> "_#{@attribute.name}",
name: @name || @form.name <> "[#{@attribute.name}]"
}
)}
</.inputs_for>
<button
:if={can_append_embed?(@form.source, @attribute.name, @attribute.type)}
type="button"
phx-click="add_form"
phx-target={@myself}
phx-value-union-type={@union_type}
phx-value-pkey={embedded_type_pkey(@attribute.type)}
phx-value-path={@form.name <> "[#{@attribute.name}]"}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-plus" class="h-4 w-4 text-gray-500" />
</button>
<% is_atom(@attribute.type) && function_exported?(@attribute.type, :values, 0) -> %>
<.input
type="select"
id={@form.id <> "_#{@attribute.name}"}
options={Enum.map(@attribute.type.values(), &{to_name(&1), &1})}
value={value(@value, @form, @attribute, List.first(@attribute.type.values()))}
prompt={allow_nil_option(@attribute, @value)}
name={@name || @form.name <> "[#{@attribute.name}]"}
/>
<% true -> %>
{render_fallback_attribute(assigns, @form, @attribute, @value, @name, @id, @union_type)}
<% end %>
"""
end
end
defp nested_form_id(id, form_id, attribute_name, inner_form) do
if id do
"#{id}_#{inner_form.index}"
else
"#{form_id}_#{attribute_name}_#{inner_form.index}"
end
end
defp nested_form_name(name, form_name, attribute_name, inner_form) do
if name do
"#{name}[#{inner_form.index}]"
else
"#{form_name}[#{attribute_name}][#{inner_form.index}]"
end
end
defp render_fallback_attribute(
assigns,
form,
%{type: {:array, type}} = attribute,
value,
name,
id,
union_type
) do
name = name || form.name <> "[#{attribute.name}]"
id = id || form.id <> "_#{attribute.name}"
assigns =
assign(assigns,
form: form,
attribute: attribute,
type: type,
value: value,
name: name,
id: id,
union_type: union_type || default_union_type(type, attribute.constraints[:items] || [])
)
~H"""
<div>
<div :for={
{this_value, index} <-
Enum.with_index(list_value(@value || value(@value, @form, @attribute)))
}>
{render_attribute_input(
assigns,
%{@attribute | type: @type, constraints: @attribute.constraints[:items] || []},
@form,
{:list_value, this_value},
@name <> "[#{index}]",
@id <> "_#{index}",
@union_type
)}
<button
type="button"
phx-click="remove_value"
phx-target={@myself}
phx-value-path={@form.name}
phx-value-field={@attribute.name}
phx-value-index={index}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-minus" class="h-4 w-4 text-gray-500" />
</button>
</div>
<button
type="button"
phx-click="append_value"
phx-target={@myself}
phx-value-path={@form.name}
phx-value-field={@attribute.name}
phx-value-union-type={@union_type}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
<.icon name="hero-plus" class="h-4 w-4 text-gray-500" />
</button>
</div>
"""
end
defp render_fallback_attribute(assigns, form, attribute, value, name, id, _union_type) do
assigns =
assign(assigns,
form: form,
attribute: attribute,
value: value,
name: name,
id: id
)
~H"""
<.input
type={text_input_type(@form.source.resource, @attribute)}
placeholder={placeholder(@attribute.default)}
value={@value}
name={@name || @form.name <> "[#{@attribute.name}]"}
id={@id || @form.id <> "_#{@attribute.name}"}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
"""
rescue
_ ->
case Map.fetch(form.params, to_string(attribute.name)) do
{:ok, value} ->
assigns = assign(assigns, value: value)
~H"""
<.input
type={text_input_type(@form.source.resource, @attribute)}
placeholder={placeholder(@attribute.default)}
value={@value}
name={@name || @form.name <> "[#{@attribute.name}]"}
id={@id || @form.id <> "_#{@attribute.name}"}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
"""
:error ->
~H"""
<.input
type="text"
disabled
value="..."
name={@name || @form.name <> "[#{@attribute.name}]"}
id={@id || @form.id <> "_#{@attribute.name}"}
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
"""
end
end
defp default_union_type(Ash.Type.Union, constraints) do
constraints[:types]
|> List.wrap()
|> Enum.at(0)
|> elem(0)
|> to_string()
end
defp default_union_type(_, _), do: nil
defp non_nil_form_field(_form, []), do: nil
defp non_nil_form_field(form, [field | rest]) do
case form[field] do
%Phoenix.HTML.FormField{form: %{data: %{value: %Ash.Union{type: type}}}} ->
type
%Phoenix.HTML.FormField{value: value} when not is_nil(value) ->
value
_ ->
non_nil_form_field(form, rest)
end
end
defp list_value(value) do
if is_map(value) do
value
|> Map.to_list()
|> Enum.sort_by(fn {key, _} -> String.to_integer(key) end)
|> Enum.map(&elem(&1, 1))
else
List.wrap(value)
end
end
defp embedded_type_pkey({:array, type}) do
embedded_type_pkey(type)
end
defp embedded_type_pkey(type) do
type
|> Ash.Resource.Info.primary_key()
|> Enum.flat_map(fn attr ->
if Ash.Resource.Info.attribute(type, attr).public? do
[attr]
else
[]
end
end)
|> Enum.join("-")
end
defp value(value, form, attribute, default \\ nil) do
case do_value(value, form, attribute, default) do
%AshPhoenix.Form{resource: AshPhoenix.Form.WrappedValue} = form ->
form
|> AshPhoenix.Form.value(:value)
|> Phoenix.HTML.Safe.to_iodata()
value ->
value
end
end
defp do_value({:list_value, %Ash.Union{value: value}}, _, _, _), do: value
defp do_value({:list_value, value}, _, _, _), do: value
defp do_value(%Ash.Union{value: value}, _form, _attribute, _) when not is_nil(value), do: value
defp do_value(value, _form, _attribute, _) when not is_nil(value), do: value
defp do_value(
_value,
%{source: %AshPhoenix.FilterForm.Arguments{} = arguments},
%{name: attribute_name},
_default
) do
with :error <- Map.fetch(arguments.params, to_string(attribute_name)),
:error <- Map.fetch(arguments.input, attribute_name) do
nil
else
{:ok, v} -> v
end
end
defp do_value(_value, %{source: form}, attribute, _default) do
case AshPhoenix.Form.value(form, attribute.name) do
%Ash.Union{value: value} -> value
value -> value
end
end
defp default_atom_list_value(%{allow_nil?: false, constraints: [one_of: [atom | _]]}), do: atom
defp default_atom_list_value(%{default: default}), do: default
defp default_atom_list_value(%{constraints: [one_of: [atom | _]]}), do: atom
defp default_atom_list_value(_), do: nil
defp allow_nil_option(_, {:list_value, _}), do: "-"
defp allow_nil_option(%{allow_nil?: true}, _), do: "-"
defp allow_nil_option(_, _), do: "Select an option"
defp can_append_embed?(form, attribute, _) do
case AshPhoenix.Form.value(form, attribute) do
%Ash.Union{value: nil} ->
true
nil ->
true
value when is_list(value) ->
true
_ ->
false
end
end
defp placeholder(value) when is_function(value) do
"DEFAULT"
end
defp placeholder(_), do: nil
defp text_input_type(resource, %{name: name, sensitive?: true}) do
show_sensitive_fields = AshAdmin.Resource.show_sensitive_fields(resource)
if Enum.member?(show_sensitive_fields, name) do
"text"
else
"password"
end
end
defp text_input_type(_, _), do: "text"
defp redirect_to(socket, record) do
if AshAdmin.Resource.show_action(socket.assigns.resource) do
{:noreply,
socket
|> redirect(
to:
"#{socket.assigns.prefix || "/"}?domain=#{AshAdmin.Domain.name(socket.assigns.domain)}&resource=#{AshAdmin.Resource.name(socket.assigns.resource)}&table=#{socket.assigns.table}&primary_key=#{encode_primary_key(record)}&action_type=read"
)}
else
case AshAdmin.Helpers.primary_action(socket.assigns.resource, :update) do
nil ->
{:noreply,
redirect(socket,
to:
"#{socket.assigns.prefix || "/"}?domain=#{AshAdmin.Domain.name(socket.assigns.domain)}&resource=#{AshAdmin.Resource.name(socket.assigns.resource)}"
)}
_update ->
{:noreply,
socket
|> redirect(
to:
"#{socket.assigns.prefix || "/"}?domain=#{AshAdmin.Domain.name(socket.assigns.domain)}&resource=#{AshAdmin.Resource.name(socket.assigns.resource)}&action_type=update&table=#{socket.assigns.table}&primary_key=#{encode_primary_key(record)}"
)}
end
end
end
def handle_event("union-type-changed", %{"_target" => path} = params, socket) do
new_type = get_in(params, path)
# The last part of the path in this case is the field name
path =
socket.assigns.form
|> AshPhoenix.Form.parse_path!(:lists.droplast(path))
new_union_types = (socket.assigns[:union_types] || %{}) |> Map.put(path, new_type)
if AshPhoenix.Form.has_form?(socket.assigns.form, path) do
nested_form = AshPhoenix.Form.get_form(socket.assigns.form, path)
form =
socket.assigns.form
|> AshPhoenix.Form.remove_form(path)
|> AshPhoenix.Form.add_form(path,
params: %{"_new_union_type" => new_type, "_union_type" => new_type},
type: nested_form.type
)
{:noreply, assign(socket, form: form, union_types: new_union_types)}
else
{:noreply, assign(socket, union_types: new_union_types)}
end
end
def handle_event("change_table", %{"table" => %{"table" => table}}, socket) do
case socket.assigns.action.type do
:create ->
{:noreply,
push_patch(socket,
to: self_path(socket.assigns.url_path, socket.assigns.params, %{"table" => table})
)}
:update ->
{:noreply,
push_patch(socket,
to: self_path(socket.assigns.url_path, socket.assigns.params, %{"table" => table})
)}
:destroy ->
{:noreply,
push_patch(socket,
to: self_path(socket.assigns.url_path, socket.assigns.params, %{"table" => table})
)}
end
end
def handle_event("change_table", _, socket) do
{:noreply, socket}
end
def handle_event("change_action", %{"action" => %{"action" => new_action}}, socket) do
action =
Enum.find(
Ash.Resource.Info.actions(socket.assigns.resource),
fn action ->
to_string(action.name) == new_action
end
)
{:noreply,
push_patch(socket,
to: self_path(socket.assigns.url_path, socket.assigns.params, %{"action" => action.name}),
replace: true
)}
end
def handle_event("change_action", _, socket) do
{:noreply, socket}
end
def handle_event("add_form", %{"path" => path} = params, socket) do
AshPhoenix.Form.params(socket.assigns.form)
type =
case params["type"] do
"lookup" -> :read
_ -> :create
end
params =
if params["union-type"] && params["union-type"] != "" do
path = AshPhoenix.Form.parse_path!(socket.assigns.form, path)
%{"_union_type" => socket.assigns[:union_types][path] || params["union-type"]}
else
%{}
end
form = AshPhoenix.Form.add_form(socket.assigns.form, path, type: type, params: params)
{:noreply,
socket
|> assign(:form, form)
|> allow_uploading_form_arguments()}
end
def handle_event("remove_form", %{"path" => path}, socket) do
form = AshPhoenix.Form.remove_form(socket.assigns.form, path)
{:noreply,
socket
|> assign(:form, form)}
end
def handle_event("append_value", %{"path" => path, "field" => field} = params, socket) do
to_append =
case params["union-type"] do
nil -> nil
value when value not in ["", nil] -> %{"_union_type" => value}
_ -> nil
end
list =
AshPhoenix.Form.get_form(socket.assigns.form, path)
|> AshPhoenix.Form.value(String.to_existing_atom(field))
|> List.wrap()
|> Enum.map(fn
%AshPhoenix.Form{} = form ->
AshPhoenix.Form.params(form)
other ->
other
end)
|> append_to_and_map(to_append)
params =
put_in_creating(
socket.assigns.form.source.raw_params || %{},
Enum.map(AshPhoenix.Form.parse_path!(socket.assigns.form, path) ++ [field], &to_string/1),
list
)
form = AshPhoenix.Form.validate(socket.assigns.form, params)
{:noreply,
socket
|> assign(:form, form)}
end
def handle_event("load", %{"path" => path, "relationship" => relationship}, socket) do
relationship = String.to_existing_atom(relationship)
form =
AshPhoenix.Form.update_form(
socket.assigns.form,
path,
fn adding_form ->
if adding_form.data do
new_data =
Ash.load!(adding_form.data, relationship,
actor: socket.assigns.actor,
tenant: socket.assigns.tenant,
domain: socket.assigns.domain
)
updated_form =
adding_form
|> Map.put(:data, new_data)
|> AshPhoenix.Form.validate(adding_form.raw_params, errors: false)
|> AshPhoenix.Form.update_options(fn opts ->
Keyword.update(opts, :forms, [], fn forms ->
Keyword.new(forms, fn {key, val} ->
if val[:managed_relationship] == {adding_form.resource, relationship} do
new_data =
case val[:type] do
:single -> Enum.at(List.wrap(Map.get(new_data, relationship)), 0)
_ -> new_data
end
{key, Keyword.put(val, :data, new_data)}
else
{key, val}
end
end)
end)
end)
if Map.has_key?(adding_form.source, :data) do
%{updated_form | data: new_data, source: %{adding_form.source | data: new_data}}
else
updated_form
end
else
adding_form
end
end
)
{:noreply,
socket
|> assign(:form, form)}
end
def handle_event("remove_value", %{"path" => path, "field" => field, "index" => index}, socket) do
form =
AshPhoenix.Form.update_form(
socket.assigns.form,
path,
&remove_value(&1, field, index)
)
{:noreply,
socket
|> assign(:form, form)}
end
def handle_event("save", %{"form" => form_params}, socket) do
form = socket.assigns.form
before_submit = fn changeset ->
changeset
|> set_table(socket.assigns[:table])
|> Map.put(:actor, socket.assigns[:actor])
end
socket = consume_file_uploads(socket)
params =
form_params
|> replace_new_union_stubs()
|> replace_unused()
|> add_file_uploads(socket.assigns.uploaded_files)
case AshPhoenix.Form.submit(form,
before_submit: before_submit,
force?: true,
params: params
) do
{:ok, result} ->
redirect_to(socket, result)
:ok ->
{:noreply,
socket
|> redirect(
to:
"#{socket.assigns.prefix}?domain=#{AshAdmin.Domain.name(socket.assigns.domain)}&resource=#{AshAdmin.Resource.name(socket.assigns.resource)}"
)}
{:error, form} ->
{:noreply, assign(socket, :form, form)}
end
end
def handle_event("remove_upload", %{"upload-key" => upload_key}, socket) do
{:noreply,
update(socket, :uploaded_files, fn uploaded_files ->
Map.delete(uploaded_files, upload_key)
end)}
end
def handle_event("validate", %{"form" => params} = event, socket) do
params =
params
|> replace_new_union_stubs()
|> replace_unused()
form =
AshPhoenix.Form.validate(socket.assigns.form, params,
only_touched?: true,
target: event["_target"] || []
)
{:noreply, assign(socket, form: form)}
end
defp consume_file_uploads(socket) do
uploaded_files =
socket.assigns[:uploads]
|> case do
nil -> %{}
uploads -> uploads
end
|> Enum.filter(fn {_, upload_config} ->
is_struct(upload_config, Phoenix.LiveView.UploadConfig)
end)
|> Enum.flat_map(fn {name, _} ->
consume_uploaded_entries(socket, name, fn %{path: path}, entry ->
random_string = for _ <- 1..10, into: "", do: <<Enum.random(~c"0123456789abcdef")>>
tmp_dir = Path.join([System.tmp_dir!(), random_string])
tmp_file = Path.join([tmp_dir, entry.client_name])
File.mkdir_p!(tmp_dir)
File.cp!(path, tmp_file)
{:ok, {entry.upload_config, Ash.Type.File.from_path(tmp_file)}}
end)
end)
|> Enum.into(%{})
update(socket, :uploaded_files, fn existing_files ->
Map.merge(existing_files, uploaded_files)
end)
end
defp add_file_uploads(form_params, uploaded_files) do
Enum.reduce(uploaded_files, form_params, fn {param_path, file}, params ->
update_params_with_path(params, param_path, file)
end)
end
defp update_params_with_path(params, path, value) do
path = String.trim_leading(path, "form")
path =
path
|> String.replace("[", "")
|> String.split("]")
|> Enum.reject(&(&1 == ""))
put_in(params, Enum.map(path, &Access.key(&1, %{})), value)
end
defp replace_new_union_stubs(value) when is_list(value) do
Enum.flat_map(value, fn value ->
if new_union_stub?(value) do
[]
else
[replace_new_union_stubs(value)]
end
end)
end
defp replace_new_union_stubs(params) when is_map(params) and not is_struct(params) do
params =
if Map.has_key?(params, "_new_union_type") and not Map.has_key?(params, "_union_type") do
params
|> Map.delete("_new_union_type")
|> Map.put("_union_type", params["_new_union_type"])
else
params
end
Enum.reduce(params, %{}, fn {key, value}, acc ->
if new_union_stub?(value) do
acc
else
Map.put(acc, key, replace_new_union_stubs(value))
end
end)
end
defp replace_new_union_stubs(value) do
value
end
defp replace_unused(params) when is_map(params) do
Map.to_list(params)
|> replace_unused()
|> Map.new()
end
defp replace_unused(params) when is_list(params) do
params
|> Enum.map(&replace_unused/1)
|> Enum.reject(&is_nil(&1))
end
defp replace_unused({"_unused_" <> _attr, _value}), do: nil
defp replace_unused({attribute, value}) when is_map(value),
do: {attribute, replace_unused(value)}
defp replace_unused({attribute, value}), do: {attribute, value}
defp replace_unused(value), do: value
defp new_union_stub?(value) do
is_map(value) and Map.has_key?(value, "_new_union_type") and map_size(value) == 1
end
defp put_in_creating(map, [key], value) do
Map.put(map || %{}, key, value)
end
defp put_in_creating(list, [key | rest], value) when is_list(list) do
List.update_at(list, String.to_integer(key), &put_in_creating(&1, rest, value))
end
defp put_in_creating(map, [key | rest], value) do
map
|> Kernel.||(%{})
|> Map.put_new(key, %{})
|> Map.update!(key, &put_in_creating(&1, rest, value))
end
defp remove_value(form, field, index) do
current_value =
form
|> AshPhoenix.Form.value(String.to_existing_atom(field))
|> case do
map when is_map(map) ->
map
list ->
list
|> List.wrap()
|> Enum.map(fn
%AshPhoenix.Form{} = form ->
AshPhoenix.Form.params(form)
other ->
other
end)
|> Enum.with_index()
|> Map.new(fn {value, index} ->
{to_string(index), value}
end)
end
new_value = Map.delete(current_value, index)
new_value =
if new_value == %{} do
nil
else
new_value
end
new_params = Map.put(form.raw_params, field, new_value)
AshPhoenix.Form.validate(form, new_params)
end
defp append_to_and_map(list, value) do
list
|> Enum.concat([value])
|> Enum.with_index()
|> Map.new(fn {v, i} ->
{"#{i}", v}
end)
end
def relationships(resource, action, exactly \\ nil)
def relationships(_resource, %{type: :read}, _), do: []
def relationships(resource, nil, exactly) when not is_nil(exactly) do
resource
|> Ash.Resource.Info.relationships()
|> Enum.filter(&(&1.name in exactly))
|> sort_relationships()
end
def relationships(resource, :show, _) do
resource
|> Ash.Resource.Info.relationships()
|> sort_relationships()
end
def relationships(_resource, %{type: :destroy}, _) do
[]
end
def relationships(_resource, _action, _) do
[]
end
defp sort_relationships(relationships) do
{join_assocs, regular_assocs} =
[:belongs_to, :has_one, :has_many, :many_to_many]
|> Enum.map(fn type ->
Enum.filter(relationships, &(&1.type == type))
end)
|> Enum.concat()
|> Enum.split_with(&String.ends_with?(to_string(&1.name), "join_assoc"))
regular_assocs ++ join_assocs
end
def attributes(resource, action, exactly \\ nil)
def attributes(resource, %Ash.Resource.Calculation{arguments: arguments}, _exacly) do
sort_attributes(arguments, resource)
|> relate_attributes(resource)
end
def attributes(resource, %{type: :read, arguments: arguments}, exactly)
when not is_nil(exactly) do
resource
|> Ash.Resource.Info.attributes()
|> Enum.map(&Map.put(&1, :default, nil))
|> Enum.concat(arguments)
|> Enum.filter(&(&1.name in exactly))
|> sort_attributes(resource)
|> relate_attributes(resource)
end
def attributes(resource, %{type: :read, arguments: arguments}, _) do
sort_attributes(arguments, resource)
|> relate_attributes(resource)
end
def attributes(resource, nil, exactly) when not is_nil(exactly) do
resource
|> Ash.Resource.Info.attributes()
|> Enum.filter(&(&1.name in exactly))
|> sort_attributes(resource)
|> relate_attributes(resource)
end
def attributes(resource, :show, _) do
resource
|> Ash.Resource.Info.attributes()
|> Enum.reject(&(&1.name == :autogenerated_id))
|> sort_attributes(resource)
|> relate_attributes(resource)
end
def attributes(resource, %{type: :destroy} = action, _) do
action.arguments
|> Enum.filter(& &1.public?)
|> sort_attributes(resource, action)
|> relate_attributes(resource)
end
def attributes(resource, action, _) do
arguments =
action.arguments
|> Enum.filter(& &1.public?)
attributes =
resource
|> Ash.Resource.Info.attributes()
|> Enum.filter(& &1.writable?)
|> Enum.reject(&(&1.name == :autogenerated_id))
|> only_accepted(action)
|> Enum.reject(fn attribute ->
Enum.any?(arguments, &(&1.name == attribute.name))
end)
attributes
|> Enum.concat(arguments)
|> sort_attributes(resource, action)
|> relate_attributes(resource)
end
defp sort_attributes(attributes, resource, action \\ nil) do
{relationship_args, rest} =
attributes
|> Enum.map(fn
%Ash.Resource.Actions.Argument{} = argument ->
if action do
case manages_relationship(argument, action) do
nil ->
argument
{relationship, opts} ->
{relationship, argument, opts}
end
else
argument
end
attribute ->
attribute
end)
|> Enum.split_with(&is_tuple/1)
{flags, rest} =
Enum.split_with(rest, fn attribute ->
attribute.type == Ash.Type.Boolean
end)
{defaults, rest} =
Enum.split_with(rest, fn attribute ->
Ash.Type.embedded_type?(attribute.type) ||
(not is_nil(attribute.default) && !Map.get(attribute, :primary_key?))
end)
auto_sorted =
Enum.sort_by(rest, fn attribute ->
{
# Non-primary keys go to the bottom
!Map.get(attribute, :primary_key?),
# Things with a default go at the bottom
not is_nil(attribute.default),
# Long text goes at the bottom
long_text?(resource, attribute),
# short text goes at the top
not short_text?(resource, attribute),
# Other strings go at the bottom
attribute.type in [Ash.Type.CiString, Ash.Type.String, Ash.Type.UUID]
}
end)
sorted_defaults =
Enum.sort_by(
defaults,
fn attribute ->
{!Ash.Type.embedded_type?(attribute.type), attribute.type != Ash.Type.Boolean}
end
)
{auto_sorted, flags, sorted_defaults, relationship_args}
end
defp relate_attributes({auto_sorted, flags, sorted_defaults, relationship_args}, resource) do
auto_sorted_with_relationships =
Enum.map(auto_sorted, fn
%Ash.Resource.Attribute{} = attribute ->
relationships = Ash.Resource.Info.relationships(resource)
if attribute.primary_key? do
case Enum.find(relationships, fn
%Ash.Resource.Relationships.BelongsTo{destination_attribute: dest_attr} ->
dest_attr == attribute.name
_other ->
false
end) do
%{source: source} -> Map.put(attribute, :related_resource, source)
_ -> attribute
end
else
case Enum.find(relationships, fn
%Ash.Resource.Relationships.BelongsTo{source_attribute: src_attr} ->
src_attr == attribute.name
_other ->
false
end) do
%{destination: destination} -> Map.put(attribute, :related_resource, destination)
_ -> attribute
end
end
attribute ->
attribute
end)
{auto_sorted_with_relationships, flags, sorted_defaults, relationship_args}
end
defp map_type?({:array, type}) do
map_type?(type)
end
defp map_type?(:map), do: true
defp map_type?(Ash.Type.Map), do: true
defp map_type?(type) do
if Ash.Type.embedded_type?(type) do
if is_atom(type) && :erlang.function_exported(type, :admin_map_type?, 0) do
type.admin_map_type?()
else
false
end
else
false
end
end
defp manages_relationship(argument, action) do
if Map.get(action, :changes) do
Enum.find_value(action.changes, fn
%{change: {Ash.Resource.Change.ManageRelationship, opts}} ->
if opts[:argument] == argument.name do
{opts[:relationship], opts[:opts]}
end
_ ->
false
end)
end
end
defp only_accepted(attributes, %{type: :read}), do: attributes
defp only_accepted(_, %{type: :action}), do: []
defp only_accepted(attributes, %{accept: accept}) do
Enum.filter(attributes, &(&1.name in accept))
end
defp actions(resource, type) do
action_names =
case type do
:create ->
AshAdmin.Resource.create_actions(resource)
:update ->
AshAdmin.Resource.update_actions(resource)
:destroy ->
AshAdmin.Resource.destroy_actions(resource)
end
Enum.map(action_names, &{to_name(&1), to_string(&1)})
end
defp assign_form(socket) do
transform_errors = fn
_, %{class: :forbidden} ->
{nil, "Forbidden", []}
_, other ->
if AshPhoenix.FormData.Error.impl_for(other) do
other
else
{nil, "Internal Error: " <> Exception.message(other), []}
end
end
auto_forms =
AshPhoenix.Form.Auto.auto(socket.assigns.resource, socket.assigns.action.name,
include_non_map_types?: true
)
form =
case socket.assigns.action.type do
:create ->
socket.assigns.resource
|> AshPhoenix.Form.for_create(socket.assigns.action.name,
domain: socket.assigns.domain,
actor: socket.assigns[:actor],
authorize?: socket.assigns[:authorizing],
forms: auto_forms,
transform_errors: transform_errors,
tenant: socket.assigns[:tenant]
)
:update ->
socket.assigns.record
|> AshPhoenix.Form.for_update(socket.assigns.action.name,
domain: socket.assigns.domain,
forms: auto_forms,
actor: socket.assigns[:actor],
authorize?: socket.assigns[:authorizing],
transform_errors: transform_errors,
tenant: socket.assigns[:tenant]
)
:destroy ->
socket.assigns.record
|> AshPhoenix.Form.for_destroy(socket.assigns.action.name,
domain: socket.assigns.domain,
forms: auto_forms,
actor: socket.assigns[:actor],
authorize?: socket.assigns[:authorizing],
transform_errors: transform_errors,
tenant: socket.assigns[:tenant]
)
end
assign(socket, :form, form |> to_form())
end
defp collect_forms_recursively(%AshPhoenix.Form{} = form) do
collect_recursive(form, [])
end
defp collect_recursive(form, acc) do
acc = [form | acc]
children = List.flatten(Map.values(form.forms))
Enum.reduce(children, acc, fn child, acc ->
collect_recursive(child, acc)
end)
end
defp uploadable_arguments(form) do
Enum.filter(form.source.action.arguments, fn %{type: type} -> type == Ash.Type.File end)
end
defp allow_uploading_form_arguments(socket) do
socket.assigns.form.source
|> collect_forms_recursively()
|> Enum.flat_map(fn form ->
form
|> uploadable_arguments()
|> Enum.map(fn argument ->
%{
upload_key: upload_key(form, argument),
field: AshAdmin.Resource.field(form.resource, argument.name)
}
end)
end)
|> Enum.reduce(socket, fn %{upload_key: upload_key, field: field}, socket ->
if upload_allowed?(socket, upload_key) do
socket
else
allow_upload(socket, upload_key,
accept: field[:accepted_extensions] || :any,
# 8 megabyte (SI) default
max_file_size: field[:max_file_size] || 8_000_000
)
end
end)
end
defp upload_allowed?(socket, upload_key) do
Map.get(socket.assigns, :uploads, %{})[upload_key]
end
defp upload_key(form, %Ash.Resource.Actions.Argument{name: name}) do
"#{form.name}[#{name}]"
end
defp upload_key(form, %Ash.Resource.Attribute{name: name}) do
"#{form.name}[#{name}]"
end
defp error_to_string(:too_large), do: "The file is too large"
defp error_to_string(:too_many_files), do: "You have selected too many files"
defp error_to_string(:not_accepted), do: "You have selected an unacceptable file type"
end