Implements validation for required custom fields closes #274 #301

Open
carla wants to merge 2 commits from bugfix/274_required_custom_fields into main
3 changed files with 605 additions and 0 deletions

View file

@ -329,6 +329,71 @@ defmodule Mv.Membership.Member do
{:error, field: :email, message: "is not a valid email"}
end
end
# Validate required custom fields
validate fn changeset, _ ->
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
# If argument is not provided (nil), check existing values from member data (update scenario)
# If argument is provided (empty list or list with values), use those values
provided_values =
if is_nil(custom_field_values_arg) do
# Update scenario: check existing custom field values from member
case Ash.load(changeset.data, :custom_field_values) do
{:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, fn cfv, acc ->
value = if is_map(cfv.value), do: Map.get(cfv.value, :value), else: nil
Map.put(acc, cfv.custom_field_id, value)
end)
_ ->
%{}
end
else
# Create or update scenario: use provided argument values
Enum.reduce(custom_field_values_arg, %{}, fn cfv, acc ->
custom_field_id = Map.get(cfv, "custom_field_id")
value_map = Map.get(cfv, "value", %{})
# Support both "value" and "_union_value" keys, without using || to preserve false values
actual_value =
cond do
Map.has_key?(value_map, "value") -> Map.get(value_map, "value")
Map.has_key?(value_map, "_union_value") -> Map.get(value_map, "_union_value")
true -> nil
end
Map.put(acc, custom_field_id, actual_value)
end)
end
# Load all required custom fields
case Mv.Membership.list_custom_fields() do
{:ok, all_custom_fields} ->
required_custom_fields = Enum.filter(all_custom_fields, & &1.required)
# Check each required custom field
missing_fields =
Enum.filter(required_custom_fields, fn cf ->
value = Map.get(provided_values, cf.id)
not value_present?(value, cf.value_type)
end)
if Enum.empty?(missing_fields) do
:ok
else
missing_names = Enum.map_join(missing_fields, ", ", & &1.name)
{:error,
field: :custom_field_values,
message: "Required custom fields missing: #{missing_names}"}
end
{:error, _} ->
# If we can't load custom fields, skip validation (shouldn't happen in normal operation)
:ok
end
end
end
attributes do
@ -665,4 +730,18 @@ defmodule Mv.Membership.Member do
query
end
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 is invalid, 0 is valid
# Date: nil is invalid
# Email: nil is 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), do: not is_nil(value)
defp value_present?(value, :date), do: not is_nil(value)
defp value_present?(value, :email), do: not is_nil(value)
defp value_present?(_value, _type), do: false
end

View file

@ -144,6 +144,7 @@ defmodule MvWeb.MemberLive.Form do
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
required={cf.required}
/>
</.inputs_for>
<input

View file

@ -0,0 +1,525 @@
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, 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,
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
}) 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]}
}
]
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 "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 "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 "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