diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index 7dc82554d..3e9e9df67 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -357,6 +357,21 @@ defmodule Backpex.Field do def readonly?(%{readonly: readonly}, assigns) when is_function(readonly, 1), do: readonly.(assigns) def readonly?(_field_options, _assigns), do: false + @doc """ + Drops readonly field changes from the given change map. + + Takes a map of string-keyed form params, a keyword list of field definitions, + and assigns. Returns the change map with readonly field keys removed. + """ + def drop_readonly_changes(change, fields, assigns) do + read_only = + fields + |> Enum.filter(fn {_name, options} -> readonly?(options, assigns) end) + |> Enum.map(fn {name, _options} -> Atom.to_string(name) end) + + Map.drop(change, read_only) + end + def translate_error_fun(%{translate_error: translate_error}, _assigns) when is_function(translate_error, 1), do: translate_error diff --git a/lib/backpex/live_components/form_component.ex b/lib/backpex/live_components/form_component.ex index 82a2a666a..a8979ea28 100644 --- a/lib/backpex/live_components/form_component.ex +++ b/lib/backpex/live_components/form_component.ex @@ -406,12 +406,7 @@ defmodule Backpex.FormComponent do end defp drop_readonly_changes(change, fields, assigns) do - read_only = - fields - |> Enum.filter(&Backpex.Field.readonly?(&1, assigns)) - |> Enum.map(&Atom.to_string(&1.name)) - - Map.drop(change, read_only) + Backpex.Field.drop_readonly_changes(change, fields, assigns) end defp drop_unused_changes(change) do diff --git a/test/field_test.exs b/test/field_test.exs new file mode 100644 index 000000000..4bc91e473 --- /dev/null +++ b/test/field_test.exs @@ -0,0 +1,66 @@ +defmodule Backpex.FieldTest do + use ExUnit.Case, async: true + + alias Backpex.Field + + # Simulates a LiveResource fields/0 callback structure + defmodule UpstreamPrices do + def fields do + [ + name: %{module: Backpex.Fields.Text, label: "Name"}, + upstream_price: %{module: Backpex.Fields.Number, label: "Upstream Price", readonly: true}, + override_price: %{module: Backpex.Fields.Number, label: "Our Price"} + ] + end + end + + describe "drop_readonly_changes/3" do + test "readonly fields are correctly filtered from LiveResource fields" do + fields = UpstreamPrices.fields() + change = %{"name" => "Backpack", "upstream_price" => "100", "override_price" => "200"} + + filtered = Field.drop_readonly_changes(change, fields, %{}) + + assert filtered == %{"name" => "Backpack", "override_price" => "200"} + refute Map.has_key?(filtered, "upstream_price") + end + + test "readonly function field is filtered when condition is true" do + fields = [ + name: %{module: Backpex.Fields.Text, label: "Name"}, + secret: %{module: Backpex.Fields.Text, label: "Secret", readonly: fn assigns -> assigns[:role] == :viewer end} + ] + + change = %{"name" => "Test", "secret" => "hidden"} + + # As viewer - secret should be filtered + filtered = Field.drop_readonly_changes(change, fields, %{role: :viewer}) + assert filtered == %{"name" => "Test"} + + # As admin - secret should remain + filtered = Field.drop_readonly_changes(change, fields, %{role: :admin}) + assert filtered == %{"name" => "Test", "secret" => "hidden"} + end + end + + describe "readonly?/2" do + test "returns true for boolean true" do + assert Field.readonly?(%{readonly: true}, %{}) == true + end + + test "returns false for boolean false" do + assert Field.readonly?(%{readonly: false}, %{}) == false + end + + test "returns false when readonly key is missing" do + assert Field.readonly?(%{}, %{}) == false + end + + test "evaluates function against assigns" do + readonly_fn = fn assigns -> assigns[:role] == :viewer end + + assert Field.readonly?(%{readonly: readonly_fn}, %{role: :viewer}) == true + assert Field.readonly?(%{readonly: readonly_fn}, %{role: :admin}) == false + end + end +end