diff --git a/lib/ash_phoenix/changeset/form_data.ex b/lib/ash_phoenix/changeset/form_data.ex index 760f1c9..23b69fd 100644 --- a/lib/ash_phoenix/changeset/form_data.ex +++ b/lib/ash_phoenix/changeset/form_data.ex @@ -3,8 +3,9 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do # The goal here is to eventually lift complex validations # up into the form. + @impl true def input_type(%{resource: resource, action: action}, _, field) do - attribute = Ash.Resource.attribute(resource, field) + attribute = Ash.Resource.Info.attribute(resource, field) if attribute do type_to_form_type(attribute.type) @@ -39,45 +40,37 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do end end - def input_validations(_changeset, _form, _field) do - [] - end - - # # Returns the HTML5 validations that would apply to the given field. - - def input_value(changeset, _form, field) do - params_only? = field in (changeset.context[:params_only] || []) - - case get_changing_value(changeset, field, params_only?) do + @impl true + def input_value(changeset, form, field) do + case Keyword.fetch(form.options, :value) do {:ok, value} -> - value + value || "" - :error -> - unless params_only? do - Map.get(changeset.data, field) + _ -> + case get_changing_value(changeset, field) do + {:ok, value} -> + value + + :error -> + case Map.fetch(changeset.data, field) do + {:ok, value} -> + value + + _ -> + Ash.Changeset.get_argument(changeset, field) + end end end end - defp get_changing_value(changeset, field, params_only?) do - if params_only? do - case Map.fetch(changeset.params, field) do - {:ok, value} -> - value - - :error -> - Map.fetch(changeset.params, to_string(field)) - end - else - with :error <- Map.fetch(changeset.attributes, field), - :error <- Map.fetch(changeset.params, field) do - Map.fetch(changeset.params, to_string(field)) - end + defp get_changing_value(changeset, field) do + with :error <- Map.fetch(changeset.attributes, field), + :error <- Map.fetch(changeset.params, field) do + Map.fetch(changeset.params, to_string(field)) end end - # # Returns the value for the given field. - + @impl true def to_form(changeset, opts) do {name, opts} = Keyword.pop(opts, :as) @@ -85,12 +78,17 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do id = Keyword.get(opts, :id) || name hidden = - changeset.data - |> Map.take(Ash.Resource.primary_key(changeset.resource)) - |> Enum.to_list() + if changeset.action_type == :update do + changeset.data + |> Map.take(Ash.Resource.Info.primary_key(changeset.resource)) + |> Enum.to_list() + else + [] + end %Phoenix.HTML.Form{ - source: changeset, + action: changeset.action.name, + source: Ash.Changeset.put_context(changeset, :form, %{path: []}), impl: __MODULE__, id: id, name: name, @@ -98,15 +96,636 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do data: changeset.data, params: changeset.params, hidden: hidden, - options: - Keyword.delete(Keyword.put_new(opts, :method, form_for_method(changeset)), :only_params) + options: Keyword.put_new(opts, :method, form_for_method(changeset)) } end - def to_form(_changeset, _form, _field, _opts) do - [] + @impl true + def to_form(changeset, form, field, opts) do + {name, opts} = Keyword.pop(opts, :as) + {id, opts} = Keyword.pop(opts, :id) + {prepend, opts} = Keyword.pop(opts, :prepend, []) + {append, opts} = Keyword.pop(opts, :append, []) + changeset_opts = [skip_defaults: :all] + id = to_string(id || form.id <> "_#{field}") + name = to_string(name || form.name <> "[#{field}]") + + {source, resource, data, opts} = + cond do + attr = Ash.Resource.Info.attribute(changeset.resource, field) -> + case get_embedded(attr.type) do + nil -> + raise "Cannot use `form_for` with an attribute unless the type is an embedded resource" + + resource -> + data = Ash.Changeset.get_attribute(changeset, attr.name) + + data = + case attr.type do + {:array, _} -> + List.wrap(data) + + _ -> + data + end + + {{:attr, attr}, resource, data, opts} + end + + arg = + Enum.find( + changeset.action.arguments, + &(&1.name == field || to_string(&1.name) == field) + ) -> + case get_embedded(arg.type) do + nil -> + case get_managed_relationship(changeset.resource, changeset.action, arg.name) do + nil -> + raise "Cannot use `form_for` with an argument unless the type is an embedded resource, or unless there is a `manage_relationship` change that references the argument" + + {relationship, manage_opts} -> + data = Map.get(changeset.relationships, relationship.name) + + data = + case relationship.cardinality do + :many -> + List.wrap(data) + + _ when is_list(data) -> + Enum.at(data, 0) + + _ -> + data + end + + {{:manage_relationship, relationship}, relationship.destination, data, + Keyword.merge(manage_opts, opts)} + end + + resource -> + data = Ash.Changeset.get_argument(changeset, arg.name) + + data = + case arg.type do + {:array, _} -> + List.wrap(data) + + _ -> + data + end + + {{:embed_arg, arg}, resource, data} + end + + relationship = Ash.Resource.Info.relationship(changeset.resource, field) -> + data = relationship_data(changeset, relationship, changeset_opts) + + data = + case relationship.cardinality do + :many -> + List.wrap(data) + + _ when is_list(data) -> + Enum.at(data, 0) + + _ -> + data + end + + {{:manage_relationship, relationship}, relationship.destination, data, opts} + end + + data = + if is_list(data) do + prepend ++ data ++ append + else + data + end + + changeset + |> to_nested_form(data, source, resource, id, name, opts, changeset_opts) + |> List.wrap() end + defp relationship_data(changeset, relationship, changeset_opts) do + value = + case Map.get(changeset.data, relationship.name) do + %Ash.NotLoaded{} -> + [] + + value -> + value + end + + case relationship.type do + :many_to_many -> + join_relationship = + Ash.Resource.Info.relationship(relationship.destination, relationship.destination) + + value + |> List.wrap() + |> Enum.map(&Ash.Changeset.new/1) + |> Enum.map(&Ash.Changeset.set_context(&1, relationship.context)) + |> Enum.map(fn value_changeset -> + case Map.get(changeset.data, join_relationship.name) do + %Ash.NotLoaded{} -> + value_changeset + + value -> + value + |> Enum.find(fn join_row -> + Map.get(join_row, relationship.source_field_on_join_table) == + Map.get(changeset.data, relationship.source_field) && + Map.get(join_row, relationship) == + Map.get(changeset.data, relationship.destination_field) + end) + |> case do + nil -> + value_changeset + + join_row -> + join_changeset = + join_row + |> Ash.Changeset.new() + |> Ash.Changeset.set_context(join_relationship.context) + + Ash.Changeset.put_context(changeset, :private, %{ + join_changeset: join_changeset + }) + end + end + end) + |> apply_relationship_instructions(changeset, relationship, changeset_opts) + + _ -> + value + |> List.wrap() + |> Enum.map(&Ash.Changeset.new/1) + |> apply_relationship_instructions(changeset, relationship, changeset_opts) + end + end + + defp apply_relationship_instructions(value, changeset, relationship, changeset_opts) do + changeset.relationships + |> Map.get(relationship.name) + |> List.wrap() + |> Enum.reduce(value, fn {changes, opts}, value -> + apply_relationship_change(value, changes, relationship, opts, changeset_opts) + end) + end + + defp apply_relationship_change(value, manage_value, relationship, opts, changeset_opts) do + pkeys = pkeys(relationship) + + {relationship_value, unused_inputs} = + Enum.reduce(value, {[], manage_value}, fn changeset, {acc, manage_value} -> + case find_match(manage_value, changeset, pkeys) do + nil -> + case opts[:on_missing] do + instruction when instruction in [:error, :ignore] -> + {[changeset | acc], manage_value} + + _ -> + {acc, manage_value} + end + + match -> + case opts[:on_match] do + instruction when instruction in [:error, :ignore] -> + {[changeset | acc], manage_value -- [match]} + + instruction when instruction in [:destroy, :unrelate] -> + {acc, manage_value -- match} + + :create -> + {acc, manage_value} + + {:unrelate, _} -> + {acc, manage_value} + + :update -> + action_name = Ash.Resource.Info.primary_action!(changeset.resource, :update).name + + {[Ash.Changeset.for_update(changeset, action_name, match, changeset_opts) | acc], + [match | manage_value]} + + {:update, action_name} -> + {[Ash.Changeset.for_update(changeset, action_name, match, changeset_opts) | acc], + [match | manage_value]} + + {:update, action_name, join_table_action_name, params} -> + join_row = + case changeset.context[:private][:join_row] do + nil -> + raise "The join relationship must be loaded if using `inputs_for` with a managed relationship that specifies a join action/params" + + join_row -> + Ash.Changeset.for_update( + join_row, + join_table_action_name, + Map.take(match, params ++ Enum.map(params, &to_string/1)), + changeset_opts + ) + end + + changeset = + changeset + |> Ash.Changeset.for_update(action_name, match, changeset_opts) + |> Ash.Changeset.put_context(:private, %{join_row: join_row}) + + {[changeset | acc], manage_value -- [match]} + end + end + end) + + new_changesets = + if opts[:on_no_match] in [:ignore, :error] do + [] + else + for input <- unused_inputs do + case opts[:on_no_match] do + :create -> + action = Ash.Resource.Info.primary_action!(relationship.destination, :create) + + Ash.Changeset.for_create( + relationship.destination, + action.name, + input, + changeset_opts + ) + + {:create, action_name} -> + Ash.Changeset.for_create( + relationship.destination, + action_name, + input, + changeset_opts + ) + + {:create, action_name, join_table_action_name, params} -> + join_row = + Ash.Changeset.for_create( + relationship.through, + join_table_action_name, + Map.take(input, params ++ Enum.map(params, &to_string/1)), + changeset_opts + ) + + relationship.destination + |> Ash.Changeset.for_create(action_name, input, changeset_opts) + |> Ash.Changeset.put_context(:private, %{join_row: join_row}) + end + end + end + + Enum.reverse(relationship_value, new_changesets) + end + + defp get_managed_relationship(resource, action, arg_name) do + action.changes + |> Enum.find(fn + %{change: {Ash.Resource.Change.ManageRelationship, opts}} -> + opts[:argument] == arg_name + + _ -> + false + end) + |> case do + nil -> + nil + + %{change: {_, opts}} -> + {Ash.Resource.Info.relationship(resource, opts[:relationship_name]), opts[:opts]} + end + end + + defp pkeys(relationship) do + identities = + relationship.destination + |> Ash.Resource.Info.identities() + |> Enum.map(& &1.keys) + + [Ash.Resource.Info.primary_key(relationship.destination) | identities] + end + + defp find_match(current_value, input, pkeys) do + Enum.find(current_value, fn + %Ash.NotLoaded{} -> + false + + loaded -> + Enum.any?(pkeys, fn pkey -> + matches?(loaded, input, pkey) + end) + end) + end + + defp matches?(current_value, input, pkey) do + Enum.all?(pkey, fn field -> + with {:ok, left} <- fetch_field(current_value, field), + {:ok, right} <- fetch_field(input, field) do + left == right + else + _ -> + false + end + end) + end + + defp fetch_field(input, field) do + case Map.fetch(input, field) do + {:ok, value} -> + {:ok, value} + + :error -> + Map.fetch(input, to_string(field)) + end + end + + defp to_nested_form( + original_changeset, + changesets, + {:manage_relationship, %{cardinality: :many} = rel}, + _resource, + id, + name, + opts, + _changeset_opts + ) do + changesets + |> Enum.map(&customize_changeset(&1, original_changeset, :relationship, rel)) + |> Enum.with_index() + |> Enum.map(fn {changeset, index} -> + index_string = Integer.to_string(index) + + hidden = + if changeset.action_type == :update do + changeset.data + |> Map.take(Ash.Resource.Info.primary_key(changeset.resource)) + |> Enum.to_list() + else + [] + end + + %Phoenix.HTML.Form{ + action: changeset.action.name, + source: changeset, + impl: __MODULE__, + id: id <> "_" <> index_string, + name: name <> "[" <> index_string <> "]", + index: index, + errors: form_for_errors(changeset, opts), + data: changeset.data, + params: changeset.params, + hidden: hidden, + options: opts |> IO.inspect() + } + end) + end + + defp to_nested_form( + original_changeset, + changeset, + {:manage_relationship, %{cardinality: :one} = rel}, + _resource, + id, + name, + opts, + _changeset_opts + ) do + if changeset do + changeset = customize_changeset(changeset, original_changeset, :relationship, rel) + + hidden = + if changeset.action_type == :update do + changeset.data + |> Map.take(Ash.Resource.Info.primary_key(changeset.resource)) + |> Enum.to_list() + else + [] + end + + %Phoenix.HTML.Form{ + action: changeset.action && changeset.action.name, + source: changeset, + impl: __MODULE__, + id: id, + name: name, + errors: form_for_errors(changeset, opts), + data: changeset.data, + params: changeset.params, + hidden: hidden, + options: opts + } + else + [] + end + end + + defp to_nested_form( + original_changeset, + data, + {type, attribute}, + resource, + id, + name, + opts, + changeset_opts + ) + when is_list(data) and type in [:attr, :embed_arg] do + create_action = + attribute.constraints[:create_action] || + Ash.Resource.Info.primary_action!(resource, :create).name + + update_action = + attribute.constraints[:update_action] || + Ash.Resource.Info.primary_action!(resource, :update).name + + changesets = + data + |> Enum.map(fn data -> + if is_struct(data) do + Ash.Changeset.for_update(data, update_action, %{}, changeset_opts) + else + Ash.Changeset.for_create(resource, create_action, data, changeset_opts) + end + end) + |> Enum.map(&customize_changeset(&1, original_changeset, :embed, attribute)) + + for {changeset, index} <- Enum.with_index(changesets) do + index_string = Integer.to_string(index) + + hidden = + if changeset.action_type == :update do + changeset.data + |> Map.take(Ash.Resource.Info.primary_key(changeset.resource)) + |> Enum.to_list() + else + [] + end + + %Phoenix.HTML.Form{ + action: changeset.action.name, + source: + Ash.Changeset.put_context(changeset, :form, %{ + path: (original_changeset.context[:form][:path] || []) ++ [attribute.name] + }), + impl: __MODULE__, + id: id <> "_" <> index_string, + name: name <> "[" <> index_string <> "]", + index: index, + errors: form_for_errors(changeset, opts), + data: data, + params: changeset.params, + hidden: hidden, + options: opts + } + end + end + + defp to_nested_form( + original_changeset, + data, + {type, attribute}, + resource, + id, + name, + opts, + changeset_opts + ) + when type in [:attr, :embed_arg] do + create_action = + attribute.constraints[:create_action] || + Ash.Resource.Info.primary_action!(resource, :create).name + + update_action = + attribute.constraints[:update_action] || + Ash.Resource.Info.primary_action!(resource, :update).name + + changeset = + cond do + is_struct(data) -> + Ash.Changeset.for_update(data, update_action, %{}, changeset_opts) + + is_nil(data) -> + nil + + true -> + Ash.Changeset.for_create(resource, create_action, data, changeset_opts) + end + + changeset = customize_changeset(changeset, original_changeset, :embed, attribute) + + if changeset do + hidden = + if changeset.action_type == :update do + changeset.data + |> Map.take(Ash.Resource.Info.primary_key(changeset.resource)) + |> Enum.to_list() + else + [] + end + + %Phoenix.HTML.Form{ + source: changeset, + impl: __MODULE__, + id: id, + name: name, + errors: form_for_errors(changeset, opts), + data: data, + params: changeset.params, + hidden: hidden, + options: opts + } + end + end + + defp customize_changeset(nil, _, _, _), do: nil + + defp customize_changeset(changeset, original_changeset, type, attribute_or_relationship) do + customize = original_changeset.context[:form][:customize_changeset] + + changeset = + Ash.Changeset.set_context(changeset, %{ + customize_changeset: customize, + form: %{ + path: + (original_changeset.context[:form][:path] || []) ++ [attribute_or_relationship.name] + } + }) + + case customize do + nil -> + changeset + + function when is_function(function, 3) -> + function.(changeset, type, attribute_or_relationship) + end + end + + defp get_embedded({:array, type}), do: get_embedded(type) + + defp get_embedded(type) when is_atom(type) do + if Ash.Resource.Info.embedded?(type) do + type + end + end + + defp get_embedded(_), do: nil + + @impl true + def input_validations(changeset, _, field) do + attribute_or_argument = + Ash.Resource.Info.attribute(changeset.resource, field) || + get_argument(changeset.action, field) + + if attribute_or_argument do + [required: !attribute_or_argument.allow_nil?] ++ type_validations(attribute_or_argument) + else + [] + end + end + + defp type_validations(%{type: Ash.Types.Integer, constraints: constraints}) do + constraints + |> Kernel.||([]) + |> Keyword.take([:max, :min]) + |> Keyword.put(:step, 1) + end + + defp type_validations(%{type: Ash.Types.Decimal, constraints: constraints}) do + constraints + |> Kernel.||([]) + |> Keyword.take([:max, :min]) + |> Keyword.put(:step, "any") + end + + defp type_validations(%{type: Ash.Types.String, constraints: constraints}) do + if constraints[:trim?] do + # We should consider using the `match` validation here, but we can't + # add a title here, so we can't set an error message + # min_length = to_string(constraints[:min_length]) + # max_length = to_string(constraints[:max_length]) + # [match: "(\S\s*){#{min_length},#{max_length}}"] + [] + else + validations = + if constraints[:min_length] do + [min_length: constraints[:min_length]] + else + [] + end + + if constraints[:min_length] do + Keyword.put(constraints, :min_length, constraints[:min_length]) + else + validations + end + end + end + + defp type_validations(_), do: [] + defp form_for_errors(changeset, opts) do changeset.errors |> Enum.filter(&(Map.has_key?(&1, :field) || Map.has_key?(&1, :fields))) @@ -149,12 +768,16 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do else Enum.filter(errors, fn {field, _} -> field in (opts[:error_keys] || []) || - Map.has_key?(changeset.params, field) || - Map.has_key?(changeset.params, to_string(field)) + has_non_empty_key?(changeset.params, field) || + has_non_empty_key?(changeset.params, to_string(field)) end) end end + defp has_non_empty_key?(params, field) do + Map.has_key?(params, field) && params[field] not in [nil, "", []] + end + defp vars(%{vars: vars}, opts) do Keyword.merge(vars, opts) end diff --git a/lib/ash_phoenix/live_view.ex b/lib/ash_phoenix/live_view.ex index 536f683..46ee2b6 100644 --- a/lib/ash_phoenix/live_view.ex +++ b/lib/ash_phoenix/live_view.ex @@ -436,7 +436,7 @@ defmodule AshPhoenix.LiveView do nil first = List.first(current_list).__struct__ - pkey = Ash.Resource.primary_key(first) + pkey = Ash.Resource.Info.primary_key(first) resulting_page = run_callback(callback, socket, nil) @@ -460,7 +460,7 @@ defmodule AshPhoenix.LiveView do true -> first = List.first(current_page.results).__struct__ - pkey = Ash.Resource.primary_key(first) + pkey = Ash.Resource.Info.primary_key(first) filter = case pkey do diff --git a/mix.exs b/mix.exs index 1c0c557..299389a 100644 --- a/mix.exs +++ b/mix.exs @@ -72,7 +72,8 @@ defmodule AshPhoenix.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, ash_version("~> 1.31 and >= 1.31.1")}, + # {:ash, ash_version("~> 1.31 and >= 1.31.1")}, + {:ash, path: "../ash"}, {:phoenix, "~> 1.5.6"}, {:phoenix_html, "~> 2.14"}, {:phoenix_live_view, "~> 0.15"}, diff --git a/mix.lock b/mix.lock index e9ec76a..d6c2944 100644 --- a/mix.lock +++ b/mix.lock @@ -8,7 +8,7 @@ "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, - "ecto": {:hex, :ecto, "3.5.6", "29c77e999e471921c7ce7347732bab7bfa3e24c587640a36f17e0744d1474b8e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3ae1f3eaecc3e72eeb65ed43239b292bb1eaf335c7e6cea3a7fc27aadb6e93e7"}, + "ecto": {:hex, :ecto, "3.5.7", "f440a476bf1be361173a43a4a18f04a2fdf4e6fac5b0457f03d8686e55f13f7e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "04c4e69d4f1cc2bb085aa760d50389ba8ae3003f80c112fbde87d57f5ed75d39"}, "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},