diff --git a/README.md b/README.md
index 28c7bfc..7cf493d 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,8 @@ add(:channel, :map)
`cast_polymorphic_embed/3` must be called to cast the polymorphic embed's parameters.
+After casting a polymorphic embed, its entry in `changeset.params` contains the child changeset's parameters. For `polymorphic_embeds_many`, this is a list in the final cast order, with dropped and ignored rows removed. Form helpers use these parameters to preserve submitted values and row identifiers.
+
#### Options
* `:required` – if the embed is a required field.
diff --git a/lib/polymorphic_embed.ex b/lib/polymorphic_embed.ex
index 47e8ad9..388a80b 100644
--- a/lib/polymorphic_embed.ex
+++ b/lib/polymorphic_embed.ex
@@ -319,6 +319,7 @@ defmodule PolymorphicEmbed do
{action, struct} ->
embed_changeset = changeset_fun.(struct, params)
embed_changeset = %{embed_changeset | action: action}
+ changeset = put_embed_params(changeset, field, embed_changeset.params || params)
case embed_changeset do
%{valid?: true} = embed_changeset ->
@@ -431,14 +432,17 @@ defmodule PolymorphicEmbed do
%{changeset_fun.(struct(module), params) | action: :insert}
end
- maybe_apply_changes(embed_changeset)
+ {maybe_apply_changes(embed_changeset), embed_changeset.params || params}
end
end)
if Enum.any?(embeds, &(&1 == :error)) do
Ecto.Changeset.add_error(changeset, field, "is invalid")
else
- embeds = Enum.filter(embeds, &(&1 != :ignore))
+ {embeds, params} =
+ embeds
+ |> Enum.reject(&(&1 == :ignore))
+ |> Enum.unzip()
any_invalid? =
Enum.any?(embeds, fn
@@ -446,6 +450,9 @@ defmodule PolymorphicEmbed do
_ -> false
end)
+ # Keep each row's parameters in cast order, including children converted to structs.
+ changeset = put_embed_params(changeset, field, params)
+
changeset = Ecto.Changeset.put_change(changeset, field, embeds)
if any_invalid? do
@@ -464,6 +471,10 @@ defmodule PolymorphicEmbed do
defp maybe_apply_changes(%Changeset{valid?: false} = changeset), do: changeset
+ defp put_embed_params(changeset, field, params) do
+ %{changeset | params: Map.put(changeset.params || %{}, to_string(field), params)}
+ end
+
@impl true
def cast(_data, _params),
do:
diff --git a/lib/polymorphic_embed/html/helpers.ex b/lib/polymorphic_embed/html/helpers.ex
index bcd4b76..be99c84 100644
--- a/lib/polymorphic_embed/html/helpers.ex
+++ b/lib/polymorphic_embed/html/helpers.ex
@@ -49,7 +49,7 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d
form.source.data.__struct__
end
- def to_form(%{action: parent_action} = source_changeset, form, field, options) do
+ def to_form(source_changeset, %{action: parent_action} = form, field, options) do
id = to_string(form.id <> "_#{field}")
name = to_string(form.name <> "[#{field}]")
@@ -57,37 +57,16 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d
struct = Ecto.Changeset.apply_changes(source_changeset)
- list_data =
- case Map.get(struct, field) do
- nil ->
- type = Keyword.get(options, :polymorphic_type, get_polymorphic_type(form, field))
- module = PolymorphicEmbed.get_polymorphic_module(struct.__struct__, field, type)
- if module, do: [struct(module)], else: []
-
- data ->
- List.wrap(data)
- end
-
- list_data
+ resolve_field_data(source_changeset, struct, form, field, options)
|> Enum.with_index()
- |> Enum.map(fn {data, i} ->
- params = Enum.at(params, i) || %{}
-
- %Ecto.Changeset{} =
- changeset =
- data
- |> Ecto.Changeset.change()
- |> apply_action(parent_action)
-
- errors = get_errors(changeset)
-
- changeset = %Ecto.Changeset{
- changeset
- | action: parent_action,
- params: params,
- errors: errors,
- valid?: errors == []
- }
+ |> Enum.map(&prepare_changeset(&1, params, parent_action))
+ |> Enum.map(fn prepared_data ->
+ %{
+ changeset: changeset,
+ params: params,
+ errors: errors,
+ index: i
+ } = prepared_data
%schema{} = source_changeset.data
@@ -107,7 +86,7 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d
name: if(array?, do: name <> "[" <> index_string <> "]", else: name),
index: if(array?, do: i),
errors: errors,
- data: data,
+ data: changeset.data,
action: parent_action,
params: params,
hidden: [{type_field_name, to_string(type)}],
@@ -116,9 +95,70 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d
end)
end
- # If the parent changeset had no action, we need to remove the action
- # from children changeset so we ignore all errors accordingly.
- defp apply_action(changeset, nil), do: %{changeset | action: nil}
+ defp resolve_field_data(source_changeset, struct, form, field, options) do
+ case Map.get(source_changeset.changes, field) do
+ nil ->
+ case Map.get(struct, field) do
+ nil ->
+ type = Keyword.get(options, :polymorphic_type, get_polymorphic_type(form, field))
+ module = PolymorphicEmbed.get_polymorphic_module(struct.__struct__, field, type)
+ if module, do: [struct(module)], else: []
+
+ data ->
+ List.wrap(data)
+ end
+
+ data ->
+ List.wrap(data)
+ end
+ end
+
+ defp prepare_changeset({%Ecto.Changeset{} = changeset, i}, _params, parent_action) do
+ params = changeset.params || %{}
+ changeset = apply_action(changeset, parent_action)
+ errors = get_errors(changeset)
+
+ %{
+ changeset: changeset,
+ params: params,
+ errors: errors,
+ index: i
+ }
+ end
+
+ defp prepare_changeset({data, i}, params, parent_action) do
+ params = Enum.at(params, i) || %{}
+
+ changeset =
+ data
+ |> Ecto.Changeset.change()
+ |> apply_action(parent_action)
+
+ errors = get_errors(changeset)
+
+ changeset = %{changeset | params: params}
+
+ # The changeset has no changes, so phoenix_ecto would read nested embed
+ # values from the raw form parameters. Drop them to read the cast data.
+ # The changeset keeps them for nested polymorphic forms.
+ params = Map.drop(params, embed_param_keys(changeset.types))
+
+ %{changeset: changeset, params: params, errors: errors, index: i}
+ end
+
+ defp embed_param_keys(types) do
+ for {field, type} <- types, embed_type?(type), do: to_string(field)
+ end
+
+ defp embed_type?({:embed, _}), do: true
+ defp embed_type?({:parameterized, {PolymorphicEmbed, _}}), do: true
+ defp embed_type?({:array, {:parameterized, {PolymorphicEmbed, _}}}), do: true
+ defp embed_type?(_), do: false
+
+ defp apply_action(changeset, action) when action in [nil, :ignore],
+ do: %{changeset | action: action}
+
+ defp apply_action(%{action: nil} = changeset, action), do: %{changeset | action: action}
defp apply_action(changeset, _action), do: changeset
defp get_errors(%{action: nil}), do: []
diff --git a/test/polymorphic_embed/html/helpers_test.exs b/test/polymorphic_embed/html/helpers_test.exs
new file mode 100644
index 0000000..59c8d36
--- /dev/null
+++ b/test/polymorphic_embed/html/helpers_test.exs
@@ -0,0 +1,240 @@
+defmodule PolymorphicEmbed.HTML.HelpersTest do
+ use ExUnit.Case, async: true
+
+ alias Ecto.Changeset
+ alias PolymorphicEmbed.Channel.{Email, SMSAttempts, SMSResult, TwilioSMSProvider}
+ alias PolymorphicEmbed.Country
+ alias PolymorphicEmbed.HTML.Helpers
+ alias PolymorphicEmbed.Reminder
+ alias PolymorphicEmbed.Reminder.Context.{Age, Location}
+
+ defmodule Group do
+ use Ecto.Schema
+ import PolymorphicEmbed
+
+ embedded_schema do
+ polymorphic_embeds_many(:contexts, types: [age: Age], on_replace: :delete)
+ end
+
+ def changeset(data, params) do
+ data
+ |> Changeset.cast(params, [])
+ |> cast_polymorphic_embed(:contexts, sort_param: :order)
+ end
+ end
+
+ defmodule Container do
+ use Ecto.Schema
+ import PolymorphicEmbed
+
+ embedded_schema do
+ polymorphic_embeds_one(:group, types: [group: Group], on_replace: :update)
+ end
+ end
+
+ test "preserves sorted row parameters inside a valid single embed" do
+ first = %{"__type__" => "age", "age" => "same", "_persistent_id" => "first"}
+ second = %{"__type__" => "age", "age" => "same", "_persistent_id" => "second"}
+
+ parent =
+ %Container{}
+ |> Changeset.cast(
+ %{
+ "group" => %{
+ "__type__" => "group",
+ "contexts" => %{"0" => first, "1" => second},
+ "order" => ["1", "0"]
+ }
+ },
+ []
+ )
+ |> PolymorphicEmbed.cast_polymorphic_embed(:group)
+
+ [group] = forms(parent, :group)
+ rows = Helpers.to_form(group.source, group, :contexts, [])
+ assert Enum.map(rows, & &1.params) == [second, first]
+ end
+
+ test "keeps row parameters through sorting, dropping and ignored types" do
+ first = %{"__type__" => "age", "age" => "same", "_persistent_id" => "first"}
+ second = %{"__type__" => "age", "age" => "same", "_persistent_id" => "second"}
+ invalid = %{"__type__" => "location", "address" => "", "_persistent_id" => "invalid"}
+
+ params = %{
+ "contexts2" => %{
+ "0" => first,
+ "1" => second,
+ "2" => invalid,
+ "3" => %{"__type__" => "unknown"},
+ "4" => %{"__type__" => "age", "age" => "dropped"}
+ },
+ "order" => ["1", "2", "3", "4", "0"],
+ "remove" => ["4"]
+ }
+
+ options = [sort_param: :order, drop_param: :remove]
+
+ changeset =
+ %Reminder{}
+ |> Changeset.cast(params, [])
+ |> PolymorphicEmbed.cast_polymorphic_embed(:contexts2, options)
+
+ forms = forms(changeset, :contexts2)
+ assert Enum.map(forms, & &1.params) == [second, invalid, first]
+ assert Enum.map(forms, & &1.data.__struct__) == [Age, Location, Age]
+ assert Enum.map(forms, & &1.source.valid?) == [true, false, true]
+
+ recast = PolymorphicEmbed.cast_polymorphic_embed(changeset, :contexts2, options)
+ assert Enum.map(forms(recast, :contexts2), & &1.params) == [second, invalid, first]
+ end
+
+ test "uses numeric row order without an explicit sort parameter" do
+ earlier = %{"__type__" => "age", "age" => "earlier", "_persistent_id" => "two"}
+ later = %{"__type__" => "age", "age" => "later", "_persistent_id" => "ten"}
+
+ changeset =
+ %Reminder{}
+ |> Changeset.cast(%{"contexts" => %{"10" => later, "2" => earlier}}, [])
+ |> PolymorphicEmbed.cast_polymorphic_embed(:contexts)
+
+ assert Enum.map(forms(changeset, :contexts), & &1.params) == [earlier, later]
+ end
+
+ test "keeps parameters for a valid row created by sort without field parameters" do
+ options = [sort_param: :order, default_type_on_sort_create: :age]
+
+ changeset =
+ %Reminder{}
+ |> Changeset.cast(%{"order" => ["new"]}, [])
+ |> PolymorphicEmbed.cast_polymorphic_embed(:contexts, options)
+
+ assert [%{data: %Age{}, params: %{"__type__" => :age}}] = forms(changeset, :contexts)
+
+ recast = PolymorphicEmbed.cast_polymorphic_embed(changeset, :contexts, options)
+ assert [%{data: %Age{}, params: %{"__type__" => :age}}] = forms(recast, :contexts)
+ end
+
+ test "keeps list parameters aligned after an unknown type is ignored" do
+ row = %{"__type__" => "age", "age" => "kept", "_persistent_id" => "kept"}
+
+ changeset =
+ %Reminder{}
+ |> Changeset.cast(%{"contexts2" => [%{"__type__" => "unknown"}, row]}, [])
+ |> PolymorphicEmbed.cast_polymorphic_embed(:contexts2)
+
+ assert [%{params: ^row}] = forms(changeset, :contexts2)
+ end
+
+ for {parent_action, child_action, effective_action, show_errors?} <- [
+ {nil, :insert, nil, false},
+ {:ignore, :insert, :ignore, false},
+ {:insert, nil, :insert, true},
+ {:validate, nil, :validate, true},
+ {:validate, :update, :update, true},
+ {:insert, :ignore, :ignore, false}
+ ] do
+ test "prepares child action #{inspect(child_action)} with parent action #{inspect(parent_action)}" do
+ child =
+ %Email{}
+ |> Email.changeset(%{"address" => "a"})
+ |> Map.put(:action, unquote(child_action))
+
+ parent =
+ %Reminder{}
+ |> Changeset.change()
+ |> Changeset.put_change(:channel, child)
+ |> Map.put(:action, unquote(parent_action))
+
+ [form] = forms(parent, :channel)
+ assert form.action == unquote(parent_action)
+ assert form.source.action == unquote(effective_action)
+ assert form.source.errors == child.errors
+ refute form.source.valid?
+ assert form.errors == if(unquote(show_errors?), do: child.errors, else: [])
+ assert Phoenix.Component.to_form(form.source).errors == form.errors
+ end
+ end
+
+ test "assigns the parent action when preparing a struct" do
+ for action <- [:insert, :validate] do
+ parent =
+ %Reminder{channel: %Email{address: "valid"}}
+ |> Changeset.change()
+ |> Map.put(:action, action)
+
+ [form] = forms(parent, :channel)
+ assert form.source.action == action
+ assert form.errors == []
+ end
+ end
+
+ test "retains nested errors when another form uses the prepared source" do
+ child = Location.changeset(%Location{}, %{address: "valid", country: %{name: "A"}})
+
+ parent =
+ %Reminder{}
+ |> Changeset.change()
+ |> Changeset.put_change(:contexts, [child])
+ |> Map.put(:action, :insert)
+
+ [form] = forms(parent, :contexts)
+ rebuilt = Phoenix.Component.to_form(form.source)
+
+ [country] = Phoenix.HTML.FormData.to_form(form.source, rebuilt, :country, [])
+ assert country.errors == child.changes.country.errors
+ assert country.errors != []
+ end
+
+ test "reads nested embeds of valid rows from data instead of parameters" do
+ params = %{
+ "contexts" => %{
+ "0" => %{
+ "__type__" => "location",
+ "address" => "first",
+ "country" => %{"name" => "Belgium"},
+ "_persistent_id" => "first"
+ }
+ }
+ }
+
+ changeset =
+ %Reminder{}
+ |> Changeset.cast(params, [])
+ |> PolymorphicEmbed.cast_polymorphic_embed(:contexts)
+ |> Map.put(:action, :validate)
+
+ [form] = forms(changeset, :contexts)
+ assert form.params["_persistent_id"] == "first"
+ assert form[:address].value == "first"
+ assert %Country{name: "Belgium"} = form[:country].value
+ end
+
+ test "reads nested embeds of a valid single embed from data instead of parameters" do
+ params = %{
+ "channel" => %{
+ "my_type_field" => "sms",
+ "number" => "02/807.05.53",
+ "country_code" => "1",
+ "result" => %{"success" => "true"},
+ "attempts" => %{"0" => %{"date" => "2020-05-28T07:27:05Z"}},
+ "provider" => %{"__type__" => "twilio", "api_key" => "foo"}
+ }
+ }
+
+ changeset =
+ %Reminder{}
+ |> Changeset.cast(params, [])
+ |> PolymorphicEmbed.cast_polymorphic_embed(:channel)
+ |> Map.put(:action, :validate)
+
+ [form] = forms(changeset, :channel)
+ assert form[:number].value == "02/807.05.53"
+ assert %SMSResult{success: true} = form[:result].value
+ assert [%SMSAttempts{}] = form[:attempts].value
+ assert %TwilioSMSProvider{api_key: "foo"} = form[:provider].value
+ end
+
+ defp forms(changeset, field) do
+ Helpers.to_form(changeset, Phoenix.Component.to_form(changeset), field, [])
+ end
+end
diff --git a/test/polymorphic_embed_test.exs b/test/polymorphic_embed_test.exs
index ba50279..7f24242 100644
--- a/test/polymorphic_embed_test.exs
+++ b/test/polymorphic_embed_test.exs
@@ -2770,25 +2770,58 @@ defmodule PolymorphicEmbedTest do
for generator <- @generators do
reminder_module = get_module(Reminder, generator)
- attrs = %{
- date: ~U[2020-05-28 02:57:19Z],
- text: "This is an Email reminder",
- channel: %{
- address: "a",
- valid: true,
- confirmed: true
- }
- }
+ attrs =
+ if polymorphic?(generator) do
+ %{
+ date: ~U[2020-05-28 02:57:19Z],
+ text: "This is an Email reminder",
+ channel: %{
+ address: "a",
+ valid: true,
+ confirmed: true
+ }
+ }
+ else
+ %{
+ number: ~U[2020-05-28 02:57:19Z],
+ text: "This is a non polymorphic reminder",
+ channel: %{
+ number: "a"
+ }
+ }
+ end
changeset =
struct(reminder_module)
|> reminder_module.changeset(attrs)
+ # Without parent action, errors should be empty in both cases
+ # (mirroring standard Ecto embed behavior)
+ safe_inputs_for(changeset, :channel, generator, fn f ->
+ assert f.impl == Phoenix.HTML.FormData.Ecto.Changeset
+ assert is_struct(f.data), "f.data should be a struct, not a changeset"
+ refute match?(%Ecto.Changeset{}, f.data), "f.data should not be a changeset"
+ assert f.errors == []
+ assert f.action == nil
+
+ if polymorphic?(generator), do: text_input(f, :address), else: text_input(f, :number)
+ end)
+
+ changeset = Map.put(changeset, :action, :insert)
+
+ # With parent action, validation errors should surface in both cases
contents =
safe_inputs_for(changeset, :channel, generator, fn f ->
assert f.impl == Phoenix.HTML.FormData.Ecto.Changeset
- assert f.errors == []
- text_input(f, :address)
+ assert is_struct(f.data), "f.data should be a struct, not a changeset"
+ refute match?(%Ecto.Changeset{}, f.data), "f.data should not be a changeset"
+
+ assert f.errors != [],
+ "errors should be present when parent has action and data is invalid"
+
+ assert f.action == :insert
+
+ if polymorphic?(generator), do: text_input(f, :address), else: text_input(f, :number)
end)
expected_contents =
@@ -2798,7 +2831,7 @@ defmodule PolymorphicEmbedTest do
""",
else: ~s"""
-
+
"""
)
@@ -2811,7 +2844,12 @@ defmodule PolymorphicEmbedTest do
generator,
fn f ->
assert f.impl == Phoenix.HTML.FormData.Ecto.Changeset
- text_input(f, :address)
+
+ if polymorphic?(generator) do
+ text_input(f, :address)
+ else
+ text_input(f, :number)
+ end
end
)
@@ -2822,7 +2860,7 @@ defmodule PolymorphicEmbedTest do
""",
else: ~s"""
-
+
"""
)
@@ -2830,6 +2868,57 @@ defmodule PolymorphicEmbedTest do
end
end
+ test "polymorphic_embed_inputs_for ignores child errors when parent action is nil" do
+ reminder_module = get_module(Reminder, :polymorphic)
+
+ attrs = %{
+ date: ~U[2020-05-28 02:57:19Z],
+ text: "This is an Email reminder",
+ channel: %{
+ address: "a",
+ valid: true,
+ confirmed: true
+ }
+ }
+
+ changeset =
+ struct(reminder_module)
+ |> reminder_module.changeset(attrs)
+
+ safe_inputs_for(changeset, :channel, :polymorphic, fn f ->
+ assert f.impl == Phoenix.HTML.FormData.Ecto.Changeset
+ assert f.errors == []
+ text_input(f, :address)
+ end)
+ end
+
+ test "polymorphic_embed_inputs_for/4 applies parent action to a prebuilt child changeset" do
+ child_changeset =
+ PolymorphicEmbed.Channel.Email.changeset(
+ %PolymorphicEmbed.Channel.Email{},
+ %{address: "a", valid: true, confirmed: true}
+ )
+
+ changeset =
+ Ecto.Changeset.change(%PolymorphicEmbed.Reminder{})
+ |> Ecto.Changeset.put_change(:channel, child_changeset)
+ |> Map.put(:action, :insert)
+
+ safe_inputs_for(changeset, :channel, :polymorphic, fn f ->
+ assert f.impl == Phoenix.HTML.FormData.Ecto.Changeset
+ assert f.action == :insert
+ assert f.source.action == :insert
+
+ assert f.errors == [
+ {:address,
+ {"should be at least %{count} character(s)",
+ [count: 3, validation: :length, kind: :min, type: :string]}}
+ ]
+
+ text_input(f, :address)
+ end)
+ end
+
test "polymorphic_embed_inputs_for/4 for list of embeds" do
for generator <- @generators do
reminder_module = get_module(Reminder, generator)
@@ -2867,7 +2956,7 @@ defmodule PolymorphicEmbedTest do
if(polymorphic?(generator),
do: ~s"""
-
+
""",
@@ -2894,7 +2983,7 @@ defmodule PolymorphicEmbedTest do
if(polymorphic?(generator),
do: ~s"""
-
+
""",
@@ -2908,6 +2997,64 @@ defmodule PolymorphicEmbedTest do
end
end
+ test "to_form/4 keeps sorted embeds_many params aligned with each row" do
+ reminder_module = get_module(Reminder, :polymorphic)
+
+ attrs = %{
+ "date" => ~U[2020-05-28 02:57:19Z],
+ "text" => "This is a reminder with sorted contexts",
+ "channel" => %{
+ "my_type_field" => "sms",
+ "number" => "02/807.05.53",
+ "country_code" => 1,
+ "provider" => %{
+ "__type__" => "twilio",
+ "api_key" => "foo"
+ }
+ },
+ "contexts" => %{
+ "0" => %{
+ "__type__" => "device",
+ "_persistent_id" => "device-row",
+ "ref" => "12345",
+ "type" => "cellphone"
+ },
+ "1" => %{
+ "__type__" => "age",
+ "_persistent_id" => "age-row",
+ "age" => "aquarius"
+ }
+ },
+ "contexts_sort" => ["1", "0"]
+ }
+
+ changeset =
+ struct(reminder_module)
+ |> reminder_module.changeset(attrs)
+
+ form = Phoenix.Component.to_form(changeset)
+ forms = PolymorphicEmbed.HTML.Helpers.to_form(changeset, form, :contexts, [])
+
+ assert Enum.map(forms, & &1.data.__struct__) == [
+ PolymorphicEmbed.Reminder.Context.Age,
+ PolymorphicEmbed.Reminder.Context.Device
+ ]
+
+ assert Enum.map(forms, & &1.params) == [
+ %{
+ "__type__" => "age",
+ "_persistent_id" => "age-row",
+ "age" => "aquarius"
+ },
+ %{
+ "__type__" => "device",
+ "_persistent_id" => "device-row",
+ "ref" => "12345",
+ "type" => "cellphone"
+ }
+ ]
+ end
+
test "polymorphic_embed_inputs_for/4 after invalid insert" do
for generator <- @generators do
reminder_module = get_module(Reminder, generator)