From c86781c32bbcc0aec8e2dbbc5f7cf3adf81bd530 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:10:35 +0100 Subject: [PATCH 01/13] Setting: add member_field_required and update_single_member_field Add JSONB attribute member_field_required, migration, Change and Membership code interface for atomic per-field required flag. --- lib/membership/membership.ex | 42 +++++ lib/membership/setting.ex | 62 +++++++ .../changes/update_single_member_field.ex | 170 ++++++++++++++++++ ..._add_member_field_required_to_settings.exs | 21 +++ .../repo/settings/20260223195453.json | 152 ++++++++++++++++ 5 files changed, 447 insertions(+) create mode 100644 lib/membership/setting/changes/update_single_member_field.ex create mode 100644 priv/repo/migrations/20260223195453_add_member_field_required_to_settings.exs create mode 100644 priv/resource_snapshots/repo/settings/20260223195453.json diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 74735e4..2583718 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -64,6 +64,8 @@ defmodule Mv.Membership do define :update_single_member_field_visibility, action: :update_single_member_field_visibility + + define :update_single_member_field, action: :update_single_member_field end resource Mv.Membership.Group do @@ -257,6 +259,46 @@ defmodule Mv.Membership do |> Ash.update(domain: __MODULE__) 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 """ Gets a group by its slug. diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index f56daa0..154288b 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -11,6 +11,8 @@ defmodule Mv.Membership.Setting do - `club_name` - The name of the association/club (required, cannot be empty) - `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`. + - `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) - `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 {: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 {:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false}) """ @@ -68,6 +73,7 @@ defmodule Mv.Membership.Setting do accept [ :club_name, :member_field_visibility, + :member_field_required, :include_joining_cycle, :default_membership_fee_type_id, :vereinfacht_api_url, @@ -84,6 +90,7 @@ defmodule Mv.Membership.Setting do accept [ :club_name, :member_field_visibility, + :member_field_required, :include_joining_cycle, :default_membership_fee_type_id, :vereinfacht_api_url, @@ -109,6 +116,17 @@ defmodule Mv.Membership.Setting do change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility 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 description "Updates the membership fee configuration" require_atomic? false @@ -162,6 +180,44 @@ defmodule Mv.Membership.Setting do end, 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 fn changeset, context -> fee_type_id = @@ -219,6 +275,12 @@ defmodule Mv.Membership.Setting do description: "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 attribute :include_joining_cycle, :boolean do allow_nil? false diff --git a/lib/membership/setting/changes/update_single_member_field.ex b/lib/membership/setting/changes/update_single_member_field.ex new file mode 100644 index 0000000..a479164 --- /dev/null +++ b/lib/membership/setting/changes/update_single_member_field.ex @@ -0,0 +1,170 @@ +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: :member_field_visibility, + 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: :member_field_visibility, + message: "Invalid member field: #{field}" + )} + end + end + end + + defp get_and_validate_boolean(changeset, arg_name) do + case Ash.Changeset.get_argument(changeset, arg_name) do + nil -> + {:error, + add_error( + changeset, + field: :member_field_visibility, + message: "#{arg_name} argument is required" + )} + + value when is_boolean(value) -> + {:ok, value} + + _ -> + {:error, + add_error( + changeset, + field: :member_field_visibility, + 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 + ) + 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_visibility, + message: "Settings not found" + )} + + {:error, error} -> + Logger.error("Failed to atomically update member field settings: #{inspect(error)}") + + {:error, + Invalid.exception( + field: :member_field_visibility, + 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 diff --git a/priv/repo/migrations/20260223195453_add_member_field_required_to_settings.exs b/priv/repo/migrations/20260223195453_add_member_field_required_to_settings.exs new file mode 100644 index 0000000..b6696fe --- /dev/null +++ b/priv/repo/migrations/20260223195453_add_member_field_required_to_settings.exs @@ -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 diff --git a/priv/resource_snapshots/repo/settings/20260223195453.json b/priv/resource_snapshots/repo/settings/20260223195453.json new file mode 100644 index 0000000..770e8ec --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20260223195453.json @@ -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" +} \ No newline at end of file -- 2.47.2 From fec2f7b6f6d94f17d975334281909887d350e3be Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:10:38 +0100 Subject: [PATCH 02/13] Constants: add vereinfacht_required_member_fields Defines first_name, last_name, street, postal_code, city as required when Vereinfacht integration is active. --- lib/mv/constants.ex | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 4ef355d..de429e8 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -27,8 +27,26 @@ defmodule Mv.Constants do @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 + @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 """ Returns the prefix used for custom field keys in field visibility maps. -- 2.47.2 From 17fd5e13d50dc0d51fbb8d64ac0b2859c24f35d9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:10:51 +0100 Subject: [PATCH 03/13] Member: validate configurable and Vereinfacht-required fields Add validation for required member fields from settings and for Vereinfacht-required fields when integration is configured. --- lib/membership/member.ex | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 7b70e89..114814a 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -544,6 +544,43 @@ defmodule Mv.Membership.Member do "Unable to validate required custom fields. Please try again or contact support."} end end + + # Validate member fields that are marked as required in settings or by Vereinfacht + validate fn changeset, _context -> + case Mv.Membership.get_settings() do + {:ok, settings} -> + required_config = settings.member_field_required || %{} + normalized = VisibilityConfig.normalize(required_config) + vereinfacht_required? = Mv.Config.vereinfacht_configured?() + + required_fields = + Enum.filter(Mv.Constants.member_fields(), fn field -> + field == :email || + (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) || + Map.get(normalized, field, false) + 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 + # Return first missing field error (Ash shows one at a time per field) + field = hd(missing) + + {:error, + field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")} + end + + {:error, _} -> + # If settings cannot be loaded, skip this validation (e.g. bootstrap) + :ok + end + end end attributes do @@ -1420,4 +1457,14 @@ defmodule Mv.Membership.Member do defp value_present?(_value, :email), 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 -- 2.47.2 From 8933ad9d14854db6c05f3513a0b01c68fcaedd4e Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:11:02 +0100 Subject: [PATCH 04/13] Member field settings: required checkbox, line break, toggle fix Index/Form use member_field_required; Required disabled for email and Vereinfacht-required fields with tooltip. Rebuild form with to_form on validate to fix checkbox toggle. Add mt-4 block before Required. --- .../live/member_field_live/form_component.ex | 245 +++++++++--------- .../live/member_field_live/index_component.ex | 43 ++- priv/gettext/de/LC_MESSAGES/default.po | 7 +- priv/gettext/default.pot | 7 +- priv/gettext/en/LC_MESSAGES/default.po | 7 +- 5 files changed, 154 insertions(+), 155 deletions(-) diff --git a/lib/mv_web/live/member_field_live/form_component.ex b/lib/mv_web/live/member_field_live/form_component.ex index 1bba048..eba86d1 100644 --- a/lib/mv_web/live/member_field_live/form_component.ex +++ b/lib/mv_web/live/member_field_live/form_component.ex @@ -16,8 +16,8 @@ defmodule MvWeb.MemberFieldLive.FormComponent do - `on_cancel` - Callback function to call when form is cancelled ## Note - Member fields are technical fields that cannot be changed (name, value_type, description, required). - Only the visibility (show_in_overview) can be modified. + Member fields are technical fields that cannot be changed (name, value_type). + Visibility (show_in_overview) and required flag are stored in Settings and can be modified. """ use MvWeb, :live_component @@ -27,14 +27,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do alias MvWeb.Helpers.FieldTypeFormatter alias MvWeb.Translations.MemberFields - @required_fields [:first_name, :last_name, :email] - @impl true def render(assigns) do assigns = assigns |> assign(:field_attributes, get_field_attributes(assigns.member_field)) |> assign(:is_email_field?, assigns.member_field == :email) + |> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns)) |> assign(:field_label, MemberFields.label(assigns.member_field)) ~H""" @@ -117,89 +116,64 @@ defmodule MvWeb.MemberFieldLive.FormComponent do -
-
- -
-
- <.input - :if={not @is_email_field?} - field={@form[:description]} - type="text" - label={gettext("Description")} - disabled={@is_email_field?} - readonly={@is_email_field?} - /> - -
-
-
+
+ <.input + :if={not @is_email_field? and not @vereinfacht_required_field?} + field={@form[:required]} + type="checkbox" + label={gettext("Required")} + /> - <.input - field={@form[:show_in_overview]} - type="checkbox" - label={gettext("Show in overview")} - /> + <.input + field={@form[:show_in_overview]} + type="checkbox" + label={gettext("Show in overview")} + /> +
<.button type="button" phx-click="cancel" phx-target={@myself}> @@ -225,24 +199,35 @@ defmodule MvWeb.MemberFieldLive.FormComponent do @impl true 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 - - updated_params = - member_field_params - |> Map.put( - "show_in_overview", + # Unchecked checkboxes are not in params; preserve current form value when key is missing + show_in_overview = + if Map.has_key?(member_field_params, "show_in_overview") do TypeParsers.parse_boolean(member_field_params["show_in_overview"]) - ) - |> Map.put("name", form.source["name"]) - |> Map.put("value_type", form.source["value_type"]) - |> Map.put("description", form.source["description"]) - |> Map.put("required", form.source["required"]) + 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 + + # 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 = - form - |> Map.put(:value, updated_params) + to_form(merged_source, as: "member_field") |> Map.put(:errors, []) {:noreply, assign(socket, form: updated_form)} @@ -250,23 +235,36 @@ defmodule MvWeb.MemberFieldLive.FormComponent do @impl true def handle_event("save", %{"member_field" => member_field_params}, socket) do - # Only show_in_overview can be changed for member fields - show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"]) + form = socket.assigns.form + # 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) - # Use atomic action to update only this single field - # This prevents lost updates in concurrent scenarios - case Membership.update_single_member_field_visibility( + case Membership.update_single_member_field( socket.assigns.settings, field: field_string, - show_in_overview: show_in_overview + show_in_overview: show_in_overview, + required: required ) do {:ok, _updated_settings} -> socket.assigns.on_save.(socket.assigns.member_field, "update") {:noreply, socket} {:error, error} -> - # Add error to form form = socket.assigns.form |> Map.put(:errors, [ @@ -288,16 +286,22 @@ defmodule MvWeb.MemberFieldLive.FormComponent do defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do field_attributes = get_field_attributes(member_field) visibility_config = settings.member_field_visibility || %{} - normalized_config = VisibilityConfig.normalize(visibility_config) - show_in_overview = Map.get(normalized_config, member_field, true) + required_config = settings.member_field_required || %{} + 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?() + + # 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 = %{ "name" => MemberFields.label(member_field), "value_type" => FieldTypeFormatter.format(field_attributes.value_type), - "description" => field_attributes.description || "", - "required" => field_attributes.required, + "required" => required, "show_in_overview" => show_in_overview } @@ -307,24 +311,14 @@ defmodule MvWeb.MemberFieldLive.FormComponent do end defp get_field_attributes(field) when is_atom(field) do - # Get attribute info from Member Resource alias Ash.Resource.Info case Info.attribute(Mv.Membership.Member, field) do nil -> - # Fallback for fields not in resource (shouldn't happen with Constants) - %{ - value_type: :string, - description: nil, - required: field in @required_fields - } + %{value_type: :string} attribute -> - %{ - value_type: attribute.type, - description: nil, - required: not attribute.allow_nil? - } + %{value_type: attribute.type} end end @@ -335,4 +329,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do defp format_error(error) do inspect(error) end + + defp vereinfacht_required_field?(assigns) do + Mv.Config.vereinfacht_configured?() && + Mv.Constants.vereinfacht_required_field?(assigns.member_field) + end end diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index 5204030..db62778 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -22,7 +22,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do assigns = assigns |> assign(:member_fields, get_member_fields_with_visibility(assigns.settings)) - |> assign(:required?, &required?/1) ~H"""
@@ -62,22 +61,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do {format_value_type(field_data.field)} - <:col :let={{_field_name, field_data}} label={gettext("Description")}> - {field_data.description || ""} - - <:col :let={{_field_name, field_data}} label={gettext("Required")} class="max-w-[9.375rem] text-center" > - + {gettext("Required")} - + {gettext("Optional")} @@ -173,26 +165,35 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do {:error, _} -> # Return a minimal struct-like map for fallback # This is only used for initial rendering, actual settings will be loaded properly - %{member_field_visibility: %{}} + %{member_field_visibility: %{}, member_field_required: %{}} end end defp get_member_fields_with_visibility(settings) do member_fields = Mv.Constants.member_fields() 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_config = VisibilityConfig.normalize(visibility_config) + normalized_visibility = VisibilityConfig.normalize(visibility_config) + normalized_required = VisibilityConfig.normalize(required_config) 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) %{ field: field, show_in_overview: show_in_overview, - value_type: (attribute && attribute.type) || :string, - description: nil + required: required, + value_type: (attribute && attribute.type) || :string } end) |> Enum.map(fn field_data -> @@ -206,14 +207,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do attribute -> FieldTypeFormatter.format(attribute.type) 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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index d39d86b..c418dca 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -287,8 +287,6 @@ msgstr "Abbrechen" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/index.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/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex @@ -2911,3 +2909,8 @@ msgstr "Okt." #, elixir-autogen, elixir-format msgid "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." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ff466ab..2e7e480 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -288,8 +288,6 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/index.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/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex @@ -2911,3 +2909,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Sep." 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e5e0181..3c53a7e 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -288,8 +288,6 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/index.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/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex @@ -2911,3 +2909,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Sep." 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." -- 2.47.2 From 27b9cbe8149e86a85aff09b73f1272e5a9e872f4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:11:11 +0100 Subject: [PATCH 05/13] Member form: required per field from settings and Vereinfacht Load settings, build member_field_required_map and pass required to inputs for asterisk, tooltip and validation. --- lib/mv_web/live/member_live/form.ex | 93 +++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 10 deletions(-) diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 7c138c4..6b3ce67 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -23,6 +23,8 @@ defmodule MvWeb.MemberLive.Form do import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] + alias Mv.Membership + alias Mv.Membership.Helpers.VisibilityConfig alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.MembershipFeeHelpers @@ -84,30 +86,54 @@ defmodule MvWeb.MemberLive.Form do <%!-- Name Row --%>
- <.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]} + />
- <.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]} + />
<%!-- Address Row --%>
- <.input field={@form[:street]} label={gettext("Street")} /> + <.input + field={@form[:street]} + label={gettext("Street")} + required={@member_field_required_map[:street]} + />
- <.input field={@form[:house_number]} label={gettext("Nr.")} /> + <.input + field={@form[:house_number]} + label={gettext("Nr.")} + required={@member_field_required_map[:house_number]} + />
- <.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]} + />
- <.input field={@form[:city]} label={gettext("City")} /> + <.input + field={@form[:city]} + label={gettext("City")} + required={@member_field_required_map[:city]} + />
- <%!-- Email --%> + <%!-- Email (always required) --%>
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
@@ -115,16 +141,31 @@ defmodule MvWeb.MemberLive.Form do <%!-- Membership Dates Row --%>
- <.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]} + />
- <.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]} + />
<%!-- Notes --%>
- <.input field={@form[:notes]} label={gettext("Notes")} type="textarea" /> + <.input + field={@form[:notes]} + label={gettext("Notes")} + type="textarea" + required={@member_field_required_map[:notes]} + />
@@ -254,6 +295,9 @@ defmodule MvWeb.MemberLive.Form do # Load available membership fee types 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, socket |> assign(:return_to, return_to(params["return_to"])) @@ -263,9 +307,38 @@ defmodule MvWeb.MemberLive.Form do |> assign(:page_title, page_title) |> assign(:available_fee_types, available_fee_types) |> assign(:interval_warning, nil) + |> assign(:member_field_required_map, member_field_required_map) |> assign_form()} 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(_), do: "index" -- 2.47.2 From d44c5bdf945e42accc280836e0b815293d3d3a98 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:11:15 +0100 Subject: [PATCH 06/13] Tests: member required fields, setting, member field live, sync_contact Add tests for required validation, update_single_member_field, form required map. Add street/postal_code/city to sync_contact when Vereinfacht configured. --- test/membership/member_test.exs | 61 ++++++++++++++++ test/membership/setting_test.exs | 59 ++++++++++++++++ .../vereinfacht/changes/sync_contact_test.exs | 14 +++- .../index_component_test.exs | 69 ++++++++++--------- .../member_live/form_error_handling_test.exs | 39 +++++++++++ 5 files changed, 208 insertions(+), 34 deletions(-) diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs index 705ab61..ab67a32 100644 --- a/test/membership/member_test.exs +++ b/test/membership/member_test.exs @@ -92,6 +92,67 @@ defmodule Mv.Membership.MemberTest do end end + describe "Settings-driven required fields" do + @valid_attrs %{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + + 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" + + # Reset so other tests (e.g. "First name is optional") are not affected + {:ok, settings} = Membership.get_settings() + + Membership.update_single_member_field(settings, + field: "first_name", + show_in_overview: true, + required: false + ) + 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) + + # Reset + {:ok, settings} = Membership.get_settings() + + Membership.update_single_member_field(settings, + field: "first_name", + show_in_overview: true, + required: false + ) + end + end + describe "Authorization" do @valid_attrs %{ first_name: "John", diff --git a/test/membership/setting_test.exs b/test/membership/setting_test.exs index 531ab88..53ba492 100644 --- a/test/membership/setting_test.exs +++ b/test/membership/setting_test.exs @@ -39,6 +39,65 @@ defmodule Mv.Membership.SettingTest do assert error_message(errors, :club_name) =~ "must be present" 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 # Helper function to extract error messages diff --git a/test/mv/vereinfacht/changes/sync_contact_test.exs b/test/mv/vereinfacht/changes/sync_contact_test.exs index aa102a5..f1dbc9c 100644 --- a/test/mv/vereinfacht/changes/sync_contact_test.exs +++ b/test/mv/vereinfacht/changes/sync_contact_test.exs @@ -54,7 +54,10 @@ defmodule Mv.Vereinfacht.Changes.SyncContactTest do attrs = %{ first_name: "API", 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) @@ -66,7 +69,14 @@ defmodule Mv.Vereinfacht.Changes.SyncContactTest do test "update_member succeeds and after_transaction runs without error (API may fail)" do 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() assert {:ok, updated} = diff --git a/test/mv_web/live/member_field_live/index_component_test.exs b/test/mv_web/live/member_field_live/index_component_test.exs index 037a77c..af8799f 100644 --- a/test/mv_web/live/member_field_live/index_component_test.exs +++ b/test/mv_web/live/member_field_live/index_component_test.exs @@ -5,8 +5,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do Tests cover: - Rendering all member fields from Mv.Constants.member_fields() - Displaying show_in_overview status as badge (Yes/No) - - Displaying required status for required fields (first_name, last_name, email) - - Current status is displayed based on settings.member_field_visibility + - Displaying required status from settings.member_field_required (email is always required) + - Current status is displayed based on settings.member_field_visibility and member_field_required - Default status is "Yes" (visible) when not configured in settings """ use MvWeb.ConnCase, async: false @@ -45,11 +45,10 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do assert html =~ "badge" or html =~ "Yes" or html =~ "No" 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") - # Required fields: first_name, last_name, email - # Should have "Required" column or indicator + # Should have "Required" column; email is always required assert html =~ "Required" or html =~ "required" end @@ -85,40 +84,46 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end describe "required fields" do - test "marks first_name as required", %{conn: conn} do + test "marks email as required (always from settings)", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/settings") - # first_name should be marked as required - assert html =~ "first_name" or html =~ "First name" - # Should have required indicator - assert html =~ "required" or html =~ "Required" - end - - test "marks last_name as required", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") - - # last_name should be marked as 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 + # Email is always required assert html =~ "email" or html =~ "Email" - # Should have required indicator - assert html =~ "required" or html =~ "Required" + assert html =~ "Required" or html =~ "Optional" 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") - # Optional fields should not have required indicator - # Check that street (optional) doesn't have required badge - # This test verifies that only required fields show the indicator - assert html =~ "street" or html =~ "Street" + # First name row should show Required (and Optional for others) + assert html =~ "First name" or html =~ "first_name" + assert html =~ "Required" + + # Reset + {:ok, settings} = Membership.get_settings() + + Membership.update_single_member_field(settings, + field: "first_name", + show_in_overview: true, + required: false + ) + 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 diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index 9e55cd8..44d7745 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -74,6 +74,45 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do html =~ "Please correct" or html =~ "Bitte korrigieren" 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" + + # Reset settings + {:ok, settings} = Mv.Membership.get_settings() + + Mv.Membership.update_single_member_field(settings, + field: "first_name", + show_in_overview: true, + required: false + ) + end + test "shows flash message when member update fails", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() -- 2.47.2 From bbededf3b92225e872702e8f88ace5fb22de5c01 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:11:18 +0100 Subject: [PATCH 07/13] CODE_GUIDELINES: document member_field_required and Vereinfacht required fields --- CODE_GUIDELINES.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 439eee8..3e2bbd0 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -2849,12 +2849,14 @@ Building accessible applications ensures that all users, including those with di **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 - + <.input field={@form[:first_name]} label={gettext("First Name")} - required + required={@member_field_required_map[:first_name]} aria-required="true" /> ``` -- 2.47.2 From cca2ca46323df4508089ac26bdb1fcdc754f66f6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:49:52 +0100 Subject: [PATCH 08/13] FormComponent: persist vereinfacht_required_field? in socket Assign in assign_form so validate/save enforce server-side without relying on render assigns; use socket.assigns.vereinfacht_required_field? --- lib/mv_web/live/member_field_live/form_component.ex | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/mv_web/live/member_field_live/form_component.ex b/lib/mv_web/live/member_field_live/form_component.ex index eba86d1..ae9e239 100644 --- a/lib/mv_web/live/member_field_live/form_component.ex +++ b/lib/mv_web/live/member_field_live/form_component.ex @@ -209,7 +209,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do end required = - socket.assigns[:vereinfacht_required_field?] || + socket.assigns.vereinfacht_required_field? || if Map.has_key?(member_field_params, "required") do TypeParsers.parse_boolean(member_field_params["required"]) else @@ -245,7 +245,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do end required = - socket.assigns[:vereinfacht_required_field?] || + socket.assigns.vereinfacht_required_field? || if Map.has_key?(member_field_params, "required") do TypeParsers.parse_boolean(member_field_params["required"]) else @@ -291,6 +291,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do 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 = -- 2.47.2 From 0d1b776e78017ecb0cb289b2445aa16e60d7bd33 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:49:54 +0100 Subject: [PATCH 09/13] Member: enforce email + Vereinfacht-required when get_settings fails Compute vereinfacht_required? outside case; on error log and validate only base required (email + Vereinfacht fields), not full settings. --- lib/membership/member.ex | 52 +++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 114814a..074f7e4 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -545,40 +545,48 @@ defmodule Mv.Membership.Member do end end - # Validate member fields that are marked as required in settings or by Vereinfacht + # 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 -> - case Mv.Membership.get_settings() do - {:ok, settings} -> - required_config = settings.member_field_required || %{} - normalized = VisibilityConfig.normalize(required_config) - vereinfacht_required? = Mv.Config.vereinfacht_configured?() + 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) - required_fields = Enum.filter(Mv.Constants.member_fields(), fn field -> field == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) || Map.get(normalized, field, false) end) - missing = - Enum.filter(required_fields, fn field -> - value = Ash.Changeset.get_attribute(changeset, field) - not member_field_value_present?(field, value) + {: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 - if Enum.empty?(missing) do - :ok - else - # Return first missing field error (Ash shows one at a time per field) - field = hd(missing) + missing = + Enum.filter(required_fields, fn field -> + value = Ash.Changeset.get_attribute(changeset, field) + not member_field_value_present?(field, value) + end) - {:error, - field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")} - end + if Enum.empty?(missing) do + :ok + else + field = hd(missing) - {:error, _} -> - # If settings cannot be loaded, skip this validation (e.g. bootstrap) - :ok + {:error, + field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")} end end end -- 2.47.2 From 717b8f567677b83d7486c5585a9ae0d44330672e Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:49:56 +0100 Subject: [PATCH 10/13] UpdateSingleMemberField: error attribution, updated_at, snapshot newline Attach errors to :field, :show_in_overview, :member_field_required. Set updated_at in SQL UPDATE. Add trailing newline to snapshot JSON. --- .../changes/update_single_member_field.ex | 25 +++++++++++++------ .../repo/settings/20260223195453.json | 2 +- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/membership/setting/changes/update_single_member_field.ex b/lib/membership/setting/changes/update_single_member_field.ex index a479164..e24860c 100644 --- a/lib/membership/setting/changes/update_single_member_field.ex +++ b/lib/membership/setting/changes/update_single_member_field.ex @@ -38,7 +38,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do nil -> {:error, add_error(changeset, - field: :member_field_visibility, + field: :field, message: "field argument is required" )} @@ -51,20 +51,28 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do {:error, add_error( changeset, - field: :member_field_visibility, + field: :field, message: "Invalid member field: #{field}" )} end end end - defp get_and_validate_boolean(changeset, arg_name) do + 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: :member_field_visibility, + field: error_field, message: "#{arg_name} argument is required" )} @@ -75,7 +83,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do {:error, add_error( changeset, - field: :member_field_visibility, + field: error_field, message: "#{arg_name} must be a boolean" )} end @@ -102,7 +110,8 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do 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 """ @@ -125,7 +134,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do {:ok, %{rows: []}} -> {:error, Invalid.exception( - field: :member_field_visibility, + field: :member_field_required, message: "Settings not found" )} @@ -134,7 +143,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do {:error, Invalid.exception( - field: :member_field_visibility, + field: :member_field_required, message: "Failed to update member field settings" )} end diff --git a/priv/resource_snapshots/repo/settings/20260223195453.json b/priv/resource_snapshots/repo/settings/20260223195453.json index 770e8ec..e035389 100644 --- a/priv/resource_snapshots/repo/settings/20260223195453.json +++ b/priv/resource_snapshots/repo/settings/20260223195453.json @@ -149,4 +149,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} -- 2.47.2 From 50c4ab049dceba3f19bd00b1c7530403ee009089 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 22:49:58 +0100 Subject: [PATCH 11/13] core_components: set aria-required for required inputs (WCAG) ensure_aria_required_for_input/1 adds aria-required when required in rest; applied to select, textarea and default input. --- lib/mv_web/components/core_components.ex | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 40cb800..21e3546 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -448,6 +448,8 @@ defmodule MvWeb.CoreComponents do end def input(%{type: "select"} = assigns) do + assigns = ensure_aria_required_for_input(assigns) + ~H"""