diff --git a/lib/ash_phoenix/form/auto.ex b/lib/ash_phoenix/form/auto.ex index b4db758..57a0286 100644 --- a/lib/ash_phoenix/form/auto.ex +++ b/lib/ash_phoenix/form/auto.ex @@ -212,6 +212,7 @@ defmodule AshPhoenix.Form.Auto do prepare_source: prepare_source, transform_params: transform_params, embed?: true, + union?: true, save_updates?: false, forms: Keyword.new( diff --git a/lib/ash_phoenix/form/form.ex b/lib/ash_phoenix/form/form.ex index 9f5b2fd..ab229aa 100644 --- a/lib/ash_phoenix/form/form.ex +++ b/lib/ash_phoenix/form/form.ex @@ -1664,6 +1664,7 @@ defmodule AshPhoenix.Form do id: form.id <> "_#{key}_#{index}" ) end + |> clear_embed_source_errors(opts) Map.update(forms, key, [new_form], &(&1 ++ [new_form])) @@ -1682,6 +1683,7 @@ defmodule AshPhoenix.Form do ) |> Map.put(:name, form.name <> "[#{key}][#{index}]") |> Map.put(:id, form.id <> "_#{key}_#{index}") + |> clear_embed_source_errors(opts) Map.update(forms, key, [validated], fn nested_forms -> nested_forms ++ @@ -1722,6 +1724,7 @@ defmodule AshPhoenix.Form do accessing_from: opts[:managed_relationship], prepare_source: opts[:prepare_source] ) + |> clear_embed_source_errors(opts) Map.put(forms, key, new_form) else @@ -1758,6 +1761,7 @@ defmodule AshPhoenix.Form do as: form.name <> "[#{key}]", id: form.id <> "_#{key}" ) + |> clear_embed_source_errors(opts) Map.put(forms, key, new_form) end @@ -2393,6 +2397,23 @@ defmodule AshPhoenix.Form do end end + # The parent action casts and validates embedded params inline, so running + # the embed's own action would produce a second, duplicate validation that + # bypasses parent-level logic. We still need the standalone source for + # rendering; `carry_over_errors/2` replays any real errors from the parent. + # + # Unions are excluded: they rewrite params into an `%Ash.Union{}` struct + # before handing them up, so the parent can't catch input errors and the + # standalone validation is the only line of defense. `union?` is set by + # `AshPhoenix.Form.Auto.unions/3` to mark these forms explicitly. + defp clear_embed_source_errors(form, opts) do + if opts[:embed?] && !opts[:union?] && form.source do + %{form | source: %{form.source | errors: [], valid?: true}} + else + form + end + end + defp carry_over_errors(form, additional_errors \\ nil) do errors = List.wrap(form.source.errors) diff --git a/test/form_test.exs b/test/form_test.exs index 6489131..44f933e 100644 --- a/test/form_test.exs +++ b/test/form_test.exs @@ -1464,6 +1464,66 @@ defmodule AshPhoenix.FormTest do assert is_map(all_raw_errors) assert Map.has_key?(all_raw_errors, []) end + + test "embedded forms are not validated in isolation when parent cast_input allows empty values" do + form = + Author + |> Form.for_create(:create, forms: [auto?: true]) + |> Form.add_form(:address, params: %{}) + |> Form.validate(%{ + "email" => "me@example.com", + "address" => %{"line1" => "", "city" => "", "postcode" => ""} + }) + + assert Form.errors(form, for_path: :all) == %{} + assert form.valid? + end + + test "embedded forms still surface parent errors for partial input" do + form = + Author + |> Form.for_create(:create, forms: [auto?: true]) + |> Form.add_form(:address, params: %{}) + |> Form.validate(%{ + "email" => "me@example.com", + "address" => %{"line1" => "1 Main St", "city" => "", "postcode" => ""} + }) + + errors = Form.errors(form, for_path: [:address]) + assert {:postcode, _} = List.keyfind(errors, :postcode, 0) + refute form.valid? + end + + test "manage_relationship forms still validate standalone on validate" do + form = + Comment + |> Form.for_create(:create, forms: [auto?: true]) + |> Form.add_form(:post, params: %{}) + |> Form.validate(%{"text" => "comment text", "post" => %{}}) + + assert Form.errors(form, for_path: [:post]) == [text: "is required"] + refute form.valid? + end + + test "update form with existing embedded data is valid without changes" do + author = %Author{ + id: Ash.UUID.generate(), + email: "me@example.com", + address: %AshPhoenix.Test.Address{ + line1: "1 Main St", + city: "Sydney", + postcode: "2000" + } + } + + form = + author + |> Form.for_update(:update, forms: [auto?: true]) + |> Form.validate(%{"email" => "new@example.com"}) + + assert Form.errors(form, for_path: :all) == %{} + assert form.valid? + end end describe "data" do diff --git a/test/support/resources/address.ex b/test/support/resources/address.ex new file mode 100644 index 0000000..236de5d --- /dev/null +++ b/test/support/resources/address.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2020 ash_phoenix contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPhoenix.Test.Address do + @moduledoc false + use Ash.Resource, data_layer: :embedded + + attributes do + attribute :line1, :string, allow_nil?: false, public?: true + attribute :city, :string, allow_nil?: false, public?: true + attribute :postcode, :string, allow_nil?: false, public?: true + end + + defoverridable cast_input: 2 + + def cast_input(value, constraints) when is_map(value) and not is_struct(value) do + if Enum.all?(Map.values(value), &(&1 in [nil, ""])) do + {:ok, nil} + else + super(value, constraints) + end + end + + def cast_input(value, constraints), do: super(value, constraints) +end diff --git a/test/support/resources/author.ex b/test/support/resources/author.ex index e62876a..6545514 100644 --- a/test/support/resources/author.ex +++ b/test/support/resources/author.ex @@ -16,6 +16,7 @@ defmodule AshPhoenix.Test.Author do attributes do uuid_primary_key(:id) attribute(:email, :string, allow_nil?: false, public?: true) + attribute(:address, AshPhoenix.Test.Address, allow_nil?: true, public?: true) end actions do