feat: add join form settings
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-03-10 14:29:49 +01:00
parent b7a83d9298
commit fa738aae88
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
12 changed files with 846 additions and 54 deletions

View file

@ -990,7 +990,7 @@ defmodule MvWeb.CoreComponents do
/>
</th>
<th :if={@action != []} class={table_th_sticky_class(@sticky_header)}>
<span class="sr-only">{gettext("Actions")}</span>
{gettext("Actions")}
</th>
</tr>
</thead>

View file

@ -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,144 @@ 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) --%>
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4 max-w-2xl">
<.table
id="join-form-fields-table"
rows={@join_form_fields}
row_id={fn field -> "join-field-#{field.id}" end}
>
<: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>
</.table>
</div>
</div>
</.form_section>
<%!-- Vereinfacht Integration Section --%>
<.form_section title={gettext("Vereinfacht Integration")}>
<%= if @vereinfacht_env_configured do %>
@ -426,6 +577,126 @@ 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
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 +980,94 @@ 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 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