From 9f747fd95e30ed16bc1fda0a9b59483139ef3200 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 7 Mar 2026 18:52:26 +0100 Subject: [PATCH 1/8] failing test: expect data to be preserved mirroring classic embeds failing test: expect errors for polymorphic inputs --- test/polymorphic_embed_test.exs | 66 ++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/test/polymorphic_embed_test.exs b/test/polymorphic_embed_test.exs index ba50279..a33e2ca 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""" - + """ ) From dd63da2f5ba26cb7a11f599d873e93f99fef0227 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 7 Mar 2026 18:52:40 +0100 Subject: [PATCH 2/8] fix: keep data from source_changeset --- lib/polymorphic_embed/html/helpers.ex | 75 +++++++++++++++++---------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/lib/polymorphic_embed/html/helpers.ex b/lib/polymorphic_embed/html/helpers.ex index bcd4b76..285f59f 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,60 @@ 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: [] + Map.get(source_changeset.changes, field) + |> case 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 - list_data + data when is_list(data) -> + data + + data -> + List.wrap(data) + end |> Enum.with_index() - |> Enum.map(fn {data, i} -> - params = Enum.at(params, i) || %{} + |> Enum.map(fn + {%Ecto.Changeset{} = changeset, i} -> + params = changeset.params || %{} + errors = get_errors(changeset) + + %{changeset: changeset, params: params, errors: errors, index: i} + + {data, i} -> + params = Enum.at(params, i) || %{} - %Ecto.Changeset{} = changeset = - data - |> Ecto.Changeset.change() - |> apply_action(parent_action) + data + |> Ecto.Changeset.change() + |> apply_action(parent_action) - errors = get_errors(changeset) + errors = get_errors(changeset) - changeset = %Ecto.Changeset{ - changeset - | action: parent_action, - params: params, - errors: errors, - valid?: errors == [] - } + changeset = %{ + changeset + | action: parent_action, + params: params, + errors: errors, + valid?: errors == [] + } + + %{changeset: changeset, params: params, errors: errors, index: i} + end) + |> Enum.map(fn prepared_data -> + %{ + changeset: changeset, + params: params, + errors: errors, + index: i + } = prepared_data %schema{} = source_changeset.data @@ -107,7 +130,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)}], From fd30b4321787978f0edd8d629aae5fb0f0f31d31 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 10 Mar 2026 15:50:56 +0100 Subject: [PATCH 3/8] refactor extracting data and preparing changeset --- lib/polymorphic_embed/html/helpers.ex | 94 ++++++++++++++------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/lib/polymorphic_embed/html/helpers.ex b/lib/polymorphic_embed/html/helpers.ex index 285f59f..33c6038 100644 --- a/lib/polymorphic_embed/html/helpers.ex +++ b/lib/polymorphic_embed/html/helpers.ex @@ -57,53 +57,9 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d struct = Ecto.Changeset.apply_changes(source_changeset) - Map.get(source_changeset.changes, field) - |> case 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 when is_list(data) -> - data - - data -> - List.wrap(data) - end + resolve_field_data(source_changeset, struct, form, field, options) |> Enum.with_index() - |> Enum.map(fn - {%Ecto.Changeset{} = changeset, i} -> - params = changeset.params || %{} - errors = get_errors(changeset) - - %{changeset: changeset, params: params, errors: errors, index: i} - - {data, i} -> - params = Enum.at(params, i) || %{} - - changeset = - data - |> Ecto.Changeset.change() - |> apply_action(parent_action) - - errors = get_errors(changeset) - - changeset = %{ - changeset - | action: parent_action, - params: params, - errors: errors, - valid?: errors == [] - } - - %{changeset: changeset, params: params, errors: errors, index: i} - end) + |> Enum.map(&prepare_changeset(&1, params, parent_action)) |> Enum.map(fn prepared_data -> %{ changeset: changeset, @@ -139,6 +95,52 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d end) end + 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 || %{} + 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 = %Ecto.Changeset{ + changeset + | action: parent_action, + params: params, + errors: errors, + valid?: errors == [] + } + + %{changeset: changeset, params: params, errors: errors, index: i} + 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} From c1ea40603c10130c25b184c0e4939d67c6c3f250 Mon Sep 17 00:00:00 2001 From: Mathieu Decaffmeyer <5883963+mathieuprog@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:09:25 +0800 Subject: [PATCH 4/8] Add failing test for polymorphic_embed_inputs_for child errors with nil parent action --- test/polymorphic_embed_test.exs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/polymorphic_embed_test.exs b/test/polymorphic_embed_test.exs index a33e2ca..650bd4e 100644 --- a/test/polymorphic_embed_test.exs +++ b/test/polymorphic_embed_test.exs @@ -2868,6 +2868,30 @@ 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 for list of embeds" do for generator <- @generators do reminder_module = get_module(Reminder, generator) From 631fcd8f77c6621267b76c81472233f6a6b5b07d Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 7 Apr 2026 10:14:47 +0200 Subject: [PATCH 5/8] fix --- lib/polymorphic_embed/html/helpers.ex | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/polymorphic_embed/html/helpers.ex b/lib/polymorphic_embed/html/helpers.ex index 33c6038..f1e8578 100644 --- a/lib/polymorphic_embed/html/helpers.ex +++ b/lib/polymorphic_embed/html/helpers.ex @@ -113,11 +113,17 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d end end - defp prepare_changeset({%Ecto.Changeset{} = changeset, i}, _params, _parent_action) do + 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} + %{ + changeset: changeset, + params: params, + errors: errors, + index: i + } end defp prepare_changeset({data, i}, params, parent_action) do @@ -132,8 +138,7 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d changeset = %Ecto.Changeset{ changeset - | action: parent_action, - params: params, + | params: params, errors: errors, valid?: errors == [] } From 7fbe87e6cc2fd2d77c3787a017eab828d7b59d4d Mon Sep 17 00:00:00 2001 From: Mathieu Decaffmeyer <5883963+mathieuprog@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:11:05 +0800 Subject: [PATCH 6/8] Add regression tests for HTML helper edge cases --- test/polymorphic_embed_test.exs | 85 +++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/test/polymorphic_embed_test.exs b/test/polymorphic_embed_test.exs index 650bd4e..ad3600c 100644 --- a/test/polymorphic_embed_test.exs +++ b/test/polymorphic_embed_test.exs @@ -2892,6 +2892,33 @@ defmodule PolymorphicEmbedTest do 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) @@ -2970,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) From e6ad49701265cfca8181710c8b92f005be976aa1 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 21 Oct 2026 16:17:02 +0200 Subject: [PATCH 7/8] Preserve child form parameters and actions --- README.md | 2 + lib/polymorphic_embed.ex | 15 +- lib/polymorphic_embed/html/helpers.ex | 14 +- test/polymorphic_embed/html/helpers_test.exs | 190 +++++++++++++++++++ test/polymorphic_embed_test.exs | 4 +- 5 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 test/polymorphic_embed/html/helpers_test.exs 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 f1e8578..4cb577f 100644 --- a/lib/polymorphic_embed/html/helpers.ex +++ b/lib/polymorphic_embed/html/helpers.ex @@ -136,19 +136,15 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d errors = get_errors(changeset) - changeset = %Ecto.Changeset{ - changeset - | params: params, - errors: errors, - valid?: errors == [] - } + changeset = %{changeset | params: params} %{changeset: changeset, params: params, errors: errors, index: i} 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 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..a9b4e2d --- /dev/null +++ b/test/polymorphic_embed/html/helpers_test.exs @@ -0,0 +1,190 @@ +defmodule PolymorphicEmbed.HTML.HelpersTest do + use ExUnit.Case, async: true + + alias Ecto.Changeset + alias PolymorphicEmbed.Channel.Email + 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 + + 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 ad3600c..7f24242 100644 --- a/test/polymorphic_embed_test.exs +++ b/test/polymorphic_embed_test.exs @@ -2956,7 +2956,7 @@ defmodule PolymorphicEmbedTest do if(polymorphic?(generator), do: ~s""" - + """, @@ -2983,7 +2983,7 @@ defmodule PolymorphicEmbedTest do if(polymorphic?(generator), do: ~s""" - + """, From c134615f6ee5122000995468be009e9476029c74 Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 22 Oct 2026 11:45:32 +0200 Subject: [PATCH 8/8] Read nested embed values of valid children from data A valid child form has no changes, so phoenix_ecto read nested embed values from the raw form parameters. Drop embed keys from the form parameters and keep them on the changeset for nested polymorphic forms. --- lib/polymorphic_embed/html/helpers.ex | 14 ++++++ test/polymorphic_embed/html/helpers_test.exs | 52 +++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/polymorphic_embed/html/helpers.ex b/lib/polymorphic_embed/html/helpers.ex index 4cb577f..be99c84 100644 --- a/lib/polymorphic_embed/html/helpers.ex +++ b/lib/polymorphic_embed/html/helpers.ex @@ -138,9 +138,23 @@ if Code.ensure_loaded?(Phoenix.HTML) && Code.ensure_loaded?(Phoenix.HTML.Form) d 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} diff --git a/test/polymorphic_embed/html/helpers_test.exs b/test/polymorphic_embed/html/helpers_test.exs index a9b4e2d..59c8d36 100644 --- a/test/polymorphic_embed/html/helpers_test.exs +++ b/test/polymorphic_embed/html/helpers_test.exs @@ -2,7 +2,8 @@ defmodule PolymorphicEmbed.HTML.HelpersTest do use ExUnit.Case, async: true alias Ecto.Changeset - alias PolymorphicEmbed.Channel.Email + alias PolymorphicEmbed.Channel.{Email, SMSAttempts, SMSResult, TwilioSMSProvider} + alias PolymorphicEmbed.Country alias PolymorphicEmbed.HTML.Helpers alias PolymorphicEmbed.Reminder alias PolymorphicEmbed.Reminder.Context.{Age, Location} @@ -184,6 +185,55 @@ defmodule PolymorphicEmbed.HTML.HelpersTest do 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