Configurable member field "required" flag and Vereinfacht-required fields closes #440 #441
20 changed files with 1069 additions and 231 deletions
|
|
@ -2849,12 +2849,14 @@ Building accessible applications ensures that all users, including those with di
|
||||||
|
|
||||||
**Required Fields:**
|
**Required Fields:**
|
||||||
|
|
||||||
|
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional.
|
||||||
|
|
||||||
```heex
|
```heex
|
||||||
<!-- Mark required fields -->
|
<!-- Mark required fields (value from settings or always true for email) -->
|
||||||
<.input
|
<.input
|
||||||
field={@form[:first_name]}
|
field={@form[:first_name]}
|
||||||
label={gettext("First Name")}
|
label={gettext("First Name")}
|
||||||
required
|
required={@member_field_required_map[:first_name]}
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -500,48 +500,97 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate required custom fields (actor from validation context only; no fallback)
|
# Validate required custom fields (actor from validation context only; no fallback).
|
||||||
|
# Only for create_member/update_member; skip for set_vereinfacht_contact_id (internal sync
|
||||||
|
# only sets vereinfacht_contact_id; custom fields were already validated and saved).
|
||||||
validate fn changeset, context ->
|
validate fn changeset, context ->
|
||||||
provided_values = provided_custom_field_values(changeset)
|
provided_values = provided_custom_field_values(changeset)
|
||||||
actor = context.actor
|
actor = context.actor
|
||||||
|
|
||||||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||||||
{:ok, required_custom_fields} ->
|
{:ok, required_custom_fields} ->
|
||||||
missing_fields = missing_required_fields(required_custom_fields, provided_values)
|
missing_fields =
|
||||||
|
missing_required_fields(required_custom_fields, provided_values)
|
||||||
|
|
||||||
if Enum.empty?(missing_fields) do
|
if Enum.empty?(missing_fields) do
|
||||||
:ok
|
:ok
|
||||||
else
|
else
|
||||||
build_custom_field_validation_error(missing_fields)
|
build_custom_field_validation_error(missing_fields)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
"Required custom fields validation: actor not authorized to read CustomField"
|
"Required custom fields validation: actor not authorized to read CustomField"
|
||||||
)
|
)
|
||||||
|
|
||||||
{:error,
|
{:error,
|
||||||
field: :custom_field_values,
|
field: :custom_field_values,
|
||||||
message:
|
message:
|
||||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||||
|
|
||||||
{:error, :missing_actor} ->
|
{:error, :missing_actor} ->
|
||||||
Logger.warning("Required custom fields validation: no actor in context")
|
Logger.warning("Required custom fields validation: no actor in context")
|
||||||
|
|
||||||
{:error,
|
{:error,
|
||||||
field: :custom_field_values,
|
field: :custom_field_values,
|
||||||
message:
|
message:
|
||||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
Logger.error(
|
Logger.error(
|
||||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||||
)
|
)
|
||||||
|
|
||||||
{:error,
|
{:error,
|
||||||
field: :custom_field_values,
|
field: :custom_field_values,
|
||||||
message:
|
message:
|
||||||
"Unable to validate required custom fields. Please try again or contact support."}
|
"Unable to validate required custom fields. Please try again or contact support."}
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
where: [action_is([:create_member, :update_member])]
|
||||||
|
|
||||||
|
# Validate member fields that are marked as required in settings or by Vereinfacht.
|
||||||
|
# When settings cannot be loaded, we still enforce email + Vereinfacht-required fields.
|
||||||
|
validate fn changeset, _context ->
|
||||||
|
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||||
|
|
||||||
|
required_fields =
|
||||||
|
case Mv.Membership.get_settings() do
|
||||||
|
{:ok, settings} ->
|
||||||
|
required_config = settings.member_field_required || %{}
|
||||||
|
normalized = VisibilityConfig.normalize(required_config)
|
||||||
|
|
||||||
|
Enum.filter(Mv.Constants.member_fields(), fn field ->
|
||||||
|
field == :email ||
|
||||||
|
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
|
||||||
|
Map.get(normalized, field, false)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
|
||||||
|
"Enforcing only email and Vereinfacht-required fields."
|
||||||
|
)
|
||||||
|
|
||||||
|
Enum.filter(Mv.Constants.member_fields(), fn field ->
|
||||||
|
field == :email ||
|
||||||
|
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
missing =
|
||||||
|
Enum.filter(required_fields, fn field ->
|
||||||
|
value = Ash.Changeset.get_attribute(changeset, field)
|
||||||
|
not member_field_value_present?(field, value)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if Enum.empty?(missing) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
field = hd(missing)
|
||||||
|
|
||||||
|
{:error,
|
||||||
|
field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -1420,4 +1469,14 @@ defmodule Mv.Membership.Member do
|
||||||
defp value_present?(_value, :email), do: false
|
defp value_present?(_value, :email), do: false
|
||||||
|
|
||||||
defp value_present?(_value, _type), do: false
|
defp value_present?(_value, _type), do: false
|
||||||
|
|
||||||
|
# Used by member-field-required validation (settings-driven required fields)
|
||||||
|
defp member_field_value_present?(_field, nil), do: false
|
||||||
|
|
||||||
|
defp member_field_value_present?(_, value) when is_binary(value),
|
||||||
|
do: String.trim(value) != ""
|
||||||
|
|
||||||
|
defp member_field_value_present?(_, %Date{}), do: true
|
||||||
|
defp member_field_value_present?(_, value) when is_struct(value, Date), do: true
|
||||||
|
defp member_field_value_present?(_, _), do: false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ defmodule Mv.Membership do
|
||||||
|
|
||||||
define :update_single_member_field_visibility,
|
define :update_single_member_field_visibility,
|
||||||
action: :update_single_member_field_visibility
|
action: :update_single_member_field_visibility
|
||||||
|
|
||||||
|
define :update_single_member_field, action: :update_single_member_field
|
||||||
end
|
end
|
||||||
|
|
||||||
resource Mv.Membership.Group do
|
resource Mv.Membership.Group do
|
||||||
|
|
@ -257,6 +259,46 @@ defmodule Mv.Membership do
|
||||||
|> Ash.update(domain: __MODULE__)
|
|> Ash.update(domain: __MODULE__)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Atomically updates visibility and required for a single member field.
|
||||||
|
|
||||||
|
Updates both `member_field_visibility` and `member_field_required` in one
|
||||||
|
operation. Use this when saving from the member field settings form.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- `settings` - The settings record to update
|
||||||
|
- `field` - The member field name as a string (e.g., "first_name", "street")
|
||||||
|
- `show_in_overview` - Boolean value indicating visibility in member overview
|
||||||
|
- `required` - Boolean value indicating whether the field is required in member forms
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- `{:ok, updated_settings}` - Successfully updated settings
|
||||||
|
- `{:error, error}` - Validation or update error
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
iex> {:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
||||||
|
iex> updated.member_field_required["first_name"]
|
||||||
|
true
|
||||||
|
|
||||||
|
"""
|
||||||
|
def update_single_member_field(settings,
|
||||||
|
field: field,
|
||||||
|
show_in_overview: show_in_overview,
|
||||||
|
required: required
|
||||||
|
) do
|
||||||
|
settings
|
||||||
|
|> Ash.Changeset.new()
|
||||||
|
|> Ash.Changeset.set_argument(:field, field)
|
||||||
|
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||||
|
|> Ash.Changeset.set_argument(:required, required)
|
||||||
|
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||||
|
|> Ash.update(domain: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a group by its slug.
|
Gets a group by its slug.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ defmodule Mv.Membership.Setting do
|
||||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||||
|
- `member_field_required` - JSONB map storing which member fields are required in forms
|
||||||
|
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
|
||||||
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
||||||
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
||||||
|
|
||||||
|
|
@ -42,6 +44,9 @@ defmodule Mv.Membership.Setting do
|
||||||
# Update member field visibility
|
# Update member field visibility
|
||||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||||
|
|
||||||
|
# Update visibility and required for a single member field (e.g. from settings UI)
|
||||||
|
{:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
||||||
|
|
||||||
# Update membership fee settings
|
# Update membership fee settings
|
||||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||||
"""
|
"""
|
||||||
|
|
@ -68,6 +73,7 @@ defmodule Mv.Membership.Setting do
|
||||||
accept [
|
accept [
|
||||||
:club_name,
|
:club_name,
|
||||||
:member_field_visibility,
|
:member_field_visibility,
|
||||||
|
:member_field_required,
|
||||||
:include_joining_cycle,
|
:include_joining_cycle,
|
||||||
:default_membership_fee_type_id,
|
:default_membership_fee_type_id,
|
||||||
:vereinfacht_api_url,
|
:vereinfacht_api_url,
|
||||||
|
|
@ -84,6 +90,7 @@ defmodule Mv.Membership.Setting do
|
||||||
accept [
|
accept [
|
||||||
:club_name,
|
:club_name,
|
||||||
:member_field_visibility,
|
:member_field_visibility,
|
||||||
|
:member_field_required,
|
||||||
:include_joining_cycle,
|
:include_joining_cycle,
|
||||||
:default_membership_fee_type_id,
|
:default_membership_fee_type_id,
|
||||||
:vereinfacht_api_url,
|
:vereinfacht_api_url,
|
||||||
|
|
@ -109,6 +116,17 @@ defmodule Mv.Membership.Setting do
|
||||||
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
|
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
|
||||||
end
|
end
|
||||||
|
|
||||||
|
update :update_single_member_field do
|
||||||
|
description "Atomically updates visibility and required for a single member field"
|
||||||
|
require_atomic? false
|
||||||
|
|
||||||
|
argument :field, :string, allow_nil?: false
|
||||||
|
argument :show_in_overview, :boolean, allow_nil?: false
|
||||||
|
argument :required, :boolean, allow_nil?: false
|
||||||
|
|
||||||
|
change Mv.Membership.Setting.Changes.UpdateSingleMemberField
|
||||||
|
end
|
||||||
|
|
||||||
update :update_membership_fee_settings do
|
update :update_membership_fee_settings do
|
||||||
description "Updates the membership fee configuration"
|
description "Updates the membership fee configuration"
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
|
|
@ -162,6 +180,44 @@ defmodule Mv.Membership.Setting do
|
||||||
end,
|
end,
|
||||||
on: [:create, :update]
|
on: [:create, :update]
|
||||||
|
|
||||||
|
# Validate member_field_required map structure and content
|
||||||
|
validate fn changeset, _context ->
|
||||||
|
required_config = Ash.Changeset.get_attribute(changeset, :member_field_required)
|
||||||
|
|
||||||
|
if required_config && is_map(required_config) do
|
||||||
|
invalid_values =
|
||||||
|
Enum.filter(required_config, fn {_key, value} ->
|
||||||
|
not is_boolean(value)
|
||||||
|
end)
|
||||||
|
|
||||||
|
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
|
|
||||||
|
invalid_keys =
|
||||||
|
Enum.filter(required_config, fn {key, _value} ->
|
||||||
|
key not in valid_field_strings
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {key, _value} -> key end)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
not Enum.empty?(invalid_values) ->
|
||||||
|
{:error,
|
||||||
|
field: :member_field_required,
|
||||||
|
message: "All values in member_field_required must be booleans"}
|
||||||
|
|
||||||
|
not Enum.empty?(invalid_keys) ->
|
||||||
|
{:error,
|
||||||
|
field: :member_field_required,
|
||||||
|
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
on: [:create, :update]
|
||||||
|
|
||||||
# Validate default_membership_fee_type_id exists if set
|
# Validate default_membership_fee_type_id exists if set
|
||||||
validate fn changeset, context ->
|
validate fn changeset, context ->
|
||||||
fee_type_id =
|
fee_type_id =
|
||||||
|
|
@ -219,6 +275,12 @@ defmodule Mv.Membership.Setting do
|
||||||
description:
|
description:
|
||||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||||
|
|
||||||
|
attribute :member_field_required, :map,
|
||||||
|
allow_nil?: true,
|
||||||
|
public?: true,
|
||||||
|
description:
|
||||||
|
"Configuration for which member fields are required in forms (JSONB map). Keys are member field names (strings), values are booleans. Email is always required."
|
||||||
|
|
||||||
# Membership fee settings
|
# Membership fee settings
|
||||||
attribute :include_joining_cycle, :boolean do
|
attribute :include_joining_cycle, :boolean do
|
||||||
allow_nil? false
|
allow_nil? false
|
||||||
|
|
|
||||||
179
lib/membership/setting/changes/update_single_member_field.ex
Normal file
179
lib/membership/setting/changes/update_single_member_field.ex
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
||||||
|
@moduledoc """
|
||||||
|
Ash change that atomically updates visibility and required for a single member field.
|
||||||
|
|
||||||
|
Updates both `member_field_visibility` and `member_field_required` JSONB maps
|
||||||
|
in one SQL UPDATE to avoid lost updates when saving from the settings UI.
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
- `field` - The member field name as a string (e.g., "street", "first_name")
|
||||||
|
- `show_in_overview` - Boolean value indicating visibility in member overview
|
||||||
|
- `required` - Boolean value indicating whether the field is required in member forms
|
||||||
|
|
||||||
|
## Example
|
||||||
|
settings
|
||||||
|
|> Ash.Changeset.for_update(:update_single_member_field, %{},
|
||||||
|
arguments: %{field: "first_name", show_in_overview: true, required: true}
|
||||||
|
)
|
||||||
|
|> Ash.update(domain: Mv.Membership)
|
||||||
|
"""
|
||||||
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
alias Ash.Error.Invalid
|
||||||
|
alias Ecto.Adapters.SQL
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
def change(changeset, _opts, _context) do
|
||||||
|
with {:ok, field} <- get_and_validate_field(changeset),
|
||||||
|
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview),
|
||||||
|
{:ok, required} <- get_and_validate_boolean(changeset, :required) do
|
||||||
|
add_after_action(changeset, field, show_in_overview, required)
|
||||||
|
else
|
||||||
|
{:error, updated_changeset} -> updated_changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_and_validate_field(changeset) do
|
||||||
|
case Ash.Changeset.get_argument(changeset, :field) do
|
||||||
|
nil ->
|
||||||
|
{:error,
|
||||||
|
add_error(changeset,
|
||||||
|
field: :field,
|
||||||
|
message: "field argument is required"
|
||||||
|
)}
|
||||||
|
|
||||||
|
field ->
|
||||||
|
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
|
|
||||||
|
if field in valid_fields do
|
||||||
|
{:ok, field}
|
||||||
|
else
|
||||||
|
{:error,
|
||||||
|
add_error(
|
||||||
|
changeset,
|
||||||
|
field: :field,
|
||||||
|
message: "Invalid member field: #{field}"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_and_validate_boolean(changeset, :show_in_overview = arg_name) do
|
||||||
|
do_validate_boolean(changeset, arg_name, :show_in_overview)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_and_validate_boolean(changeset, :required = arg_name) do
|
||||||
|
do_validate_boolean(changeset, arg_name, :member_field_required)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_validate_boolean(changeset, arg_name, error_field) do
|
||||||
|
case Ash.Changeset.get_argument(changeset, arg_name) do
|
||||||
|
nil ->
|
||||||
|
{:error,
|
||||||
|
add_error(
|
||||||
|
changeset,
|
||||||
|
field: error_field,
|
||||||
|
message: "#{arg_name} argument is required"
|
||||||
|
)}
|
||||||
|
|
||||||
|
value when is_boolean(value) ->
|
||||||
|
{:ok, value}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error,
|
||||||
|
add_error(
|
||||||
|
changeset,
|
||||||
|
field: error_field,
|
||||||
|
message: "#{arg_name} must be a boolean"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_error(changeset, opts) do
|
||||||
|
Ash.Changeset.add_error(changeset, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_after_action(changeset, field, show_in_overview, required) do
|
||||||
|
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
|
||||||
|
# Update both JSONB columns in one statement
|
||||||
|
sql = """
|
||||||
|
UPDATE settings
|
||||||
|
SET
|
||||||
|
member_field_visibility = jsonb_set(
|
||||||
|
COALESCE(member_field_visibility, '{}'::jsonb),
|
||||||
|
ARRAY[$1::text],
|
||||||
|
to_jsonb($2::boolean),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
member_field_required = jsonb_set(
|
||||||
|
COALESCE(member_field_required, '{}'::jsonb),
|
||||||
|
ARRAY[$1::text],
|
||||||
|
to_jsonb($3::boolean),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
updated_at = (now() AT TIME ZONE 'utc')
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING member_field_visibility, member_field_required
|
||||||
|
"""
|
||||||
|
|
||||||
|
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||||
|
|
||||||
|
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
|
||||||
|
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
|
||||||
|
vis = normalize_jsonb_result(updated_visibility)
|
||||||
|
req = normalize_jsonb_result(updated_required)
|
||||||
|
|
||||||
|
updated_settings = %{
|
||||||
|
settings
|
||||||
|
| member_field_visibility: vis,
|
||||||
|
member_field_required: req
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, updated_settings}
|
||||||
|
|
||||||
|
{:ok, %{rows: []}} ->
|
||||||
|
{:error,
|
||||||
|
Invalid.exception(
|
||||||
|
field: :member_field_required,
|
||||||
|
message: "Settings not found"
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
|
||||||
|
|
||||||
|
{:error,
|
||||||
|
Invalid.exception(
|
||||||
|
field: :member_field_required,
|
||||||
|
message: "Failed to update member field settings"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_jsonb_result(updated_jsonb) do
|
||||||
|
case updated_jsonb do
|
||||||
|
map when is_map(map) ->
|
||||||
|
Enum.reduce(map, %{}, fn
|
||||||
|
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
||||||
|
{k, v}, acc -> Map.put(acc, k, v)
|
||||||
|
end)
|
||||||
|
|
||||||
|
binary when is_binary(binary) ->
|
||||||
|
case Jason.decode(binary) do
|
||||||
|
{:ok, decoded} when is_map(decoded) ->
|
||||||
|
decoded
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
%{}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -27,8 +27,26 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@email_validator_checks [:html_input, :pow]
|
@email_validator_checks [:html_input, :pow]
|
||||||
|
|
||||||
|
# Member fields that are required when Vereinfacht integration is active (contact sync)
|
||||||
|
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city]
|
||||||
|
|
||||||
def member_fields, do: @member_fields
|
def member_fields, do: @member_fields
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns member fields that are always required when Vereinfacht integration is configured.
|
||||||
|
|
||||||
|
Used for validation, member form required indicators, and settings UI (checkbox disabled).
|
||||||
|
"""
|
||||||
|
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether the given member field is required by Vereinfacht when integration is active.
|
||||||
|
"""
|
||||||
|
def vereinfacht_required_field?(field) when is_atom(field),
|
||||||
|
do: field in @vereinfacht_required_member_fields
|
||||||
|
|
||||||
|
def vereinfacht_required_field?(_), do: false
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the prefix used for custom field keys in field visibility maps.
|
Returns the prefix used for custom field keys in field visibility maps.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -448,6 +448,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
|
|
||||||
def input(%{type: "select"} = assigns) do
|
def input(%{type: "select"} = assigns) do
|
||||||
|
assigns = ensure_aria_required_for_input(assigns)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<fieldset class="mb-2 fieldset">
|
<fieldset class="mb-2 fieldset">
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -475,6 +477,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
|
|
||||||
def input(%{type: "textarea"} = assigns) do
|
def input(%{type: "textarea"} = assigns) do
|
||||||
|
assigns = ensure_aria_required_for_input(assigns)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<fieldset class="mb-2 fieldset">
|
<fieldset class="mb-2 fieldset">
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -502,6 +506,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||||
def input(assigns) do
|
def input(assigns) do
|
||||||
|
assigns = ensure_aria_required_for_input(assigns)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<fieldset class="mb-2 fieldset">
|
<fieldset class="mb-2 fieldset">
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -529,6 +535,18 @@ defmodule MvWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# WCAG 2.1: set aria-required when required is true so screen readers announce required state
|
||||||
|
defp ensure_aria_required_for_input(assigns) do
|
||||||
|
rest = assigns.rest || %{}
|
||||||
|
|
||||||
|
rest =
|
||||||
|
if rest[:required],
|
||||||
|
do: Map.put(rest, :aria_required, "true"),
|
||||||
|
else: rest
|
||||||
|
|
||||||
|
assign(assigns, :rest, rest)
|
||||||
|
end
|
||||||
|
|
||||||
# Helper used by inputs to generate form errors
|
# Helper used by inputs to generate form errors
|
||||||
defp error(assigns) do
|
defp error(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
- `on_cancel` - Callback function to call when form is cancelled
|
- `on_cancel` - Callback function to call when form is cancelled
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
Member fields are technical fields that cannot be changed (name, value_type, description, required).
|
Member fields are technical fields that cannot be changed (name, value_type).
|
||||||
Only the visibility (show_in_overview) can be modified.
|
Visibility (show_in_overview) and required flag are stored in Settings and can be modified.
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
|
|
||||||
|
|
@ -27,14 +27,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
alias MvWeb.Helpers.FieldTypeFormatter
|
alias MvWeb.Helpers.FieldTypeFormatter
|
||||||
alias MvWeb.Translations.MemberFields
|
alias MvWeb.Translations.MemberFields
|
||||||
|
|
||||||
@required_fields [:first_name, :last_name, :email]
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
assigns =
|
assigns =
|
||||||
assigns
|
assigns
|
||||||
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|
||||||
|> assign(:is_email_field?, assigns.member_field == :email)
|
|> assign(:is_email_field?, assigns.member_field == :email)
|
||||||
|
|> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns))
|
||||||
|> assign(:field_label, MemberFields.label(assigns.member_field))
|
|> assign(:field_label, MemberFields.label(assigns.member_field))
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -117,89 +116,64 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<%!-- Line break before Required / Show in overview block --%>
|
||||||
:if={@is_email_field?}
|
<div class="mt-4">
|
||||||
class="tooltip tooltip-right"
|
<%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%>
|
||||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
<div
|
||||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
:if={@is_email_field? or @vereinfacht_required_field?}
|
||||||
>
|
class="tooltip tooltip-right"
|
||||||
<fieldset class="mb-2 fieldset">
|
data-tip={
|
||||||
<label>
|
if(@is_email_field?,
|
||||||
<span class="mb-1 label flex items-center gap-2">
|
do: gettext("This is a technical field and cannot be changed"),
|
||||||
{gettext("Description")}
|
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
|
||||||
<.icon
|
)
|
||||||
name="hero-information-circle"
|
}
|
||||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
aria-label={
|
||||||
aria-hidden="true"
|
if(@is_email_field?,
|
||||||
/>
|
do: gettext("This is a technical field and cannot be changed"),
|
||||||
</span>
|
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
|
||||||
<input
|
)
|
||||||
type="text"
|
}
|
||||||
name={@form[:description].name}
|
>
|
||||||
id={@form[:description].id}
|
<fieldset class="mb-2 fieldset">
|
||||||
value={@form[:description].value}
|
<label>
|
||||||
disabled
|
<input type="hidden" name={@form[:required].name} value="true" />
|
||||||
readonly
|
<span class="label flex items-center gap-2">
|
||||||
class="w-full input"
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
</label>
|
name={@form[:required].name}
|
||||||
</fieldset>
|
id={@form[:required].id}
|
||||||
</div>
|
value="true"
|
||||||
<.input
|
checked={@form[:required].value}
|
||||||
:if={not @is_email_field?}
|
disabled
|
||||||
field={@form[:description]}
|
readonly
|
||||||
type="text"
|
class="checkbox checkbox-sm"
|
||||||
label={gettext("Description")}
|
|
||||||
disabled={@is_email_field?}
|
|
||||||
readonly={@is_email_field?}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
:if={@is_email_field?}
|
|
||||||
class="tooltip tooltip-right"
|
|
||||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
|
||||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
|
||||||
>
|
|
||||||
<fieldset class="mb-2 fieldset">
|
|
||||||
<label>
|
|
||||||
<input type="hidden" name={@form[:required].name} value="false" disabled />
|
|
||||||
<span class="label flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name={@form[:required].name}
|
|
||||||
id={@form[:required].id}
|
|
||||||
value="true"
|
|
||||||
checked={@form[:required].value}
|
|
||||||
disabled
|
|
||||||
readonly
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
{gettext("Required")}
|
|
||||||
<.icon
|
|
||||||
name="hero-information-circle"
|
|
||||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{gettext("Required")}
|
||||||
|
<.icon
|
||||||
|
name="hero-information-circle"
|
||||||
|
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</label>
|
||||||
</label>
|
</fieldset>
|
||||||
</fieldset>
|
</div>
|
||||||
</div>
|
<.input
|
||||||
<.input
|
:if={not @is_email_field? and not @vereinfacht_required_field?}
|
||||||
:if={not @is_email_field?}
|
field={@form[:required]}
|
||||||
field={@form[:required]}
|
type="checkbox"
|
||||||
type="checkbox"
|
label={gettext("Required")}
|
||||||
label={gettext("Required")}
|
/>
|
||||||
disabled={@is_email_field?}
|
|
||||||
readonly={@is_email_field?}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={@form[:show_in_overview]}
|
field={@form[:show_in_overview]}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
label={gettext("Show in overview")}
|
label={gettext("Show in overview")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="justify-end mt-4 card-actions">
|
<div class="justify-end mt-4 card-actions">
|
||||||
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
||||||
|
|
@ -225,24 +199,35 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("validate", %{"member_field" => member_field_params}, socket) do
|
def handle_event("validate", %{"member_field" => member_field_params}, socket) do
|
||||||
# For member fields, we only validate show_in_overview
|
|
||||||
# Other fields are read-only or derived from the Member Resource
|
|
||||||
form = socket.assigns.form
|
form = socket.assigns.form
|
||||||
|
# Unchecked checkboxes are not in params; preserve current form value when key is missing
|
||||||
updated_params =
|
show_in_overview =
|
||||||
member_field_params
|
if Map.has_key?(member_field_params, "show_in_overview") do
|
||||||
|> Map.put(
|
|
||||||
"show_in_overview",
|
|
||||||
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||||
)
|
else
|
||||||
|> Map.put("name", form.source["name"])
|
form.source["show_in_overview"]
|
||||||
|> Map.put("value_type", form.source["value_type"])
|
end
|
||||||
|> Map.put("description", form.source["description"])
|
|
||||||
|> Map.put("required", form.source["required"])
|
required =
|
||||||
|
socket.assigns.vereinfacht_required_field? ||
|
||||||
|
if Map.has_key?(member_field_params, "required") do
|
||||||
|
TypeParsers.parse_boolean(member_field_params["required"])
|
||||||
|
else
|
||||||
|
form.source["required"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge so we keep name/value_type and have current checkbox state; use as new form source
|
||||||
|
merged_source =
|
||||||
|
form.source
|
||||||
|
|> Map.merge(%{
|
||||||
|
"show_in_overview" => show_in_overview,
|
||||||
|
"required" => required,
|
||||||
|
"name" => form.source["name"],
|
||||||
|
"value_type" => form.source["value_type"]
|
||||||
|
})
|
||||||
|
|
||||||
updated_form =
|
updated_form =
|
||||||
form
|
to_form(merged_source, as: "member_field")
|
||||||
|> Map.put(:value, updated_params)
|
|
||||||
|> Map.put(:errors, [])
|
|> Map.put(:errors, [])
|
||||||
|
|
||||||
{:noreply, assign(socket, form: updated_form)}
|
{:noreply, assign(socket, form: updated_form)}
|
||||||
|
|
@ -250,23 +235,36 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"member_field" => member_field_params}, socket) do
|
def handle_event("save", %{"member_field" => member_field_params}, socket) do
|
||||||
# Only show_in_overview can be changed for member fields
|
form = socket.assigns.form
|
||||||
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
# Unchecked checkboxes are not in submit params; use form source when key missing
|
||||||
|
show_in_overview =
|
||||||
|
if Map.has_key?(member_field_params, "show_in_overview") do
|
||||||
|
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||||
|
else
|
||||||
|
form.source["show_in_overview"]
|
||||||
|
end
|
||||||
|
|
||||||
|
required =
|
||||||
|
socket.assigns.vereinfacht_required_field? ||
|
||||||
|
if Map.has_key?(member_field_params, "required") do
|
||||||
|
TypeParsers.parse_boolean(member_field_params["required"])
|
||||||
|
else
|
||||||
|
form.source["required"]
|
||||||
|
end
|
||||||
|
|
||||||
field_string = Atom.to_string(socket.assigns.member_field)
|
field_string = Atom.to_string(socket.assigns.member_field)
|
||||||
|
|
||||||
# Use atomic action to update only this single field
|
case Membership.update_single_member_field(
|
||||||
# This prevents lost updates in concurrent scenarios
|
|
||||||
case Membership.update_single_member_field_visibility(
|
|
||||||
socket.assigns.settings,
|
socket.assigns.settings,
|
||||||
field: field_string,
|
field: field_string,
|
||||||
show_in_overview: show_in_overview
|
show_in_overview: show_in_overview,
|
||||||
|
required: required
|
||||||
) do
|
) do
|
||||||
{:ok, _updated_settings} ->
|
{:ok, _updated_settings} ->
|
||||||
socket.assigns.on_save.(socket.assigns.member_field, "update")
|
socket.assigns.on_save.(socket.assigns.member_field, "update")
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
# Add error to form
|
|
||||||
form =
|
form =
|
||||||
socket.assigns.form
|
socket.assigns.form
|
||||||
|> Map.put(:errors, [
|
|> Map.put(:errors, [
|
||||||
|
|
@ -288,16 +286,29 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
|
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
|
||||||
field_attributes = get_field_attributes(member_field)
|
field_attributes = get_field_attributes(member_field)
|
||||||
visibility_config = settings.member_field_visibility || %{}
|
visibility_config = settings.member_field_visibility || %{}
|
||||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
required_config = settings.member_field_required || %{}
|
||||||
show_in_overview = Map.get(normalized_config, member_field, true)
|
normalized_visibility = VisibilityConfig.normalize(visibility_config)
|
||||||
|
normalized_required = VisibilityConfig.normalize(required_config)
|
||||||
|
show_in_overview = Map.get(normalized_visibility, member_field, true)
|
||||||
|
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||||
|
# Persist in socket so validate/save can enforce server-side without relying on render assigns
|
||||||
|
socket =
|
||||||
|
assign(
|
||||||
|
socket,
|
||||||
|
:vereinfacht_required_field?,
|
||||||
|
vereinfacht_required_field?(%{member_field: member_field})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Email always required; Vereinfacht-required fields when integration active; else from settings
|
||||||
|
required =
|
||||||
|
member_field == :email ||
|
||||||
|
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(member_field)) ||
|
||||||
|
Map.get(normalized_required, member_field, false)
|
||||||
|
|
||||||
# Create a manual form structure with string keys
|
|
||||||
# Note: immutable is not included as it's not editable for member fields
|
|
||||||
form_data = %{
|
form_data = %{
|
||||||
"name" => MemberFields.label(member_field),
|
"name" => MemberFields.label(member_field),
|
||||||
"value_type" => FieldTypeFormatter.format(field_attributes.value_type),
|
"value_type" => FieldTypeFormatter.format(field_attributes.value_type),
|
||||||
"description" => field_attributes.description || "",
|
"required" => required,
|
||||||
"required" => field_attributes.required,
|
|
||||||
"show_in_overview" => show_in_overview
|
"show_in_overview" => show_in_overview
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,24 +318,14 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_field_attributes(field) when is_atom(field) do
|
defp get_field_attributes(field) when is_atom(field) do
|
||||||
# Get attribute info from Member Resource
|
|
||||||
alias Ash.Resource.Info
|
alias Ash.Resource.Info
|
||||||
|
|
||||||
case Info.attribute(Mv.Membership.Member, field) do
|
case Info.attribute(Mv.Membership.Member, field) do
|
||||||
nil ->
|
nil ->
|
||||||
# Fallback for fields not in resource (shouldn't happen with Constants)
|
%{value_type: :string}
|
||||||
%{
|
|
||||||
value_type: :string,
|
|
||||||
description: nil,
|
|
||||||
required: field in @required_fields
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute ->
|
attribute ->
|
||||||
%{
|
%{value_type: attribute.type}
|
||||||
value_type: attribute.type,
|
|
||||||
description: nil,
|
|
||||||
required: not attribute.allow_nil?
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -335,4 +336,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
defp format_error(error) do
|
defp format_error(error) do
|
||||||
inspect(error)
|
inspect(error)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp vereinfacht_required_field?(assigns) do
|
||||||
|
Mv.Config.vereinfacht_configured?() &&
|
||||||
|
Mv.Constants.vereinfacht_required_field?(assigns.member_field)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
assigns =
|
assigns =
|
||||||
assigns
|
assigns
|
||||||
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|
||||||
|> assign(:required?, &required?/1)
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div id={@id}>
|
<div id={@id}>
|
||||||
|
|
@ -62,22 +61,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
{format_value_type(field_data.field)}
|
{format_value_type(field_data.field)}
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
|
|
||||||
{field_data.description || ""}
|
|
||||||
</:col>
|
|
||||||
|
|
||||||
<:col
|
<:col
|
||||||
:let={{_field_name, field_data}}
|
:let={{_field_name, field_data}}
|
||||||
label={gettext("Required")}
|
label={gettext("Required")}
|
||||||
class="max-w-[9.375rem] text-center"
|
class="max-w-[9.375rem] text-center"
|
||||||
>
|
>
|
||||||
<span
|
<span :if={field_data.required} class="text-base-content font-semibold">
|
||||||
:if={@required?.(field_data.field)}
|
|
||||||
class="text-base-content font-semibold"
|
|
||||||
>
|
|
||||||
{gettext("Required")}
|
{gettext("Required")}
|
||||||
</span>
|
</span>
|
||||||
<span :if={!@required?.(field_data.field)} class="text-base-content/70">
|
<span :if={!field_data.required} class="text-base-content/70">
|
||||||
{gettext("Optional")}
|
{gettext("Optional")}
|
||||||
</span>
|
</span>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
@ -173,26 +165,35 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
# Return a minimal struct-like map for fallback
|
# Return a minimal struct-like map for fallback
|
||||||
# This is only used for initial rendering, actual settings will be loaded properly
|
# This is only used for initial rendering, actual settings will be loaded properly
|
||||||
%{member_field_visibility: %{}}
|
%{member_field_visibility: %{}, member_field_required: %{}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_member_fields_with_visibility(settings) do
|
defp get_member_fields_with_visibility(settings) do
|
||||||
member_fields = Mv.Constants.member_fields()
|
member_fields = Mv.Constants.member_fields()
|
||||||
visibility_config = settings.member_field_visibility || %{}
|
visibility_config = settings.member_field_visibility || %{}
|
||||||
|
required_config = settings.member_field_required || %{}
|
||||||
|
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||||
|
|
||||||
# Normalize visibility config keys to atoms
|
normalized_visibility = VisibilityConfig.normalize(visibility_config)
|
||||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
normalized_required = VisibilityConfig.normalize(required_config)
|
||||||
|
|
||||||
Enum.map(member_fields, fn field ->
|
Enum.map(member_fields, fn field ->
|
||||||
show_in_overview = Map.get(normalized_config, field, true)
|
show_in_overview = Map.get(normalized_visibility, field, true)
|
||||||
|
|
||||||
|
# Email always required; Vereinfacht-required fields when integration active; else from settings
|
||||||
|
required =
|
||||||
|
field == :email ||
|
||||||
|
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
|
||||||
|
Map.get(normalized_required, field, false)
|
||||||
|
|
||||||
attribute = Info.attribute(Mv.Membership.Member, field)
|
attribute = Info.attribute(Mv.Membership.Member, field)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
field: field,
|
field: field,
|
||||||
show_in_overview: show_in_overview,
|
show_in_overview: show_in_overview,
|
||||||
value_type: (attribute && attribute.type) || :string,
|
required: required,
|
||||||
description: nil
|
value_type: (attribute && attribute.type) || :string
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|> Enum.map(fn field_data ->
|
|> Enum.map(fn field_data ->
|
||||||
|
|
@ -206,14 +207,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
attribute -> FieldTypeFormatter.format(attribute.type)
|
attribute -> FieldTypeFormatter.format(attribute.type)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if a field is required by checking the actual attribute definition
|
|
||||||
defp required?(field) when is_atom(field) do
|
|
||||||
case Info.attribute(Mv.Membership.Member, field) do
|
|
||||||
nil -> false
|
|
||||||
attribute -> not attribute.allow_nil?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp required?(_), do: false
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
alias Mv.Membership.Helpers.VisibilityConfig
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
@ -84,30 +86,54 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
<%!-- Name Row --%>
|
<%!-- Name Row --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="w-48">
|
<div class="w-48">
|
||||||
<.input field={@form[:first_name]} label={gettext("First Name")} />
|
<.input
|
||||||
|
field={@form[:first_name]}
|
||||||
|
label={gettext("First Name")}
|
||||||
|
required={@member_field_required_map[:first_name]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-48">
|
<div class="w-48">
|
||||||
<.input field={@form[:last_name]} label={gettext("Last Name")} />
|
<.input
|
||||||
|
field={@form[:last_name]}
|
||||||
|
label={gettext("Last Name")}
|
||||||
|
required={@member_field_required_map[:last_name]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Address Row --%>
|
<%!-- Address Row --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<.input field={@form[:street]} label={gettext("Street")} />
|
<.input
|
||||||
|
field={@form[:street]}
|
||||||
|
label={gettext("Street")}
|
||||||
|
required={@member_field_required_map[:street]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-16">
|
<div class="w-16">
|
||||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
<.input
|
||||||
|
field={@form[:house_number]}
|
||||||
|
label={gettext("Nr.")}
|
||||||
|
required={@member_field_required_map[:house_number]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-24">
|
||||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
<.input
|
||||||
|
field={@form[:postal_code]}
|
||||||
|
label={gettext("Postal Code")}
|
||||||
|
required={@member_field_required_map[:postal_code]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
<.input field={@form[:city]} label={gettext("City")} />
|
<.input
|
||||||
|
field={@form[:city]}
|
||||||
|
label={gettext("City")}
|
||||||
|
required={@member_field_required_map[:city]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Email --%>
|
<%!-- Email (always required) --%>
|
||||||
<div>
|
<div>
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,16 +141,31 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
<%!-- Membership Dates Row --%>
|
<%!-- Membership Dates Row --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="w-36">
|
<div class="w-36">
|
||||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
<.input
|
||||||
|
field={@form[:join_date]}
|
||||||
|
label={gettext("Join Date")}
|
||||||
|
type="date"
|
||||||
|
required={@member_field_required_map[:join_date]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-36">
|
<div class="w-36">
|
||||||
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
|
<.input
|
||||||
|
field={@form[:exit_date]}
|
||||||
|
label={gettext("Exit Date")}
|
||||||
|
type="date"
|
||||||
|
required={@member_field_required_map[:exit_date]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Notes --%>
|
<%!-- Notes --%>
|
||||||
<div>
|
<div>
|
||||||
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" />
|
<.input
|
||||||
|
field={@form[:notes]}
|
||||||
|
label={gettext("Notes")}
|
||||||
|
type="textarea"
|
||||||
|
required={@member_field_required_map[:notes]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
|
|
@ -254,6 +295,9 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
# Load available membership fee types
|
# Load available membership fee types
|
||||||
available_fee_types = load_available_fee_types(member, actor)
|
available_fee_types = load_available_fee_types(member, actor)
|
||||||
|
|
||||||
|
# Load settings to know which member fields are required (for asterisk/tooltip)
|
||||||
|
member_field_required_map = get_member_field_required_map()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|
|
@ -263,9 +307,38 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|> assign(:page_title, page_title)
|
|> assign(:page_title, page_title)
|
||||||
|> assign(:available_fee_types, available_fee_types)
|
|> assign(:available_fee_types, available_fee_types)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|
|> assign(:member_field_required_map, member_field_required_map)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp get_member_field_required_map do
|
||||||
|
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||||
|
|
||||||
|
case Membership.get_settings() do
|
||||||
|
{:ok, settings} ->
|
||||||
|
required_config = settings.member_field_required || %{}
|
||||||
|
normalized = VisibilityConfig.normalize(required_config)
|
||||||
|
|
||||||
|
Mv.Constants.member_fields()
|
||||||
|
|> Enum.map(fn field ->
|
||||||
|
required =
|
||||||
|
field == :email ||
|
||||||
|
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
|
||||||
|
Map.get(normalized, field, false)
|
||||||
|
|
||||||
|
{field, required}
|
||||||
|
end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
# Email always required; Vereinfacht fields when integration active
|
||||||
|
Map.new(Mv.Constants.member_fields(), fn f ->
|
||||||
|
{f,
|
||||||
|
f == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(f))}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp return_to("show"), do: "show"
|
defp return_to("show"), do: "show"
|
||||||
defp return_to(_), do: "index"
|
defp return_to(_), do: "index"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -287,8 +287,6 @@ msgstr "Abbrechen"
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
|
@ -2911,3 +2909,8 @@ msgstr "Okt."
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sep."
|
msgid "Sep."
|
||||||
msgstr "Sep."
|
msgstr "Sep."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||||
|
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."
|
||||||
|
|
|
||||||
|
|
@ -288,8 +288,6 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
|
@ -2911,3 +2909,8 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sep."
|
msgid "Sep."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -288,8 +288,6 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
|
@ -2911,3 +2909,8 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sep."
|
msgid "Sep."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||||
|
msgstr "Required for Vereinfacht integration and cannot be disabled."
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddMemberFieldRequiredToSettings do
|
||||||
|
@moduledoc """
|
||||||
|
Updates resources based on their most recent snapshots.
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :member_field_required, :map
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:settings) do
|
||||||
|
remove :member_field_required
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
152
priv/resource_snapshots/repo/settings/20260223195453.json
Normal file
152
priv/resource_snapshots/repo/settings/20260223195453.json
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"gen_random_uuid()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "club_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "member_field_visibility",
|
||||||
|
"type": "map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "true",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "include_joining_cycle",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "default_membership_fee_type_id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_api_url",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_api_key",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_club_id",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_app_url",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "inserted_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "updated_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "4C29CEF273C1180162E7231A7F7CCE5DABD035E121648E48B6FBE30AE5191FF0",
|
||||||
|
"identities": [],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "settings"
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,66 @@ defmodule Mv.Membership.MemberTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Settings-driven required fields" do
|
||||||
|
@valid_attrs %{
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Doe",
|
||||||
|
email: "john@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
saved_visibility = settings.member_field_visibility || %{}
|
||||||
|
saved_required = settings.member_field_required || %{}
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
|
||||||
|
Membership.update_settings(s, %{
|
||||||
|
member_field_visibility: saved_visibility,
|
||||||
|
member_field_required: saved_required
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when first_name is required in settings, create without first_name fails", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.update_single_member_field(settings,
|
||||||
|
field: "first_name",
|
||||||
|
show_in_overview: true,
|
||||||
|
required: true
|
||||||
|
)
|
||||||
|
|
||||||
|
attrs = Map.delete(@valid_attrs, :first_name)
|
||||||
|
|
||||||
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
|
Membership.create_member(attrs, actor: actor)
|
||||||
|
|
||||||
|
assert error_message(errors, :first_name) =~ "can't be blank"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when first_name is required in settings, create with first_name succeeds", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.update_single_member_field(settings,
|
||||||
|
field: "first_name",
|
||||||
|
show_in_overview: true,
|
||||||
|
required: true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:ok, _member} = Membership.create_member(@valid_attrs, actor: actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "Authorization" do
|
describe "Authorization" do
|
||||||
@valid_attrs %{
|
@valid_attrs %{
|
||||||
first_name: "John",
|
first_name: "John",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,23 @@ defmodule Mv.Membership.SettingTest do
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
describe "Settings Resource" do
|
describe "Settings Resource" do
|
||||||
|
setup do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
saved_visibility = settings.member_field_visibility || %{}
|
||||||
|
saved_required = settings.member_field_required || %{}
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
|
||||||
|
Membership.update_settings(s, %{
|
||||||
|
member_field_visibility: saved_visibility,
|
||||||
|
member_field_required: saved_required
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
test "can read settings" do
|
test "can read settings" do
|
||||||
# Settings should be a singleton resource
|
# Settings should be a singleton resource
|
||||||
assert {:ok, _settings} = Membership.get_settings()
|
assert {:ok, _settings} = Membership.get_settings()
|
||||||
|
|
@ -39,6 +56,65 @@ defmodule Mv.Membership.SettingTest do
|
||||||
|
|
||||||
assert error_message(errors, :club_name) =~ "must be present"
|
assert error_message(errors, :club_name) =~ "must be present"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "can update and read member_field_required" do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
required_config = %{"first_name" => true, "last_name" => true}
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Membership.update_settings(settings, %{member_field_required: required_config})
|
||||||
|
|
||||||
|
assert updated.member_field_required["first_name"] == true
|
||||||
|
assert updated.member_field_required["last_name"] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member_field_required rejects invalid keys" do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
|
Membership.update_settings(settings, %{
|
||||||
|
member_field_required: %{"invalid_field" => true}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert error_message(errors, :member_field_required) =~ "Invalid member field"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member_field_required rejects non-boolean values" do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
|
Membership.update_settings(settings, %{
|
||||||
|
member_field_required: %{"first_name" => "yes"}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert error_message(errors, :member_field_required) =~ "must be booleans"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_single_member_field updates both visibility and required" do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Membership.update_single_member_field(settings,
|
||||||
|
field: "first_name",
|
||||||
|
show_in_overview: true,
|
||||||
|
required: true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.member_field_visibility["first_name"] == true
|
||||||
|
assert updated.member_field_required["first_name"] == true
|
||||||
|
|
||||||
|
# Update same field to required: false
|
||||||
|
assert {:ok, updated2} =
|
||||||
|
Membership.update_single_member_field(updated,
|
||||||
|
field: "first_name",
|
||||||
|
show_in_overview: false,
|
||||||
|
required: false
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated2.member_field_visibility["first_name"] == false
|
||||||
|
assert updated2.member_field_required["first_name"] == false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper function to extract error messages
|
# Helper function to extract error messages
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,10 @@ defmodule Mv.Vereinfacht.Changes.SyncContactTest do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
first_name: "API",
|
first_name: "API",
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "api_test_#{System.unique_integer([:positive])}@example.com"
|
email: "api_test_#{System.unique_integer([:positive])}@example.com",
|
||||||
|
street: "Test St",
|
||||||
|
postal_code: "12345",
|
||||||
|
city: "Test City"
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {:ok, member} = Membership.create_member(attrs, actor: system_actor)
|
assert {:ok, member} = Membership.create_member(attrs, actor: system_actor)
|
||||||
|
|
@ -66,7 +69,14 @@ defmodule Mv.Vereinfacht.Changes.SyncContactTest do
|
||||||
|
|
||||||
test "update_member succeeds and after_transaction runs without error (API may fail)" do
|
test "update_member succeeds and after_transaction runs without error (API may fail)" do
|
||||||
set_vereinfacht_env()
|
set_vereinfacht_env()
|
||||||
member = Mv.Fixtures.member_fixture()
|
|
||||||
|
member =
|
||||||
|
Mv.Fixtures.member_fixture(%{
|
||||||
|
street: "Test St",
|
||||||
|
postal_code: "12345",
|
||||||
|
city: "Test City"
|
||||||
|
})
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
assert {:ok, updated} =
|
assert {:ok, updated} =
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
|
||||||
Tests cover:
|
Tests cover:
|
||||||
- Rendering all member fields from Mv.Constants.member_fields()
|
- Rendering all member fields from Mv.Constants.member_fields()
|
||||||
- Displaying show_in_overview status as badge (Yes/No)
|
- Displaying show_in_overview status as badge (Yes/No)
|
||||||
- Displaying required status for required fields (first_name, last_name, email)
|
- Displaying required status from settings.member_field_required (email is always required)
|
||||||
- Current status is displayed based on settings.member_field_visibility
|
- Current status is displayed based on settings.member_field_visibility and member_field_required
|
||||||
- Default status is "Yes" (visible) when not configured in settings
|
- Default status is "Yes" (visible) when not configured in settings
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
@ -45,11 +45,10 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
|
||||||
assert html =~ "badge" or html =~ "Yes" or html =~ "No"
|
assert html =~ "badge" or html =~ "Yes" or html =~ "No"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays required status for required fields", %{conn: conn} do
|
test "displays required status column", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
# Required fields: first_name, last_name, email
|
# Should have "Required" column; email is always required
|
||||||
# Should have "Required" column or indicator
|
|
||||||
assert html =~ "Required" or html =~ "required"
|
assert html =~ "Required" or html =~ "required"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -85,40 +84,54 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "required fields" do
|
describe "required fields" do
|
||||||
test "marks first_name as required", %{conn: conn} do
|
setup do
|
||||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
saved_visibility = settings.member_field_visibility || %{}
|
||||||
|
saved_required = settings.member_field_required || %{}
|
||||||
|
|
||||||
# first_name should be marked as required
|
on_exit(fn ->
|
||||||
assert html =~ "first_name" or html =~ "First name"
|
{:ok, s} = Membership.get_settings()
|
||||||
# Should have required indicator
|
|
||||||
assert html =~ "required" or html =~ "Required"
|
Membership.update_settings(s, %{
|
||||||
|
member_field_visibility: saved_visibility,
|
||||||
|
member_field_required: saved_required
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
test "marks last_name as required", %{conn: conn} do
|
test "marks email as required (always from settings)", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
# last_name should be marked as required
|
# Email is always required
|
||||||
assert html =~ "last_name" or html =~ "Last name"
|
|
||||||
# Should have required indicator
|
|
||||||
assert html =~ "required" or html =~ "Required"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "marks email as required", %{conn: conn} do
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
|
||||||
|
|
||||||
# email should be marked as required
|
|
||||||
assert html =~ "email" or html =~ "Email"
|
assert html =~ "email" or html =~ "Email"
|
||||||
# Should have required indicator
|
assert html =~ "Required" or html =~ "Optional"
|
||||||
assert html =~ "required" or html =~ "Required"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not mark optional fields as required", %{conn: conn} do
|
test "when first_name is set required in settings, table shows Required", %{conn: conn} do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.update_single_member_field(settings,
|
||||||
|
field: "first_name",
|
||||||
|
show_in_overview: true,
|
||||||
|
required: true
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
# Optional fields should not have required indicator
|
# First name row should show Required (and Optional for others)
|
||||||
# Check that street (optional) doesn't have required badge
|
assert html =~ "First name" or html =~ "first_name"
|
||||||
# This test verifies that only required fields show the indicator
|
assert html =~ "Required"
|
||||||
assert html =~ "street" or html =~ "Street"
|
end
|
||||||
|
|
||||||
|
test "optional fields show Optional when not required in settings", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
|
# Email is required; other fields default to optional
|
||||||
|
assert html =~ "Optional"
|
||||||
|
assert html =~ "Required"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,23 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
describe "error handling - flash messages" do
|
describe "error handling - flash messages" do
|
||||||
|
setup do
|
||||||
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
saved_visibility = settings.member_field_visibility || %{}
|
||||||
|
saved_required = settings.member_field_required || %{}
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
{:ok, s} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
|
Mv.Membership.update_settings(s, %{
|
||||||
|
member_field_visibility: saved_visibility,
|
||||||
|
member_field_required: saved_required
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
@describetag :ui
|
@describetag :ui
|
||||||
test "shows flash message when member creation fails with validation error", %{conn: conn} do
|
test "shows flash message when member creation fails with validation error", %{conn: conn} do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
@ -74,6 +91,36 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
html =~ "Please correct" or html =~ "Bitte korrigieren"
|
html =~ "Please correct" or html =~ "Bitte korrigieren"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@tag :ui
|
||||||
|
test "shows validation error when settings-required field is missing", %{conn: conn} do
|
||||||
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.update_single_member_field(settings,
|
||||||
|
field: "first_name",
|
||||||
|
show_in_overview: true,
|
||||||
|
required: true
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members/new")
|
||||||
|
|
||||||
|
# Submit without first_name (required in settings)
|
||||||
|
form_data = %{
|
||||||
|
"member[last_name]" => "User",
|
||||||
|
"member[email]" => "newuser#{System.unique_integer([:positive])}@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("#member-form", form_data)
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ "error" or html =~ "Error" or html =~ "Fehler" or
|
||||||
|
html =~ "first_name" or html =~ "First name" or html =~ "can't be blank" or
|
||||||
|
html =~ "darf nicht leer sein"
|
||||||
|
end
|
||||||
|
|
||||||
test "shows flash message when member update fails", %{conn: conn} do
|
test "shows flash message when member update fails", %{conn: conn} do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue