Merge pull request 'add join form settings' (#465) from feature/308-web-form into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #465
This commit is contained in:
commit
697673ffb6
15 changed files with 1466 additions and 5 deletions
|
|
@ -89,7 +89,8 @@ lib/
|
|||
│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest)
|
||||
│ ├── custom_field.ex # Custom field (definition) 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
|
||||
│ ├── member_group.ex # MemberGroup join table resource
|
||||
│ └── 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
|
||||
|
||||
**Static Code Analysis:**
|
||||
|
|
|
|||
|
|
@ -656,3 +656,34 @@
|
|||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
|
|
|||
143
assets/js/app.js
143
assets/js/app.js
|
|
@ -21,6 +21,7 @@ import "phoenix_html"
|
|||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
import Sortable from "../vendor/sortable"
|
||||
|
||||
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
|
||||
Hooks.SidebarState = {
|
||||
mounted() {
|
||||
|
|
|
|||
2
assets/vendor/sortable.js
vendored
Normal file
2
assets/vendor/sortable.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
- 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
|
||||
|
||||
**Seed Data:**
|
||||
|
|
|
|||
|
|
@ -455,6 +455,56 @@ defmodule Mv.Membership do
|
|||
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?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- `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)
|
||||
- `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
|
||||
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,
|
||||
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
|
||||
table "settings"
|
||||
repo Mv.Repo
|
||||
|
|
@ -86,8 +96,13 @@ defmodule Mv.Membership.Setting do
|
|||
:oidc_client_secret,
|
||||
:oidc_admin_group_name,
|
||||
: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
|
||||
|
||||
update :update do
|
||||
|
|
@ -110,8 +125,13 @@ defmodule Mv.Membership.Setting do
|
|||
:oidc_client_secret,
|
||||
:oidc_admin_group_name,
|
||||
: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
|
||||
|
||||
update :update_member_field_visibility do
|
||||
|
|
@ -232,6 +252,33 @@ defmodule Mv.Membership.Setting do
|
|||
end,
|
||||
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 fn changeset, context ->
|
||||
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)"
|
||||
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()
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1150,6 +1150,124 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
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 """
|
||||
Renders a data list.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,24 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
## Features
|
||||
- Edit the association/club name
|
||||
- Configure the public join form (Beitrittsformular)
|
||||
- Manage custom fields
|
||||
- Real-time form validation
|
||||
- Success/error feedback
|
||||
|
||||
## Settings
|
||||
- `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
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Save settings changes
|
||||
- `validate` / `save` - Club settings form
|
||||
- `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
|
||||
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.Member, as: MemberResource
|
||||
alias MvWeb.Helpers.MemberHelpers
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
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")
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
custom_fields = load_custom_fields(actor)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> 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_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|
||||
|> assign_join_form_state(settings, custom_fields)
|
||||
|> assign_form()
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -103,6 +116,159 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
</.button>
|
||||
</.form>
|
||||
</.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 --%>
|
||||
<.form_section title={gettext("Vereinfacht Integration")}>
|
||||
<%= if @vereinfacht_env_configured do %>
|
||||
|
|
@ -426,6 +592,148 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
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]
|
||||
|
||||
defp vereinfacht_params?(params) when is_map(params) do
|
||||
|
|
@ -709,4 +1017,101 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
</div>
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -357,6 +357,7 @@ msgstr "Passwort-Authentifizierung"
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_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/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2149,6 +2150,7 @@ msgstr "Mitglied ist nicht in dieser Gruppe."
|
|||
msgid "No email"
|
||||
msgstr "Keine E-Mail"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Remove"
|
||||
|
|
@ -3324,3 +3326,78 @@ msgstr "Unvollständig"
|
|||
#, 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."
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_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/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2150,6 +2151,7 @@ msgstr ""
|
|||
msgid "No email"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Remove"
|
||||
|
|
@ -3324,3 +3326,78 @@ msgstr ""
|
|||
#, 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."
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_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/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2150,6 +2151,7 @@ msgstr ""
|
|||
msgid "No email"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Remove"
|
||||
|
|
@ -3324,3 +3326,78 @@ msgstr ""
|
|||
#, 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."
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
308
test/membership/setting_join_form_test.exs
Normal file
308
test/membership/setting_join_form_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue