diff --git a/dev.exs b/dev.exs index 2cb884a..3c1519b 100644 --- a/dev.exs +++ b/dev.exs @@ -18,6 +18,7 @@ Application.put_env(:ash_admin, DemoWeb.Endpoint, ] ], live_reload: [ + iframe_attrs: [class: "hidden"], patterns: [ ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"lib/ash_admin/(components|templates/pages)/.*(ex)$" diff --git a/dev/resources/accounts/resources/user.ex b/dev/resources/accounts/resources/user.ex index d465438..2fae203 100644 --- a/dev/resources/accounts/resources/user.ex +++ b/dev/resources/accounts/resources/user.ex @@ -15,6 +15,7 @@ defmodule Demo.Accounts.User do field :first_name, type: :short_text field :last_name, type: :short_text end + table_columns [:id, :first_name, :last_name, :representative, :admin] end policies do diff --git a/dev/resources/tickets/api.ex b/dev/resources/tickets/api.ex index 206168c..e5743e2 100644 --- a/dev/resources/tickets/api.ex +++ b/dev/resources/tickets/api.ex @@ -2,12 +2,13 @@ defmodule Demo.Tickets.Api do @moduledoc false use Ash.Api - alias Demo.Tickets.{Comment, Customer, Representative, Ticket} + alias Demo.Tickets.{Comment, Customer, Representative, Ticket, TicketLink} resources do resource(Customer) resource(Representative) resource(Ticket) resource(Comment) + resource(TicketLink) end end diff --git a/dev/resources/tickets/resources/representative.ex b/dev/resources/tickets/resources/representative.ex index 8813185..26f6a0e 100644 --- a/dev/resources/tickets/resources/representative.ex +++ b/dev/resources/tickets/resources/representative.ex @@ -45,7 +45,7 @@ defmodule Demo.Tickets.Representative do update :update do primary? true - accept [:first_name, :last_name, :assigned_tickets] + accept [:first_name, :last_name] end end diff --git a/dev/resources/tickets/resources/ticket.ex b/dev/resources/tickets/resources/ticket.ex index 4e4a485..147d2fa 100644 --- a/dev/resources/tickets/resources/ticket.ex +++ b/dev/resources/tickets/resources/ticket.ex @@ -61,13 +61,47 @@ defmodule Demo.Tickets.Ticket do end create :open do - accept [:subject, :reporter] + accept [:subject] + primary? true + argument :representative, :map, allow_nil?: false + argument :tickets, {:array, :map}, allow_nil?: false + + change manage_relationship(:representative, type: :append) + change manage_relationship(:tickets, :source_links, on_lookup: {:relate_and_update, :create, :read, :all}) end update :update, primary?: true update :assign do - accept [:representative] + accept [] + argument :representative, :map + argument :reassignment_comment, :map, allow_nil?: false + + change manage_relationship(:representative, type: :append) + change manage_relationship(:reassignment_comment, :comments, type: :create) + end + + update :link do + accept [] + argument :tickets, {:array, :map}, allow_nil?: false + argument :link_comment, :map, type: :create + + # Uses the defult create action of the join table, which accepts the `type` + change manage_relationship(:tickets, :source_links, on_lookup: {:relate_and_update, :create, :read, :all}) + change manage_relationship(:link_comment, :comments, type: :create) + end + + update :nested_example do + accept [:subject] + argument :tickets, {:array, :map} + + change manage_relationship( + :tickets, + :source_links, + type: :direct_control, + on_match: {:update, :nested_example, :update, [:type]}, + on_no_match: {:create, :nested_example, :update, [:type]} + ) end destroy :destroy @@ -108,5 +142,17 @@ defmodule Demo.Tickets.Ticket do context %{data_layer: %{table: "ticket_comments"}} destination_field :resource_id end + + many_to_many :source_links, Demo.Tickets.Ticket do + through Demo.Tickets.TicketLink + source_field_on_join_table :source_id + destination_field_on_join_table :destination_id + end + + many_to_many :destination_links, Demo.Tickets.Ticket do + through Demo.Tickets.TicketLink + source_field_on_join_table :destination_id + destination_field_on_join_table :source_id + end end end diff --git a/dev/resources/tickets/resources/ticket_link.ex b/dev/resources/tickets/resources/ticket_link.ex new file mode 100644 index 0000000..ce68617 --- /dev/null +++ b/dev/resources/tickets/resources/ticket_link.ex @@ -0,0 +1,27 @@ +defmodule Demo.Tickets.TicketLink do + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + postgres do + table "ticket_links" + repo Demo.Repo + end + + attributes do + attribute :type, :atom, constraints: [ + one_of: [:causes, :caused_by, :fixes, :fixed_by] + ], allow_nil?: false + end + + relationships do + belongs_to :source, Demo.Tickets.Ticket do + primary_key? true + required? true + end + + belongs_to :destination, Demo.Tickets.Ticket do + primary_key? true + required? true + end + end +end diff --git a/lib/ash_admin/components/resource/form.ex b/lib/ash_admin/components/resource/form.ex index fae3351..7c0de8e 100644 --- a/lib/ash_admin/components/resource/form.ex +++ b/lib/ash_admin/components/resource/form.ex @@ -10,7 +10,6 @@ defmodule AshAdmin.Components.Resource.Form do Checkbox, ErrorTag, FieldContext, - HiddenInput, HiddenInputs, Inputs, Label, @@ -173,7 +172,7 @@ defmodule AshAdmin.Components.Resource.Form do {{ render_attribute_input(assigns, attribute, form) }} - + @@ -194,7 +193,7 @@ defmodule AshAdmin.Components.Resource.Form do {{ render_attribute_input(assigns, attribute, form) }} - + @@ -215,15 +214,14 @@ defmodule AshAdmin.Components.Resource.Form do {{ render_attribute_input(assigns, attribute, form) }} - +
- {{ render_relationship_input(assigns, Ash.Resource.Info.relationship(form.source.resource, relationship), form, argument.name, relationship_path <> "[#{relationship}]", opts) }} - + {{ render_relationship_input(assigns, Ash.Resource.Info.relationship(form.source.resource, relationship), form, argument, relationship_path <> "[#{relationship}]", opts) }}
@@ -232,70 +230,9 @@ defmodule AshAdmin.Components.Resource.Form do defp render_relationship_input( assigns, - %{cardinality: :one} = relationship, + relationship, form, - as, - relationship_path, - opts - ) do - ~H""" -
- "[#{as}]" }} - > - - -
- {{ render_attributes( - assigns, - relationship.destination, - new_form.source.action, - new_form, - fields, - [], - relationship_path - ) }} -
-
- - -
- """ - end - - defp render_relationship_input( - assigns, - %{cardinality: :many} = relationship, - form, - as, + %{type: {:array, _}} = argument, relationship_path, opts ) do @@ -303,59 +240,60 @@ defmodule AshAdmin.Components.Resource.Form do
"[#{as}]" }} + opts={{ form_opts(form, opts, argument.name, relationship, @actor) }} > -
- - {{ render_attributes( - assigns, - relationship.destination, - new_form.source.action, - new_form, - fields, - [], - relationship_path - ) }} +
+
+ {{ render_attributes( + assigns, + relationship.destination, + inner_form.source.action || :_lookup, + maybe_clear_errors(inner_form), # We clear errors from lookup forms + relationship_fields(inner_form, field_limit), + skip_related(relationship, is_nil(inner_form.source.action)), + relationship_path + ) }} +
+ + -
+
+
+ {{ render_attributes( + assigns, + relationship.destination, + inner_form.source.action || :_lookup, + maybe_clear_errors(inner_form), # We clear errors from lookup forms + relationship_fields(inner_form, type), + skip_related(relationship, is_nil(inner_form.source.action)), + relationship_path + ) }} +
+
+ + + +
+ """ + end - form.source.action_type == :update -> - relationship_forms_for_update(form, relationship, opts, actor) + defp relationship_forms(form, inner_form, relationship, opts, actor) do + cond do + is_nil(inner_form.source.action) -> + with_lookup_forms = + [{inner_form, nil, relationship}] ++ + lookup_forms(form, inner_form, opts, relationship, actor) - form.source.action_type == :create -> - relationship_forms_for_create(form, relationship, opts, actor) + case inner_form.source.action_type do + :create -> + with_lookup_forms ++ + create_forms(form, inner_form, opts, relationship, actor) + + :update -> + with_lookup_forms ++ + update_forms(form, inner_form, opts, relationship, actor) + + :destroy -> + with_lookup_forms ++ + destroy_forms(form, inner_form, opts, relationship, actor) + end + + inner_form.source.action_type == :update -> + [{inner_form, nil, relationship}] ++ + update_forms(form, inner_form, opts, relationship, actor) + + inner_form.source.action_type == :destroy -> + [{inner_form, nil, relationship}] ++ + destroy_forms(form, inner_form, opts, relationship, actor) + + true -> + [{inner_form, nil, relationship}] ++ + create_forms(form, inner_form, opts, relationship, actor) + end + end + + defp lookup_forms(form, inner_form, opts, relationship, actor) do + opts + |> Ash.Changeset.ManagedRelationshipHelpers.on_lookup_update_action(relationship) + |> action_form(form, inner_form, relationship, actor) + |> List.wrap() + end + + defp update_forms(form, inner_form, opts, relationship, actor) do + opts + |> Ash.Changeset.ManagedRelationshipHelpers.on_match_destination_actions(relationship) + |> Kernel.||([]) + |> drop_destination_form() + |> Enum.map(&action_form(&1, form, inner_form, relationship, actor)) + end + + defp create_forms(form, inner_form, opts, relationship, actor) do + opts + |> Ash.Changeset.ManagedRelationshipHelpers.on_no_match_destination_actions(relationship) + |> Kernel.||([]) + |> drop_destination_form() + |> Enum.map(&action_form(&1, form, inner_form, relationship, actor)) + end + + defp destroy_forms(form, inner_form, opts, relationship, actor) do + opts + |> Ash.Changeset.ManagedRelationshipHelpers.on_missing_destination_actions(relationship) + |> drop_destination_form() + |> Enum.map(&action_form(&1, form, inner_form, relationship, actor)) + end + + defp drop_destination_form([{:destination, _} | rest]), do: rest + defp drop_destination_form(other), do: other + + defp action_form(nil, _, _, _, _), do: nil + + defp action_form({:source, action_name}, form, inner_form, relationship, actor) do + new_inner_form = + form.source.data + |> Ash.Changeset.for_update(action_name, inner_form.params, actor: actor) + |> retain_hiding_errors(inner_form.source) + |> Phoenix.HTML.FormData.to_form(as: inner_form.name) + + {new_inner_form, nil, relationship} + end + + defp action_form({:destination, action_name}, _form, inner_form, relationship, actor) do + new_inner_form = + inner_form.data + |> Ash.Changeset.for_update(action_name, inner_form.params, actor: actor) + |> retain_hiding_errors(inner_form.source) + |> Phoenix.HTML.FormData.to_form(as: inner_form.name) + + {new_inner_form, nil, relationship} + end + + defp action_form({:join, action_name, keys}, form, inner_form, relationship, actor) do + limit = + if keys == :all do + nil + else + keys end - List.wrap(forms) - end + new_inner_form = + if inner_form.source.action_type == :update do + value = find_join(form.source.data, inner_form.source.data, relationship) - defp relationship_forms_for_lookup(form, relationship, opts, actor) do - case opts[:on_lookup] do - {key, create, read, fields} when relationship.type == :many_to_many -> - query = - relationship.destination - |> Ash.Query.for_read(read, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - changeset = - relationship.through - |> Ash.Changeset.for_create(create, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - [{query, Ash.Resource.Info.primary_key(relationship.destination)}, {changeset, fields}] ++ - lookup_update(key, form, relationship, opts, actor) - - {key, _update, read} -> - query = - relationship.destination - |> Ash.Query.for_read(read, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - [{query, Ash.Resource.Info.primary_key(relationship.destination)}] ++ - lookup_update(key, form, relationship, opts, actor) - - {key, create, read, fields} -> - query = - relationship.destination - |> Ash.Query.for_read(read, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - changeset = - relationship.through - |> Ash.Changeset.for_create(create, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - [{query, Ash.Resource.Info.primary_key(relationship.destination)}, {changeset, fields}] ++ - lookup_update(key, form, relationship, opts, actor) - end - end - - defp lookup_update(:relate_and_update, form, relationship, opts, actor) do - relationship_forms_for_update(form, relationship, opts, actor) - end - - defp lookup_update(_, _, _, _, _), do: [] - - defp relationship_forms_for_create(form, relationship, opts, actor) do - case opts[:on_no_match] do - {:create, action_name, join_action_name, fields} -> - join_form = - relationship.through - |> Ash.Changeset.for_create(join_action_name, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - destination_form = - relationship.destination - |> Ash.Changeset.for_create(action_name, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - [{destination_form, nil}, {join_form, fields}] - - {:create, action_name} -> - destination_form = - relationship.destination - |> Ash.Changeset.for_create(action_name, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - [{destination_form, nil}] - - :error -> - [] - end - end - - defp relationship_forms_for_update(form, relationship, opts, actor) do - case opts[:on_match] do - {:update, update, join_update, fields} -> - join_form = - form.source.data - |> Ash.Changeset.for_update(update, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - destination_form = + if value do + value + |> Ash.Changeset.for_update(action_name, inner_form.params, actor: actor) + |> retain_hiding_errors(inner_form.source) + |> Phoenix.HTML.FormData.to_form(as: inner_form.name) + else relationship.through.__struct__ - |> Ash.Changeset.for_update(join_update, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - [{destination_form, nil}, {join_form, fields}] - - {:update, action_name} -> - destination_form = - form.source.data - |> Ash.Changeset.for_update(action_name, form.source.params, actor: actor) - |> Phoenix.HTML.FormData.to_form(as: form.name) - - [{destination_form, nil}] - - {:unrelate, _action} -> - changeset = - form.source.data |> Ash.Changeset.new() - |> Map.put(:params, form.source.params) - |> Phoenix.HTML.FormData.to_form(as: form.name) + |> retain_hiding_errors(inner_form.source) + |> Map.put(:params, inner_form.params) + end + else + relationship.through + |> Ash.Changeset.for_create(action_name, inner_form.params, actor: actor) + |> retain_hiding_errors(inner_form.source) + |> Phoenix.HTML.FormData.to_form(as: inner_form.name) + end - {changeset, Ash.Resource.Info.primary_key(relationship.destination)} + {new_inner_form, limit, + Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship)} + end - value when value in [:ignore, :error] -> - changeset = - form.source.data - |> Ash.Changeset.new() - |> Map.put(:params, form.source.params) - |> Phoenix.HTML.FormData.to_form(as: form.name) + defp retain_hiding_errors(changeset, source_changeset) do + if AshPhoenix.hiding_errors?(source_changeset) do + AshPhoenix.hide_errors(changeset) + else + changeset + end + end - {changeset, Ash.Resource.Info.primary_key(relationship.destination)} + defp find_join(source, destination, relationship) do + case Map.get(source, relationship.join_relationship) do + %Ash.NotLoaded{} -> + source.__struct__.__struct__ + + related -> + related + |> List.wrap() + |> Enum.find( + source.__struct__.__struct__, + fn candidate -> + Map.get(candidate, relationship.destination_field_on_join_table) == + Map.get(destination, relationship.destination_field) + end + ) + end + end + + defp maybe_clear_errors(form) do + if form.source.action do + form + else + %{form | source: AshPhoenix.hide_errors(form.source), errors: []} + end + end + + defp relationship_fields(inner_form, limit) do + if is_nil(inner_form.source.action) do + Ash.Resource.Info.primary_key(inner_form.source.resource) + else + limit + end + end + + defp create_action(opts, relationship) do + case Ash.Changeset.ManagedRelationshipHelpers.on_no_match_destination_actions( + opts, + relationship + ) do + [{:destination, action} | _rest] -> + # do something with rest here + action _ -> - relationship_forms_for_create(form, relationship, opts, actor) + :_raw end end + defp update_action(opts, relationship) do + case Ash.Changeset.ManagedRelationshipHelpers.on_match_destination_actions(opts, relationship) do + [{:destination, action} | _rest] -> + # do something with rest here + action + + _ -> + :_raw + end + end + + defp form_opts(form, opts, as, relationship, actor) do + [ + use_data?: use_data?(opts), + as: form.name <> "[#{as}]", + create_action: create_action(opts, relationship), + update_action: update_action(opts, relationship), + actor: actor + ] + end + + defp use_data?(opts) do + Ash.Changeset.ManagedRelationshipHelpers.must_load?(opts) + end + + defp skip_related(_, true) do + [] + end + + defp skip_related(relationship, _) do + if relationship.type == :belongs_to do + [] + else + [relationship.destination_field] + end + end + + # defp relationship_forms(form, relationship, opts, actor) do + # forms = + # cond do + # form.source.action_type == :destroy -> + # form.source.action_type == :update -> + # relationship_forms_for_update(form, relationship, opts, actor) + + # form.source.action_type == :create -> + # relationship_forms_for_create(form, relationship, opts, actor) + # end + + # List.wrap(forms) + # end + + # defp relationship_forms_for_lookup(form, relationship, opts, actor) do + # case opts[:on_lookup] do + # {key, create, read, fields} when relationship.type == :many_to_many -> + # query = + # relationship.destination + # |> Ash.Query.for_read(read, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # changeset = + # relationship.through + # |> Ash.Changeset.for_create(create, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # [{query, Ash.Resource.Info.primary_key(relationship.destination)}, {changeset, fields}] ++ + # lookup_update(key, form, relationship, opts, actor) + + # {key, _update, read} -> + # query = + # relationship.destination + # |> Ash.Query.for_read(read, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # [{query, Ash.Resource.Info.primary_key(relationship.destination)}] ++ + # lookup_update(key, form, relationship, opts, actor) + + # {key, create, read, fields} -> + # query = + # relationship.destination + # |> Ash.Query.for_read(read, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # changeset = + # relationship.through + # |> Ash.Changeset.for_create(create, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # [{query, Ash.Resource.Info.primary_key(relationship.destination)}, {changeset, fields}] ++ + # lookup_update(key, form, relationship, opts, actor) + # end + # end + + # defp lookup_update(:relate_and_update, form, relationship, opts, actor) do + # relationship_forms_for_update(form, relationship, opts, actor) + # end + + # defp lookup_update(_, _, _, _, _), do: [] + + # defp relationship_forms_for_create(form, relationship, opts, actor) do + # case opts[:on_no_match] do + # {:create, action_name, join_action_name, fields} -> + # join_form = + # relationship.through + # |> Ash.Changeset.for_create(join_action_name, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # destination_form = + # relationship.destination + # |> Ash.Changeset.for_create(action_name, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # [{destination_form, nil}, {join_form, fields}] + + # {:create, action_name} -> + # destination_form = + # relationship.destination + # |> Ash.Changeset.for_create(action_name, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # [{destination_form, nil}] + + # :error -> + # [] + # end + # end + + # defp relationship_forms_for_update(form, relationship, opts, actor) do + # case opts[:on_match] do + # {:update, update, join_update, fields} -> + # join_form = + # form.source.data + # |> Ash.Changeset.for_update(update, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # destination_form = + # relationship.through.__struct__ + # |> Ash.Changeset.for_update(join_update, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # [{destination_form, nil}, {join_form, fields}] + + # {:update, action_name} -> + # destination_form = + # form.source.data + # |> Ash.Changeset.for_update(action_name, form.source.params, actor: actor) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # [{destination_form, nil}] + + # {:unrelate, _action} -> + # changeset = + # form.source.data + # |> Ash.Changeset.new() + # |> Map.put(:params, form.source.params) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # {changeset, Ash.Resource.Info.primary_key(relationship.destination)} + + # value when value in [:ignore, :error] -> + # changeset = + # form.source.data + # |> Ash.Changeset.new() + # |> Map.put(:params, form.source.params) + # |> Phoenix.HTML.FormData.to_form(as: form.name) + + # {changeset, Ash.Resource.Info.primary_key(relationship.destination)} + + # _ -> + # relationship_forms_for_create(form, relationship, opts, actor) + # end + # end + defp needs_to_load?(opts) do Ash.Changeset.ManagedRelationshipHelpers.must_load?(opts) end @@ -541,7 +758,10 @@ defmodule AshAdmin.Components.Resource.Form do manage not in [[], nil] end end) - |> Kernel.||(Map.get(changeset.data, relationship) not in [nil, []]) + end + + defp can_remove_related?(opts) do + Ash.Changeset.ManagedRelationshipHelpers.could_handle_missing?(opts) end defp could_lookup?(opts) do @@ -599,7 +819,7 @@ defmodule AshAdmin.Components.Resource.Form do form ) do ~H""" - """ end @@ -619,7 +839,8 @@ defmodule AshAdmin.Components.Resource.Form do