Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/ash_phoenix/form/auto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions lib/ash_phoenix/form/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]))

Expand All @@ -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 ++
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
60 changes: 60 additions & 0 deletions test/form_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions test/support/resources/address.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2020 ash_phoenix contributors <https://github.com/ash-project/ash_phoenix/graphs/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
1 change: 1 addition & 0 deletions test/support/resources/author.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading