defmodule AshPhoenix.Form.Auto do @auto_opts [ relationship_fetcher: [ type: :any, doc: """ A two argument function that receives the parent data, the relationship to fetch. The default simply fetches the relationship value, and if it isn't loaded, it uses `[]` or `nil`. """ ], sparse_lists?: [ type: :boolean, doc: "Sets all list type forms to `sparse?: true` by default. Has no effect on forms derived for embedded resources.", default: false ], include_non_map_types?: [ type: :boolean, doc: "Creates form for non map or array of map type inputs", default: false ] ] @moduledoc """ A tool to automatically generate available nested forms based on a resource and action. To use this, specify `forms: [auto?: true]` when creating the form. Keep in mind, you can always specify these manually when creating a form by simply specifying the `forms` option. There are two things that this builds forms for: 1. Attributes/arguments who's type is an embedded resource. 2. Arguments that have a corresponding `change manage_relationship(..)` configured. For more on relationships see the documentation for `Ash.Changeset.manage_relationship/4`. When building forms, you can switch on the action type and/or resource of the form, in order to have different fields depending on the form. For example, if you have a simple relationship called `:comments` with `on_match: :update` and `on_no_match: :create`, there are two types of forms that can be in `inputs_for(form, :comments)`. In which case you may have something like this: ```elixir <%= for comment_form <- inputs_for(f, :comments) do %> <%= hidden_inputs_for(comment_form) %> <%= if comment_form.source.type == :create do %> <%= text_input comment_form, :text %> <%= text_input comment_form, :on_create_field %> <% else %> <%= text_input comment_form, :text %> <%= text_input comment_form, :on_update_field %> <% end %> <% end %> ``` This also applies to adding forms of different types manually. For instance, if you had a "search" field to allow them to search for a record (e.g in a liveview), and you had an `on_lookup` read action, you could render a search form for that read action, and once they've selected a record, you could render the fields to update that record (in the case of `on_lookup: :relate_and_update` configurations). ## Options #{Spark.Options.docs(@auto_opts)} ## Special Considerations ### `on_lookup: :relate_and_update` For `on_lookup: :relate_and_update` configurations, the "read" form for that relationship will use the appropriate read action. However, you may also want to include the relevant fields for the update that would subsequently occur. To that end, a special nested form called `:_update` is created, that uses an empty instance of that resource as the base of its changeset. This may require some manual manipulation of that data before rendering the relevant form because it assumes all the default values. To solve for this, if you are using liveview, you could actually look up the record using the input from the read action, and then use `AshPhoenix.Form.update_form/3` to set that looked up record as the data of the `_update` form. ### Many to Many Relationships In the case that a manage_change option points to a join relationship, that form is presented via a special nested form called `_join`. So the first form in `inputs_for(form, :relationship)` would be for the destination, and then inside of that you could say `inputs_for(nested_form, :_join)`. The parameters are merged together during submission. """ @dialyzer {:nowarn_function, rel_to_resource: 2} def auto(resource, action, opts \\ []) do opts = Spark.Options.validate!(opts, @auto_opts) Keyword.new( related(resource, action, opts) ++ embedded(resource, action, opts) ++ unions(resource, action, opts) ) end def unions(resource, action, auto_opts) do action = if is_atom(action) do Ash.Resource.Info.action(resource, action) else action end resource |> accepted_attributes(action) |> Enum.concat(action.arguments) |> Enum.filter(&union?(&1.type)) |> Enum.reject(&match?({:array, {:array, _}}, &1.type)) |> Enum.map(fn attr -> {type, constraints} = if Ash.Type.NewType.new_type?(attr.type) do {Ash.Type.NewType.subtype_of(attr.type), Ash.Type.NewType.constraints(attr.type, attr.constraints)} else {attr.type, attr.constraints} end form_type = case type do {:array, _} -> :list _ -> :single end constraints = unwrap_union(type, constraints) data = case form_type do :list -> fn parent -> if parent do Map.get(parent, attr.name) || [] else [] end end :single -> fn parent -> if parent do case Map.get(parent, attr.name) do [value | _] -> value [] -> nil value -> value end end end end updater = fn opts, data, params -> if is_nil(data) and is_nil(params) do opts else {type_name, type, constraints, tag, tag_value} = determine_type(constraints, data, params) {embed, constraints, fake_embedded?} = if Ash.Type.embedded_type?(type) do {type, constraints, false} else {AshPhoenix.Form.WrappedValue, [], true} end prepare_source = if fake_embedded? do fn source -> case source do %Ash.Changeset{} -> Ash.Changeset.set_context(source, %{type: type, constraints: constraints}) %Ash.Query{} -> Ash.Query.set_context(source, %{type: type, constraints: constraints}) end end end transform_params = if fake_embedded? do union_param_transformer(type_name) else fn _form, params, _type -> set_tag_value(params, tag, tag_value) end end create_action = if constraints[:create_action] do Ash.Resource.Info.action(embed, constraints[:create_action]) else Ash.Resource.Info.primary_action(embed, :create) end update_action = if constraints[:update_action] do Ash.Resource.Info.action(embed, constraints[:update_action]) else Ash.Resource.Info.primary_action(embed, :update) end Keyword.merge(opts, resource: embed, create_action: create_action.name, update_action: update_action.name, prepare_source: prepare_source, transform_params: transform_params, embed?: true, save_updates?: false, forms: Keyword.new( embedded(embed, create_action, auto_opts) ++ embedded(embed, update_action, auto_opts) ++ unions(embed, create_action, auto_opts) ++ unions(embed, update_action, auto_opts) ) ) end end {attr.name, [ data: data, type: form_type, updater: updater, save_updates?: false ]} end) |> Keyword.new() end defp set_tag_value(params, tag, tag_value) do if tag do Map.put(params, to_string(tag), tag_value) else params end end defp determine_type(constraints, _data, %{"_union_type" => union_type} = params) do constraints[:types] |> Enum.find(fn {key, _value} -> to_string(key) == union_type end) |> case do nil -> raise """ Got "_union_type" parameter of #{inspect(union_type)}, but no type with that name was found in the constraints. Params: #{inspect(params, pretty: true)} Available types: #{inspect(constraints[:types], pretty: true)} """ {key, config} -> {key, config[:type], config[:constraints], config[:tag], config[:tag_value]} end end defp determine_type(constraints, %Ash.Union{type: type}, _params) do config = constraints[:types][type] {type, config[:type], config[:constraints], config[:tag], config[:tag_value]} end defp determine_type(constraints, _, %Ash.Union{type: type}) do config = constraints[:types][type] {type, config[:type], config[:constraints], config[:tag], config[:tag_value]} end defp determine_type(constraints, %AshPhoenix.Form.WrappedValue{value: nil}, params) when params == %{} do determine_type(constraints, nil, params) end defp determine_type( constraints, %AshPhoenix.Form.WrappedValue{value: %Ash.Union{} = union}, params ) do determine_type(constraints, union, params) end defp determine_type(constraints, nil, params) when params == %{} do {key, config} = Enum.at(constraints[:types], 0) {key, config[:type], config[:constraints], config[:tag], config[:tag_value]} end defp determine_type(constraints, data, params) do constraints[:types] |> Enum.find(fn {key, config} -> config[:tag] && (tags_equal(config, key, params) || tags_equal_data(config, key, data)) end) |> case do nil -> raise """ Got no "_union_type" parameter, and no union type had a tag & tag_value pair matching the params. If you are adding a form, select a type using `params: %{"_union_type" => "type_name"}`, or if one or more of your types is using a tag you can set that tag with `params: %{"tag" => "tag_value"}`. Params: #{inspect(params, pretty: true)} Available types: #{inspect(constraints[:types], pretty: true)} """ {key, config} -> {key, config[:type], config[:constraints], config[:tag], config[:tag_value]} end end @doc false def union_param_transformer(type_name) do fn form, params, validation_type -> if validation_type == :nested do %Ash.Union{type: type_name, value: AshPhoenix.Form.value(form, :value)} else params end end end defp tags_equal(config, key, params) do if is_map(params) do case config[:tag_value] || key do value when is_atom(value) -> params[to_string(config[:tag])] == to_string(value) || params[to_string(config[:tag])] == value value -> params[to_string(config[:tag])] == value end else false end end defp tags_equal_data(config, key, data) do if is_struct(data) do case config[:tag_value] || key do value when is_atom(value) -> Map.get(data, config[:tag]) == to_string(value) || Map.get(data, config[:tag]) == value value -> Map.get(data, config[:tag]) == value end else false end end def related(resource, action, auto_opts) do passed_in_action = action action = if is_atom(action) do Ash.Resource.Info.action(resource, action) else action end if is_nil(action) && is_atom(passed_in_action) do raise "No such action :#{passed_in_action} for #{inspect(resource)}" end action.arguments |> Enum.reject(&(!&1.public?)) |> exclude_non_map_types(auto_opts) |> Enum.flat_map(fn arg -> case find_manage_change(arg, action) do nil -> [] manage_opts -> [{arg, manage_opts}] end end) |> Enum.map(fn {arg, manage_opts} -> relationship = Ash.Resource.Info.relationship(resource, manage_opts[:relationship]) manage_opts = manage_opts[:opts] defaults = if manage_opts[:type] do Ash.Changeset.manage_relationship_opts(manage_opts[:type]) else [] end manage_opts = Ash.Changeset.ManagedRelationshipHelpers.sanitize_opts( relationship, Keyword.merge(defaults, manage_opts) ) type = case arg.type do {:array, _} -> :list _ -> :single end must_load_opts = [ type: relationship.type, action_type: action.type, could_be_related_at_creation?: Map.get(relationship, :could_be_related_at_creation?, false) ] opts = [ type: type, forms: [], sparse?: auto_opts[:sparse_lists?], managed_relationship: {relationship.source, relationship.name, manage_opts}, must_load?: Ash.Changeset.ManagedRelationshipHelpers.must_load?(manage_opts, must_load_opts), updater: fn opts -> opts = opts |> add_create_action(manage_opts, relationship, auto_opts) |> add_read_action(manage_opts, relationship, auto_opts) |> add_update_action(manage_opts, relationship, auto_opts) |> add_destroy_action(manage_opts, relationship, auto_opts) |> add_nested_forms(auto_opts) if opts[:read_action] || opts[:update_action] || opts[:destroy_action] do Keyword.put( opts, :data, relationship_fetcher(relationship, auto_opts[:relationship_fetcher], opts[:type]) ) else opts end end ] opts = if map_type?(arg.type) do opts else key = manage_opts[:value_is_key] || relationship.destination |> Ash.Resource.Info.primary_key() |> case do [key] -> key _ -> nil end if key do opts |> Keyword.put(:forms, []) |> Keyword.put(:transform_params, fn form, params, type -> if type == :nested do AshPhoenix.Form.value(form, key) else params end end) else Keyword.put(opts, :forms, []) end end {arg.name, opts} end) |> Keyword.new() end defp exclude_non_map_types(args, opts) do if opts[:include_non_map_types?] do args else Enum.filter(args, fn %{type: type} -> map_type?(type) end) end 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 add_nested_forms(opts, auto_opts) do Keyword.update!(opts, :forms, fn forms -> forms = if forms[:update_action] do forms ++ set_for_type(auto(opts[:resource], opts[:update_action], auto_opts), :update) else forms end forms = if forms[:create_action] do forms ++ set_for_type(auto(opts[:resource], opts[:create_action], auto_opts), :create) else forms end forms = if forms[:destroy_action] do forms ++ set_for_type(auto(opts[:resource], opts[:destroy_action], auto_opts), :destroy) else forms end if forms[:read_action] do forms ++ set_for_type(auto(opts[:resource], opts[:read_action], auto_opts), :read) else forms end end) end defp set_for_type(forms, type) do Enum.map(forms, fn {key, value} -> {key, Keyword.put(value, :for_type, type)} end) end defp add_read_action(opts, manage_opts, relationship, auto_opts) do manage_opts |> Ash.Changeset.ManagedRelationshipHelpers.on_lookup_read_action(relationship) |> case do nil -> opts {source_dest_or_join, action_name} -> resource = rel_to_resource(source_dest_or_join, relationship) opts |> Keyword.put(:read_resource, resource) |> Keyword.put(:read_action, action_name) |> Keyword.update!( :forms, fn forms -> case Ash.Changeset.ManagedRelationshipHelpers.on_lookup_update_action( manage_opts, relationship ) do nil -> forms ++ auto(resource, action_name, auto_opts) {source_dest_or_join, update_action} -> resource = rel_to_resource(source_dest_or_join, relationship) forms ++ auto(resource, action_name, auto_opts) ++ [ {:_update, [ resource: resource, managed_relationship: {relationship.source, relationship.name, manage_opts}, type: :single, data: resource.__struct__(), update_action: update_action ]} ] {:join, update_action, _} -> resource = relationship.through forms ++ auto(resource, action_name, auto_opts) ++ [ {:_update, [ resource: resource, managed_relationship: {relationship.source, relationship.name, manage_opts}, type: :single, data: resource.__struct__(), update_action: update_action ]} ] end end ) end end defp add_create_action(opts, manage_opts, relationship, auto_opts) do manage_opts |> Ash.Changeset.ManagedRelationshipHelpers.on_no_match_destination_actions(relationship) |> List.wrap() |> Enum.sort_by(&(elem(&1, 0) == :join)) |> case do [] -> opts [{source_dest_or_join, action_name} | rest] -> resource = rel_to_resource(source_dest_or_join, relationship) opts |> Keyword.put(:create_resource, resource) |> Keyword.put(:create_action, action_name) |> Keyword.update!( :forms, &(&1 ++ auto(resource, action_name, auto_opts)) ) |> add_join_form(relationship, rest, manage_opts) end end defp add_update_action(opts, manage_opts, relationship, auto_opts) do manage_opts |> Ash.Changeset.ManagedRelationshipHelpers.on_match_destination_actions(relationship) |> List.wrap() |> Enum.sort_by(&(elem(&1, 0) == :join)) |> case do [] -> opts [{source_dest_or_join, action_name, _} | rest] -> resource = rel_to_resource(source_dest_or_join, relationship) opts |> Keyword.put(:update_resource, resource) |> Keyword.put(:update_action, action_name) |> Keyword.update!( :forms, &(&1 ++ auto(resource, action_name, auto_opts)) ) |> add_join_form(relationship, rest, manage_opts) [{source_dest_or_join, action_name} | rest] -> resource = rel_to_resource(source_dest_or_join, relationship) opts |> Keyword.put(:update_resource, resource) |> Keyword.put(:update_action, action_name) |> Keyword.update!( :forms, &(&1 ++ auto(resource, action_name, auto_opts)) ) |> add_join_form(relationship, rest, manage_opts) end end defp add_destroy_action(opts, manage_opts, relationship, auto_opts) do manage_opts |> Ash.Changeset.ManagedRelationshipHelpers.on_missing_destination_actions(relationship) |> List.wrap() |> Enum.sort_by(&(elem(&1, 0) == :join)) |> case do [] -> opts [{:join, _action_name, _fields} = join_action] -> add_join_form(opts, relationship, [join_action], manage_opts) [{source_dest_or_join, action_name} | rest] -> resource = rel_to_resource(source_dest_or_join, relationship) opts |> Keyword.put(:destroy_resource, resource) |> Keyword.put(:destroy_action, action_name) |> Keyword.update!( :forms, &(&1 ++ auto(resource, action_name, auto_opts)) ) |> add_join_form(relationship, rest, manage_opts) end end defp add_join_form(opts, _relationship, [], _), do: opts defp add_join_form(opts, relationship, [{:join, action, fields}], manage_opts) do action = Ash.Resource.Info.action(relationship.through, action) case action.type do :update -> Keyword.update!(opts, :forms, fn forms -> update_join_forms(forms, resource: relationship.through, managed_relationship: {relationship.source, relationship.name, manage_opts}, type: :single, data: &get_join(&1, &2, relationship), update_fields: fields, merge?: true, update_action: action.name ) end) :create -> Keyword.update!(opts, :forms, fn forms -> update_join_forms(forms, resource: relationship.through, type: :single, managed_relationship: {relationship.source, relationship.name, manage_opts}, create_fields: fields, merge?: true, create_action: action.name ) end) :destroy -> Keyword.update!(opts, :forms, fn forms -> update_join_forms(forms, resource: relationship.through, managed_relationship: {relationship.source, relationship.name, manage_opts}, type: :single, data: &get_join(&1, &2, relationship), destroy_fields: fields, destroy_action: action.name, merge?: true ) end) end end defp update_join_forms(forms, config) do Keyword.update(forms, :_join, config, fn existing_config -> Keyword.merge(existing_config, config) end) end defp get_join(parent, prev_path, relationship) do case Enum.find(prev_path, &(&1.__struct__ == relationship.source)) do nil -> nil root -> case Map.get(root, relationship.join_relationship) do value when is_list(value) -> Enum.find(value, fn join -> Map.get(join, relationship.destination_attribute_on_join_resource) == Map.get(parent, relationship.destination_attribute) end) _ -> nil end end end defp unwrap_union({:array, type}, constraints) do unwrap_union(type, constraints[:items] || []) end defp unwrap_union(_type, constraints) do constraints end defp relationship_fetcher(relationship, relationship_fetcher, type) do fn parent -> if relationship_fetcher do relationship_fetcher.(parent, relationship) else case Map.get(parent, relationship.name) do %Ash.NotLoaded{} -> if type == :single do nil else [] end value -> if type == :single && is_list(value) do Enum.at(value, 0) else value end end end end end defp rel_to_resource(source_dest_or_join, relationship) do case source_dest_or_join do :source -> relationship.source :destination -> relationship.destination :join -> relationship.through end end def embedded(resource, action, auto_opts) do action = if is_atom(action) do Ash.Resource.Info.action(resource, action) else action end resource |> accepted_attributes(action) |> Enum.concat(action.arguments) |> Enum.filter(&Ash.Type.embedded_type?(&1.type)) |> Enum.reject(&match?({:array, {:array, _}}, &1.type)) |> Enum.map(fn attr -> type = case attr.type do {:array, _} -> :list _ -> :single end embed = unwrap_type(attr.type) data = case type do :list -> fn parent -> if parent do List.wrap(Map.get(parent, attr.name)) else [] end end :single -> fn parent -> if parent do case Map.get(parent, attr.name) do [value | _] -> value [] -> nil value -> value end end end end create_action = if attr.constraints[:create_action] do Ash.Resource.Info.action(embed, attr.constraints[:create_action]) else Ash.Resource.Info.primary_action(embed, :create) end update_action = if attr.constraints[:update_action] do Ash.Resource.Info.action(embed, attr.constraints[:update_action]) else Ash.Resource.Info.primary_action(embed, :update) end {attr.name, [ type: type, resource: embed, create_action: create_action && create_action.name, update_action: update_action && update_action.name, embed?: true, data: data, forms: [], updater: fn opts -> Keyword.update!(opts, :forms, fn forms -> forms ++ List.wrap(create_action && embedded(embed, create_action, auto_opts)) ++ List.wrap(update_action && embedded(embed, update_action, auto_opts)) ++ List.wrap(create_action && unions(embed, create_action, auto_opts)) ++ List.wrap(update_action && unions(embed, update_action, auto_opts)) end) end ]} end) |> Keyword.new() end defp union?({:array, type}), do: union?(type) defp union?(type) do if Ash.Type.NewType.new_type?(type) do union?(Ash.Type.NewType.subtype_of(type)) else type == Ash.Type.Union end end defp unwrap_type({:array, type}), do: unwrap_type(type) defp unwrap_type(type), do: type @doc false def accepted_attributes(resource, action) do inputs = Ash.Resource.Info.action_inputs(resource, action.name) resource |> Ash.Resource.Info.attributes() |> Enum.filter(&(&1.name in inputs)) end defp find_manage_change(argument, action) do Enum.find_value(Map.get(action, :changes, []), fn %{change: {Ash.Resource.Change.ManageRelationship, opts}} -> if opts[:argument] == argument.name do opts end _ -> nil end) end end