diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 3a0fa5b..1d6d96e 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -39,6 +39,7 @@ defmodule Mv.Membership.Member do require Ash.Query import Ash.Expr + require Logger # Module constants @member_search_limit 10 @@ -73,6 +74,9 @@ defmodule Mv.Membership.Member do create :create_member do primary? true + + # Note: Custom validation function cannot be done atomically (queries DB for required custom fields) + # In Ash 3.0, require_atomic? is not available for create actions, but the validation will still work # Custom field values can be created along with member argument :custom_field_values, {:array, :map} # Allow user to be passed as argument for relationship management @@ -417,6 +421,32 @@ defmodule Mv.Membership.Member do {:error, field: :email, message: "is not a valid email"} end end + + # Validate required custom fields + validate fn changeset, _ -> + provided_values = provided_custom_field_values(changeset) + + case Mv.Membership.list_required_custom_fields() do + {:ok, required_custom_fields} -> + missing_fields = missing_required_fields(required_custom_fields, provided_values) + + if Enum.empty?(missing_fields) do + :ok + else + build_custom_field_validation_error(missing_fields) + end + + {:error, error} -> + Logger.error( + "Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed." + ) + + {:error, + field: :custom_field_values, + message: + "Unable to validate required custom fields. Please try again or contact support."} + end + end end attributes do @@ -1132,4 +1162,127 @@ defmodule Mv.Membership.Member do query end end + + # Extracts provided custom field values from changeset + # Handles both create (from argument) and update (from existing data) scenarios + defp provided_custom_field_values(changeset) do + custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values) + + if is_nil(custom_field_values_arg) do + extract_existing_values(changeset.data) + else + extract_argument_values(custom_field_values_arg) + end + end + + # Extracts custom field values from existing member data (update scenario) + defp extract_existing_values(member_data) do + case Ash.load(member_data, :custom_field_values) do + {:ok, %{custom_field_values: existing_values}} -> + Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2) + + _ -> + %{} + end + end + + # Extracts value from a CustomFieldValue struct + defp extract_value_from_cfv(cfv, acc) do + value = extract_union_value(cfv.value) + Map.put(acc, cfv.custom_field_id, value) + end + + # Extracts value from union type (map or direct value) + defp extract_union_value(value) when is_map(value), do: Map.get(value, :value) + defp extract_union_value(value), do: value + + # Extracts custom field values from provided argument (create/update scenario) + defp extract_argument_values(custom_field_values_arg) do + Enum.reduce(custom_field_values_arg, %{}, &extract_value_from_arg/2) + end + + # Extracts value from argument map + defp extract_value_from_arg(cfv, acc) do + custom_field_id = Map.get(cfv, "custom_field_id") + value_map = Map.get(cfv, "value", %{}) + actual_value = extract_value_from_map(value_map) + Map.put(acc, custom_field_id, actual_value) + end + + # Extracts value from map, supporting both "value" and "_union_value" keys + # Also handles Ash.Union structs (which have atom keys :value and :type) + # Uses cond instead of || to preserve false values + defp extract_value_from_map(value_map) do + cond do + # Handle Ash.Union struct - check if it's a struct with __struct__ == Ash.Union + match?({:ok, Ash.Union}, Map.fetch(value_map, :__struct__)) -> + Map.get(value_map, :value) + + # Handle map with string keys + Map.has_key?(value_map, "value") -> + Map.get(value_map, "value") + + Map.has_key?(value_map, "_union_value") -> + Map.get(value_map, "_union_value") + + # Handle map with atom keys + Map.has_key?(value_map, :value) -> + Map.get(value_map, :value) + + true -> + nil + end + end + + # Finds which required custom fields are missing from provided values + defp missing_required_fields(required_custom_fields, provided_values) do + Enum.filter(required_custom_fields, fn cf -> + value = Map.get(provided_values, cf.id) + not value_present?(value, cf.value_type) + end) + end + + # Builds validation error message for missing required custom fields + defp build_custom_field_validation_error(missing_fields) do + # Sort missing fields alphabetically for consistent error messages + sorted_missing_fields = Enum.sort_by(missing_fields, & &1.name) + missing_names = Enum.map_join(sorted_missing_fields, ", ", & &1.name) + + {:error, + field: :custom_field_values, + message: + Gettext.dgettext(MvWeb.Gettext, "default", "Required custom fields missing: %{fields}", + fields: missing_names + )} + end + + # Helper function to check if a value is present for a given custom field type + # Boolean: false is valid, only nil is invalid + # String: nil or empty strings are invalid + # Integer: nil or empty strings are invalid, 0 is valid + # Date: nil or empty strings are invalid + # Email: nil or empty strings are invalid + defp value_present?(nil, _type), do: false + + defp value_present?(value, :boolean), do: not is_nil(value) + + defp value_present?(value, :string), do: is_binary(value) and String.trim(value) != "" + + defp value_present?(value, :integer) when is_integer(value), do: true + + defp value_present?(value, :integer) when is_binary(value), do: String.trim(value) != "" + + defp value_present?(_value, :integer), do: false + + defp value_present?(value, :date) when is_struct(value, Date), do: true + + defp value_present?(value, :date) when is_binary(value), do: String.trim(value) != "" + + defp value_present?(_value, :date), do: false + + defp value_present?(value, :email) when is_binary(value), do: String.trim(value) != "" + + defp value_present?(_value, :email), do: false + + defp value_present?(_value, _type), do: false end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index f5a708b..4917c7c 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -21,6 +21,9 @@ defmodule Mv.Membership do use Ash.Domain, extensions: [AshAdmin.Domain, AshPhoenix] + require Ash.Query + import Ash.Expr + admin do show? true end @@ -125,6 +128,29 @@ defmodule Mv.Membership do |> Ash.update(domain: __MODULE__) end + @doc """ + Lists only required custom fields. + + This is an optimized version that filters at the database level instead of + loading all custom fields and filtering in memory. + + ## Returns + + - `{:ok, required_custom_fields}` - List of required custom fields + - `{:error, error}` - Error reading custom fields + + ## Examples + + iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields() + iex> Enum.all?(required_fields, & &1.required) + true + """ + def list_required_custom_fields do + Mv.Membership.CustomField + |> Ash.Query.filter(expr(required == true)) + |> Ash.read(domain: __MODULE__) + end + @doc """ Updates the member field visibility configuration. diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index a1020ef..ccec5a5 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -333,7 +333,8 @@ defmodule MvWeb.CoreComponents do attr :error_class, :string, default: nil, doc: "the input error class to use over defaults" attr :rest, :global, - include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength + include: + ~w(accept autocomplete aria-required capture cols disabled form list max maxlength min minlength multiple pattern placeholder readonly required rows size step) def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do @@ -353,6 +354,24 @@ defmodule MvWeb.CoreComponents do Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) end) + # For checkboxes, we don't use HTML required attribute (means "must be checked") + # Instead, we use aria-required for screen readers (WCAG 2.1, Success Criterion 3.3.2) + # Extract required from rest and remove it, but keep aria-required if provided + rest = assigns.rest || %{} + is_required = Map.get(rest, :required, false) + aria_required = Map.get(rest, :aria_required, if(is_required, do: "true", else: nil)) + + # Remove required from rest (we don't want HTML required on checkbox) + rest_without_required = Map.delete(rest, :required) + # Ensure aria-required is set if field is required + rest_final = + if aria_required, + do: Map.put(rest_without_required, :aria_required, aria_required), + else: rest_without_required + + assigns = assign(assigns, :rest, rest_final) + assigns = assign(assigns, :is_required, is_required) + ~H"""
diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 63ab513..53754aa 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -148,6 +148,7 @@ defmodule MvWeb.MemberLive.Form do field={value_form[:value]} label={cf.name} type={custom_field_input_type(cf.value_type)} + required={cf.required} /> Ash.Changeset.for_create(:create, %{ + name: "required_string", + value_type: :string, + required: true + }) + |> Ash.create() + + {:ok, required_integer_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "required_integer", + value_type: :integer, + required: true + }) + |> Ash.create() + + {:ok, required_boolean_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "required_boolean", + value_type: :boolean, + required: true + }) + |> Ash.create() + + {:ok, required_date_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "required_date", + value_type: :date, + required: true + }) + |> Ash.create() + + {:ok, required_email_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "required_email", + value_type: :email, + required: true + }) + |> Ash.create() + + {:ok, optional_field} = + Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "optional_string", + value_type: :string, + required: false + }) + |> Ash.create() + + %{ + required_string_field: required_string_field, + required_integer_field: required_integer_field, + required_boolean_field: required_boolean_field, + required_date_field: required_date_field, + required_email_field: required_email_field, + optional_field: optional_field + } + end + + # Helper function to create all required custom fields with valid default values + defp all_required_custom_fields_with_defaults(%{ + required_string_field: string_field, + required_integer_field: integer_field, + required_boolean_field: boolean_field, + required_date_field: date_field, + required_email_field: email_field + }) do + [ + %{ + "custom_field_id" => string_field.id, + "value" => %{"_union_type" => "string", "_union_value" => "default"} + }, + %{ + "custom_field_id" => integer_field.id, + "value" => %{"_union_type" => "integer", "_union_value" => 0} + }, + %{ + "custom_field_id" => boolean_field.id, + "value" => %{"_union_type" => "boolean", "_union_value" => false} + }, + %{ + "custom_field_id" => date_field.id, + "value" => %{"_union_type" => "date", "_union_value" => ~D[2020-01-01]} + }, + %{ + "custom_field_id" => email_field.id, + "value" => %{"_union_type" => "email", "_union_value" => "test@example.com"} + } + ] + end + + describe "create_member with required custom fields" do + @valid_attrs %{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + + test "fails when required custom field is missing", %{required_string_field: field} do + attrs = Map.put(@valid_attrs, :custom_field_values, []) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required string custom field has nil value", + %{ + required_string_field: field + } = context do + # Start with all required fields having valid values + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "string", "_union_value" => nil}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required string custom field has empty string value", + %{ + required_string_field: field + } = context do + # Start with all required fields having valid values + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required string custom field has whitespace-only value", + %{ + required_string_field: field + } = context do + # Start with all required fields having valid values + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "string", "_union_value" => " "}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required string custom field has valid value", + %{ + required_string_field: field + } = context do + # Start with all required fields having valid values, then update the string field + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test value"}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when required integer custom field has nil value", + %{ + required_integer_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "integer", "_union_value" => nil}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required integer custom field has empty string value", + %{ + required_integer_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "integer", "_union_value" => ""}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required integer custom field has zero value", + %{ + required_integer_field: _field + } = context do + custom_field_values = all_required_custom_fields_with_defaults(context) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "succeeds when required integer custom field has positive value", + %{ + required_integer_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when required boolean custom field has nil value", + %{ + required_boolean_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => nil}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required boolean custom field has false value", + %{ + required_boolean_field: _field + } = context do + custom_field_values = all_required_custom_fields_with_defaults(context) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "succeeds when required boolean custom field has true value", + %{ + required_boolean_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when required date custom field has nil value", + %{ + required_date_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "date", "_union_value" => nil}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required date custom field has empty string value", + %{ + required_date_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "date", "_union_value" => ""}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required date custom field has valid date value", + %{ + required_date_field: _field + } = context do + custom_field_values = all_required_custom_fields_with_defaults(context) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when required email custom field has nil value", + %{ + required_email_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "email", "_union_value" => nil}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when required email custom field has empty string value", + %{ + required_email_field: field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "email", "_union_value" => ""}} + else + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when required email custom field has valid email value", + %{ + required_email_field: _field + } = context do + custom_field_values = all_required_custom_fields_with_defaults(context) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "succeeds when multiple required custom fields are provided", + %{ + required_string_field: string_field, + required_integer_field: integer_field, + required_boolean_field: boolean_field + } = context do + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + cond do + cfv["custom_field_id"] == string_field.id -> + %{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}} + + cfv["custom_field_id"] == integer_field.id -> + %{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}} + + cfv["custom_field_id"] == boolean_field.id -> + %{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}} + + true -> + cfv + end + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "fails when one of multiple required custom fields is missing", + %{ + required_string_field: string_field, + required_integer_field: integer_field + } = context do + # Provide only string field, missing integer, boolean, and date + custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.filter(fn cfv -> + cfv["custom_field_id"] == string_field.id + end) + |> Enum.map(fn cfv -> + %{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}} + end) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ integer_field.name + end + + test "succeeds when optional custom field is missing", %{} = context do + # Provide all required fields, but no optional field + custom_field_values = all_required_custom_fields_with_defaults(context) + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "succeeds when optional custom field has nil value", + %{optional_field: field} = context do + # Provide all required fields plus optional field with nil + custom_field_values = + all_required_custom_fields_with_defaults(context) ++ + [ + %{ + "custom_field_id" => field.id, + "value" => %{"_union_type" => "string", "_union_value" => nil} + } + ] + + attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values) + + assert {:ok, _member} = Membership.create_member(attrs) + end + end + + describe "update_member with required custom fields" do + test "fails when removing a required custom field value", + %{ + required_string_field: field + } = context do + # Create member with all required custom fields + custom_field_values = all_required_custom_fields_with_defaults(context) + + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com", + custom_field_values: custom_field_values + }) + + # Try to update without the required custom field + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Membership.update_member(member, %{custom_field_values: []}) + + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "fails when setting required custom field value to empty", + %{ + required_string_field: field + } = context do + # Create member with all required custom fields + custom_field_values = all_required_custom_fields_with_defaults(context) + + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com", + custom_field_values: custom_field_values + }) + + # Try to update with empty value for the string field + updated_custom_field_values = + all_required_custom_fields_with_defaults(context) + |> Enum.map(fn cfv -> + if cfv["custom_field_id"] == field.id do + %{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}} + else + cfv + end + end) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Membership.update_member(member, %{ + custom_field_values: updated_custom_field_values + }) + + assert error_message(errors, :custom_field_values) =~ "Required custom fields missing" + assert error_message(errors, :custom_field_values) =~ field.name + end + + test "succeeds when updating required custom field to valid value", + %{ + required_string_field: field + } = context do + # Create member with all required custom fields + custom_field_values = all_required_custom_fields_with_defaults(context) + + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com", + custom_field_values: custom_field_values + }) + + # Load existing custom field values to get their IDs + {:ok, member_with_cfvs} = Ash.load(member, :custom_field_values) + + # Update with new valid value for the string field, using existing IDs + updated_custom_field_values = + member_with_cfvs.custom_field_values + |> Enum.map(fn cfv -> + if cfv.custom_field_id == field.id do + %{ + "id" => cfv.id, + "custom_field_id" => cfv.custom_field_id, + "value" => %{"_union_type" => "string", "_union_value" => "new value"} + } + else + # Keep other fields as they are + value_type = Atom.to_string(cfv.value.type) + actual_value = cfv.value.value + + %{ + "id" => cfv.id, + "custom_field_id" => cfv.custom_field_id, + "value" => %{"_union_type" => value_type, "_union_value" => actual_value} + } + end + end) + + assert {:ok, _updated_member} = + Membership.update_member(member, %{ + custom_field_values: updated_custom_field_values + }) + end + end + + # Helper function for error evaluation + defp error_message(errors, field) do + errors + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map_join(" ", &Map.get(&1, :message, "")) + end +end