Configurable member field "required" flag and Vereinfacht-required fields closes #440 #441

Merged
moritz merged 13 commits from fix/required_fields into main 2026-02-23 23:28:36 +01:00
20 changed files with 1069 additions and 231 deletions

View file

@ -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"
/> />
``` ```

View file

@ -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

View file

@ -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.

View file

@ -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

View 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

View file

@ -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.

View file

@ -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"""

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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."

View file

@ -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 ""

View file

@ -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."

View file

@ -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

View 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"
}

View file

@ -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",

View file

@ -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

View file

@ -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} =

View file

@ -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

View file

@ -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()