Compare commits

..

1 commit

Author SHA1 Message Date
Renovate Bot
0f48aa921e chore(deps): update postgres to v18
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 15:12:30 +00:00
36 changed files with 862 additions and 4479 deletions

View file

@ -4,7 +4,7 @@ name: check
services:
- name: postgres
image: docker.io/library/postgres:17.6
image: docker.io/library/postgres:18.1
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -57,7 +57,7 @@ steps:
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:17.6
image: docker.io/library/postgres:18.1
commands:
# Wait for postgres to become available
- |
@ -166,7 +166,7 @@ environment:
steps:
- name: renovate
image: renovate/renovate:41.173
image: renovate/renovate:41.151
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:

View file

@ -1,3 +1,3 @@
elixir 1.18.3-otp-27
erlang 27.3.4
just 1.43.1
just 1.43.0

View file

@ -1,7 +1,4 @@
set dotenv-load := true
set export := true
MIX_QUIET := "1"
run: install-dependencies start-database migrate-database seed-database
mix phx.server
@ -93,7 +90,7 @@ clean:
remove-gettext-conflicts:
#!/usr/bin/env bash
set -euo pipefail
find priv/gettext -type f -exec sed -i '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \;
find priv/gettext -type f -exec sed -i '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//' {} \;
# Production environment commands
# ================================

View file

@ -45,7 +45,7 @@ Our philosophy: **software should help people spend less time on administration
- 🚧 Sorting & filtering
- 🚧 Roles & permissions (e.g. board, treasurer)
- ✅ Custom fields (flexible per club needs)
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
- ✅ SSO via OIDC (tested with Rauthy)
- 🚧 Self-service & online application
- 🚧 Accessibility, GDPR, usability improvements
- 🚧 Email sending
@ -147,26 +147,7 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i
5. copy client secret to `.env` file
6. abort and run `just run` again
Now you can log in to Mila via OIDC!
### OIDC with other providers (Authentik, Keycloak, etc.)
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
Example for Authentik:
1. Create an OAuth2/OpenID Provider in Authentik
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
3. Configure environment variables:
```bash
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
OIDC_CLIENT_ID=your-client-id
OIDC_BASE_URL=https://auth.example.com/application/o/your-app
OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE
```
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
Now you can log in to Mila via OIDC!
## ⚙️ Configuration
@ -229,13 +210,13 @@ For testing the production Docker build locally:
# Required variables:
SECRET_KEY_BASE=<your-generated-secret>
TOKEN_SIGNING_SECRET=<your-generated-secret>
DOMAIN=localhost # or PHX_HOST=localhost
PHX_HOST=localhost
# Optional OIDC configuration:
# Optional (have defaults in docker-compose.prod.yml):
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=<from-rauthy-client>
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base

View file

@ -112,21 +112,12 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
""")
# PHX_HOST or DOMAIN can be used to set the host for the application.
# DOMAIN is commonly used in deployment environments (e.g., Portainer templates).
host =
System.get_env("PHX_HOST") ||
System.get_env("DOMAIN") ||
raise "Please define the PHX_HOST or DOMAIN environment variable."
host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable."
port = String.to_integer(System.get_env("PORT") || "4000")
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
# Note: The strategy is named :rauthy internally, but works with any OIDC provider.
# The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider.
#
# Rauthy OIDC configuration
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
oidc_base_url = System.get_env("OIDC_BASE_URL")
@ -143,15 +134,12 @@ if config_env() == :prod do
get_env_or_file.("OIDC_CLIENT_SECRET", nil)
end
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
# Uses HTTPS since production runs behind TLS termination.
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
config :mv, :rauthy,
client_id: oidc_client_id || "mv",
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
client_secret: client_secret,
redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri
redirect_uri:
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
# Token signing secret from environment variable
# This overrides the placeholder value set in prod.exs

View file

@ -33,7 +33,7 @@ services:
restart: unless-stopped
db-prod:
image: postgres:16-alpine
image: postgres:18-alpine
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres

View file

@ -4,7 +4,7 @@ networks:
services:
db:
image: postgres:17.6-alpine
image: postgres:18.1-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres

View file

@ -54,9 +54,6 @@ defmodule Mv.Accounts.User do
auth_method :client_secret_jwt
code_verifier true
# Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.)
authorization_params scope: "openid email profile"
# id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
end
@ -72,7 +69,7 @@ defmodule Mv.Accounts.User do
# Default actions for framework/tooling integration:
# - :read -> Standard read used across the app and by admin tooling.
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
#
#
# NOTE: :create is INTENTIONALLY excluded from defaults!
# Using a default :create would bypass email-synchronization logic.
# Always use one of these explicit create actions instead:
@ -188,9 +185,7 @@ defmodule Mv.Accounts.User do
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
# Get the new email from OIDC user_info
# Support both "email" (standard OIDC) and "preferred_username" (Rauthy)
new_email =
Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username")
new_email = Map.get(oidc_user_info, "preferred_username")
changeset
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
@ -244,11 +239,8 @@ defmodule Mv.Accounts.User do
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
# Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy)
email = user_info["email"] || user_info["preferred_username"]
changeset
|> Ash.Changeset.change_attribute(:email, email)
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
end

View file

@ -401,70 +401,6 @@ defmodule Mv.Membership.Member do
identity :unique_email, [:email]
end
@doc """
Checks if a member field should be shown in the overview.
Reads the visibility configuration from Settings resource. If a field is not
configured in settings, it defaults to `true` (visible).
## Parameters
- `field` - Atom representing the member field name (e.g., `:email`, `:street`)
## Returns
- `true` if the field should be shown in overview (default)
- `false` if the field is configured as hidden in settings
## Examples
iex> Member.show_in_overview?(:email)
true
iex> Member.show_in_overview?(:street)
true # or false if configured in settings
"""
@spec show_in_overview?(atom()) :: boolean()
def show_in_overview?(field) when is_atom(field) do
case Mv.Membership.get_settings() do
{:ok, settings} ->
visibility_config = settings.member_field_visibility || %{}
# Normalize map keys to atoms (JSONB may return string keys)
normalized_config = normalize_visibility_config(visibility_config)
# Get value from normalized config, default to true
Map.get(normalized_config, field, true)
{:error, _} ->
# If settings can't be loaded, default to visible
true
end
end
def show_in_overview?(_), do: true
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
@doc """
Performs fuzzy search on members using PostgreSQL trigram similarity.

View file

@ -18,17 +18,5 @@ defmodule Mv.Constants do
:postal_code
]
@custom_field_prefix "custom_field_"
def member_fields, do: @member_fields
@doc """
Returns the prefix used for custom field keys in field visibility maps.
## Examples
iex> Mv.Constants.custom_field_prefix()
"custom_field_"
"""
def custom_field_prefix, do: @custom_field_prefix
end

View file

@ -119,123 +119,6 @@ defmodule MvWeb.CoreComponents do
end
end
@doc """
Renders a dropdown menu.
## Examples
<.dropdown_menu items={@items} open={@open} phx_target={@myself} />
"""
attr :id, :string, default: "dropdown-menu"
attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
attr :button_label, :string, default: "Dropdown"
attr :icon, :string, default: nil
attr :checkboxes, :boolean, default: false
attr :selected, :map, default: %{}
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
def dropdown_menu(assigns) do
~H"""
<div
class="relative"
phx-click-away="close_dropdown"
phx-target={@phx_target}
phx-window-keydown="close_dropdown"
phx-key="Escape"
data-testid="dropdown-menu"
>
<button
type="button"
tabindex="0"
role="button"
aria-haspopup="menu"
aria-expanded={@open}
aria-controls={@id}
class="btn btn-ghost"
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid="dropdown-button"
>
<%= if @icon do %>
<.icon name={@icon} />
<% end %>
<span>{@button_label}</span>
</button>
<ul
:if={@open}
id={@id}
role="menu"
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
tabindex="0"
phx-target={@phx_target}
>
<li :if={@show_select_buttons} role="none">
<div class="flex justify-between items-center mb-2 px-2">
<span class="font-semibold">{gettext("Options")}</span>
<div class="flex gap-1">
<button
type="button"
role="menuitem"
aria-label={gettext("Select all")}
phx-click="select_all"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("All")}
</button>
<button
type="button"
role="menuitem"
aria-label={gettext("Select none")}
phx-click="select_none"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("None")}
</button>
</div>
</div>
</li>
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
<%= for item <- @items do %>
<li role="none">
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
phx-value-item={item.value}
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary"
tabindex="-1"
aria-hidden="true"
/>
<% end %>
<span>{item.label}</span>
</button>
</li>
<% end %>
</ul>
</div>
"""
end
@doc """
Renders an input with label and error messages.

View file

@ -23,7 +23,7 @@ defmodule MvWeb.Layouts.Navbar do
<a class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
<li>
<details>

View file

@ -1,176 +0,0 @@
defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
@moduledoc """
LiveComponent for managing field visibility in the member overview.
Provides an accessible dropdown menu where users can select/deselect
which member fields and custom fields are visible in the table.
## Props
- `:all_fields` - List of all available fields
- `:custom_fields` - List of CustomField resources
- `:selected_fields` - Map field_name boolean
- `:id` - Component ID
## Events sent to parent:
- `{:field_toggled, field, value}`
- `{:fields_selected, map}`
"""
use MvWeb, :live_component
# ---------------------------------------------------------------------------
# UPDATE
# ---------------------------------------------------------------------------
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:open, fn -> false end)
|> assign_new(:all_fields, fn -> [] end)
|> assign_new(:custom_fields, fn -> [] end)
|> assign_new(:selected_fields, fn -> %{} end)
{:ok, socket}
end
# ---------------------------------------------------------------------------
# RENDER
# ---------------------------------------------------------------------------
@impl true
def render(assigns) do
all_fields = assigns.all_fields || []
custom_fields = assigns.custom_fields || []
all_items =
Enum.map(extract_member_field_keys(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
}
end) ++
Enum.map(extract_custom_field_keys(all_fields), fn field ->
%{
value: field,
label: format_custom_field_label(field, custom_fields)
}
end)
assigns = assign(assigns, :all_items, all_items)
# LiveComponents require a static HTML element as root, not a function component
~H"""
<div>
<.dropdown_menu
id="field-visibility-menu"
icon="hero-adjustments-horizontal"
button_label={gettext("Columns")}
items={@all_items}
checkboxes={true}
selected={@selected_fields}
open={@open}
show_select_buttons={true}
phx_target={@myself}
/>
</div>
"""
end
# ---------------------------------------------------------------------------
# EVENTS (matching the Core Component API)
# ---------------------------------------------------------------------------
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
# toggle single item
def handle_event("select_item", %{"item" => item}, socket) do
current = Map.get(socket.assigns.selected_fields, item, true)
updated = Map.put(socket.assigns.selected_fields, item, !current)
send(self(), {:field_toggled, item, !current})
{:noreply, assign(socket, :selected_fields, updated)}
end
# select all
def handle_event("select_all", _params, socket) do
all =
socket.assigns.all_fields
|> Enum.map(&field_to_string/1)
|> Enum.map(&{&1, true})
|> Enum.into(%{})
send(self(), {:fields_selected, all})
{:noreply, assign(socket, :selected_fields, all)}
end
# select none
def handle_event("select_none", _params, socket) do
none =
socket.assigns.all_fields
|> Enum.map(&field_to_string/1)
|> Enum.map(&{&1, false})
|> Enum.into(%{})
send(self(), {:fields_selected, none})
{:noreply, assign(socket, :selected_fields, none)}
end
# ---------------------------------------------------------------------------
# HELPERS (with defensive nil guards)
# ---------------------------------------------------------------------------
defp extract_member_field_keys(nil), do: []
defp extract_member_field_keys(fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.filter(fields, fn field ->
is_atom(field) ||
(is_binary(field) && not String.starts_with?(field, prefix))
end)
end
defp extract_custom_field_keys(nil), do: []
defp extract_custom_field_keys(fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.filter(fields, fn field ->
is_binary(field) && String.starts_with?(field, prefix)
end)
end
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
defp format_field_label(field) do
field
|> field_to_string()
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp format_custom_field_label(field_string, custom_fields) do
id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix())
find_custom_field_name(id, field_string, custom_fields)
end
defp find_custom_field_name("", field_string, _custom_fields), do: field_string
defp find_custom_field_name(id, _field_string, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
nil -> gettext("Custom Field %{id}", id: id)
custom_field -> custom_field.name
end
end
end

View file

@ -0,0 +1,142 @@
defmodule MvWeb.CustomFieldLive.Form do
@moduledoc """
LiveView form for creating and editing custom fields (admin).
## Features
- Create new custom field definitions
- Edit existing custom fields
- Select value type from supported types
- Set immutable and required flags
- Real-time validation
## Form Fields
**Required:**
- name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
- value_type - Data type (:string, :integer, :boolean, :date, :email)
**Optional:**
- description - Human-readable explanation
- immutable - If true, values cannot be changed after creation (default: false)
- required - If true, all members must have this custom field (default: false)
- show_in_overview - If true, this custom field will be displayed in the member overview table (default: true)
## Value Type Selection
- `:string` - Text data (unlimited length)
- `:integer` - Numeric data
- `:boolean` - True/false flags
- `:date` - Date values
- `:email` - Validated email addresses
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update custom field)
## Security
Custom field management is restricted to admin users.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage custom_field records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} />
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")}
</.button>
<.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
custom_field =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.CustomField, id)
end
action = if is_nil(custom_field), do: "New", else: "Edit"
page_title = action <> " " <> "Custom field"
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(custom_field: custom_field)
|> assign(:page_title, page_title)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
end
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
{:ok, custom_field} ->
notify_parent({:saved, custom_field})
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket =
socket
|> put_flash(:info, gettext("Custom field %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form =
if custom_field do
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
else
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
end
assign(socket, form: to_form(form))
end
defp return_path("index", _custom_field), do: ~p"/custom_fields"
defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}"
end

View file

@ -1,127 +0,0 @@
defmodule MvWeb.CustomFieldLive.FormComponent do
@moduledoc """
LiveComponent form for creating and editing custom fields (embedded in settings).
## Features
- Create new custom field definitions
- Edit existing custom fields
- Select value type from supported types
- Set immutable and required flags
- Real-time validation
## Props
- `custom_field` - The custom field to edit (nil for new)
- `on_save` - Callback function to call when form is saved
- `on_cancel` - Callback function to call when form is cancelled
"""
use MvWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
<div class="card-body">
<div class="flex items-center gap-4 mb-4">
<.button
type="button"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to custom field overview")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.button>
<h3 class="card-title">
{if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")}
</h3>
</div>
<.form
for={@form}
id={@id <> "-form"}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
<.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input
field={@form[:show_in_overview]}
type="checkbox"
label={gettext("Show in overview")}
/>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")}
</.button>
</div>
</.form>
</div>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
@impl true
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
end
@impl true
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
{:ok, custom_field} ->
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket.assigns.on_save.(custom_field, action)
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("cancel", _params, socket) do
socket.assigns.on_cancel.()
{:noreply, socket}
end
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form =
if custom_field do
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
else
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
end
assign(socket, form: to_form(form))
end
end

View file

@ -0,0 +1,199 @@
defmodule MvWeb.CustomFieldLive.Index do
@moduledoc """
LiveView for managing custom field definitions (admin).
## Features
- List all custom fields
- Display type information (name, value type, description)
- Show immutable and required flags
- Create new custom fields
- Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values)
## Displayed Information
- Name: Unique identifier for the custom field
- Value type: Data type constraint (string, integer, boolean, date, email)
- Description: Human-readable explanation
- Immutable: Whether custom field values can be changed after creation
- Required: Whether all members must have this custom field (future feature)
## Events
- `prepare_delete` - Opens deletion confirmation modal with member count
- `confirm_delete` - Executes deletion after slug verification
- `cancel_delete` - Cancels deletion and closes modal
- `update_slug_confirmation` - Updates slug input state
## Security
Custom field management is restricted to admin users.
Deletion requires entering the custom field's slug to prevent accidental deletions.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Custom fields
<:actions>
<.button variant="primary" navigate={~p"/custom_fields/new"}>
<.icon name="hero-plus" /> New Custom field
</.button>
</:actions>
</.header>
<.table
id="custom_fields"
rows={@streams.custom_fields}
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end}
>
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
<:action :let={{_id, custom_field}}>
<div class="sr-only">
<.link navigate={~p"/custom_fields/#{custom_field}"}>Show</.link>
</div>
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}>
Delete
</.link>
</:action>
</.table>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg">{gettext("Delete Custom Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<div>
<p class="font-semibold">
{ngettext(
"%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this custom field.",
@custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count
)}
</p>
<p class="text-sm mt-2">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
)}
</p>
</div>
</div>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="font-mono font-bold text-lg mb-2 p-2 bg-base-200 rounded break-all">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation">
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="input input-bordered w-full"
/>
</form>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
</div>
</div>
</dialog>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Custom fields")
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
end
@impl true
def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
{:noreply,
socket
|> assign(:custom_field_to_delete, custom_field)
|> assign(:show_delete_modal, true)
|> assign(:slug_confirmation, "")}
end
@impl true
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
{:noreply, assign(socket, :slug_confirmation, slug)}
end
@impl true
def handle_event("confirm_delete", _params, socket) do
custom_field = socket.assigns.custom_field_to_delete
if socket.assigns.slug_confirmation == custom_field.slug do
# Delete the custom field (CASCADE will handle custom field values)
case Ash.destroy(custom_field) do
:ok ->
{:noreply,
socket
|> put_flash(:info, "Custom field deleted successfully")
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream_delete(:custom_fields, custom_field)}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, "Failed to delete custom field: #{inspect(error)}")}
end
else
{:noreply,
socket
|> put_flash(:error, "Slug does not match. Deletion cancelled.")}
end
end
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
end

View file

@ -1,261 +0,0 @@
defmodule MvWeb.CustomFieldLive.IndexComponent do
@moduledoc """
LiveComponent for managing custom field definitions (embedded in settings).
## Features
- List all custom fields
- Display type information (name, value type, description)
- Show immutable and required flags
- Create new custom fields
- Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values)
"""
use MvWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div id={@id}>
<.header>
{gettext("Custom Fields")}
<:subtitle>
{gettext("These will appear in addition to other data when adding new members.")}
</:subtitle>
<:actions>
<.button variant="primary" phx-click="new_custom_field" phx-target={@myself}>
<.icon name="hero-plus" /> {gettext("New Custom field")}
</.button>
</:actions>
</.header>
<%!-- Show form when creating or editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.CustomFieldLive.FormComponent}
id={@form_id}
custom_field={@editing_custom_field}
on_save={
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="custom_fields"
rows={@streams.custom_fields}
row_click={
fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end
}
>
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
{custom_field.value_type}
</:col>
<:col :let={{_id, custom_field}} label={gettext("Description")}>
{custom_field.description}
</:col>
<:col :let={{_id, custom_field}} label={gettext("Show in Overview")}>
<span :if={custom_field.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Edit")}
</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
{gettext("Delete")}
</.link>
</:action>
</.table>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>
<p class="font-semibold">
{ngettext(
"%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this custom field.",
@custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count
)}
</p>
<p class="mt-2 text-sm">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
)}
</p>
</div>
</div>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation" phx-target={@myself}>
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="w-full input input-bordered"
/>
</form>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
</div>
</div>
</dialog>
</div>
"""
end
@impl true
def update(assigns, socket) do
# If show_form is explicitly provided in assigns, reset editing state
socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
socket
|> assign(:editing_custom_field, nil)
|> assign(:form_id, "custom-field-form-new")
else
socket
end
{:ok,
socket
|> assign(assigns)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|> assign_new(:editing_custom_field, fn -> nil end)
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_new(:custom_field_to_delete, fn -> nil end)
|> assign_new(:slug_confirmation, fn -> "" end)
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)}
end
@impl true
def handle_event("new_custom_field", _params, socket) do
{:noreply,
socket
|> assign(:show_form, true)
|> assign(:editing_custom_field, nil)
|> assign(:form_id, "custom-field-form-new")}
end
@impl true
def handle_event("edit_custom_field", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id)
{:noreply,
socket
|> assign(:show_form, true)
|> assign(:editing_custom_field, custom_field)
|> assign(:form_id, "custom-field-form-#{id}")}
end
@impl true
def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
{:noreply,
socket
|> assign(:custom_field_to_delete, custom_field)
|> assign(:show_delete_modal, true)
|> assign(:slug_confirmation, "")}
end
@impl true
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
{:noreply, assign(socket, :slug_confirmation, slug)}
end
@impl true
def handle_event("confirm_delete", _params, socket) do
custom_field = socket.assigns.custom_field_to_delete
if socket.assigns.slug_confirmation == custom_field.slug do
case Ash.destroy(custom_field) do
:ok ->
send(self(), {:custom_field_deleted, custom_field})
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream_delete(:custom_fields, custom_field)}
{:error, error} ->
send(self(), {:custom_field_delete_error, error})
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
else
send(self(), :custom_field_slug_mismatch)
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
end
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
end

View file

@ -0,0 +1,75 @@
defmodule MvWeb.CustomFieldLive.Show do
@moduledoc """
LiveView for displaying a single custom field's details (admin).
## Features
- Display custom field definition
- Show all attributes (name, value type, description, flags)
- Navigate to edit form
- Return to custom field list
## Displayed Information
- ID: Internal UUID identifier
- Slug: URL-friendly identifier (auto-generated, immutable)
- Name: Unique identifier
- Value type: Data type constraint
- Description: Optional explanation
- Immutable flag: Whether values can be changed
- Required flag: Whether all members need this custom field
## Navigation
- Back to custom field list
- Edit custom field
## Security
Custom field details are restricted to admin users.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Custom field {@custom_field.slug}
<:subtitle>This is a custom_field record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/custom_fields"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Custom field
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@custom_field.id}</:item>
<:item title="Slug">
{@custom_field.slug}
<p class="mt-2 text-sm leading-6 text-zinc-600">
{gettext("Auto-generated identifier (immutable)")}
</p>
</:item>
<:item title="Name">{@custom_field.name}</:item>
<:item title="Description">{@custom_field.description}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Custom field")
|> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))}
end
end

View file

@ -4,7 +4,6 @@ defmodule MvWeb.GlobalSettingsLive do
## Features
- Edit the association/club name
- Manage custom fields
- Real-time form validation
- Success/error feedback
@ -29,7 +28,7 @@ defmodule MvWeb.GlobalSettingsLive do
{:ok,
socket
|> assign(:page_title, gettext("Settings"))
|> assign(:page_title, gettext("Club Settings"))
|> assign(:settings, settings)
|> assign_form()}
end
@ -39,16 +38,12 @@ defmodule MvWeb.GlobalSettingsLive do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Settings")}
{gettext("Club Settings")}
<:subtitle>
{gettext("Manage global settings for the association.")}
</:subtitle>
</.header>
<%!-- Club Settings Section --%>
<.header>
{gettext("Club Settings")}
</.header>
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<.input
field={@form[:club_name]}
@ -61,12 +56,6 @@ defmodule MvWeb.GlobalSettingsLive do
{gettext("Save Settings")}
</.button>
</.form>
<%!-- Custom Fields Section --%>
<.live_component
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
/>
</Layouts.app>
"""
end
@ -77,7 +66,6 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end
@impl true
def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
{:ok, updated_settings} ->
@ -94,37 +82,6 @@ defmodule MvWeb.GlobalSettingsLive do
end
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
show_form: false
)
{:noreply,
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))}
end
@impl true
def handle_info({:custom_field_delete_error, error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete custom field: %{error}", error: inspect(error))
)}
end
@impl true
def handle_info(:custom_field_slug_mismatch, socket) do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(

View file

@ -33,11 +33,9 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix Mv.Constants.custom_field_prefix()
@custom_field_prefix "custom_field_"
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
@ -52,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
"""
@impl true
def mount(_params, session, socket) do
# Load custom fields that should be shown in overview (for display)
def mount(_params, _session, socket) do
# Load custom fields that should be shown in overview
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
# and result in a 500 error page. This is appropriate for LiveViews where errors
# should be visible to the user rather than silently failing.
@ -63,12 +61,6 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
# Load ALL custom fields for the dropdown (to show all available fields)
all_custom_fields =
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
@ -77,20 +69,6 @@ defmodule MvWeb.MemberLive.Index do
{:error, _} -> %{member_field_visibility: %{}}
end
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)
# Get all available fields (for dropdown - includes ALL custom fields)
all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
# Merge session selection with global settings for initial state (use all_custom_fields)
initial_selection =
FieldVisibility.merge_with_global_settings(
session_selection,
settings,
all_custom_fields
)
socket =
socket
|> assign(:page_title, gettext("Members"))
@ -99,15 +77,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(
:member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
)
|> assign(:member_fields_visible, get_visible_member_fields(settings))
# We call handle params to use the query from the URL
{:ok, socket}
@ -212,8 +183,6 @@ defmodule MvWeb.MemberLive.Index do
## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
"""
@impl true
def handle_info({:sort, field_str}, socket) do
@ -282,111 +251,24 @@ defmodule MvWeb.MemberLive.Index do
)}
end
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
# Update user field selection
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
# Save to session (cookie will be saved on next page load via handle_params)
socket = update_session_field_selection(socket, new_selection)
# Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
final_selection =
FieldVisibility.merge_with_global_settings(
new_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> push_field_selection_url()
{:noreply, socket}
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
# Save to session
socket = update_session_field_selection(socket, selection)
# Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> push_field_selection_url()
{:noreply, socket}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
Parses query parameters for search query, sort field, sort order, and payment filter,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@impl true
def handle_params(params, _url, socket) do
# Parse field selection from URL
url_selection = FieldSelection.parse_from_url(params)
# Merge with session selection (URL has priority)
merged_selection =
FieldSelection.merge_sources(
url_selection,
socket.assigns.user_field_selection,
%{}
)
# Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
merged_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
@ -399,17 +281,10 @@ defmodule MvWeb.MemberLive.Index do
# - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member
#
# Only includes custom fields that are visible according to user field selection.
#
# Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
# Use all_custom_fields to allow users to enable globally hidden custom fields
dynamic_cols =
socket.assigns.all_custom_fields
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
|> Enum.map(fn custom_field ->
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
@ -502,58 +377,6 @@ defmodule MvWeb.MemberLive.Index do
)}
end
# Builds query parameters including field selection
defp build_query_params(socket, base_params) do
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
base_params
|> Map.put("query", query_value)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
end
# Adds field selection to query params if present
defp maybe_add_field_selection(params, nil), do: params
defp maybe_add_field_selection(params, selection) when is_map(selection) do
fields_param = FieldSelection.to_url_param(selection)
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
end
defp maybe_add_field_selection(params, _), do: params
# Pushes URL with updated field selection
defp push_field_selection_url(socket) do
base_params = %{
"sort_field" => field_to_string(socket.assigns.sort_field),
"sort_order" => Atom.to_string(socket.assigns.sort_order)
}
# Include paid_filter if set
base_params =
case socket.assigns.paid_filter do
nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end
query_params = build_query_params(socket, base_params)
new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true)
end
# Converts field to string
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
# Updates session field selection (stored in socket for now, actual session update via controller)
defp update_session_field_selection(socket, selection) do
# Store in socket for now - actual session persistence would require a controller
# This is a placeholder for future session persistence
assign(socket, :user_field_selection, selection)
end
# Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do
@ -612,9 +435,9 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
# Load custom field values for visible custom fields (based on user selection)
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
query = load_custom_field_values(query, visible_custom_field_ids)
# Load custom field values for visible custom fields
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
query = load_custom_field_values(query, custom_field_ids_list)
# Apply the search filter first
query = apply_search_filter(query, search_query)
@ -792,18 +615,6 @@ defmodule MvWeb.MemberLive.Index do
defp extract_custom_field_id(_), do: nil
# Extracts custom field IDs from visible custom field strings
# Format: "custom_field_<id>" -> <id>
defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, @custom_field_prefix) do
["", id] -> id
_ -> nil
end
end)
|> Enum.filter(&(&1 != nil))
end
# Sorts members in memory by a custom field value.
#
# Process:
@ -1100,6 +911,34 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Gets the list of member fields that should be visible in the overview.
#
# Reads the visibility configuration from Settings and returns only the fields
# where show_in_overview is true. Fields not configured in settings default to true.
#
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
# Settings should be loaded once in mount/3 and passed to this function.
#
# Parameters:
# - `settings` - The settings struct loaded from the database
#
# Returns a list of atoms representing visible member field names.
#
# Fields are read from the global Constants module.
@spec get_visible_member_fields(map()) :: [atom()]
defp get_visible_member_fields(settings) do
# Get all eligible fields from the global constants
all_fields = Mv.Constants.member_fields()
# JSONB stores keys as strings
visibility_config = settings.member_field_visibility || %{}
# Filter to only return visible fields
Enum.filter(all_fields, fn field ->
Map.get(visibility_config, Atom.to_string(field), true)
end)
end
# Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date)
end

View file

@ -44,13 +44,6 @@
paid_filter={@paid_filter}
member_count={length(@members)}
/>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
</div>
<.table
@ -92,7 +85,6 @@
</:col>
<:col
:let={member}
:if={:first_name in @member_fields_visible}
label={
~H"""
<.live_component
@ -106,25 +98,7 @@
"""
}
>
{member.first_name}
</:col>
<:col
:let={member}
:if={:last_name in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_last_name}
field={:last_name}
label={gettext("Last name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.last_name}
{member.first_name} {member.last_name}
</:col>
<:col
:let={member}
@ -252,7 +226,7 @@
>
{MvWeb.MemberLive.Index.format_date(member.join_date)}
</:col>
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
<:col :let={member} label={gettext("Paid")}>
<span class={[
"badge",
if(member.paid == true, do: "badge-success", else: "badge-error")

View file

@ -1,231 +0,0 @@
defmodule MvWeb.MemberLive.Index.FieldSelection do
@moduledoc """
Handles user-specific field selection persistence and URL parameter parsing.
This module manages:
- Reading/writing field selection from cookies (persistent storage)
- Reading/writing field selection from session (temporary storage)
- Parsing field selection from URL parameters
- Merging multiple sources with priority: URL > Session > Cookie
## Data Format
Field selection is stored as a map:
```elixir
%{
"first_name" => true,
"email" => true,
"street" => false,
"custom_field_abc-123" => true
}
```
## Cookie/Session Format
Stored as JSON string: `{"first_name":true,"email":true}`
## URL Format
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
"""
@cookie_name "member_field_selection"
@cookie_max_age 365 * 24 * 60 * 60
@session_key "member_field_selection"
@doc """
Reads field selection from session.
Returns a map of field names (strings) to boolean visibility values.
Returns empty map if no selection is stored.
"""
@spec get_from_session(map()) :: %{String.t() => boolean()}
def get_from_session(session) when is_map(session) do
case Map.get(session, @session_key) do
nil -> %{}
json_string when is_binary(json_string) -> parse_json(json_string)
_ -> %{}
end
end
def get_from_session(_), do: %{}
@doc """
Saves field selection to session.
Converts the map to JSON string and stores it in the session.
"""
@spec save_to_session(map(), %{String.t() => boolean()}) :: map()
def save_to_session(session, selection) when is_map(selection) do
json_string = Jason.encode!(selection)
Map.put(session, @session_key, json_string)
end
def save_to_session(session, _), do: session
@doc """
Reads field selection from cookie.
Returns a map of field names (strings) to boolean visibility values.
Returns empty map if no cookie is present.
Note: This function parses the raw Cookie header. In LiveView, cookies
are typically accessed via get_connect_info.
"""
@spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()}
def get_from_cookie(conn) do
# get_req_header always returns a list ([] if no header, [value] if present)
case Plug.Conn.get_req_header(conn, "cookie") do
[] ->
%{}
[cookie_header | _rest] ->
cookies = parse_cookie_header(cookie_header)
case Map.get(cookies, @cookie_name) do
nil -> %{}
json_string when is_binary(json_string) -> parse_json(json_string)
_ -> %{}
end
end
end
# Parses cookie header string into a map
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
cookie_header
|> String.split(";")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Enum.reduce(%{}, fn
[key, value], acc -> Map.put(acc, key, URI.decode(value))
[key], acc -> Map.put(acc, key, "")
_, acc -> acc
end)
end
defp parse_cookie_header(_), do: %{}
@doc """
Saves field selection to cookie.
Sets a persistent cookie with the field selection as JSON.
"""
@spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t()
def save_to_cookie(conn, selection) when is_map(selection) do
json_string = Jason.encode!(selection)
secure = Application.get_env(:mv, :use_secure_cookies, false)
Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string,
max_age: @cookie_max_age,
same_site: "Lax",
http_only: true,
secure: secure
)
end
def save_to_cookie(conn, _), do: conn
@doc """
Parses field selection from URL parameters.
Expects a comma-separated list of field names in the `fields` parameter.
All fields in the list are set to `true` (visible).
## Examples
iex> parse_from_url(%{"fields" => "first_name,email"})
%{"first_name" => true, "email" => true}
iex> parse_from_url(%{"fields" => "custom_field_abc-123"})
%{"custom_field_abc-123" => true}
iex> parse_from_url(%{})
%{}
"""
@spec parse_from_url(map()) :: %{String.t() => boolean()}
def parse_from_url(params) when is_map(params) do
case Map.get(params, "fields") do
nil -> %{}
"" -> %{}
fields_string when is_binary(fields_string) -> parse_fields_string(fields_string)
_ -> %{}
end
end
def parse_from_url(_), do: %{}
@doc """
Merges multiple field selection sources with priority.
Priority order (highest to lowest):
1. URL parameters
2. Session
3. Cookie
Later sources override earlier ones for the same field.
## Examples
iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true})
%{"first_name" => true, "email" => true, "street" => true}
iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{})
%{"first_name" => false} # URL has priority
"""
@spec merge_sources(
%{String.t() => boolean()},
%{String.t() => boolean()},
%{String.t() => boolean()}
) :: %{String.t() => boolean()}
def merge_sources(url_selection, session_selection, cookie_selection) do
%{}
|> Map.merge(cookie_selection)
|> Map.merge(session_selection)
|> Map.merge(url_selection)
end
@doc """
Converts field selection map to URL parameter string.
Returns a comma-separated string of visible fields (where value is `true`).
## Examples
iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false})
"first_name,email"
"""
@spec to_url_param(%{String.t() => boolean()}) :: String.t()
def to_url_param(selection) when is_map(selection) do
selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map_join(",", fn {field, _visible} -> field end)
end
def to_url_param(_), do: ""
# Parses a JSON string into a map, handling errors gracefully
defp parse_json(json_string) when is_binary(json_string) do
case Jason.decode(json_string) do
{:ok, decoded} when is_map(decoded) ->
# Ensure all values are booleans
Enum.reduce(decoded, %{}, fn
{key, value}, acc when is_boolean(value) -> Map.put(acc, key, value)
{key, _value}, acc -> Map.put(acc, key, true)
end)
_ ->
%{}
end
end
defp parse_json(_), do: %{}
# Parses a comma-separated string of field names
defp parse_fields_string(fields_string) do
fields_string
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.filter(&(&1 != ""))
|> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end)
end
end

View file

@ -1,239 +0,0 @@
defmodule MvWeb.MemberLive.Index.FieldVisibility do
@moduledoc """
Manages field visibility by merging user-specific selection with global settings.
This module handles:
- Getting all available fields (member fields + custom fields)
- Merging user selection with global settings (user selection takes priority)
- Falling back to global settings when no user selection exists
- Converting between different field name formats (atoms vs strings)
## Field Naming Convention
- **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
- **Custom Fields**: Strings with format `"custom_field_<id>"` (e.g., `"custom_field_abc-123"`)
## Priority Order
1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database)
3. Default (all fields visible)
"""
@doc """
Gets all available fields for selection.
Returns a list of field identifiers:
- Member fields as atoms (e.g., `:first_name`, `:email`)
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
## Parameters
- `custom_fields` - List of CustomField resources that are available
## Returns
List of field identifiers (atoms and strings)
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
member_fields = Mv.Constants.member_fields()
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
end
@doc """
Merges user field selection with global settings.
User selection takes priority over global settings. If a field is not in the
user selection, the global setting is used. If a field is not in global settings,
it defaults to `true` (visible).
## Parameters
- `user_selection` - Map of field names (strings) to boolean visibility
- `global_settings` - Settings struct with `member_field_visibility` field
- `custom_fields` - List of CustomField resources
## Returns
Map of field names (strings) to boolean visibility values
## Examples
iex> user_selection = %{"first_name" => false}
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
iex> merge_with_global_settings(user_selection, settings, [])
%{"first_name" => false, "email" => true} # User selection overrides global
"""
@spec merge_with_global_settings(
%{String.t() => boolean()},
map(),
[struct()]
) :: %{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
all_fields = get_all_available_fields(custom_fields)
global_visibility = get_global_visibility_map(global_settings, custom_fields)
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
visibility =
case Map.get(user_selection, field_string) do
nil -> Map.get(global_visibility, field_string, true)
user_value -> user_value
end
Map.put(acc, field_string, visibility)
end)
end
@doc """
Gets the list of visible fields from a field selection map.
Returns only fields where visibility is `true`.
## Parameters
- `field_selection` - Map of field names to boolean visibility
## Returns
List of field identifiers (atoms for member fields, strings for custom fields)
## Examples
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
iex> get_visible_fields(selection)
[:first_name, :street]
"""
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
def get_visible_fields(field_selection) when is_map(field_selection) do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
end
def get_visible_fields(_), do: []
@doc """
Gets visible member fields from field selection.
Returns only member fields (atoms) that are visible.
## Examples
iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
iex> get_visible_member_fields(selection)
[:first_name, :email]
"""
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do
member_fields = Mv.Constants.member_fields()
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in member_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
end
def get_visible_member_fields(_), do: []
@doc """
Gets visible custom fields from field selection.
Returns only custom field identifiers (strings) that are visible.
## Examples
iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
iex> get_visible_custom_fields(selection)
["custom_field_123"]
"""
@spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
def get_visible_custom_fields(field_selection) when is_map(field_selection) do
prefix = Mv.Constants.custom_field_prefix()
field_selection
|> Enum.filter(fn {field_string, visible} ->
visible && String.starts_with?(field_string, prefix)
end)
|> Enum.map(fn {field_string, _visible} -> field_string end)
end
def get_visible_custom_fields(_), do: []
# Gets global visibility map from settings
defp get_global_visibility_map(settings, custom_fields) do
member_visibility = get_member_field_visibility_from_settings(settings)
custom_field_visibility = get_custom_field_visibility(custom_fields)
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
show_in_overview = Map.get(visibility_config, field, true)
Map.put(acc, field_string, show_in_overview)
end)
end
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
defp get_custom_field_visibility(custom_fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
field_string = "#{prefix}#{custom_field.id}"
visible = Map.get(custom_field, :show_in_overview, true)
Map.put(acc, field_string, visible)
end)
end
# Normalizes visibility config map keys from strings to atoms
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
field_string
else
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
end
end
# Converts field identifier to string
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
end

View file

@ -55,6 +55,12 @@ defmodule MvWeb.Router do
live "/members/:id", MemberLive.Show, :show
live "/members/:id/show/edit", MemberLive.Show, :edit
live "/custom_fields", CustomFieldLive.Index, :index
live "/custom_fields/new", CustomFieldLive.Form, :new
live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit
live "/custom_fields/:id", CustomFieldLive.Show, :show
live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit
live "/custom_field_values", CustomFieldValueLive.Index, :index
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit

View file

@ -11,7 +11,6 @@ msgstr ""
"Language: en\n"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr "Aktionen"
@ -29,20 +28,17 @@ msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
@ -50,13 +46,11 @@ msgstr "Löschen"
msgid "Edit"
msgstr "Bearbeite"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -86,7 +80,6 @@ msgstr "Beitrittsdatum"
msgid "Last Name"
msgstr "Nachname"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "New Member"
@ -119,12 +112,13 @@ msgstr "schließen"
msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "Hausnummer"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -132,7 +126,6 @@ msgid "Notes"
msgstr "Notizen"
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -140,13 +133,16 @@ msgstr "Notizen"
msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Postal Code"
msgstr "Postleitzahl"
@ -156,7 +152,7 @@ msgstr "Postleitzahl"
msgid "Save Member"
msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_live/form.ex
@ -167,11 +163,16 @@ msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Street"
msgstr "Straße"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Id"
msgstr "ID"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@ -184,7 +185,11 @@ msgstr "Nein"
msgid "Show Member"
msgstr "Mitglied anzeigen"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@ -192,14 +197,14 @@ msgstr "Mitglied anzeigen"
msgid "Yes"
msgstr "Ja"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "create"
msgstr "erstellt"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
@ -241,8 +246,8 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt"
msgid "Your password has successfully been reset"
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: 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/form.ex
#: lib/mv_web/live/custom_field_live/index.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/user_live/form.ex
@ -255,8 +260,7 @@ msgstr "Abbrechen"
msgid "Choose a member"
msgstr "Mitglied auswählen"
#: 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/form.ex
#, elixir-autogen, elixir-format
msgid "Description"
msgstr "Beschreibung"
@ -271,7 +275,7 @@ msgstr "Benutzer*in bearbeiten"
msgid "Enabled"
msgstr "Aktiviert"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr "Unveränderlich"
@ -293,16 +297,13 @@ msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Members"
msgstr "Mitglieder"
#: lib/mv_web/live/contribution_type_live/index.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/form.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
@ -317,7 +318,6 @@ msgstr "Neue*r Benutzer*in"
msgid "Not enabled"
msgstr "Nicht aktiviert"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Note"
@ -333,7 +333,7 @@ msgstr "Passwort-Authentifizierung"
msgid "Profil"
msgstr "Profil"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Required"
msgstr "Erforderlich"
@ -349,7 +349,6 @@ msgid "Select member"
msgstr "Mitglied auswählen"
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr "Einstellungen"
@ -390,7 +389,7 @@ msgstr "Benutzer*in"
msgid "Value"
msgstr "Wert"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr "Wertetyp"
@ -581,12 +580,18 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
msgid "Choose a custom field"
msgstr "Wähle ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Custom Field Values"
msgstr "Benutzerdefinierte Feldwerte"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr "Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
@ -601,7 +606,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr "Benutzerdefiniertes Feld speichern"
@ -611,9 +616,12 @@ msgstr "Benutzerdefiniertes Feld speichern"
msgid "Save Custom field value"
msgstr "Benutzerdefinierten Feldwert speichern"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage custom_field records in your database."
msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."
#: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format
msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder"
@ -623,39 +631,44 @@ msgstr "Benutzerdefinierte Felder"
msgid "Use this form to manage Custom Field Value records in your database."
msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/show.ex
#, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)"
msgstr "Automatisch generierter Bezeichner (unveränderlich)"
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete Custom Field"
msgstr "Benutzerdefiniertes Feld löschen"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete Custom Field and All Values"
msgstr "Benutzerdefiniertes Feld und alle Werte löschen"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
msgstr "Obigen Text zur Bestätigung eingeben"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "To confirm deletion, please enter this text:"
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Show in overview"
msgstr "In der Mitglieder-Übersicht anzeigen"
@ -675,7 +688,6 @@ msgstr "Vereinsdaten"
msgid "Manage global settings for the association."
msgstr "Passe übergreifende Einstellungen für den Verein an."
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Settings"
@ -778,12 +790,16 @@ msgstr "Im E-Mail-Programm öffnen"
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformität"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Fields marked with an asterisk (*) cannot be empty."
msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben."
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
@ -804,641 +820,17 @@ msgstr "Nicht bezahlt"
msgid "Payment filter"
msgstr "Zahlungsfilter"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
msgstr "Adresse"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back"
msgstr "Zurück"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Coming soon"
msgstr "Demnächst verfügbar"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contact Data"
msgstr "Kontaktdaten"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution"
msgstr "Beitrag"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Nr."
msgstr "Nr."
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Cycle"
msgstr "Zahlungszyklus"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Data"
msgstr "Beitragsdaten"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr "Zahlungen"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Pending"
msgstr "Ausstehend"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Personal Data"
msgstr "Persönliche Daten"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Phone"
msgstr "Telefon"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save"
msgstr "Speichern"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "This data is for demonstration purposes only (mockup)."
msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "monthly"
msgstr "monatlich"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "yearly"
msgstr "jährlich"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Create Member"
msgstr "Mitglied erstellen"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "%{count} period selected"
msgid_plural "%{count} periods selected"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Contribution Types"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Amount"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back to Settings"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete - members assigned"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Change Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings for membership contributions."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Settings"
msgstr "Beitrag"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Start"
msgstr "Beitrag"
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Types"
msgstr "Beitrag"
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution start"
msgstr "Beitrag"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution type"
msgstr "Beitrag"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions"
msgstr "Beitrag"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions for %{name}"
msgstr "Beitrag"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Current"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Deletion"
msgstr "Löschen"
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Example: Member Contribution View"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Examples"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Family"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Generated periods"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Global Settings"
msgstr "Vereinsdaten"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly contribution for supporting members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Honorary"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Include joining period"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Interval"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Joining date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Joining year - reduced to 0"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage contribution types for membership fees."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Paid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Suspended"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Unpaid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member Contributions"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays for the year they joined"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays from the joining month"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays from the next full quarter"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays from the next full year"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member since"
msgstr "Mitglieder"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Monthly"
msgstr "monatlich"
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Monthly Interval - Joining Period Included"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Contribution Type"
msgstr "Beitrag"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "No fee for honorary members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Open Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Paid via bank transfer"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Preview Mockup"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Quarterly"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Quarterly Interval - Joining Period Excluded"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced fee for unemployed, pensioners, or low income"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Regular"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reopen"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Status"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Student"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Supporting Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Suspend"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Time Period"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "View Example Member"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When active: Members pay from the period of their joining."
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When inactive: Members pay from the next full period after joining."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Yearly"
msgstr "jährlich"
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Period Excluded"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Period Included"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Columns"
msgstr "Spalten"
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Custom Field %{id}"
msgstr "Benutzerdefiniertes Feld %{id}"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
msgstr "Nachname"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "None"
msgstr "Keine"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Options"
msgstr "Optionen"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Select all"
msgstr "Alle auswählen"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Select none"
msgstr "Keine auswählen"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to custom field overview"
msgstr "Zurück zur Felderliste"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field deleted successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit Custom Field"
msgstr "Benutzerdefiniertes Feld löschen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete custom field: %{error}"
msgstr "Konnte Feld nicht löschen: %{error}"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom Field"
msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom field"
msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Show in Overview"
msgstr "In der Mitglieder-Übersicht anzeigen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled."
msgstr "Eingegebener Text war nicht korrekt. Vorgang abgebrochen."
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "These will appear in addition to other data when adding new members."
msgstr "Diese Felder können zusätzlich zu den normalen Daten ausgefüllt werden, wenn ein neues Mitglied angelegt wird."
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Value Type"
msgstr "Wertetyp"
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Birth Date"
#~ msgstr "Geburtsdatum"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Custom Field Values"
#~ msgstr "Benutzerdefinierte Feldwerte"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Fields marked with an asterisk (*) cannot be empty."
#~ msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben."
#~ #: lib/mv_web/live/custom_field_live/form.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "ID"
#~ msgstr "ID"
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Id"
#~ msgstr "ID"
#~ #: lib/mv_web/live/user_live/form.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not set"
@ -1449,13 +841,3 @@ msgstr "Wertetyp"
#~ #, elixir-autogen, elixir-format
#~ msgid "OIDC ID"
#~ msgstr "OIDC ID"
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "This is a member record from your database."
#~ msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#~ #: lib/mv_web/live/custom_field_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Use this form to manage custom_field records in your database."
#~ msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,6 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
@ -30,20 +29,17 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
@ -51,13 +47,11 @@ msgstr ""
msgid "Edit"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -87,7 +81,6 @@ msgstr ""
msgid "Last Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "New Member"
@ -120,12 +113,13 @@ msgstr ""
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -133,7 +127,6 @@ msgid "Notes"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -141,13 +134,16 @@ msgstr ""
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Postal Code"
msgstr ""
@ -157,7 +153,7 @@ msgstr ""
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_live/form.ex
@ -168,11 +164,16 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Street"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@ -185,7 +186,11 @@ msgstr ""
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@ -193,14 +198,14 @@ msgstr ""
msgid "Yes"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
@ -242,8 +247,8 @@ msgstr ""
msgid "Your password has successfully been reset"
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/custom_field_live/form.ex
#: lib/mv_web/live/custom_field_live/index.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/user_live/form.ex
@ -256,8 +261,7 @@ msgstr ""
msgid "Choose a member"
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/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@ -272,7 +276,7 @@ msgstr ""
msgid "Enabled"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
@ -294,16 +298,13 @@ msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.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/form.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -318,7 +319,6 @@ msgstr ""
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Note"
@ -334,7 +334,7 @@ msgstr ""
msgid "Profil"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
@ -350,7 +350,6 @@ msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
@ -391,7 +390,7 @@ msgstr ""
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -582,12 +581,18 @@ msgstr ""
msgid "Choose a custom field"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Custom Field Values"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
@ -602,7 +607,7 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
@ -612,9 +617,12 @@ msgstr ""
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage custom_field records in your database."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom Fields"
msgstr ""
@ -624,39 +632,44 @@ msgstr ""
msgid "Use this form to manage Custom Field Value records in your database."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/show.ex
#, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete Custom Field and All Values"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "To confirm deletion, please enter this text:"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/form.ex
#, elixir-autogen, elixir-format
msgid "Show in overview"
msgstr ""
@ -676,7 +689,6 @@ msgstr ""
msgid "Manage global settings for the association."
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Settings"
@ -779,12 +791,16 @@ msgstr ""
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Fields marked with an asterisk (*) cannot be empty."
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
@ -805,639 +821,17 @@ msgstr ""
msgid "Payment filter"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Coming soon"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contact Data"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Nr."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Cycle"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Pending"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Personal Data"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Phone"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "This data is for demonstration purposes only (mockup)."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "monthly"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "yearly"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Create Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "%{count} period selected"
msgid_plural "%{count} periods selected"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Contribution Types"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Amount"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back to Settings"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete - members assigned"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Change Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings for membership contributions."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Contribution Settings"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution Start"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution Types"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Contribution start"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution type"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format
msgid "Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contributions for %{name}"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Current"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Deletion"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Example: Member Contribution View"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Examples"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Family"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Generated periods"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Global Settings"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly contribution for supporting members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Honorary"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Include joining period"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Interval"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Joining date"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Joining year - reduced to 0"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage contribution types for membership fees."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Paid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Suspended"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Mark as Unpaid"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member Contributions"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays for the year they joined"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays from the joining month"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays from the next full quarter"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Member pays from the next full year"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member since"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Monthly"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Monthly Interval - Joining Period Included"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "New Contribution Type"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "No fee for honorary members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Open Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Paid via bank transfer"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Preview Mockup"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Quarterly"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Quarterly Interval - Joining Period Excluded"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reduced fee for unemployed, pensioners, or low income"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Regular"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reopen"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Status"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Student"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Supporting Member"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Suspend"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Time Period"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "View Example Member"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When active: Members pay from the period of their joining."
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When inactive: Members pay from the next full period after joining."
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Yearly"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Period Excluded"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Period Included"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Columns"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom Field %{id}"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Last name"
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "None"
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Options"
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select all"
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select none"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to custom field overview"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field deleted successfully"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit Custom Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete custom field: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Show in Overview"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "These will appear in addition to other data when adding new members."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Value Type"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Birth Date"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Custom Field Values"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Fields marked with an asterisk (*) cannot be empty."
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "ID"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Id"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Not set"
@ -1448,13 +842,3 @@ msgstr ""
#~ #, elixir-autogen, elixir-format
#~ msgid "OIDC ID"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "This is a member record from your database."
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/form.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Use this form to manage custom_field records in your database."
#~ msgstr ""

View file

@ -11,70 +11,4 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
use Mv.DataCase, async: true
alias Mv.Membership.Member
describe "show_in_overview?/1" do
test "returns true for all member fields by default" do
# When no settings exist or member_field_visibility is not configured
# Test with fields from constants
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Member.show_in_overview?(field) == true,
"Field #{field} should be visible by default"
end)
end
test "returns false for fields with show_in_overview: false in settings" do
# Get or create settings
{:ok, settings} = Mv.Membership.get_settings()
# Use a field that exists in member fields
member_fields = Mv.Constants.member_fields()
field_to_hide = List.first(member_fields)
field_to_show = List.last(member_fields)
# Update settings to hide a field (use string keys for JSONB)
{:ok, _updated_settings} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: %{Atom.to_string(field_to_hide) => false}
})
# JSONB may convert atom keys to string keys, so we check via show_in_overview? instead
assert Member.show_in_overview?(field_to_hide) == false
assert Member.show_in_overview?(field_to_show) == true
end
test "returns true for non-configured fields (default)" do
# Get or create settings
{:ok, settings} = Mv.Membership.get_settings()
# Use fields that exist in member fields
member_fields = Mv.Constants.member_fields()
fields_to_hide = Enum.take(member_fields, 2)
fields_to_show = Enum.take(member_fields, -2)
# Update settings to hide some fields (use string keys for JSONB)
visibility_config =
Enum.reduce(fields_to_hide, %{}, fn field, acc ->
Map.put(acc, Atom.to_string(field), false)
end)
{:ok, _updated_settings} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: visibility_config
})
# Hidden fields should be false
Enum.each(fields_to_hide, fn field ->
assert Member.show_in_overview?(field) == false,
"Field #{field} should be hidden"
end)
# Unconfigured fields should still be true (default)
Enum.each(fields_to_show, fn field ->
assert Member.show_in_overview?(field) == true,
"Field #{field} should be visible by default"
end)
end
end
end

View file

@ -1,21 +0,0 @@
defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "field visibility dropdown in member view" do
test "renders and toggles visibility", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
# Renders Dropdown
assert has_element?(view, "[data-testid='dropdown-menu']")
# Opens Dropdown
view |> element("[data-testid='dropdown-button']") |> render_click()
assert has_element?(view, "#field-visibility-menu")
assert has_element?(view, "button[phx-click='select_item'][phx-value-item='email']")
assert has_element?(view, "button[phx-click='select_all']")
assert has_element?(view, "button[phx-click='select_none']")
end
end
end

View file

@ -150,27 +150,35 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
test "icon distribution is correct for all fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test neutral state - only one field should have active sort icon
# Test neutral state - all fields except first name (default) should show neutral icons
{:ok, _view, html_neutral} = live(conn, "/members")
# Count active icons (should be exactly 1 - ascending for default sort field)
# Count neutral icons (should be 7 - one for each field)
neutral_count =
html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
assert neutral_count == 7
# Count active icons (should be 1)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert down_count == 0
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
# Test ascending state - one field active, others neutral
{:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Test descending state
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
# Should have exactly 1 ascending icon and 7 neutral icons
up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
assert up_count == 1
assert neutral_count == 7
assert down_count == 0
end
end

View file

@ -1,7 +1,6 @@
defmodule MvWeb.CustomFieldLive.DeletionTest do
@moduledoc """
Tests for CustomFieldLive.IndexComponent deletion modal and slug confirmation.
Tests the custom field management component embedded in the settings page.
Tests for CustomFieldLive.Index deletion modal and slug confirmation.
Tests cover:
- Opening deletion confirmation modal
@ -40,11 +39,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Create custom field value
create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
# Click delete button - find the delete link within the component
# Click delete button
view
|> element("#custom-fields-component a", "Delete")
|> element("a", "Delete")
|> render_click()
# Modal should be visible
@ -66,10 +65,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
create_custom_field_value(member1, custom_field, "test1")
create_custom_field_value(member2, custom_field, "test2")
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("#custom-fields-component a", "Delete")
|> element("a", "Delete")
|> render_click()
# Should show plural form
@ -79,10 +78,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "shows 0 members for custom field without values", %{conn: conn} do
{:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("#custom-fields-component a", "Delete")
|> element("a", "Delete")
|> render_click()
# Should show 0 members
@ -94,16 +93,15 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "updates confirmation state when typing", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("#custom-fields-component a", "Delete")
|> element("a", "Delete")
|> render_click()
# Type in slug input - use element to find the form with phx-target
# Type in slug input
view
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => custom_field.slug})
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
# Confirm button should be enabled now (no disabled attribute)
html = render(view)
@ -113,16 +111,15 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "delete button is disabled when slug doesn't match", %{conn: conn} do
{:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("#custom-fields-component a", "Delete")
|> element("a", "Delete")
|> render_click()
# Type wrong slug - use element to find the form with phx-target
# Type wrong slug
view
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"})
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
# Button should be disabled
html = render(view)
@ -136,21 +133,20 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
# Open modal
view
|> element("#custom-fields-component a", "Delete")
|> element("a", "Delete")
|> render_click()
# Enter correct slug - use element to find the form with phx-target
# Enter correct slug
view
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => custom_field.slug})
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
# Click confirm
view
|> element("#delete-custom-field-modal button", "Delete Custom Field and All Values")
|> element("button", "Delete Custom Field and All Values")
|> render_click()
# Should show success message
@ -166,28 +162,27 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
assert {:ok, _} = Ash.get(Member, member.id)
end
test "button remains disabled and custom field not deleted when slug doesn't match", %{
conn: conn
} do
test "shows error when slug doesn't match", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("#custom-fields-component a", "Delete")
|> element("a", "Delete")
|> render_click()
# Enter wrong slug - use element to find the form with phx-target
# Enter wrong slug
view
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"})
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
# Button should be disabled and we cannot click it
# The test verifies that the button is properly disabled in the UI
html = render(view)
assert html =~ ~r/disabled(?:=""|(?!\w))/
# Try to confirm (button should be disabled, but test the handler anyway)
view
|> render_click("confirm_delete", %{})
# Custom field should still exist since deletion couldn't proceed
# Should show error message
assert render(view) =~ "Slug does not match"
# Custom field should still exist
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
end
end
@ -196,10 +191,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "closes modal without deleting", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("#custom-fields-component a", "Delete")
|> element("a", "Delete")
|> render_click()
# Modal should be visible
@ -207,7 +202,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Click cancel
view
|> element("#delete-custom-field-modal button", "Cancel")
|> element("button", "Cancel")
|> render_click()
# Modal should be gone

View file

@ -1,370 +0,0 @@
defmodule MvWeb.MemberLive.Index.FieldSelectionTest do
@moduledoc """
Tests for FieldSelection module handling cookie/session/URL management.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.FieldSelection
describe "get_from_session/1" do
test "returns empty map when session is empty" do
assert FieldSelection.get_from_session(%{}) == %{}
end
test "returns empty map when session key is missing" do
session = %{"other_key" => "value"}
assert FieldSelection.get_from_session(session) == %{}
end
test "parses valid JSON from session" do
json = Jason.encode!(%{"first_name" => true, "email" => false})
session = %{"member_field_selection" => json}
result = FieldSelection.get_from_session(session)
assert result == %{"first_name" => true, "email" => false}
end
test "handles invalid JSON gracefully" do
session = %{"member_field_selection" => "invalid json{["}
result = FieldSelection.get_from_session(session)
assert result == %{}
end
test "converts non-boolean values to true" do
json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true})
session = %{"member_field_selection" => json}
result = FieldSelection.get_from_session(session)
# All values should be booleans, non-booleans default to true
assert result["first_name"] == true
assert result["email"] == true
assert result["street"] == true
end
test "handles nil session" do
assert FieldSelection.get_from_session(nil) == %{}
end
test "handles non-map session" do
assert FieldSelection.get_from_session("not a map") == %{}
end
end
describe "save_to_session/2" do
test "saves field selection to session as JSON" do
session = %{}
selection = %{"first_name" => true, "email" => false}
result = FieldSelection.save_to_session(session, selection)
assert Map.has_key?(result, "member_field_selection")
assert Jason.decode!(result["member_field_selection"]) == selection
end
test "overwrites existing selection" do
session = %{"member_field_selection" => Jason.encode!(%{"old" => true})}
selection = %{"new" => true}
result = FieldSelection.save_to_session(session, selection)
assert Jason.decode!(result["member_field_selection"]) == selection
end
test "handles empty selection" do
session = %{}
selection = %{}
result = FieldSelection.save_to_session(session, selection)
assert Jason.decode!(result["member_field_selection"]) == %{}
end
test "handles invalid selection gracefully" do
session = %{}
result = FieldSelection.save_to_session(session, "not a map")
assert result == session
end
end
describe "get_from_cookie/1" do
test "returns empty map when cookie header is missing" do
conn = %Plug.Conn{}
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "returns empty map when cookie is empty string" do
conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "")
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "parses valid JSON from cookie" do
selection = %{"first_name" => true, "email" => false}
cookie_value = selection |> Jason.encode!() |> URI.encode()
cookie_header = "member_field_selection=#{cookie_value}"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == selection
end
test "handles invalid JSON in cookie gracefully" do
cookie_value = URI.encode("invalid{[")
cookie_header = "member_field_selection=#{cookie_value}"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "handles cookie with other values" do
selection = %{"street" => true}
cookie_value = selection |> Jason.encode!() |> URI.encode()
cookie_header = "other_cookie=value; member_field_selection=#{cookie_value}; another=test"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == selection
end
end
describe "save_to_cookie/2" do
test "saves field selection to cookie" do
conn = %Plug.Conn{}
selection = %{"first_name" => true, "email" => false}
result = FieldSelection.save_to_cookie(conn, selection)
# Check that cookie is set
assert result.resp_cookies["member_field_selection"]
cookie = result.resp_cookies["member_field_selection"]
assert cookie[:max_age] == 365 * 24 * 60 * 60
assert cookie[:same_site] == "Lax"
assert cookie[:http_only] == true
end
test "handles invalid selection gracefully" do
conn = %Plug.Conn{}
result = FieldSelection.save_to_cookie(conn, "not a map")
assert result == conn
end
end
describe "parse_from_url/1" do
test "returns empty map when params is empty" do
assert FieldSelection.parse_from_url(%{}) == %{}
end
test "returns empty map when fields parameter is missing" do
params = %{"query" => "test", "sort_field" => "first_name"}
assert FieldSelection.parse_from_url(params) == %{}
end
test "parses comma-separated field names" do
params = %{"fields" => "first_name,email,street"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true,
"street" => true
}
end
test "handles custom field names" do
params = %{"fields" => "custom_field_abc-123,custom_field_def-456"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"custom_field_abc-123" => true,
"custom_field_def-456" => true
}
end
test "handles mixed member and custom fields" do
params = %{"fields" => "first_name,custom_field_123,email"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"custom_field_123" => true,
"email" => true
}
end
test "trims whitespace from field names" do
params = %{"fields" => " first_name , email , street "}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true,
"street" => true
}
end
test "handles empty fields string" do
params = %{"fields" => ""}
assert FieldSelection.parse_from_url(params) == %{}
end
test "handles nil fields parameter" do
params = %{"fields" => nil}
assert FieldSelection.parse_from_url(params) == %{}
end
test "filters out empty field names" do
params = %{"fields" => "first_name,,email,"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true
}
end
test "handles non-map params" do
assert FieldSelection.parse_from_url(nil) == %{}
assert FieldSelection.parse_from_url("not a map") == %{}
end
end
describe "merge_sources/3" do
test "merges all sources with URL having highest priority" do
url_selection = %{"first_name" => false}
session_selection = %{"first_name" => true, "email" => true}
cookie_selection = %{"first_name" => true, "street" => true}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
# URL overrides session, session overrides cookie
assert result["first_name"] == false
assert result["email"] == true
assert result["street"] == true
end
test "handles empty sources" do
result = FieldSelection.merge_sources(%{}, %{}, %{})
assert result == %{}
end
test "cookie only" do
cookie_selection = %{"first_name" => true}
result = FieldSelection.merge_sources(%{}, %{}, cookie_selection)
assert result == %{"first_name" => true}
end
test "session overrides cookie" do
session_selection = %{"first_name" => false}
cookie_selection = %{"first_name" => true}
result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection)
assert result["first_name"] == false
end
test "URL overrides everything" do
url_selection = %{"first_name" => true}
session_selection = %{"first_name" => false}
cookie_selection = %{"first_name" => false}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
assert result["first_name"] == true
end
test "combines fields from all sources" do
url_selection = %{"url_field" => true}
session_selection = %{"session_field" => true}
cookie_selection = %{"cookie_field" => true}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
assert result["url_field"] == true
assert result["session_field"] == true
assert result["cookie_field"] == true
end
end
describe "to_url_param/1" do
test "converts selection to comma-separated string" do
selection = %{"first_name" => true, "email" => true, "street" => false}
result = FieldSelection.to_url_param(selection)
# Only visible fields should be included (order may vary)
fields = String.split(result, ",") |> Enum.sort()
assert fields == ["email", "first_name"]
end
test "handles empty selection" do
assert FieldSelection.to_url_param(%{}) == ""
end
test "handles all fields hidden" do
selection = %{"first_name" => false, "email" => false}
result = FieldSelection.to_url_param(selection)
assert result == ""
end
test "preserves field order" do
selection = %{
"z_field" => true,
"a_field" => true,
"m_field" => true
}
result = FieldSelection.to_url_param(selection)
# Order should be preserved (map iteration order)
assert String.contains?(result, "z_field")
assert String.contains?(result, "a_field")
assert String.contains?(result, "m_field")
end
test "handles custom fields" do
selection = %{
"first_name" => true,
"custom_field_abc-123" => true,
"email" => false
}
result = FieldSelection.to_url_param(selection)
assert String.contains?(result, "first_name")
assert String.contains?(result, "custom_field_abc-123")
refute String.contains?(result, "email")
end
test "handles invalid input" do
assert FieldSelection.to_url_param(nil) == ""
assert FieldSelection.to_url_param("not a map") == ""
end
end
end

View file

@ -1,336 +0,0 @@
defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
@moduledoc """
Tests for FieldVisibility module handling field visibility merging logic.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.FieldVisibility
# Mock custom field structs for testing
defp create_custom_field(id, name, show_in_overview \\ true) do
%{
id: id,
name: name,
show_in_overview: show_in_overview
}
end
describe "get_all_available_fields/1" do
test "returns member fields and custom fields" do
custom_fields = [
create_custom_field("cf1", "Custom Field 1"),
create_custom_field("cf2", "Custom Field 2")
]
result = FieldVisibility.get_all_available_fields(custom_fields)
# Should include all member fields
assert :first_name in result
assert :email in result
assert :street in result
# Should include custom fields as strings
assert "custom_field_cf1" in result
assert "custom_field_cf2" in result
end
test "handles empty custom fields list" do
result = FieldVisibility.get_all_available_fields([])
# Should only have member fields
assert :first_name in result
assert :email in result
refute Enum.any?(result, fn field ->
is_binary(field) and String.starts_with?(field, "custom_field_")
end)
end
test "includes all member fields from constants" do
custom_fields = []
result = FieldVisibility.get_all_available_fields(custom_fields)
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert field in result
end)
end
end
describe "merge_with_global_settings/3" do
test "user selection overrides global settings" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{first_name: true, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "falls back to global settings when user selection is empty" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "defaults to true when field not in settings" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# first_name from settings
assert result["first_name"] == false
# email defaults to true (not in settings)
assert result["email"] == true
end
test "handles custom fields visibility" do
user_selection = %{}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true),
create_custom_field("cf2", "Custom 2", false)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == true
assert result["custom_field_cf2"] == false
end
test "user selection overrides custom field visibility" do
user_selection = %{"custom_field_cf1" => false}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == false
end
test "handles string keys in settings (JSONB format)" do
user_selection = %{}
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles mixed atom and string keys in settings" do
user_selection = %{}
# Use string keys only (as JSONB would return)
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles nil settings gracefully" do
user_selection = %{}
settings = %{member_field_visibility: nil}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should default all fields to true
assert result["first_name"] == true
assert result["email"] == true
end
test "handles missing member_field_visibility key" do
user_selection = %{}
settings = %{}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should default all fields to true
assert result["first_name"] == true
assert result["email"] == true
end
test "includes all fields in result" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{email: true}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should include all member fields
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Map.has_key?(result, Atom.to_string(field))
end)
# Should include custom fields
assert Map.has_key?(result, "custom_field_cf1")
end
end
describe "get_visible_fields/1" do
test "returns only fields with true visibility" do
selection = %{
"first_name" => true,
"email" => false,
"street" => true,
"custom_field_123" => false
}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :street in result
refute :email in result
refute "custom_field_123" in result
end
test "converts member field strings to atoms" do
selection = %{"first_name" => true, "email" => true}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :email in result
end
test "keeps custom fields as strings" do
selection = %{"custom_field_abc-123" => true}
result = FieldVisibility.get_visible_fields(selection)
assert "custom_field_abc-123" in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_fields(%{}) == []
end
test "handles all fields hidden" do
selection = %{"first_name" => false, "email" => false}
assert FieldVisibility.get_visible_fields(selection) == []
end
test "handles invalid input" do
assert FieldVisibility.get_visible_fields(nil) == []
end
end
describe "get_visible_member_fields/1" do
test "returns only member fields that are visible" do
selection = %{
"first_name" => true,
"email" => true,
"custom_field_123" => true,
"street" => false
}
result = FieldVisibility.get_visible_member_fields(selection)
assert :first_name in result
assert :email in result
refute :street in result
refute "custom_field_123" in result
end
test "filters out custom fields" do
selection = %{
"first_name" => true,
"custom_field_123" => true,
"custom_field_456" => true
}
result = FieldVisibility.get_visible_member_fields(selection)
assert :first_name in result
refute "custom_field_123" in result
refute "custom_field_456" in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_member_fields(%{}) == []
end
test "handles invalid input" do
assert FieldVisibility.get_visible_member_fields(nil) == []
end
end
describe "get_visible_custom_fields/1" do
test "returns only custom fields that are visible" do
selection = %{
"first_name" => true,
"custom_field_123" => true,
"custom_field_456" => false,
"email" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
refute "custom_field_456" in result
refute :first_name in result
refute :email in result
end
test "filters out member fields" do
selection = %{
"first_name" => true,
"email" => true,
"custom_field_123" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
refute :first_name in result
refute :email in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_custom_fields(%{}) == []
end
test "handles fields that look like custom fields but aren't" do
selection = %{
"custom_field_123" => true,
"custom_field_like_name" => true,
"not_custom_field" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
assert "custom_field_like_name" in result
refute "not_custom_field" in result
end
test "handles invalid input" do
assert FieldVisibility.get_visible_custom_fields(nil) == []
end
end
end

View file

@ -148,6 +148,8 @@ defmodule MvWeb.ProfileNavigationTest do
"/members/new",
"/custom_field_values",
"/custom_field_values/new",
"/custom_fields",
"/custom_fields/new",
"/users",
"/users/new"
]

View file

@ -242,7 +242,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
assert html =~ "alice.private@example.com"
end
test "shows empty cell for members without custom field values", %{
test "shows empty cell or placeholder for members without custom field values", %{
conn: conn,
member2: _member2,
field_show_string: field
@ -253,14 +253,11 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
# The custom field column should exist
assert html =~ field.name
# Member2 should exist in the table (first_name and last_name are in separate columns)
assert html =~ "Bob"
assert html =~ "Brown"
# The value from member1 should appear (phone number)
# Member2 should have an empty cell for this field
# We check that member2's row exists but doesn't have the value
assert html =~ "Bob Brown"
# The value should not appear for member2 (only for member1)
# We check that the value appears somewhere (for member1) but member2 row should have "-"
assert html =~ "+49123456789"
# Note: Member2 doesn't have this custom field value, so the cell is empty
# The implementation shows "" for missing values, which is the expected behavior
end
end

View file

@ -1,452 +0,0 @@
defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
@moduledoc """
Integration tests for field visibility dropdown functionality.
Tests cover:
- Field selection dropdown rendering
- Toggling field visibility
- URL parameter persistence
- Select all / deselect all
- Integration with member list display
- Custom fields visibility
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main St",
city: "Berlin"
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com",
street: "Second St",
city: "Hamburg"
})
|> Ash.create()
# Create custom field
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
# Create custom field values
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: custom_field.id,
value: "M001"
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member2.id,
custom_field_id: custom_field.id,
value: "M002"
})
|> Ash.create()
%{
member1: member1,
member2: member2,
custom_field: custom_field
}
end
describe "field visibility dropdown" do
test "renders dropdown button", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Columns"
assert html =~ ~s(aria-controls="field-visibility-menu")
end
test "opens dropdown when button is clicked", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially closed
refute has_element?(view, "ul#field-visibility-menu")
# Click button
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Should be open now
assert has_element?(view, "ul#field-visibility-menu")
end
test "displays all member fields in dropdown", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
# Check for member fields (formatted labels)
assert html =~ "First Name" or html =~ "first_name"
assert html =~ "Email" or html =~ "email"
assert html =~ "Street" or html =~ "street"
end
test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
assert html =~ custom_field.name
end
end
describe "field visibility toggling" do
test "hiding a field removes it from display", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify email is visible initially
html = render(view)
assert html =~ "alice@example.com"
# Open dropdown and hide email
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Email should no longer be visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "bob@example.com"
end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify custom field is visible initially
html = render(view)
assert html =~ "M001" or html =~ custom_field.name
# Open dropdown and hide custom field
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
custom_field_id = custom_field.id
custom_field_string = "custom_field_#{custom_field_id}"
view
|> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Custom field should no longer be visible
html = render(view)
refute html =~ "M001"
refute html =~ "M002"
end
end
describe "select all / deselect all" do
test "select all makes all fields visible", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with some fields hidden
{:ok, view, _html} = live(conn, "/members?fields=first_name")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Click select all
view
|> element("button[phx-click='select_all']")
|> render_click()
# Wait for update
:timer.sleep(100)
# All fields should be visible
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Main St"
assert html =~ "Berlin"
end
test "deselect all hides all fields except first_name", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Click deselect all
view
|> element("button[phx-click='select_none']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Only first_name should be visible (it's always shown)
html = render(view)
# Email and street should be hidden
refute html =~ "alice@example.com"
refute html =~ "Main St"
end
end
describe "URL parameter persistence" do
test "field selection is persisted in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown and hide email
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
# Wait for URL update
:timer.sleep(100)
# Check that URL contains fields parameter
# Note: In LiveView tests, we check the rendered HTML for the updated state
# The actual URL update happens via push_patch
end
test "loading page with fields parameter applies selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Load with first_name and city explicitly set in URL
# Note: Other fields may still be visible due to global settings
{:ok, view, _html} = live(conn, "/members?fields=first_name,city")
html = render(view)
# first_name and city should be visible
assert html =~ "Alice"
assert html =~ "Berlin"
# Note: email and street may still be visible if global settings allow it
# This test verifies that the URL parameters work, not that they hide other fields
end
test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
custom_field_id = custom_field.id
# Load with custom field visible
{:ok, view, _html} =
live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}")
html = render(view)
# Custom field should be visible
assert html =~ "M001" or html =~ custom_field.name
end
end
describe "integration with global settings" do
test "respects global settings when no user selection", %{conn: conn} do
# This test would require setting up global settings
# For now, we verify that the system works with default settings
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# All fields should be visible by default
assert html =~ "alice@example.com"
assert html =~ "Main St"
end
test "user selection overrides global settings", %{conn: conn} do
# This would require setting up global settings first
# Then verifying that user selection takes precedence
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Hide a field via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
:timer.sleep(100)
html = render(view)
refute html =~ "alice@example.com"
end
end
describe "edge cases" do
test "handles empty fields parameter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=")
# Should fall back to global settings
assert html =~ "alice@example.com"
end
test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
# Should ignore invalid fields and use defaults
assert html =~ "alice@example.com"
end
test "handles custom field that doesn't exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent")
# Should work without errors
assert html =~ "Alice"
end
test "handles rapid toggling", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Rapidly toggle a field multiple times
for _ <- 1..5 do
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
:timer.sleep(50)
end
# Should still work correctly
html = render(view)
assert html =~ "Alice"
end
end
describe "accessibility" do
test "dropdown has proper ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ ~s(aria-controls="field-visibility-menu")
assert html =~ ~s(aria-haspopup="menu")
assert html =~ ~s(role="button")
end
test "menu items have proper ARIA attributes when open", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
assert html =~ ~s(role="menu")
assert html =~ ~s(role="menuitemcheckbox")
assert html =~ ~s(aria-checked)
end
test "keyboard navigation works", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Check that elements are keyboard accessible
html = render(view)
assert html =~ ~s(tabindex="0")
# Check that keyboard events are supported
assert html =~ ~s(phx-keydown="select_item")
assert html =~ ~s(phx-key="Enter")
end
test "keyboard activation with Enter key works", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify email is visible initially
html = render(view)
assert html =~ "alice@example.com"
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Simulate Enter key press on email field button
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_keydown(%{key: "Enter"})
# Wait for update
:timer.sleep(100)
# Email should no longer be visible
html = render(view)
refute html =~ "alice@example.com"
end
end
end