add join form settings #465

Merged
simon merged 4 commits from feature/308-web-form into main 2026-03-10 17:02:39 +01:00
15 changed files with 1466 additions and 5 deletions

View file

@ -89,7 +89,8 @@ lib/
│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest) │ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest)
│ ├── custom_field.ex # Custom field (definition) resource │ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource │ ├── custom_field_value.ex # Custom field value resource
│ ├── setting.ex # Global settings (singleton resource) │ ├── setting.ex # Global settings (singleton resource; incl. join form config)
│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.)
│ ├── group.ex # Group resource │ ├── group.ex # Group resource
│ ├── member_group.ex # MemberGroup join table resource │ ├── member_group.ex # MemberGroup join table resource
│ └── email.ex # Email custom type │ └── email.ex # Email custom type
@ -1139,6 +1140,12 @@ let liveSocket = new LiveSocket("/live", Socket, {
}) })
``` ```
**Vendor assets (third-party JS):**
Some JavaScript libraries are committed as vendored files in `assets/vendor/` (e.g. `topbar`, `sortable.js`) when they are not available as npm packages or we need a specific build. Document their origin and how to update them:
- **Sortable.js** (`assets/vendor/sortable.js`): From [SortableJS](https://github.com/SortableJS/Sortable), version noted in the file header (e.g. `/*! Sortable 1.15.6 - MIT ... */`). To update: download the desired release from the repo and replace the file; keep the header comment for traceability.
### 3.8 Code Quality: Credo ### 3.8 Code Quality: Credo
**Static Code Analysis:** **Static Code Analysis:**

View file

@ -656,3 +656,34 @@
} }
/* This file is for your main application CSS */ /* This file is for your main application CSS */
/* ============================================
SortableList: drag-and-drop table rows
============================================ */
/* Ghost row: placeholder showing where the dragged item will be dropped.
Background fills the gap; text invisible so layout matches original row. */
.sortable-ghost {
background-color: var(--color-base-300) !important;
opacity: 0.5;
}
.sortable-ghost td {
border-color: transparent !important;
}
/* Chosen row: the row being actively dragged (follows the cursor). */
.sortable-chosen {
background-color: var(--color-base-200);
box-shadow: 0 4px 16px -2px oklch(0 0 0 / 0.18);
cursor: grabbing !important;
}
/* Drag handle button: only grab cursor, no hover effect for mouse users.
Keyboard outline is handled via JS outline style. */
[data-sortable-handle] button {
cursor: grab;
}
[data-sortable-handle] button:hover {
background-color: transparent !important;
color: inherit;
}

View file

@ -21,6 +21,7 @@ import "phoenix_html"
import {Socket} from "phoenix" import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view" import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar" import topbar from "../vendor/topbar"
import Sortable from "../vendor/sortable"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
@ -120,6 +121,148 @@ Hooks.TabListKeydown = {
} }
} }
// SortableList hook: Accessible reorderable table/list.
// Mouse drag: SortableJS (smooth animation, ghost row, items push apart).
// Keyboard: Space = grab/drop, Arrow up/down = move, Escape = cancel, matching the Salesforce a11y pattern.
// Container must have data-reorder-event and data-list-id.
// Each row (tr) must have data-row-index; locked rows have data-locked="true".
// Pushes event with { from_index, to_index } (both integers) on reorder.
Hooks.SortableList = {
mounted() {
this.reorderEvent = this.el.dataset.reorderEvent
this.listId = this.el.dataset.listId
// Keyboard state: store grabbed row id so it survives LiveView re-renders
this.grabbedRowId = null
this.announcementEl = this.listId ? document.getElementById(this.listId + "-announcement") : null
const announce = (msg) => {
if (!this.announcementEl) return
// Clear then re-set to force screen reader re-read
this.announcementEl.textContent = ""
setTimeout(() => { if (this.announcementEl) this.announcementEl.textContent = msg }, 50)
}
const tbody = this.el.querySelector("tbody")
if (!tbody) return
this.getRows = () => Array.from(tbody.querySelectorAll("tr"))
this.getRowIndex = (tr) => {
const idx = tr.getAttribute("data-row-index")
return idx != null ? parseInt(idx, 10) : -1
}
this.isLocked = (tr) => tr.getAttribute("data-locked") === "true"
// SortableJS for mouse drag-and-drop with animation
this.sortable = new Sortable(tbody, {
animation: 150,
handle: "[data-sortable-handle]",
// Disable sorting for locked rows (first row = email)
filter: "[data-locked='true']",
preventOnFilter: true,
// Ghost (placeholder showing where the item will land)
ghostClass: "sortable-ghost",
// The item being dragged
chosenClass: "sortable-chosen",
// Cursor while dragging
dragClass: "sortable-drag",
// Don't trigger on handle area clicks (only actual drag)
delay: 0,
onEnd: (e) => {
if (e.oldIndex === e.newIndex) return
this.pushEvent(this.reorderEvent, { from_index: e.oldIndex, to_index: e.newIndex })
announce(`Dropped. Position ${e.newIndex + 1} of ${this.getRows().length}.`)
// LiveView will reconcile the DOM order after re-render
}
})
// Keyboard handler (Salesforce a11y pattern: Space=grab/drop, Arrows=move, Escape=cancel)
this.handleKeyDown = (e) => {
// Don't intercept Space on interactive elements (checkboxes, buttons, inputs)
const tag = e.target.tagName
if (tag === "INPUT" || tag === "BUTTON" || tag === "SELECT" || tag === "TEXTAREA") return
const tr = e.target.closest("tr")
if (!tr || this.isLocked(tr)) return
const rows = this.getRows()
const idx = this.getRowIndex(tr)
if (idx < 0) return
const total = rows.length
if (e.key === " ") {
e.preventDefault()
const rowId = tr.id
if (this.grabbedRowId === rowId) {
// Drop
this.grabbedRowId = null
tr.style.outline = ""
announce(`Dropped. Position ${idx + 1} of ${total}.`)
} else {
// Grab
this.grabbedRowId = rowId
tr.style.outline = "2px solid var(--color-primary)"
tr.style.outlineOffset = "-2px"
announce(`Grabbed. Position ${idx + 1} of ${total}. Use arrow keys to move, Space to drop, Escape to cancel.`)
}
return
}
if (e.key === "Escape") {
if (this.grabbedRowId != null) {
e.preventDefault()
const grabbedTr = document.getElementById(this.grabbedRowId)
if (grabbedTr) { grabbedTr.style.outline = ""; grabbedTr.style.outlineOffset = "" }
this.grabbedRowId = null
announce("Reorder cancelled.")
}
return
}
if (this.grabbedRowId == null) return
// Do not move into a locked row (e.g. email always first)
if (e.key === "ArrowUp" && idx > 0) {
const targetRow = rows[idx - 1]
if (!this.isLocked(targetRow)) {
e.preventDefault()
this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 })
announce(`Position ${idx} of ${total}.`)
}
} else if (e.key === "ArrowDown" && idx < total - 1) {
const targetRow = rows[idx + 1]
if (!this.isLocked(targetRow)) {
e.preventDefault()
this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 })
announce(`Position ${idx + 2} of ${total}.`)
}
}
}
this.el.addEventListener("keydown", this.handleKeyDown, true)
},
updated() {
// Re-apply keyboard outline and restore focus after LiveView re-render.
// LiveView DOM patching loses focus; without explicit re-focus the next keypress
// goes to document.body (Space scrolls the page instead of triggering our handler).
if (this.grabbedRowId) {
const tr = document.getElementById(this.grabbedRowId)
if (tr) {
tr.style.outline = "2px solid var(--color-primary)"
tr.style.outlineOffset = "-2px"
tr.focus()
} else {
// Row no longer exists (removed while grabbed), clear state
this.grabbedRowId = null
}
}
},
destroyed() {
if (this.sortable) this.sortable.destroy()
this.el.removeEventListener("keydown", this.handleKeyDown, true)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state // SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = { Hooks.SidebarState = {
mounted() { mounted() {

2
assets/vendor/sortable.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -809,6 +809,14 @@ end
- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders. - **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders.
- Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` all pass. - Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` all pass.
**Subtask 3 Admin: Join form settings (done):**
- **Setting resource** (`lib/membership/setting.ex`): 3 new attributes `join_form_enabled` (boolean, default false), `join_form_field_ids` ({:array, :string} ordered list of member field names or custom field UUIDs), `join_form_field_required` (:map field ID → boolean). Added to `:create` and `:update` accept lists. Validation rejects field IDs that are neither valid member field names nor UUID format. Migration: `20260310114701_add_join_form_settings_to_settings.exs`.
- **NormalizeJoinFormSettings** (`lib/membership/setting/changes/normalize_join_form_settings.ex`): Change applied on create/update whenever join form attrs are changing. Ensures email is always in `join_form_field_ids`, forces `join_form_field_required["email"] = true`, drops required flags for fields not in `join_form_field_ids`.
- **Domain** (`lib/membership/membership.ex`): `Mv.Membership.get_join_form_allowlist/0` returns `[]` when join form is disabled, otherwise a list of `%{id, required, type}` maps (type = `:member_field` or `:custom_field` based on ID format).
- **GlobalSettingsLive** (`lib/mv_web/live/global_settings_live.ex`): New "Join Form" / "Beitrittsformular" section between Club Settings and Vereinfacht. Checkbox to enable/disable, table of selected fields (with Required checkbox per field email always checked/disabled, other fields can be toggled), "Add field" dropdown with all unselected member fields and custom fields, "Save Join Form Settings" button. State is managed locally (not via AshPhoenix.Form); saved on explicit save click.
- **Translations**: 14 new German strings in `priv/gettext/de/LC_MESSAGES/default.po` (Beitrittsformular, Felder im Beitrittsformular, Feld hinzufügen, etc.).
- Tests: All 13 tests in `test/membership/setting_join_form_test.exs` pass; full test suite 1900 tests, 0 failures.
### Test Data Management ### Test Data Management
**Seed Data:** **Seed Data:**

View file

@ -455,6 +455,56 @@ defmodule Mv.Membership do
end end
end end
@doc """
Returns the allowlist of fields configured for the public join form.
Reads the current settings. When the join form is disabled (or no settings exist),
returns an empty list. When enabled, returns each configured field as a map with:
- `:id` - field identifier string (member field name or custom field UUID)
- `:required` - boolean; email is always true
- `:type` - `:member_field` or `:custom_field`
This is the server-side allowlist used by the join form submit action (Subtask 4)
to enforce which fields are accepted from user input.
## Returns
- `[%{id: String.t(), required: boolean(), type: :member_field | :custom_field}]`
- `[]` when join form is disabled or settings are missing
## Examples
iex> Mv.Membership.get_join_form_allowlist()
[%{id: "email", required: true, type: :member_field},
%{id: "first_name", required: false, type: :member_field}]
"""
def get_join_form_allowlist do
case get_settings() do
{:ok, settings} ->
if settings.join_form_enabled do
build_join_form_allowlist(settings)
else
[]
end
{:error, _} ->
[]
end
end
defp build_join_form_allowlist(settings) do
field_ids = settings.join_form_field_ids || []
required_config = settings.join_form_field_required || %{}
member_field_names = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
Enum.map(field_ids, fn id ->
type = if id in member_field_names, do: :member_field, else: :custom_field
required = Map.get(required_config, id, false)
%{id: id, required: required, type: type}
end)
end
defp expired?(nil), do: true defp expired?(nil), do: true
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
end end

View file

@ -15,6 +15,12 @@ defmodule Mv.Membership.Setting do
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional. (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)
- `join_form_enabled` - Whether the public /join page is active (default: false)
- `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is
either a member field name string (e.g. "email") or a custom field UUID. Email is always
included and always required; normalization enforces this automatically.
- `join_form_field_required` - Map of field ID => required boolean for the join form.
Email is always forced to true.
## Singleton Pattern ## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record. This resource uses a singleton pattern - there should only be one settings record.
@ -54,6 +60,10 @@ defmodule Mv.Membership.Setting do
domain: Mv.Membership, domain: Mv.Membership,
data_layer: AshPostgres.DataLayer data_layer: AshPostgres.DataLayer
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
postgres do postgres do
table "settings" table "settings"
repo Mv.Repo repo Mv.Repo
@ -86,8 +96,13 @@ defmodule Mv.Membership.Setting do
:oidc_client_secret, :oidc_client_secret,
:oidc_admin_group_name, :oidc_admin_group_name,
:oidc_groups_claim, :oidc_groups_claim,
:oidc_only :oidc_only,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
] ]
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
end end
update :update do update :update do
@ -110,8 +125,13 @@ defmodule Mv.Membership.Setting do
:oidc_client_secret, :oidc_client_secret,
:oidc_admin_group_name, :oidc_admin_group_name,
:oidc_groups_claim, :oidc_groups_claim,
:oidc_only :oidc_only,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
] ]
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
end end
update :update_member_field_visibility do update :update_member_field_visibility do
@ -232,6 +252,33 @@ defmodule Mv.Membership.Setting do
end, end,
on: [:create, :update] on: [:create, :update]
# Validate join_form_field_ids: each entry must be a known member field name
# or a UUID-format string (custom field ID). Normalization (NormalizeJoinFormSettings
# change) runs before validations, so email is already present when this runs.
validate fn changeset, _context ->
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
if is_list(field_ids) and field_ids != [] do
invalid_ids =
Enum.reject(field_ids, fn id ->
is_binary(id) and
(id in @valid_join_form_member_fields or Regex.match?(@uuid_pattern, id))
end)
if Enum.empty?(invalid_ids) do
:ok
else
{:error,
field: :join_form_field_ids,
message:
"Invalid field identifiers: #{inspect(invalid_ids)}. Use member field names or custom field UUIDs."}
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 =
@ -382,6 +429,30 @@ defmodule Mv.Membership.Setting do
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)" description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
end end
# Join form (Beitrittsformular) settings
attribute :join_form_enabled, :boolean do
allow_nil? false
default false
public? true
description "When true, the public /join page is active and new members can submit a request."
end
attribute :join_form_field_ids, {:array, :string} do
allow_nil? true
default []
public? true
description "Ordered list of field IDs shown on the join form. Each entry is a member field name (e.g. 'email') or a custom field UUID. Email is always present after normalization."
end
attribute :join_form_field_required, :map do
allow_nil? true
public? true
description "Map of field ID => required boolean for the join form. Email is always true after normalization."
end
timestamps() timestamps()
end end

View file

@ -0,0 +1,60 @@
defmodule Mv.Membership.Setting.Changes.NormalizeJoinFormSettings do
@moduledoc """
Ash change that normalizes join form field settings before persist.
Applied on create and update actions whenever join form attributes are present.
Rules enforced:
- Email is always added to join_form_field_ids if not already present.
- Email is always marked as required (true) in join_form_field_required.
- Keys in join_form_field_required that are not in join_form_field_ids are dropped.
Only runs when join_form_field_ids is being changed; if only
join_form_field_required changes, normalization still uses the current
(possibly changed) field_ids to strip orphaned required flags.
"""
use Ash.Resource.Change
def change(changeset, _opts, _context) do
changing_ids? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_ids)
changing_required? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_required)
if changing_ids? or changing_required? do
normalize(changeset)
else
changeset
end
end
defp normalize(changeset) do
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
required_config = Ash.Changeset.get_attribute(changeset, :join_form_field_required)
field_ids = normalize_field_ids(field_ids)
required_config = normalize_required(field_ids, required_config)
changeset
|> Ash.Changeset.force_change_attribute(:join_form_field_ids, field_ids)
|> Ash.Changeset.force_change_attribute(:join_form_field_required, required_config)
end
defp normalize_field_ids(nil), do: ["email"]
defp normalize_field_ids(ids) when is_list(ids) do
if "email" in ids do
ids
else
["email" | ids]
end
end
defp normalize_field_ids(_), do: ["email"]
defp normalize_required(field_ids, required_config) do
base = if is_map(required_config), do: required_config, else: %{}
base
|> Map.take(field_ids)
|> Map.put("email", true)
end
end

View file

@ -1150,6 +1150,124 @@ defmodule MvWeb.CoreComponents do
defp table_th_sticky_class(_), do: nil defp table_th_sticky_class(_), do: nil
@doc """
Renders a reorderable table (sortable list) with drag handle and keyboard support.
Uses the SortableList hook for accessible drag-and-drop and keyboard reorder
(Space to grab/drop, Arrow up/down to move, Escape to cancel). Pushes the
given reorder event with `from_index` and `to_index`; the parent LiveView
should reorder the list and re-render.
## Attributes
* `:id` Required. Unique id for the list (used for hook and live region).
* `:rows` Required. List of row data.
* `:row_id` Required. Function to get a unique id for each row (e.g. for locked_ids).
* `:locked_ids` List of row ids that cannot be reordered (e.g. `["join-field-email"]`). Default `[]`.
* `:reorder_event` Required. LiveView event name to push on reorder (payload: `%{from_index: i, to_index: j}`).
* `:row_item` Function to map row before passing to slots. Default `&Function.identity/1`.
## Slots
* `:col` Data columns (attr `:label`, `:class`). Content is rendered with the row item.
* `:action` Optional. Last column (e.g. delete button) with the row item.
## Examples
<.sortable_table
id="join-form-fields"
rows={@join_form_fields}
row_id={fn f -> "join-field-\#{f.id}" end}
locked_ids={["join-field-email"]}
reorder_event="reorder_join_form_field"
>
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">{field.label}</:col>
<:col :let={field} label={gettext("Required")}>...</:col>
<:action :let={field}><.button>Remove</.button></:action>
</.sortable_table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, required: true
attr :locked_ids, :list, default: []
attr :reorder_event, :string, required: true
attr :row_item, :any, default: &Function.identity/1
slot :col, required: true do
attr :label, :string, required: true
attr :class, :string
end
slot :action
def sortable_table(assigns) do
assigns = assign(assigns, :locked_set, MapSet.new(assigns.locked_ids))
~H"""
<div
id={@id}
phx-hook="SortableList"
data-reorder-event={@reorder_event}
data-locked-ids={Jason.encode!(@locked_ids)}
data-list-id={@id}
class="overflow-auto"
>
<span
id={"#{@id}-announcement"}
aria-live="assertive"
aria-atomic="true"
class="sr-only"
/>
<table class="table table-zebra">
<thead>
<tr>
<th class="w-10" aria-label={gettext("Reorder")}></th>
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th>
<th :if={@action != []} class="w-0 font-semibold">{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<tr
:for={{row, idx} <- Enum.with_index(@rows)}
id={@row_id.(row)}
data-row-index={idx}
data-locked={if(MapSet.member?(@locked_set, @row_id.(row)), do: "true", else: nil)}
tabindex={if(not MapSet.member?(@locked_set, @row_id.(row)), do: "0", else: nil)}
>
<td class="w-10 align-middle" data-sortable-handle>
<span
:if={MapSet.member?(@locked_set, @row_id.(row))}
class="inline-block w-6 text-base-content/20"
aria-hidden="true"
>
<.icon name="hero-bars-3" class="size-4" />
</span>
<span
:if={not MapSet.member?(@locked_set, @row_id.(row))}
class="inline-flex items-center justify-center w-6 h-6 cursor-grab text-base-content/40"
aria-hidden="true"
>
<.icon name="hero-bars-3" class="size-4" />
</span>
</td>
<td
:for={col <- @col}
class={["max-w-xs", Map.get(col, :class) || ""] |> Enum.join(" ")}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@doc """ @doc """
Renders a data list. Renders a data list.

View file

@ -4,16 +4,24 @@ defmodule MvWeb.GlobalSettingsLive do
## Features ## Features
- Edit the association/club name - Edit the association/club name
- Configure the public join form (Beitrittsformular)
- Manage custom fields - Manage custom fields
- Real-time form validation - Real-time form validation
- Success/error feedback - Success/error feedback
## Settings ## Settings
- `club_name` - The name of the association/club (required) - `club_name` - The name of the association/club (required)
- `join_form_enabled` - Whether the public /join page is active
- `join_form_field_ids` - Ordered list of field IDs shown on the join form
- `join_form_field_required` - Map of field ID => required boolean
## Events ## Events
- `validate` - Real-time form validation - `validate` / `save` - Club settings form
- `save` - Save settings changes - `toggle_join_form_enabled` - Enable/disable the join form
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields
- `toggle_join_form_field_required` - Toggle required flag per field
- `toggle_add_field_dropdown` / `hide_add_field_dropdown` - Dropdown visibility
- Join form changes (enable/disable, add/remove fields, required toggles) are persisted immediately
## Note ## Note
Settings is a singleton resource - there is only one settings record. Settings is a singleton resource - there is only one settings record.
@ -31,6 +39,7 @@ defmodule MvWeb.GlobalSettingsLive do
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource alias Mv.Membership.Member, as: MemberResource
alias MvWeb.Helpers.MemberHelpers alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Translations.MemberFields
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@ -42,6 +51,9 @@ defmodule MvWeb.GlobalSettingsLive do
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale) Gettext.put_locale(MvWeb.Gettext, locale)
actor = MvWeb.LiveHelpers.current_actor(socket)
custom_fields = load_custom_fields(actor)
socket = socket =
socket socket
|> assign(:page_title, gettext("Settings")) |> assign(:page_title, gettext("Settings"))
@ -65,6 +77,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?()) |> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret)) |> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|> assign_join_form_state(settings, custom_fields)
|> assign_form() |> assign_form()
{:ok, socket} {:ok, socket}
@ -103,6 +116,159 @@ defmodule MvWeb.GlobalSettingsLive do
</.button> </.button>
</.form> </.form>
</.form_section> </.form_section>
<%!-- Join Form Section (Beitrittsformular) --%>
<.form_section title={gettext("Join Form")}>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Configure the public join form that allows new members to submit a join request.")}
</p>
<%!-- Enable/disable --%>
<div class="flex items-center gap-3 mb-3">
<input
type="checkbox"
id="join-form-enabled-checkbox"
class="checkbox checkbox-sm"
checked={@join_form_enabled}
phx-click="toggle_join_form_enabled"
aria-label={gettext("Join form enabled")}
/>
<label for="join-form-enabled-checkbox" class="cursor-pointer font-medium">
{gettext("Join form enabled")}
</label>
</div>
<%!-- Board approval (future feature) --%>
<div class="flex items-center gap-3 mb-6">
<input
type="checkbox"
id="join-form-board-approval-checkbox"
class="checkbox checkbox-sm"
checked={false}
disabled
aria-label={gettext("Board approval required (in development)")}
/>
<label for="join-form-board-approval-checkbox" class="text-base-content/60 font-medium">
{gettext("Board approval required (in development)")}
</label>
</div>
<div :if={@join_form_enabled}>
<%!-- Field list header + Add button (left-aligned) --%>
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
<div class="relative mb-3 w-fit" phx-click-away="hide_add_field_dropdown">
<.button
type="button"
variant="primary"
phx-click="toggle_add_field_dropdown"
disabled={
Enum.empty?(@available_join_form_member_fields) and
Enum.empty?(@available_join_form_custom_fields)
}
aria-haspopup="listbox"
aria-expanded={to_string(@show_add_field_dropdown)}
>
<.icon name="hero-plus" class="size-4" />
{gettext("Add field")}
</.button>
<%!-- Available fields dropdown (sections: Personal data, Custom fields) --%>
<div
:if={@show_add_field_dropdown}
class="absolute left-0 mt-1 w-56 bg-base-100 border border-base-300 rounded-lg shadow-lg z-10 max-h-64 overflow-y-auto"
role="listbox"
aria-label={gettext("Available fields")}
>
<div :if={not Enum.empty?(@available_join_form_member_fields)} class="pt-2">
<div class="px-4 py-1 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
{gettext("Personal data")}
</div>
<div
:for={field <- @available_join_form_member_fields}
role="option"
tabindex="0"
class="px-4 py-2 cursor-pointer hover:bg-base-200 text-sm"
phx-click="add_join_form_field"
phx-value-field_id={field.id}
>
{field.label}
</div>
</div>
<div
:if={not Enum.empty?(@available_join_form_custom_fields)}
class={
if(Enum.empty?(@available_join_form_member_fields),
do: "pt-2",
else: "border-t border-base-300"
)
}
>
<div class="px-4 py-1 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
{gettext("Individual fields")}
</div>
<div
:for={field <- @available_join_form_custom_fields}
role="option"
tabindex="0"
class="px-4 py-2 cursor-pointer hover:bg-base-200 text-sm last:pb-2"
phx-click="add_join_form_field"
phx-value-field_id={field.id}
>
{field.label}
</div>
</div>
</div>
</div>
<%!-- Empty state --%>
<p :if={Enum.empty?(@join_form_fields)} class="text-sm text-base-content/60 italic mb-4">
{gettext("No fields selected. Add at least the email field.")}
</p>
<%!-- Fields table (compact width, reorderable) --%>
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4 max-w-2xl">
<.sortable_table
id="join-form-fields-table"
rows={@join_form_fields}
row_id={fn field -> "join-field-#{field.id}" end}
reorder_event="reorder_join_form_field"
>
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">
{field.label}
</:col>
<:col :let={field} label={gettext("Required")} class="w-24 max-w-[9.375rem] text-center">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={field.required}
disabled={not field.can_toggle_required}
phx-click={if field.can_toggle_required, do: "toggle_join_form_field_required"}
phx-value-field_id={field.id}
aria-label={gettext("Required")}
/>
</:col>
<:action :let={field}>
<.tooltip content={gettext("Remove")} position="left">
<.button
type="button"
variant="danger"
size="sm"
disabled={not field.can_remove}
class={if(not field.can_remove, do: "opacity-50 cursor-not-allowed", else: "")}
phx-click="remove_join_form_field"
phx-value-field_id={field.id}
aria-label={gettext("Remove field %{label}", label: field.label)}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</.tooltip>
</:action>
</.sortable_table>
<p class="mt-2 text-sm text-base-content/60">
{gettext("The order of rows determines the field order in the join form.")}
</p>
</div>
</div>
</.form_section>
<%!-- Vereinfacht Integration Section --%> <%!-- Vereinfacht Integration Section --%>
<.form_section title={gettext("Vereinfacht Integration")}> <.form_section title={gettext("Vereinfacht Integration")}>
<%= if @vereinfacht_env_configured do %> <%= if @vereinfacht_env_configured do %>
@ -426,6 +592,148 @@ defmodule MvWeb.GlobalSettingsLive do
end end
end end
# ---- Join form event handlers ----
@impl true
def handle_event("toggle_join_form_enabled", _params, socket) do
socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled)
{:noreply, persist_join_form_settings(socket)}
end
@impl true
def handle_event("toggle_add_field_dropdown", _params, socket) do
{:noreply,
assign(socket, :show_add_field_dropdown, not socket.assigns.show_add_field_dropdown)}
end
@impl true
def handle_event("hide_add_field_dropdown", _params, socket) do
{:noreply, assign(socket, :show_add_field_dropdown, false)}
end
@impl true
def handle_event("add_join_form_field", %{"field_id" => field_id}, socket) do
member_avail = socket.assigns.available_join_form_member_fields
custom_avail = socket.assigns.available_join_form_custom_fields
current = socket.assigns.join_form_fields
field_to_add =
Enum.find(member_avail, &(&1.id == field_id)) ||
Enum.find(custom_avail, &(&1.id == field_id))
socket =
if field_to_add do
full_field = %{
id: field_to_add.id,
label: field_to_add.label,
type: field_to_add.type,
required: false,
can_remove: field_to_add.id != "email",
can_toggle_required: field_to_add.id != "email"
}
new_fields = current ++ [full_field]
new_member = Enum.reject(member_avail, &(&1.id == field_id))
new_custom = Enum.reject(custom_avail, &(&1.id == field_id))
socket
|> assign(:join_form_fields, new_fields)
|> assign(:available_join_form_member_fields, new_member)
|> assign(:available_join_form_custom_fields, new_custom)
|> assign(:show_add_field_dropdown, false)
else
socket
end
{:noreply, persist_join_form_settings(socket)}
end
@impl true
def handle_event("remove_join_form_field", %{"field_id" => field_id}, socket) do
if field_id == "email" do
{:noreply, socket}
else
current = socket.assigns.join_form_fields
custom_fields = socket.assigns.join_form_custom_fields
new_fields = Enum.reject(current, &(&1.id == field_id))
new_ids = Enum.map(new_fields, & &1.id)
%{member_fields: new_member, custom_fields: new_custom} =
build_available_join_form_fields(new_ids, custom_fields)
socket =
socket
|> assign(:join_form_fields, new_fields)
|> assign(:available_join_form_member_fields, new_member)
|> assign(:available_join_form_custom_fields, new_custom)
|> persist_join_form_settings()
{:noreply, socket}
end
end
@impl true
def handle_event("toggle_join_form_field_required", %{"field_id" => "email"}, socket) do
{:noreply, socket}
end
@impl true
def handle_event("toggle_join_form_field_required", %{"field_id" => field_id}, socket) do
new_fields =
Enum.map(socket.assigns.join_form_fields, &toggle_required_if_matches(&1, field_id))
socket = assign(socket, :join_form_fields, new_fields) |> persist_join_form_settings()
{:noreply, socket}
end
@impl true
def handle_event(
"reorder_join_form_field",
%{"from_index" => from_idx, "to_index" => to_idx},
socket
)
when is_integer(from_idx) and is_integer(to_idx) do
fields = socket.assigns.join_form_fields
new_fields = reorder_list(fields, from_idx, to_idx)
socket =
socket
|> assign(:join_form_fields, new_fields)
|> persist_join_form_settings()
{:noreply, socket}
end
# Ignore malformed reorder events (e.g. nil indices from aborted drags)
def handle_event("reorder_join_form_field", _params, socket), do: {:noreply, socket}
defp persist_join_form_settings(socket) do
settings = socket.assigns.settings
field_ids = Enum.map(socket.assigns.join_form_fields, & &1.id)
required_map =
socket.assigns.join_form_fields
|> Map.new(fn field -> {field.id, field.required} end)
attrs = %{
join_form_enabled: socket.assigns.join_form_enabled,
join_form_field_ids: field_ids,
join_form_field_required: required_map
}
case Membership.update_settings(settings, attrs) do
{:ok, updated_settings} ->
custom_fields = socket.assigns.join_form_custom_fields
socket
|> assign(:settings, updated_settings)
|> assign_join_form_state(updated_settings, custom_fields)
{:error, _error} ->
put_flash(socket, :error, gettext("Could not save join form settings."))
end
end
@vereinfacht_param_keys ~w[vereinfacht_api_url vereinfacht_api_key vereinfacht_club_id vereinfacht_app_url] @vereinfacht_param_keys ~w[vereinfacht_api_url vereinfacht_api_key vereinfacht_club_id vereinfacht_app_url]
defp vereinfacht_params?(params) when is_map(params) do defp vereinfacht_params?(params) when is_map(params) do
@ -709,4 +1017,101 @@ defmodule MvWeb.GlobalSettingsLive do
</div> </div>
""" """
end end
# ---- Join form helper functions ----
defp assign_join_form_state(socket, settings, custom_fields) do
enabled = settings.join_form_enabled || false
raw_ids = settings.join_form_field_ids || []
field_ids = if "email" in raw_ids, do: raw_ids, else: ["email" | raw_ids]
required_config = settings.join_form_field_required || %{}
join_form_fields = build_join_form_fields(field_ids, required_config, custom_fields)
%{member_fields: member_avail, custom_fields: custom_avail} =
build_available_join_form_fields(field_ids, custom_fields)
socket
|> assign(:join_form_enabled, enabled)
|> assign(:join_form_fields, join_form_fields)
|> assign(:available_join_form_member_fields, member_avail)
|> assign(:available_join_form_custom_fields, custom_avail)
|> assign(:show_add_field_dropdown, false)
|> assign(:join_form_custom_fields, custom_fields)
end
defp build_join_form_fields(field_ids, required_config, custom_fields) do
Enum.map(field_ids, fn id ->
label = join_form_field_label(id, custom_fields)
required = if id == "email", do: true, else: Map.get(required_config, id, false)
type = if id in member_field_id_strings(), do: :member_field, else: :custom_field
%{
id: id,
label: label,
required: required,
can_remove: id != "email",
can_toggle_required: id != "email",
type: type
}
end)
end
defp build_available_join_form_fields(selected_ids, custom_fields) do
member_fields =
Mv.Constants.member_fields()
|> Enum.reject(fn field -> Atom.to_string(field) in selected_ids end)
|> Enum.map(fn field ->
%{id: Atom.to_string(field), label: MemberFields.label(field), type: :member_field}
end)
custom_field_entries =
custom_fields
|> Enum.reject(fn cf -> cf.id in selected_ids end)
|> Enum.map(fn cf ->
%{id: cf.id, label: cf.name, type: :custom_field}
end)
|> Enum.sort_by(& &1.label)
%{member_fields: member_fields, custom_fields: custom_field_entries}
end
defp join_form_field_label(id, custom_fields) do
if id in member_field_id_strings() do
MemberFields.label(String.to_existing_atom(id))
else
case Enum.find(custom_fields, &(&1.id == id)) do
nil -> id
cf -> cf.name
end
end
end
defp toggle_required_if_matches(%{id: id} = field, id),
do: Map.put(field, :required, not field.required)
defp toggle_required_if_matches(field, _field_id), do: field
defp reorder_list(list, from_index, to_index) do
item = Enum.at(list, from_index)
rest = List.delete_at(list, from_index)
List.insert_at(rest, to_index, item)
end
defp member_field_id_strings do
Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
end
defp load_custom_fields(nil), do: []
defp load_custom_fields(actor) do
case Ash.read(Mv.Membership.CustomField,
actor: actor,
domain: Mv.Membership,
authorize?: true
) do
{:ok, fields} -> fields
{:error, _} -> []
end
end
end end

View file

@ -357,6 +357,7 @@ msgstr "Passwort-Authentifizierung"
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.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/member_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2149,6 +2150,7 @@ msgstr "Mitglied ist nicht in dieser Gruppe."
msgid "No email" msgid "No email"
msgstr "Keine E-Mail" msgstr "Keine E-Mail"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Remove" msgid "Remove"
@ -3324,3 +3326,78 @@ msgstr "Unvollständig"
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
msgstr "Diese Datenfelder sind für MILA notwendig, um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden." msgstr "Diese Datenfelder sind für MILA notwendig, um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Add field"
msgstr "Feld hinzufügen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Available fields"
msgstr "Verfügbare Felder"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure the public join form that allows new members to submit a join request."
msgstr "Konfiguriere das öffentliche Beitrittsformular, über das neue Mitglieder einen Beitrittsantrag stellen können."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Could not save join form settings."
msgstr "Beitrittsformular-Einstellungen konnten nicht gespeichert werden."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr "Feld"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Fields on the join form"
msgstr "Felder im Beitrittsformular"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join Form"
msgstr "Beitrittsformular"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
msgstr "Beitrittsformular aktiv"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "No fields selected. Add at least the email field."
msgstr "Keine Felder ausgewählt. Füge mindestens das E-Mail-Feld hinzu."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Remove field %{label}"
msgstr "Feld %{label} entfernen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Board approval required (in development)"
msgstr "Bestätigung durch Vorstand erforderlich (in Entwicklung)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
msgstr "Individuelle Felder"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Personal data"
msgstr "Persönliche Daten"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reorder"
msgstr "Umordnen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular."

View file

@ -358,6 +358,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.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/member_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2150,6 +2151,7 @@ msgstr ""
msgid "No email" msgid "No email"
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Remove" msgid "Remove"
@ -3324,3 +3326,78 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Add field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Available fields"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure the public join form that allows new members to submit a join request."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Could not save join form settings."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Fields on the join form"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join Form"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "No fields selected. Add at least the email field."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Remove field %{label}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Board approval required (in development)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Personal data"
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reorder"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr ""

View file

@ -358,6 +358,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.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/member_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2150,6 +2151,7 @@ msgstr ""
msgid "No email" msgid "No email"
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Remove" msgid "Remove"
@ -3324,3 +3326,78 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Add field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Available fields"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure the public join form that allows new members to submit a join request."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Could not save join form settings."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Fields on the join form"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join Form"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "No fields selected. Add at least the email field."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Remove field %{label}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Board approval required (in development)"
msgstr "Board approval required (in development)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
msgstr "Individual fields"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Personal data"
msgstr "Personal data"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reorder"
msgstr "Reorder"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr "The order of rows determines the field order in the join form."

View file

@ -0,0 +1,27 @@
defmodule Mv.Repo.Migrations.AddJoinFormSettingsToSettings do
@moduledoc """
Adds join form configuration columns to the settings table.
- join_form_enabled: whether the public /join page is active
- join_form_field_ids: ordered list of field IDs shown on the join form (JSONB array)
- join_form_field_required: map of field ID => required boolean (JSONB)
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :join_form_enabled, :boolean, default: false, null: false
add :join_form_field_ids, {:array, :string}
add :join_form_field_required, :map
end
end
def down do
alter table(:settings) do
remove :join_form_enabled
remove :join_form_field_ids
remove :join_form_field_required
end
end
end

View file

@ -0,0 +1,308 @@
defmodule Mv.Membership.SettingJoinFormTest do
@moduledoc """
TDD tests for Join Form Settings (onboarding-join-concept subtask 3).
These tests define the expected API and behaviour for the "Onboarding / Join" section
in global settings. No functionality is implemented yet; tests are expected to fail
(red) until:
- Setting resource has attributes: `join_form_enabled`, `join_form_field_ids`,
`join_form_field_required`, plus validations and accept in the update action.
- `Mv.Membership.get_join_form_allowlist/0` is implemented and returns the allowlist
for the public join form (subtask 4).
"""
# Settings is a singleton; tests mutate shared DB state. We use async: false and on_exit to restore
# original values because Ecto Sandbox transaction rollback does not apply to this singleton pattern.
use Mv.DataCase, async: false
alias Mv.Constants
alias Mv.Helpers.SystemActor
alias Mv.Membership
setup do
{:ok, settings} = Membership.get_settings()
saved_enabled = Map.get(settings, :join_form_enabled)
saved_ids = Map.get(settings, :join_form_field_ids)
saved_required = Map.get(settings, :join_form_field_required)
on_exit(fn ->
{:ok, s} = Membership.get_settings()
attrs = %{}
attrs =
if saved_enabled != nil,
do: Map.put(attrs, :join_form_enabled, saved_enabled),
else: attrs
attrs =
if saved_ids != nil,
do: Map.put(attrs, :join_form_field_ids, saved_ids || []),
else: attrs
attrs =
if saved_required != nil,
do: Map.put(attrs, :join_form_field_required, saved_required || %{}),
else: attrs
if attrs != %{} do
Membership.update_settings(s, attrs)
end
end)
:ok
end
defp update_join_form_settings(settings, attrs) do
Membership.update_settings(settings, attrs)
end
defp error_message(errors, field) when is_atom(field) do
errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|> Enum.map(&Map.get(&1, :message, ""))
|> List.first() || ""
end
# ---- 1. Persistence and loading ----
describe "join form settings persistence and loading" do
test "save and load join_form_enabled plus field selection and required flags returns same config" do
{:ok, settings} = Membership.get_settings()
attrs = %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name"],
join_form_field_required: %{"email" => true, "first_name" => false}
}
assert {:ok, updated} = update_join_form_settings(settings, attrs)
assert updated.join_form_enabled == true
assert updated.join_form_field_ids == ["email", "first_name"]
assert updated.join_form_field_required["email"] == true
assert updated.join_form_field_required["first_name"] == false
{:ok, reloaded} = Membership.get_settings()
assert reloaded.join_form_enabled == true
assert reloaded.join_form_field_ids == ["email", "first_name"]
assert reloaded.join_form_field_required["email"] == true
assert reloaded.join_form_field_required["first_name"] == false
end
test "repeated save with changed field list overwrites config without leftovers" do
{:ok, settings} = Membership.get_settings()
assert {:ok, _} =
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name"],
join_form_field_required: %{"email" => true, "first_name" => false}
})
assert {:ok, updated} =
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "last_name"],
join_form_field_required: %{"email" => true, "last_name" => false}
})
assert updated.join_form_field_ids == ["email", "last_name"]
assert Map.has_key?(updated.join_form_field_required, "last_name")
refute Map.has_key?(updated.join_form_field_required, "first_name")
end
end
# ---- 2. Validation ----
describe "join form settings validation" do
test "only existing member fields or custom field ids are accepted; unknown field names rejected or sanitized" do
{:ok, settings} = Membership.get_settings()
result =
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "not_a_member_field"],
join_form_field_required: %{"email" => true, "not_a_member_field" => false}
})
# Until attributes exist we get NoSuchInput; once implemented we expect validation error
assert {:error, _} = result
end
test "config without email is rejected or email is auto-added and required" do
{:ok, settings} = Membership.get_settings()
result =
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["first_name", "last_name"],
join_form_field_required: %{"first_name" => true, "last_name" => false}
})
# Either rejected or, when loaded, email must be present and required
case result do
{:error, _} ->
:ok
{:ok, updated} ->
assert "email" in updated.join_form_field_ids
assert updated.join_form_field_required["email"] == true
end
end
test "required false for email is ignored or forced to true when saved" do
{:ok, settings} = Membership.get_settings()
{:ok, updated} =
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name"],
join_form_field_required: %{"email" => false, "first_name" => false}
})
assert updated.join_form_field_required["email"] == true
end
test "required flag for field not in join_form_field_ids is rejected or dropped" do
{:ok, settings} = Membership.get_settings()
result =
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email"],
join_form_field_required: %{"email" => true, "first_name" => true}
})
case result do
{:error, _} ->
:ok
{:ok, updated} ->
refute Map.has_key?(updated.join_form_field_required, "first_name")
end
end
end
# ---- 3. Allowlist for join form ----
describe "join form allowlist" do
test "allowlist returns configured fields with required/optional when join form enabled" do
{:ok, settings} = Membership.get_settings()
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name"],
join_form_field_required: %{"email" => true, "first_name" => false}
})
allowlist = Membership.get_join_form_allowlist()
assert length(allowlist) == 2
email_entry = Enum.find(allowlist, &(&1.id == "email"))
first_name_entry = Enum.find(allowlist, &(&1.id == "first_name"))
assert email_entry.required == true
assert first_name_entry.required == false
assert email_entry.type == :member_field
assert first_name_entry.type == :member_field
end
test "allowlist returns empty or defined default when join form disabled" do
{:ok, settings} = Membership.get_settings()
update_join_form_settings(settings, %{
join_form_enabled: false,
join_form_field_ids: ["email", "first_name"],
join_form_field_required: %{"email" => true, "first_name" => false}
})
allowlist = Membership.get_join_form_allowlist()
assert allowlist == []
end
@tag :requires_custom_field
test "allowlist distinguishes member fields and custom field identifiers" do
{:ok, settings} = Membership.get_settings()
actor = SystemActor.get_system_actor()
{:ok, cf} =
Membership.create_custom_field(
%{name: "join_cf_#{System.unique_integer([:positive])}", value_type: :string},
actor: actor
)
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", cf.id],
join_form_field_required: %{"email" => true, cf.id => false}
})
allowlist = Membership.get_join_form_allowlist()
email_entry = Enum.find(allowlist, &(&1.id == "email"))
cf_entry = Enum.find(allowlist, &(&1.id == cf.id))
assert email_entry.type == :member_field
assert cf_entry.type == :custom_field
end
end
# ---- 4. Defaults and fallback ----
describe "join form defaults and fallback" do
test "when no join settings stored, allowlist returns defined default (e.g. disabled, empty list)" do
allowlist = Membership.get_join_form_allowlist()
# Default: join form disabled → empty allowlist
assert is_list(allowlist)
assert allowlist == [] || Enum.all?(allowlist, &(is_map(&1) and Map.has_key?(&1, :id)))
end
test "existing settings without join keys load correctly; new join keys get defaults" do
{:ok, settings} = Membership.get_settings()
# Ensure other attributes still load
assert Map.has_key?(settings, :club_name)
# When join keys exist they have sensible defaults
join_enabled = Map.get(settings, :join_form_enabled)
join_ids = Map.get(settings, :join_form_field_ids)
if join_enabled != nil, do: assert(is_boolean(join_enabled))
if join_ids != nil, do: assert(is_list(join_ids))
end
end
# ---- 5. Authorization (backend: settings update requires authorized actor when policy enforced) ----
# Authorization for the settings page is covered by GlobalSettingsLive and page-permission tests.
# If the domain later requires an actor for update_settings, tests here would pass an actor.
# ---- 6. Robustness / edge cases ----
describe "join form settings robustness" do
test "invalid or unexpected payload structure yields clean error or ignores unknown keys" do
{:ok, settings} = Membership.get_settings()
result =
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: "not_a_list",
join_form_field_required: %{}
})
assert match?({:error, _}, result) or
(match?({:ok, _}, result) && elem(result, 1).join_form_field_ids != "not_a_list")
end
test "larger but reasonable number of fields saves and loads without error" do
{:ok, settings} = Membership.get_settings()
all_member = Constants.member_fields() |> Enum.map(&to_string/1)
required_map = Map.new(all_member, fn f -> {f, f == "email"} end)
assert {:ok, updated} =
update_join_form_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: all_member,
join_form_field_required: required_map
})
assert length(updated.join_form_field_ids) == length(all_member)
{:ok, reloaded} = Membership.get_settings()
assert length(reloaded.join_form_field_ids) == length(all_member)
end
end
end