Implements validation for required custom fields closes #274 #301
8 changed files with 849 additions and 3 deletions
|
|
@ -39,6 +39,7 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
require Logger
|
||||||
|
|
||||||
# Module constants
|
# Module constants
|
||||||
@member_search_limit 10
|
@member_search_limit 10
|
||||||
|
|
@ -73,6 +74,9 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
create :create_member do
|
create :create_member do
|
||||||
primary? true
|
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
|
# Custom field values can be created along with member
|
||||||
argument :custom_field_values, {:array, :map}
|
argument :custom_field_values, {:array, :map}
|
||||||
# Allow user to be passed as argument for relationship management
|
# Allow user to be passed as argument for relationship management
|
||||||
|
|
@ -329,6 +333,32 @@ defmodule Mv.Membership.Member do
|
||||||
{:error, field: :email, message: "is not a valid email"}
|
{:error, field: :email, message: "is not a valid email"}
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
|
|
@ -665,4 +695,124 @@ defmodule Mv.Membership.Member do
|
||||||
query
|
query
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ defmodule Mv.Membership do
|
||||||
use Ash.Domain,
|
use Ash.Domain,
|
||||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
admin do
|
admin do
|
||||||
show? true
|
show? true
|
||||||
end
|
end
|
||||||
|
|
@ -125,6 +128,29 @@ defmodule Mv.Membership do
|
||||||
|> Ash.update(domain: __MODULE__)
|
|> Ash.update(domain: __MODULE__)
|
||||||
end
|
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 """
|
@doc """
|
||||||
Updates the member field visibility configuration.
|
Updates the member field visibility configuration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
|
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
|
||||||
|
|
||||||
attr :rest, :global,
|
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)
|
multiple pattern placeholder readonly required rows size step)
|
||||||
|
|
||||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
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])
|
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||||
end)
|
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"""
|
~H"""
|
||||||
<fieldset class="mb-2 fieldset">
|
<fieldset class="mb-2 fieldset">
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -367,9 +386,9 @@ defmodule MvWeb.CoreComponents do
|
||||||
class={@class || "checkbox checkbox-sm"}
|
class={@class || "checkbox checkbox-sm"}
|
||||||
{@rest}
|
{@rest}
|
||||||
/>{@label}<span
|
/>{@label}<span
|
||||||
:if={@rest[:required]}
|
:if={@is_required}
|
||||||
class="text-red-700 tooltip tooltip-right"
|
class="text-red-700 tooltip tooltip-right"
|
||||||
data-tip={gettext("This field cannot be empty")}
|
data-tip={gettext("This field is required")}
|
||||||
>*</span>
|
>*</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
field={value_form[:value]}
|
field={value_form[:value]}
|
||||||
label={cf.name}
|
label={cf.name}
|
||||||
type={custom_field_input_type(cf.value_type)}
|
type={custom_field_input_type(cf.value_type)}
|
||||||
|
required={cf.required}
|
||||||
/>
|
/>
|
||||||
</.inputs_for>
|
</.inputs_for>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1433,6 +1433,11 @@ msgstr "Benutzerdefiniertes Feld speichern"
|
||||||
msgid "Save Custom Field Value"
|
msgid "Save Custom Field Value"
|
||||||
msgstr "Benutzerdefinierten Feldwert speichern"
|
msgstr "Benutzerdefinierten Feldwert speichern"
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This field is required"
|
||||||
|
msgstr "Dieses Feld ist erforderlich"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Auto-generated identifier (immutable)"
|
#~ msgid "Auto-generated identifier (immutable)"
|
||||||
|
|
|
||||||
|
|
@ -1433,3 +1433,8 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Custom Field Value"
|
msgid "Save Custom Field Value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This field is required"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -1434,6 +1434,11 @@ msgstr ""
|
||||||
msgid "Save Custom Field Value"
|
msgid "Save Custom Field Value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This field is required"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Auto-generated identifier (immutable)"
|
#~ msgid "Auto-generated identifier (immutable)"
|
||||||
|
|
|
||||||
635
test/membership/member_required_custom_fields_test.exs
Normal file
635
test/membership/member_required_custom_fields_test.exs
Normal file
|
|
@ -0,0 +1,635 @@
|
||||||
|
defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for required custom fields validation.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Member creation without required custom field → error
|
||||||
|
- Member creation with empty required custom field (nil/empty string) → error
|
||||||
|
- Member creation with valid required custom field → success
|
||||||
|
- Member update: removing a required custom field value → error
|
||||||
|
- Boolean required custom field: false is valid, nil is invalid
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Create required custom fields for different types
|
||||||
|
{:ok, required_string_field} =
|
||||||
|
Membership.CustomField
|
||||||
|
|> 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue