Compare commits

...

27 commits

Author SHA1 Message Date
f62d1fbf51 fix: search
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 08:03:57 +01:00
8b445cec48 feat: adds field visibility dropdown live component 2025-12-03 08:02:52 +01:00
f709edcf6f tests: added tests 2025-12-02 19:15:55 +01:00
a143c4e243 Merge pull request 'Check translations when linting' (#236) from lint-translations into main
Reviewed-on: #236
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
2025-12-02 16:51:45 +01:00
b0c94234a9
chore: update gettext
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 16:46:07 +01:00
780f5f61ea Check translations when linting
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-02 16:17:52 +01:00
ac2ad0a0d5 Merge pull request 'Implement filter for has_paid closes #227' (#237) from feature/227_payment_filter into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #237
2025-12-02 16:12:42 +01:00
875c422b7d
Fix missing search query socket assign in member index
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 16:04:07 +01:00
6d75766dba
fix: add ESC key support, security comment, and disable async tests
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 15:55:27 +01:00
354029c9cc
fix: add role=none to li elements in payment filter for ARIA compliance 2025-12-02 15:55:26 +01:00
671e6ce804
feat: add payment status filter and paid column to member list
Add PaymentFilterComponent dropdown and colored paid column. Filter supports URL bookmarking and combines with search/sort.
2025-12-02 15:55:23 +01:00
386b4c9e65 Merge pull request 'Don't show birthday field for default configurations closes #161' (#239) from feature/161_remove_birthday into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #239
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-12-02 15:48:59 +01:00
88c5f3dde0 Merge pull request 'Mark required fields in UI' (#235) from mark-required-fields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #235
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
2025-12-02 15:26:10 +01:00
a67a91cffa
Mark required fields in UI
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 15:23:44 +01:00
c8968636a8 feat: remove birth_date field from Member resource
All checks were successful
continuous-integration/drone/push Build is passing
Users who need birthday data can use custom fields instead.
Closes #161
2025-12-02 14:58:50 +01:00
40835f7a2d Merge pull request 'Implement setting to show/hide member fields technically closes #214' (#232) from feature/214_hide_memberfields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #232
2025-12-02 14:33:08 +01:00
13f77b5c0a
Refactor column visibility logic
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 14:18:27 +01:00
dce2053ce7 formatting and refactor member fields constant 2025-12-02 14:17:53 +01:00
e81aecce48 feat: adds member visibility to live view 2025-12-02 14:17:04 +01:00
397cbde9d6 feat: adds member visibility settings 2025-12-02 14:16:02 +01:00
831149f463 chore: adds constant for member_fields 2025-12-02 14:16:02 +01:00
944b868478 tests: adds tests 2025-12-02 14:16:02 +01:00
d10f2ecc90 chore: adds migration for member field visibility 2025-12-02 14:16:02 +01:00
d757d1b9be Merge pull request 'Implement bulk functionality to copy email adresses closes #230' (#234) from feature/230_email_copy into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #234
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-12-02 12:13:36 +01:00
39d2cb7820 refactor: improve email copy with MapSet, RFC 5322 commas, and cond
All checks were successful
continuous-integration/drone/push Build is passing
Performance optimization, RFC-compliant separator, better tests
2025-12-02 12:10:59 +01:00
ba78a6ac7a feat: improve email copy UX with colored alerts and mailto button
All checks were successful
continuous-integration/drone/push Build is passing
- Green success alert for copied confirmation
- Blue info alert with BCC privacy tip
- Mailto button opens email program with BCC recipients
- Alerts stack vertically instead of overlapping
2025-12-02 11:42:11 +01:00
e2ace3d2a8 feat: add bulk email copy for selected members (#230)
All checks were successful
continuous-integration/drone/push Build is passing
Copy selected members' emails to clipboard in 'First Last <email>' format
2025-12-02 10:02:58 +01:00
40 changed files with 4592 additions and 349 deletions

View file

@ -53,6 +53,8 @@ steps:
- mix hex.audit - mix hex.audit
# Provide hints for improving code quality # Provide hints for improving code quality
- mix credo - mix credo
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres - name: wait_for_postgres
image: docker.io/library/postgres:17.6 image: docker.io/library/postgres:17.6

View file

@ -12,8 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- PostgreSQL trigram-based member search with typo tolerance - PostgreSQL trigram-based member search with typo tolerance
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support - WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
- Bilingual UI (German/English) for member linking workflow - Bilingual UI (German/English) for member linking workflow
- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230)
- Email format: "First Last <email>" with semicolon separator (compatible with email clients)
- CopyToClipboard JavaScript hook with fallback for older browsers
- Button shows count of visible selected members (respects search/filter)
- German/English translations
### Fixed ### Fixed
- Email validation false positive when linking user and member with identical emails (#168 Problem #4) - Email validation false positive when linking user and member with identical emails (#168 Problem #4)
- Relationship data extraction from Ash manage_relationship during validation - Relationship data extraction from Ash manage_relationship during validation
- Copy button count now shows only visible selected members when filtering

View file

@ -29,6 +29,7 @@ lint:
mix format --check-formatted mix format --check-formatted
mix compile --warnings-as-errors mix compile --warnings-as-errors
mix credo mix credo
mix gettext.extract --check-up-to-date
audit: audit:
mix sobelow --config mix sobelow --config

View file

@ -27,6 +27,33 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("
// Hooks for LiveView components // Hooks for LiveView components
let Hooks = {} let Hooks = {}
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
Hooks.CopyToClipboard = {
mounted() {
this.handleEvent("copy_to_clipboard", ({text}) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(err => {
console.error("Clipboard write failed:", err)
})
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea")
textArea.value = text
textArea.style.position = "fixed"
textArea.style.left = "-999999px"
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand("copy")
} catch (err) {
console.error("Fallback clipboard copy failed:", err)
}
document.body.removeChild(textArea)
}
})
}
}
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown // ComboBox hook: Prevents form submission when Enter is pressed in dropdown
Hooks.ComboBox = { Hooks.ComboBox = {
mounted() { mounted() {

View file

@ -115,7 +115,6 @@ Member (1) → (N) Properties
### Member Constraints ### Member Constraints
- First name and last name required (min 1 char) - First name and last name required (min 1 char)
- Email unique, validated format (5-254 chars) - Email unique, validated format (5-254 chars)
- Birth date cannot be in future
- Join date cannot be in future - Join date cannot be in future
- Exit date must be after join date - Exit date must be after join date
- Phone: `+?[0-9\- ]{6,20}` - Phone: `+?[0-9\- ]{6,20}`
@ -169,7 +168,7 @@ Member (1) → (N) Properties
### Weighted Fields ### Weighted Fields
- **Weight A (highest):** first_name, last_name - **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes - **Weight B:** email, notes
- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code - **Weight C:** phone_number, city, street, house_number, postal_code
- **Weight D (lowest):** join_date, exit_date - **Weight D (lowest):** join_date, exit_date
### Usage Example ### Usage Example
@ -381,7 +380,7 @@ Install "DBML Language" extension to view/edit DBML files with:
- tokens (jti, purpose, extra_data) - tokens (jti, purpose, extra_data)
**Personal Data (GDPR):** **Personal Data (GDPR):**
- All member fields (name, email, birth_date, address) - All member fields (name, email, address)
- User email - User email
- Token subject - Token subject

View file

@ -122,7 +122,6 @@ Table members {
first_name text [not null, note: 'Member first name (min length: 1)'] first_name text [not null, note: 'Member first name (min length: 1)']
last_name text [not null, note: 'Member last name (min length: 1)'] last_name text [not null, note: 'Member last name (min length: 1)']
email text [not null, unique, note: 'Member email address (5-254 chars, validated)'] email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
birth_date date [null, note: 'Date of birth (cannot be in future)']
paid boolean [null, note: 'Payment status flag'] paid boolean [null, note: 'Payment status flag']
phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})'] phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})']
join_date date [null, note: 'Date when member joined club (cannot be in future)'] join_date date [null, note: 'Date when member joined club (cannot be in future)']
@ -153,7 +152,7 @@ Table members {
**Club Member Master Data** **Club Member Master Data**
Core entity for membership management containing: Core entity for membership management containing:
- Personal information (name, birth date, email) - Personal information (name, email)
- Contact details (phone, address) - Contact details (phone, address)
- Membership status (join/exit dates, payment status) - Membership status (join/exit dates, payment status)
- Additional notes - Additional notes
@ -183,7 +182,6 @@ Table members {
**Validation Rules:** **Validation Rules:**
- first_name, last_name: min 1 character - first_name, last_name: min 1 character
- email: 5-254 characters, valid email format - email: 5-254 characters, valid email format
- birth_date: cannot be in future
- join_date: cannot be in future - join_date: cannot be in future
- exit_date: must be after join_date (if both present) - exit_date: must be after join_date (if both present)
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$ - phone_number: matches pattern ^\+?[0-9\- ]{6,20}$

View file

@ -1327,6 +1327,33 @@ end
--- ---
## Session: Bulk Email Copy Feature (2025-12-02)
### Feature Summary
Implemented bulk email copy functionality for selected members (#230). Users can select members and copy their email addresses to clipboard.
**Key Features:**
- Copy button appears only when visible members are selected
- Email format: `First Last <email>` with semicolon separator (email client compatible)
- Button shows count of visible selected members (respects search/filter)
- CopyToClipboard JavaScript hook with clipboard API + fallback for older browsers
- Bilingual UI (English/German)
### Key Decisions
1. **Email Format:** "First Last <email>" with semicolon - standard for all major email clients
2. **Visible Member Count:** Button shows only visible selected members, not total selected (better UX when filtering)
3. **Server→Client:** Used `push_event/3` - server formats data, client handles clipboard
### Files Changed
- `lib/mv_web/live/member_live/index.ex` - Event handler, helper function
- `lib/mv_web/live/member_live/index.html.heex` - Copy button
- `assets/js/app.js` - CopyToClipboard hook
- `test/mv_web/member_live/index_test.exs` - 9 new tests
- `priv/gettext/de/LC_MESSAGES/default.po` - German translations
---
## Session: User-Member Linking UI Enhancement (2025-01-13) ## Session: User-Member Linking UI Enhancement (2025-01-13)
### Feature Summary ### Feature Summary
@ -1559,8 +1586,8 @@ This project demonstrates a modern Phoenix application built with:
--- ---
**Document Version:** 1.2 **Document Version:** 1.3
**Last Updated:** 2025-11-27 **Last Updated:** 2025-12-02
**Maintainer:** Development Team **Maintainer:** Development Team
**Status:** Living Document (update as project evolves) **Status:** Living Document (update as project evolves)

View file

@ -65,6 +65,7 @@
- ✅ Sorting by basic fields - ✅ Sorting by basic fields
- ✅ User-Member linking (optional 1:1) - ✅ User-Member linking (optional 1:1)
- ✅ Email synchronization between User and Member - ✅ Email synchronization between User and Member
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
**Closed Issues:** **Closed Issues:**
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12) - ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
@ -99,10 +100,10 @@
**Closed Issues:** **Closed Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) - [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) - [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02
**Open Issues:** **Open Issues:**
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks] - [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority) - [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
**Missing Features:** **Missing Features:**

View file

@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do
- Email format validation (using EctoCommons.EmailValidator) - Email format validation (using EctoCommons.EmailValidator)
- Phone number format: international format with 6-20 digits - Phone number format: international format with 6-20 digits
- Postal code format: exactly 5 digits (German format) - Postal code format: exactly 5 digits (German format)
- Date validations: birth_date and join_date not in future, exit_date after join_date - Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users - Email uniqueness: prevents conflicts with unlinked users
## Full-Text Search ## Full-Text Search
@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
@member_search_limit 10 @member_search_limit 10
@default_similarity_threshold 0.2 @default_similarity_threshold 0.2
# Use constants from Mv.Constants for member fields
# This ensures consistency across the codebase
@member_fields Mv.Constants.member_fields()
postgres do postgres do
table "members" table "members"
repo Mv.Repo repo Mv.Repo
@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation # user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true argument :user, :map, allow_nil?: true
accept [ accept @member_fields
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
change manage_relationship(:custom_field_values, type: :create) change manage_relationship(:custom_field_values, type: :create)
@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation # user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true argument :user, :map, allow_nil?: true
accept [ accept @member_fields
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
@ -308,11 +284,6 @@ defmodule Mv.Membership.Member do
end end
end end
# Birth date not in the future
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:birth_date)],
message: "cannot be in the future"
# Join date not in the future # Join date not in the future
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:join_date)], where: [present(:join_date)],
@ -375,10 +346,6 @@ defmodule Mv.Membership.Member do
constraints min_length: 5, max_length: 254 constraints min_length: 5, max_length: 254
end end
attribute :birth_date, :date do
allow_nil? true
end
attribute :paid, :boolean do attribute :paid, :boolean do
allow_nil? true allow_nil? true
end end

View file

@ -53,6 +53,7 @@ defmodule Mv.Membership do
# It's only used internally as fallback in get_settings/0 # It's only used internally as fallback in get_settings/0
# Settings should be created via seed script # Settings should be created via seed script
define :update_settings, action: :update define :update_settings, action: :update
define :update_member_field_visibility, action: :update_member_field_visibility
end end
end end
@ -123,4 +124,37 @@ defmodule Mv.Membership do
|> Ash.Changeset.for_update(:update, attrs) |> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__)
end end
@doc """
Updates the member field visibility configuration.
This is a specialized action for updating only the member field visibility settings.
It validates that all keys are valid member fields and all values are booleans.
## Parameters
- `settings` - The settings record to update
- `visibility_config` - A map of member field names (strings) to boolean visibility values
(e.g., `%{"street" => false, "house_number" => false}`)
## Returns
- `{:ok, updated_settings}` - Successfully updated settings
- `{:error, error}` - Validation or update error
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
iex> updated.member_field_visibility
%{"street" => false, "house_number" => false}
"""
def update_member_field_visibility(settings, visibility_config) do
settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config
})
|> Ash.update(domain: __MODULE__)
end
end end

View file

@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do
## Attributes ## Attributes
- `club_name` - The name of the association/club (required, cannot be empty) - `club_name` - The name of the association/club (required, cannot be empty)
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
## Singleton Pattern ## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record. This resource uses a singleton pattern - there should only be one settings record.
@ -28,6 +30,9 @@ defmodule Mv.Membership.Setting do
# Update club name # Update club name
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
# Update member field visibility
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
@ -49,18 +54,65 @@ defmodule Mv.Membership.Setting do
# Used only as fallback in get_settings/0 if settings don't exist # Used only as fallback in get_settings/0 if settings don't exist
# Settings should normally be created via seed script # Settings should normally be created via seed script
create :create do create :create do
accept [:club_name] accept [:club_name, :member_field_visibility]
end end
update :update do update :update do
primary? true primary? true
accept [:club_name] require_atomic? false
accept [:club_name, :member_field_visibility]
end
update :update_member_field_visibility do
description "Updates the visibility configuration for member fields in the overview"
require_atomic? false
accept [:member_field_visibility]
end end
end end
validations do validations do
validate present(:club_name), on: [:create, :update] validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update]
# Validate member_field_visibility map structure and content
validate fn changeset, _context ->
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
if visibility && is_map(visibility) do
# Validate all values are booleans
invalid_values =
Enum.filter(visibility, fn {_key, value} ->
not is_boolean(value)
end)
# Validate all keys are valid member fields
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
invalid_keys =
Enum.filter(visibility, fn {key, _value} ->
key not in valid_field_strings
end)
|> Enum.map(fn {key, _value} -> key end)
cond do
not Enum.empty?(invalid_values) ->
{:error,
field: :member_field_visibility,
message: "All values in member_field_visibility must be booleans"}
not Enum.empty?(invalid_keys) ->
{:error,
field: :member_field_visibility,
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
true ->
:ok
end
else
:ok
end
end,
on: [:create, :update]
end end
attributes do attributes do
@ -75,6 +127,12 @@ defmodule Mv.Membership.Setting do
min_length: 1 min_length: 1
] ]
attribute :member_field_visibility, :map,
allow_nil?: true,
public?: true,
description:
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
timestamps() timestamps()
end end
end end

22
lib/mv/constants.ex Normal file
View file

@ -0,0 +1,22 @@
defmodule Mv.Constants do
@moduledoc """
Module for defining constants and atoms.
"""
@member_fields [
:first_name,
:last_name,
:email,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
def member_fields, do: @member_fields
end

View file

@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do
attr :id, :string, doc: "the optional id of flash container" attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :kind, :atom,
values: [:info, :error, :success, :warning],
doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message" slot :inner_block, doc: "the optional inner block that renders the flash message"
@ -56,16 +60,20 @@ defmodule MvWeb.CoreComponents do
id={@id} id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert" role="alert"
class="toast toast-top toast-end z-50" class="z-50 toast toast-top toast-end"
{@rest} {@rest}
> >
<div class={[ <div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap", "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info", @kind == :info && "alert-info",
@kind == :error && "alert-error" @kind == :error && "alert-error",
@kind == :success && "bg-green-500 text-white",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
]}> ]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />
<div> <div>
<p :if={@title} class="font-semibold">{@title}</p> <p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p> <p>{msg}</p>
@ -111,6 +119,126 @@ defmodule MvWeb.CoreComponents do
end end
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, default: nil
def dropdown_menu(assigns) do
unless Map.has_key?(assigns, :phx_target) do
raise ArgumentError, ":phx_target is required in dropdown_menu/1"
end
assigns =
assign_new(assigns, :items, fn -> [] end)
|> assign_new(:button_label, fn -> "Dropdown" end)
|> assign_new(:icon, fn -> nil end)
|> assign_new(:checkboxes, fn -> false end)
|> assign_new(:selected, fn -> %{} end)
|> assign_new(:open, fn -> false end)
|> assign_new(:show_select_buttons, fn -> false end)
|> assign(:phx_target, assigns.phx_target)
|> assign_new(:id, fn -> "dropdown-menu" end)
~H"""
<div class="relative" phx-click-away="close_dropdown" phx-target={@phx_target}>
<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}
>
<%= 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-window-keydown="close_dropdown"
phx-key="Escape"
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">
<label
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-checked={@checkboxes && Map.get(@selected, item.value, true)}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200"
phx-click="select_item"
phx-value-item={item.value}
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={Map.get(@selected, item.value, true)}
tabindex="-1"
aria-hidden="true"
readonly
/>
<% end %>
<span><%= item.label %></span>
</label>
</li>
<% end %>
</ul>
</div>
"""
end
@doc """ @doc """
Renders an input with label and error messages. Renders an input with label and error messages.
@ -180,7 +308,7 @@ defmodule MvWeb.CoreComponents do
end) end)
~H""" ~H"""
<fieldset class="fieldset mb-2"> <fieldset class="mb-2 fieldset">
<label> <label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} /> <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="label"> <span class="label">
@ -192,7 +320,11 @@ defmodule MvWeb.CoreComponents do
checked={@checked} checked={@checked}
class={@class || "checkbox checkbox-sm"} class={@class || "checkbox checkbox-sm"}
{@rest} {@rest}
/>{@label} />{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span> </span>
</label> </label>
<.error :for={msg <- @errors}>{msg}</.error> <.error :for={msg <- @errors}>{msg}</.error>
@ -202,9 +334,15 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "select"} = assigns) do def input(%{type: "select"} = assigns) do
~H""" ~H"""
<fieldset class="fieldset mb-2"> <fieldset class="mb-2 fieldset">
<label> <label>
<span :if={@label} class="label mb-1">{@label}</span> <span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<select <select
id={@id} id={@id}
name={@name} name={@name}
@ -223,9 +361,15 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do def input(%{type: "textarea"} = assigns) do
~H""" ~H"""
<fieldset class="fieldset mb-2"> <fieldset class="mb-2 fieldset">
<label> <label>
<span :if={@label} class="label mb-1">{@label}</span> <span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<textarea <textarea
id={@id} id={@id}
name={@name} name={@name}
@ -244,9 +388,15 @@ defmodule MvWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here... # All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do def input(assigns) do
~H""" ~H"""
<fieldset class="fieldset mb-2"> <fieldset class="mb-2 fieldset">
<label> <label>
<span :if={@label} class="label mb-1">{@label}</span> <span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<input <input
type={@type} type={@type}
name={@name} name={@name}
@ -517,7 +667,7 @@ defmodule MvWeb.CoreComponents do
<div class="mt-14"> <div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100"> <dl class="-my-4 divide-y divide-zinc-100">
<div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8"> <div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500">{name}</dt> <dt class="flex-none w-1/4 text-zinc-500">{name}</dt>
<dd class="text-zinc-700">{value}</dd> <dd class="text-zinc-700">{value}</dd>
</div> </div>
</dl> </dl>

View file

@ -0,0 +1,172 @@
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(member_fields(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
}
end) ++
Enum.map(custom_fields(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 member_fields(nil), do: []
defp member_fields(fields) do
Enum.filter(fields, fn field ->
is_atom(field) ||
(is_binary(field) && not String.starts_with?(field, "custom_field_"))
end)
end
defp custom_fields(nil), do: []
defp custom_fields(fields) do
Enum.filter(fields, fn field ->
is_binary(field) && String.starts_with?(field, "custom_field_")
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(&String.capitalize/1)
|> Enum.join(" ")
end
defp format_custom_field_label(field_string, custom_fields) do
case String.trim_leading(field_string, "custom_field_") do
"" ->
field_string
id ->
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
end

View file

@ -65,7 +65,9 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do def flash_group(assigns) do
~H""" ~H"""
<div id={@id} aria-live="polite"> <div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} /> <.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} /> <.flash kind={:error} flash={@flash} />

View file

@ -0,0 +1,146 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by payment status (paid/not paid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
## Props
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:paid_filter, assigns[:paid_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn btn-ghost gap-2",
@paid_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == nil)}
class={@paid_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :paid)}
class={@paid_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :not_paid)}
class={@paid_filter == :not_paid && "active"}
phx-click="select_filter"
phx-value-filter="not_paid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Not paid")}
</button>
</li>
</ul>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("not_paid"), do: :not_paid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid")
end

View file

@ -14,7 +14,7 @@ defmodule MvWeb.MemberLive.Form do
- first_name, last_name, email - first_name, last_name, email
**Optional:** **Optional:**
- birth_date, phone_number, address fields (city, street, house_number, postal_code) - phone_number, address fields (city, street, house_number, postal_code)
- join_date, exit_date - join_date, exit_date
- paid status - paid status
- notes - notes
@ -37,7 +37,7 @@ defmodule MvWeb.MemberLive.Form do
<.header> <.header>
{@page_title} {@page_title}
<:subtitle> <:subtitle>
{gettext("Use this form to manage member records and their properties.")} {gettext("Fields marked with an asterisk (*) cannot be empty.")}
</:subtitle> </:subtitle>
</.header> </.header>
@ -45,7 +45,6 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:first_name]} label={gettext("First Name")} required /> <.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} required /> <.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> <.input field={@form[:email]} label={gettext("Email")} required type="email" />
<.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" />
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
<.input field={@form[:phone_number]} label={gettext("Phone Number")} /> <.input field={@form[:phone_number]} label={gettext("Phone Number")} />
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />

View file

@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index do
- `delete` - Remove a member from the database - `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection - `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members - `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
## Implementation Notes ## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery) - Search uses PostgreSQL full-text search (plainto_tsquery)
@ -29,20 +30,29 @@ defmodule MvWeb.MemberLive.Index do
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>") # Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_" @custom_field_prefix "custom_field_"
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
# Note: :id is always included for member identification
# All member fields are loaded, but visibility is controlled via settings
@overview_fields [:id | Mv.Constants.member_fields()]
@doc """ @doc """
Initializes the LiveView state. Initializes the LiveView state.
Sets up initial assigns for page title, search query, sort configuration, Sets up initial assigns for page title, search query, sort configuration,
and member selection. Actual data loading happens in `handle_params/3`. payment filter, and member selection. Actual data loading happens in `handle_params/3`.
""" """
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, session, socket) do
# Load custom fields that should be shown in overview # Load custom fields that should be shown in overview (for display)
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView # 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 # and result in a 500 error page. This is appropriate for LiveViews where errors
# should be visible to the user rather than silently failing. # should be visible to the user rather than silently failing.
@ -52,14 +62,51 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> 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
{:ok, s} -> s
# Fallback if settings can't be loaded
{: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 =
socket socket
|> assign(:page_title, gettext("Members")) |> assign(:page_title, gettext("Members"))
|> assign(:query, "") |> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end) |> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, []) |> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:custom_fields_visible, custom_fields_visible) |> 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_field_configurations, get_member_field_configurations(settings))
|> assign(
:member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
)
# We call handle params to use the query from the URL # We call handle params to use the query from the URL
{:ok, socket} {:ok, socket}
@ -91,10 +138,10 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def handle_event("select_member", %{"id" => id}, socket) do def handle_event("select_member", %{"id" => id}, socket) do
selected = selected =
if id in socket.assigns.selected_members do if MapSet.member?(socket.assigns.selected_members, id) do
List.delete(socket.assigns.selected_members, id) MapSet.delete(socket.assigns.selected_members, id)
else else
[id | socket.assigns.selected_members] MapSet.put(socket.assigns.selected_members, id)
end end
{:noreply, assign(socket, :selected_members, selected)} {:noreply, assign(socket, :selected_members, selected)}
@ -102,13 +149,11 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def handle_event("select_all", _params, socket) do def handle_event("select_all", _params, socket) do
members = socket.assigns.members all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
all_ids = Enum.map(members, & &1.id)
selected = selected =
if Enum.sort(socket.assigns.selected_members) == Enum.sort(all_ids) do if MapSet.equal?(socket.assigns.selected_members, all_ids) do
[] MapSet.new()
else else
all_ids all_ids
end end
@ -116,6 +161,52 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)} {:noreply, assign(socket, :selected_members, selected)}
end end
@impl true
def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members
# Filter members that are in the selection and have email addresses
formatted_emails =
socket.assigns.members
|> Enum.filter(fn member ->
MapSet.member?(selected_ids, member.id) && member.email && member.email != ""
end)
|> Enum.map(&format_member_email/1)
email_count = length(formatted_emails)
cond do
MapSet.size(selected_ids) == 0 ->
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
email_count == 0 ->
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
true ->
# RFC 5322 uses comma as separator for email address lists
email_string = Enum.join(formatted_emails, ", ")
socket =
socket
|> push_event("copy_to_clipboard", %{text: email_string})
|> put_flash(
:success,
ngettext(
"Copied %{count} email address to clipboard",
"Copied %{count} email addresses to clipboard",
email_count,
count: email_count
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
)
{:noreply, socket}
end
end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Handle Infos from Child Components # Handle Infos from Child Components
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -126,6 +217,8 @@ defmodule MvWeb.MemberLive.Index do
## Supported messages: ## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{: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 - `{: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 @impl true
def handle_info({:sort, field_str}, socket) do def handle_info({:sort, field_str}, socket) do
@ -146,7 +239,10 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def handle_info({:search_changed, q}, socket) do def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q) socket =
socket
|> assign(:query, q)
|> load_members()
existing_field_query = socket.assigns.sort_field existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order existing_sort_query = socket.assigns.sort_order
@ -181,6 +277,29 @@ defmodule MvWeb.MemberLive.Index do
""" """
@impl true @impl true
def handle_params(params, _url, socket) do 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 =
socket socket
|> maybe_update_search(params) |> maybe_update_search(params)
@ -197,10 +316,16 @@ defmodule MvWeb.MemberLive.Index do
# - `:custom_field` - The CustomField resource # - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member # - `: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. # Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do defp prepare_dynamic_cols(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
dynamic_cols = dynamic_cols =
Enum.map(socket.assigns.custom_fields_visible, fn custom_field -> socket.assigns.custom_fields_visible
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
|> Enum.map(fn custom_field ->
%{ %{
custom_field: custom_field, custom_field: custom_field,
render: fn member -> render: fn member ->
@ -276,11 +401,13 @@ defmodule MvWeb.MemberLive.Index do
field field
end end
query_params = %{ query_params =
"query" => socket.assigns.query, build_query_params(
"sort_field" => field_str, socket.assigns.query,
"sort_order" => Atom.to_string(order) field_str,
} Atom.to_string(order),
socket.assigns.paid_filter
)
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -291,13 +418,130 @@ defmodule MvWeb.MemberLive.Index do
)} )}
end end
# Loads members from the database with custom field values and applies search/sort filters. # 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
query_params =
build_query_params(socket, %{
"sort_field" => field_to_string(socket.assigns.sort_field),
"sort_order" => Atom.to_string(socket.assigns.sort_order)
})
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 query parameters including field selection
defp build_query_params(socket, base_params) do
base_params
|> Map.put("query", socket.assigns.query || "")
|> 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
query_params =
build_query_params(socket, %{
"sort_field" => field_to_string(socket.assigns.sort_field),
"sort_order" => Atom.to_string(socket.assigns.sort_order)
})
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
field_str =
if is_atom(sort_field) do
Atom.to_string(sort_field)
else
sort_field
end
order_str =
if is_atom(sort_order) do
Atom.to_string(sort_order)
else
sort_order
end
base_params = %{
"query" => query,
"sort_field" => field_str,
"sort_order" => order_str
}
# Only add paid_filter to URL if it's set
case 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
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
# #
# Process: # Process:
# 1. Builds base query with selected fields # 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (filtered at database level) # 2. Loads custom field values for visible custom fields (filtered at database level)
# 3. Applies search filter if provided # 3. Applies search filter if provided
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields) # 4. Applies payment status filter if set
# 5. Applies sorting (database-level for regular fields, in-memory for custom fields)
# #
# Performance Considerations: # Performance Considerations:
# - Database-level filtering: Custom field values are filtered directly in the database # - Database-level filtering: Custom field values are filtered directly in the database
@ -309,30 +553,24 @@ defmodule MvWeb.MemberLive.Index do
# consider implementing pagination (see Issue #165). # consider implementing pagination (see Issue #165).
# #
# Returns the socket with `:members` assigned. # Returns the socket with `:members` assigned.
defp load_members(socket, search_query) do defp load_members(socket) do
search_query = socket.assigns.query
query = query =
Mv.Membership.Member Mv.Membership.Member
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select([ |> Ash.Query.select(@overview_fields)
:id,
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
])
# Load custom field values for visible custom fields # Load custom field values for visible custom fields (based on user selection)
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
query = load_custom_field_values(query, custom_field_ids_list) query = load_custom_field_values(query, visible_custom_field_ids)
# Apply the search filter first # Apply the search filter first
query = apply_search_filter(query, search_query) query = apply_search_filter(query, search_query)
# Apply payment status filter
query = apply_paid_filter(query, socket.assigns.paid_filter)
# Apply sorting based on current socket state # Apply sorting based on current socket state
# For custom fields, we sort after loading # For custom fields, we sort after loading
{query, sort_after_load} = {query, sort_after_load} =
@ -407,6 +645,24 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
# Applies payment status filter to the query.
#
# Filter values:
# - nil: No filter, return all members
# - :paid: Only members with paid == true
# - :not_paid: Members with paid == false or paid == nil (not paid)
defp apply_paid_filter(query, nil), do: query
defp apply_paid_filter(query, :paid) do
Ash.Query.filter(query, expr(paid == true))
end
defp apply_paid_filter(query, :not_paid) do
# Include both false and nil as "not paid"
# Note: paid != true doesn't work correctly with NULL values in SQL
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
end
# Functions to toggle sorting order # Functions to toggle sorting order
defp toggle_order(:asc), do: :desc defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
@ -433,18 +689,13 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _, _, _), do: {query, false} defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable # Validate that a field is sortable
# Uses member fields from constants, but excludes fields that don't make sense to sort
# (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
defp valid_sort_field?(field) when is_atom(field) do defp valid_sort_field?(field) when is_atom(field) do
valid_fields = [ # All member fields are sortable, but we exclude some that don't make sense
:first_name, # :id is not in member_fields, but we don't want to sort by it anyway
:last_name, non_sortable_fields = [:notes, :paid]
:email, valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
field in valid_fields or custom_field_sort?(field) field in valid_fields or custom_field_sort?(field)
end end
@ -702,6 +953,29 @@ defmodule MvWeb.MemberLive.Index do
socket socket
end end
# Updates paid filter from URL parameters if present.
#
# Validates the filter value, falling back to nil (no filter) if invalid.
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
filter = determine_paid_filter(filter_str)
assign(socket, :paid_filter, filter)
end
defp maybe_update_paid_filter(socket, _params) do
# Reset filter if not in URL params
assign(socket, :paid_filter, nil)
end
# Determines valid paid filter from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
# Ash's security recommendation to never pass untrusted input directly to filters.
defp determine_paid_filter("paid"), do: :paid
defp determine_paid_filter("not_paid"), do: :not_paid
defp determine_paid_filter(_), do: nil
# ------------------------------------------------------------- # -------------------------------------------------------------
# Helper Functions for Custom Field Values # Helper Functions for Custom Field Values
# ------------------------------------------------------------- # -------------------------------------------------------------
@ -733,4 +1007,61 @@ defmodule MvWeb.MemberLive.Index do
nil nil
end end
end end
# Formats a member's email in the format "First Last <email>"
# Used for copy_emails feature to create email-client-friendly format.
defp format_member_email(member) do
first_name = member.first_name || ""
last_name = member.last_name || ""
name =
[first_name, last_name]
|> Enum.filter(&(&1 != ""))
|> Enum.join(" ")
if name == "" do
member.email
else
"#{name} <#{member.email}>"
end
end
# Gets the list of member fields that should be visible in the overview.
#
# Filters the member field configurations to return only fields with show_in_overview: true.
#
# Parameters:
# - `settings` - The settings struct loaded from the database
#
# Returns a list of atoms representing visible member field names.
@spec get_visible_member_fields(map()) :: [atom()]
defp get_visible_member_fields(settings) do
get_member_field_configurations(settings)
|> Enum.filter(fn {_field, show_in_overview} -> show_in_overview end)
|> Enum.map(fn {field, _show_in_overview} -> field end)
end
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
# This is a local helper to avoid N+1 queries by reusing the normalization logic.
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: %{}
end end

View file

@ -2,18 +2,51 @@
<.header> <.header>
{gettext("Members")} {gettext("Members")}
<:actions> <:actions>
<.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}
/>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
aria-label={gettext("Copy email addresses of selected members")}
>
<.icon name="hero-clipboard-document" />
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
</.button>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
href={"mailto:?bcc=#{@members |> Enum.filter(&(MapSet.member?(@selected_members, &1.id) && &1.email)) |> Enum.map(& &1.email) |> Enum.join(",")}"}
aria-label={gettext("Open email program with BCC recipients")}
>
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<.button variant="primary" navigate={~p"/members/new"}> <.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")} <.icon name="hero-plus" /> {gettext("New Member")}
</.button> </.button>
</:actions> </:actions>
</.header> </.header>
<.live_component <div class="flex flex-wrap gap-4 items-center">
module={MvWeb.Components.SearchBarComponent} <.live_component
id="search-bar" module={MvWeb.Components.SearchBarComponent}
query={@query} id="search-bar"
placeholder={gettext("Search...")} query={@query}
/> placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
paid_filter={@paid_filter}
member_count={length(@members)}
/>
</div>
<.table <.table
id="members" id="members"
@ -33,7 +66,7 @@
type="checkbox" type="checkbox"
name="select_all" name="select_all"
phx-click="select_all" phx-click="select_all"
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()} checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
aria-label={gettext("Select all members")} aria-label={gettext("Select all members")}
role="checkbox" role="checkbox"
/> />
@ -45,7 +78,7 @@
name={member.id} name={member.id}
phx-click="select_member" phx-click="select_member"
phx-value-id={member.id} phx-value-id={member.id}
checked={member.id in @selected_members} checked={MapSet.member?(@selected_members, member.id)}
phx-capture-click phx-capture-click
phx-stop-propagation phx-stop-propagation
aria-label={gettext("Select member")} aria-label={gettext("Select member")}
@ -54,6 +87,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:first_name in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -67,10 +101,29 @@
""" """
} }
> >
{member.first_name} {member.last_name} {member.first_name}
</:col> </:col>
<:col <:col
:let={member} :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}
</:col>
<:col
:let={member}
:if={:email in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -88,6 +141,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:street in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -105,6 +159,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:house_number in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -122,6 +177,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:postal_code in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -139,6 +195,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:city in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -156,6 +213,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:phone_number in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -173,6 +231,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:join_date in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -188,6 +247,14 @@
> >
{member.join_date} {member.join_date}
</:col> </:col>
<:col :let={member} label={gettext("Paid")}>
<span class={[
"badge",
if(member.paid == true, do: "badge-success", else: "badge-error")
]}>
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
</span>
</:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link> <.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>

View file

@ -0,0 +1,232 @@
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 requires the connection to have cookies parsed.
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
case Plug.Conn.get_req_header(conn, "cookie") do
nil ->
%{}
cookie_header ->
# Parse cookies manually from header
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(fn {field, _visible} -> field end)
|> Enum.join(",")
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} when is_boolean(value) -> {key, value}
{key, _value} -> {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

@ -0,0 +1,235 @@
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
field_selection
|> Enum.filter(fn {field_string, visible} ->
visible && String.starts_with?(field_string, "custom_field_")
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
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
field_string = "custom_field_#{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, "custom_field_") 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

@ -10,7 +10,7 @@ defmodule MvWeb.MemberLive.Show do
- Return to member list - Return to member list
## Displayed Information ## Displayed Information
- Basic: name, email, dates (birth, join, exit) - Basic: name, email, dates (join, exit)
- Contact: phone number - Contact: phone number
- Address: street, house number, postal code, city - Address: street, house number, postal code, city
- Status: paid flag - Status: paid flag
@ -48,7 +48,6 @@ defmodule MvWeb.MemberLive.Show do
<:item title={gettext("First Name")}>{@member.first_name}</:item> <:item title={gettext("First Name")}>{@member.first_name}</:item>
<:item title={gettext("Last Name")}>{@member.last_name}</:item> <:item title={gettext("Last Name")}>{@member.last_name}</:item>
<:item title={gettext("Email")}>{@member.email}</:item> <:item title={gettext("Email")}>{@member.email}</:item>
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
<:item title={gettext("Paid")}> <:item title={gettext("Paid")}>
{if @member.paid, do: gettext("Yes"), else: gettext("No")} {if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item> </:item>

View file

@ -10,37 +10,37 @@ msgid ""
msgstr "" msgstr ""
"Language: en\n" "Language: en\n"
#: lib/mv_web/components/core_components.ex:356 #: lib/mv_web/components/core_components.ex:386
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "Aktionen" msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "Bist du sicher?" msgstr "Bist du sicher?"
#: lib/mv_web/components/layouts.ex:80 #: lib/mv_web/components/layouts.ex:82
#: lib/mv_web/components/layouts.ex:92 #: lib/mv_web/components/layouts.ex:94
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt" msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "Stadt" msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "Löschen" msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -48,13 +48,13 @@ msgid "Edit"
msgstr "Bearbeite" msgstr "Bearbeite"
#: lib/mv_web/live/member_live/show.ex:41 #: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117 #: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "Mitglied bearbeiten" msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -69,9 +69,9 @@ msgstr "E-Mail"
msgid "First Name" msgid "First Name"
msgstr "Vorname" msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "Beitrittsdatum" msgstr "Beitrittsdatum"
@ -82,78 +82,75 @@ msgstr "Beitrittsdatum"
msgid "Last Name" msgid "Last Name"
msgstr "Nachname" msgstr "Nachname"
#: lib/mv_web/live/member_live/index.html.heex:6 #: lib/mv_web/live/member_live/index.html.heex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Member" msgid "New Member"
msgstr "Neues Mitglied" msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "Anzeigen" msgstr "Anzeigen"
#: lib/mv_web/components/layouts.ex:87 #: lib/mv_web/components/layouts.ex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!" msgstr "Etwas ist schiefgelaufen!"
#: lib/mv_web/components/layouts.ex:75 #: lib/mv_web/components/layouts.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden" msgstr "Keine Internetverbindung gefunden"
#: lib/mv_web/components/core_components.ex:74 #: lib/mv_web/components/core_components.ex:82
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "close" msgid "close"
msgstr "schließen" msgstr "schließen"
#: lib/mv_web/live/member_live/form.ex:48 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/show.ex:51 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr "Geburtsdatum"
#: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date" msgid "Exit Date"
msgstr "Austrittsdatum" msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:56 #: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:61 #: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
msgstr "Hausnummer" msgstr "Hausnummer"
#: lib/mv_web/live/member_live/form.ex:53 #: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:58 #: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "Notizen" msgstr "Notizen"
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/components/payment_filter_component.ex:94
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/components/payment_filter_component.ex:144
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "Bezahlt" msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:54
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "Telefonnummer" msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "Postleitzahl" msgstr "Postleitzahl"
#: lib/mv_web/live/member_live/form.ex:80 #: lib/mv_web/live/member_live/form.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Save Member" msgid "Save Member"
msgstr "Mitglied speichern" msgstr "Mitglied speichern"
@ -161,36 +158,32 @@ msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:78
#: lib/mv_web/live/user_live/form.ex:248 #: lib/mv_web/live/user_live/form.ex:248
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Saving..." msgid "Saving..."
msgstr "Speichern..." msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "Straße" msgstr "Straße"
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
#: lib/mv_web/live/member_live/show.ex:47 #: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Id" msgid "Id"
msgstr "ID" msgstr "ID"
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
msgstr "Nein" msgstr "Nein"
#: lib/mv_web/live/member_live/show.ex:116 #: lib/mv_web/live/member_live/show.ex:115
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Show Member" msgid "Show Member"
msgstr "Mitglied anzeigen" msgstr "Mitglied anzeigen"
@ -200,22 +193,23 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank." msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes" msgid "Yes"
msgstr "Ja" msgstr "Ja"
#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:137
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "erstellt" msgstr "erstellt"
#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "update" msgid "update"
msgstr "aktualisiert" msgstr "aktualisiert"
@ -225,7 +219,7 @@ msgstr "aktualisiert"
msgid "Incorrect email or password" msgid "Incorrect email or password"
msgstr "Falsche E-Mail oder Passwort" msgstr "Falsche E-Mail oder Passwort"
#: lib/mv_web/live/member_live/form.ex:145 #: lib/mv_web/live/member_live/form.ex:144
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member %{action} successfully" msgid "Member %{action} successfully"
msgstr "Mitglied %{action} erfolgreich" msgstr "Mitglied %{action} erfolgreich"
@ -258,7 +252,7 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:81
#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -311,7 +305,7 @@ msgid "Member"
msgstr "Mitglied" msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:25 #: lib/mv_web/components/layouts/navbar.ex:25
#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.ex:73
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
@ -365,12 +359,12 @@ msgstr "Profil"
msgid "Required" msgid "Required"
msgstr "Erforderlich" msgstr "Erforderlich"
#: lib/mv_web/live/member_live/index.html.heex:37 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "Alle Mitglieder auswählen" msgstr "Alle Mitglieder auswählen"
#: lib/mv_web/live/member_live/index.html.heex:51 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "Mitglied auswählen" msgstr "Mitglied auswählen"
@ -515,7 +509,7 @@ msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen
msgid "Linked Member" msgid "Linked Member"
msgstr "Verknüpftes Mitglied" msgstr "Verknüpftes Mitglied"
#: lib/mv_web/live/member_live/show.ex:63 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked User" msgid "Linked User"
msgstr "Verknüpfte*r Benutzer*in" msgstr "Verknüpfte*r Benutzer*in"
@ -526,7 +520,7 @@ msgstr "Verknüpfte*r Benutzer*in"
msgid "No member linked" msgid "No member linked"
msgstr "Kein Mitglied verknüpft" msgstr "Kein Mitglied verknüpft"
#: lib/mv_web/live/member_live/show.ex:73 #: lib/mv_web/live/member_live/show.ex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No user linked" msgid "No user linked"
msgstr "Keine*r Benutzer*in verknüpft" msgstr "Keine*r Benutzer*in verknüpft"
@ -556,7 +550,7 @@ msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten" msgstr "Dunklen Modus umschalten"
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "Suchen..." msgstr "Suchen..."
@ -572,7 +566,7 @@ msgstr "Benutzer*innen"
msgid "Click to sort" msgid "Click to sort"
msgstr "Klicke um zu sortieren" msgstr "Klicke um zu sortieren"
#: lib/mv_web/live/member_live/index.html.heex:63 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First name" msgid "First name"
msgstr "Vorname" msgstr "Vorname"
@ -613,8 +607,8 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
msgid "Choose a custom field" msgid "Choose a custom field"
msgstr "Wähle ein Benutzerdefiniertes Feld" msgstr "Wähle ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/member_live/form.ex:59 #: lib/mv_web/live/member_live/form.ex:58
#: lib/mv_web/live/member_live/show.ex:78 #: lib/mv_web/live/member_live/show.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom Field Values" msgid "Custom Field Values"
msgstr "Benutzerdefinierte Feldwerte" msgstr "Benutzerdefinierte Feldwerte"
@ -782,7 +776,85 @@ msgstr "Mitglied entverknüpfen"
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "Entverknüpfung geplant" msgstr "Entverknüpfung geplant"
#~ #: lib/mv_web/live/custom_field_live/index.ex:97 #: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert"
msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
#: lib/mv_web/live/member_live/index.html.heex:10
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
#: lib/mv_web/live/member_live/index.html.heex:13
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr "E-Mails kopieren"
#: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr "Keine E-Mail-Adressen gefunden"
#: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt"
#: lib/mv_web/live/member_live/index.html.heex:18
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
#: lib/mv_web/live/member_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr "Im E-Mail-Programm öffnen"
#: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format
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:40
#, 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:206
#: lib/mv_web/components/core_components.ex:223
#: lib/mv_web/components/core_components.ex:250
#: lib/mv_web/components/core_components.ex:277
#, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/live/components/payment_filter_component.ex:80
#: lib/mv_web/live/components/payment_filter_component.ex:143
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
#: lib/mv_web/live/components/payment_filter_component.ex:54
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr "Nach Zahlungsstatus filtern"
#: lib/mv_web/live/components/payment_filter_component.ex:108
#: lib/mv_web/live/components/payment_filter_component.ex:145
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr "Nicht bezahlt"
#: lib/mv_web/live/components/payment_filter_component.ex:65
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr "Zahlungsfilter"
#~ #: lib/mv_web/live/member_live/form.ex:48
#~ #: lib/mv_web/live/member_live/show.ex:51
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:" #~ msgid "Birth Date"
#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:" #~ msgstr "Geburtsdatum"

View file

@ -11,37 +11,37 @@
msgid "" msgid ""
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex:356 #: lib/mv_web/components/core_components.ex:386
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:80 #: lib/mv_web/components/layouts.ex:82
#: lib/mv_web/components/layouts.ex:92 #: lib/mv_web/components/layouts.ex:94
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -49,13 +49,13 @@ msgid "Edit"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:41 #: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117 #: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -70,9 +70,9 @@ msgstr ""
msgid "First Name" msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "" msgstr ""
@ -83,78 +83,75 @@ msgstr ""
msgid "Last Name" msgid "Last Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:6 #: lib/mv_web/live/member_live/index.html.heex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Member" msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:87 #: lib/mv_web/components/layouts.ex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:75 #: lib/mv_web/components/layouts.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex:74 #: lib/mv_web/components/core_components.ex:82
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "close" msgid "close"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:48 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/show.ex:51 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Birth Date" msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:52 #: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57 #: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/components/payment_filter_component.ex:94
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/components/payment_filter_component.ex:144
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:54
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:80 #: lib/mv_web/live/member_live/form.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Save Member" msgid "Save Member"
msgstr "" msgstr ""
@ -162,36 +159,32 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:78
#: lib/mv_web/live/user_live/form.ex:248 #: lib/mv_web/live/user_live/form.ex:248
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Saving..." msgid "Saving..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:47 #: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Id" msgid "Id"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:116 #: lib/mv_web/live/member_live/show.ex:115
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show Member" msgid "Show Member"
msgstr "" msgstr ""
@ -201,22 +194,23 @@ msgstr ""
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:137
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "update" msgid "update"
msgstr "" msgstr ""
@ -226,7 +220,7 @@ msgstr ""
msgid "Incorrect email or password" msgid "Incorrect email or password"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:145 #: lib/mv_web/live/member_live/form.ex:144
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member %{action} successfully" msgid "Member %{action} successfully"
msgstr "" msgstr ""
@ -259,7 +253,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:81
#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -312,7 +306,7 @@ msgid "Member"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:25 #: lib/mv_web/components/layouts/navbar.ex:25
#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.ex:73
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
@ -366,12 +360,12 @@ msgstr ""
msgid "Required" msgid "Required"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:37 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:51 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "" msgstr ""
@ -516,7 +510,7 @@ msgstr ""
msgid "Linked Member" msgid "Linked Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:63 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked User" msgid "Linked User"
msgstr "" msgstr ""
@ -527,7 +521,7 @@ msgstr ""
msgid "No member linked" msgid "No member linked"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:73 #: lib/mv_web/live/member_live/show.ex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No user linked" msgid "No user linked"
msgstr "" msgstr ""
@ -557,7 +551,7 @@ msgid "Toggle dark mode"
msgstr "" msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
@ -573,7 +567,7 @@ msgstr ""
msgid "Click to sort" msgid "Click to sort"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:63 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First name" msgid "First name"
msgstr "" msgstr ""
@ -614,8 +608,8 @@ msgstr ""
msgid "Choose a custom field" msgid "Choose a custom field"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:59 #: lib/mv_web/live/member_live/form.ex:58
#: lib/mv_web/live/member_live/show.ex:78 #: lib/mv_web/live/member_live/show.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom Field Values" msgid "Custom Field Values"
msgstr "" msgstr ""
@ -782,3 +776,80 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/member_live/index.html.heex:10
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:13
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:18
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Fields marked with an asterisk (*) cannot be empty."
msgstr ""
#: lib/mv_web/components/core_components.ex:206
#: lib/mv_web/components/core_components.ex:223
#: lib/mv_web/components/core_components.ex:250
#: lib/mv_web/components/core_components.ex:277
#, elixir-autogen, elixir-format
msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:80
#: lib/mv_web/live/components/payment_filter_component.ex:143
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:54
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:108
#: lib/mv_web/live/components/payment_filter_component.ex:145
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:65
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""

View file

@ -11,37 +11,37 @@ msgstr ""
"Language: en\n" "Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex:356 #: lib/mv_web/components/core_components.ex:386
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:80 #: lib/mv_web/components/layouts.ex:82
#: lib/mv_web/components/layouts.ex:92 #: lib/mv_web/components/layouts.ex:94
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -49,13 +49,13 @@ msgid "Edit"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:41 #: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117 #: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -70,9 +70,9 @@ msgstr ""
msgid "First Name" msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "" msgstr ""
@ -83,78 +83,75 @@ msgstr ""
msgid "Last Name" msgid "Last Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:6 #: lib/mv_web/live/member_live/index.html.heex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Member" msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:87 #: lib/mv_web/components/layouts.ex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:75 #: lib/mv_web/components/layouts.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex:74 #: lib/mv_web/components/core_components.ex:82
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "close" msgid "close"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:48 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/show.ex:51 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Birth Date" msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:52 #: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57 #: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/components/payment_filter_component.ex:94
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/components/payment_filter_component.ex:144
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:54
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:80 #: lib/mv_web/live/member_live/form.ex:79
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Save Member" msgid "Save Member"
msgstr "" msgstr ""
@ -162,36 +159,32 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:78
#: lib/mv_web/live/user_live/form.ex:248 #: lib/mv_web/live/user_live/form.ex:248
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Saving..." msgid "Saving..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:47 #: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Id" msgid "Id"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:116 #: lib/mv_web/live/member_live/show.ex:115
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Show Member" msgid "Show Member"
msgstr "" msgstr ""
@ -201,22 +194,23 @@ msgstr ""
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:137
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "update" msgid "update"
msgstr "" msgstr ""
@ -226,7 +220,7 @@ msgstr ""
msgid "Incorrect email or password" msgid "Incorrect email or password"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:145 #: lib/mv_web/live/member_live/form.ex:144
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member %{action} successfully" msgid "Member %{action} successfully"
msgstr "" msgstr ""
@ -259,7 +253,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:81
#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -312,7 +306,7 @@ msgid "Member"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:25 #: lib/mv_web/components/layouts/navbar.ex:25
#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.ex:73
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
@ -366,12 +360,12 @@ msgstr ""
msgid "Required" msgid "Required"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:37 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:51 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "" msgstr ""
@ -516,7 +510,7 @@ msgstr "User will be created without a password. Check 'Set Password' to add one
msgid "Linked Member" msgid "Linked Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:63 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked User" msgid "Linked User"
msgstr "" msgstr ""
@ -527,7 +521,7 @@ msgstr ""
msgid "No member linked" msgid "No member linked"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:73 #: lib/mv_web/live/member_live/show.ex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No user linked" msgid "No user linked"
msgstr "" msgstr ""
@ -557,7 +551,7 @@ msgid "Toggle dark mode"
msgstr "" msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
@ -573,7 +567,7 @@ msgstr ""
msgid "Click to sort" msgid "Click to sort"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:63 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "First name" msgid "First name"
msgstr "" msgstr ""
@ -614,8 +608,8 @@ msgstr ""
msgid "Choose a custom field" msgid "Choose a custom field"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:59 #: lib/mv_web/live/member_live/form.ex:58
#: lib/mv_web/live/member_live/show.ex:78 #: lib/mv_web/live/member_live/show.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom Field Values" msgid "Custom Field Values"
msgstr "" msgstr ""
@ -783,7 +777,85 @@ msgstr ""
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index.ex:97 #: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/member_live/index.html.heex:10
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:13
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:18
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Fields marked with an asterisk (*) cannot be empty."
msgstr ""
#: lib/mv_web/components/core_components.ex:206
#: lib/mv_web/components/core_components.ex:223
#: lib/mv_web/components/core_components.ex:250
#: lib/mv_web/components/core_components.ex:277
#, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:80
#: lib/mv_web/live/components/payment_filter_component.ex:143
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:54
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:108
#: lib/mv_web/live/components/payment_filter_component.ex:145
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:65
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex:48
#~ #: lib/mv_web/live/member_live/show.ex:51
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:" #~ msgid "Birth Date"
#~ msgstr "" #~ msgstr ""

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :member_field_visibility, :map
end
end
def down do
alter table(:settings) do
remove :member_field_visibility
end
end
end

View file

@ -0,0 +1,69 @@
defmodule Mv.Repo.Migrations.RemoveBirthDateFromMembers do
@moduledoc """
Removes the birth_date column from the members table.
The birth_date field has been removed from the application because most users
don't record birthday data. Users who need this can use a custom field instead.
This migration also updates the search_vector trigger to remove birth_date.
"""
use Ecto.Migration
def up do
# Update the trigger function to remove birth_date from search_vector
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# Remove the birth_date column
alter table(:members) do
remove :birth_date
end
end
def down do
# Add the birth_date column back
alter table(:members) do
add :birth_date, :date
end
# Restore the trigger function with birth_date
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
end
end

View file

@ -112,7 +112,6 @@ for member_attrs <- [
first_name: "Hans", first_name: "Hans",
last_name: "Müller", last_name: "Müller",
email: "hans.mueller@example.de", email: "hans.mueller@example.de",
birth_date: ~D[1985-06-15],
join_date: ~D[2023-01-15], join_date: ~D[2023-01-15],
paid: true, paid: true,
phone_number: "+49301234567", phone_number: "+49301234567",
@ -125,7 +124,6 @@ for member_attrs <- [
first_name: "Greta", first_name: "Greta",
last_name: "Schmidt", last_name: "Schmidt",
email: "greta.schmidt@example.de", email: "greta.schmidt@example.de",
birth_date: ~D[1990-03-22],
join_date: ~D[2023-02-01], join_date: ~D[2023-02-01],
paid: false, paid: false,
phone_number: "+49309876543", phone_number: "+49309876543",
@ -139,7 +137,6 @@ for member_attrs <- [
first_name: "Friedrich", first_name: "Friedrich",
last_name: "Wagner", last_name: "Wagner",
email: "friedrich.wagner@example.de", email: "friedrich.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10], join_date: ~D[2022-11-10],
paid: true, paid: true,
phone_number: "+49301122334", phone_number: "+49301122334",
@ -151,7 +148,6 @@ for member_attrs <- [
first_name: "Marianne", first_name: "Marianne",
last_name: "Wagner", last_name: "Wagner",
email: "marianne.wagner@example.de", email: "marianne.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10], join_date: ~D[2022-11-10],
paid: true, paid: true,
phone_number: "+49301122334", phone_number: "+49301122334",
@ -186,7 +182,6 @@ linked_members = [
first_name: "Maria", first_name: "Maria",
last_name: "Weber", last_name: "Weber",
email: "maria.weber@example.de", email: "maria.weber@example.de",
birth_date: ~D[1992-07-14],
join_date: ~D[2023-03-15], join_date: ~D[2023-03-15],
paid: true, paid: true,
phone_number: "+49301357924", phone_number: "+49301357924",
@ -202,7 +197,6 @@ linked_members = [
first_name: "Thomas", first_name: "Thomas",
last_name: "Klein", last_name: "Klein",
email: "thomas.klein@example.de", email: "thomas.klein@example.de",
birth_date: ~D[1988-12-03],
join_date: ~D[2023-04-01], join_date: ~D[2023-04-01],
paid: false, paid: false,
phone_number: "+49302468135", phone_number: "+49302468135",

View file

@ -0,0 +1,144 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "immutable",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "required",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "show_in_overview",
"type": "boolean"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_fields"
}

View file

@ -0,0 +1,79 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "club_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "member_field_visibility",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}

View file

@ -0,0 +1,14 @@
defmodule Mv.Membership.MemberFieldVisibilityTest do
@moduledoc """
Tests for member field visibility configuration.
Tests cover:
- Member fields are visible by default (show_in_overview: true)
- Member fields can be hidden (show_in_overview: false)
- Checking if a specific field is visible
- Configuration is stored in Settings resource
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
end

View file

@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
@valid_attrs %{ @valid_attrs %{
first_name: "John", first_name: "John",
last_name: "Doe", last_name: "Doe",
birth_date: ~D[1990-01-01],
paid: true, paid: true,
email: "john@example.com", email: "john@example.com",
phone_number: "+49123456789", phone_number: "+49123456789",
@ -43,12 +42,6 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :email) =~ "is not a valid email" assert error_message(errors, :email) =~ "is not a valid email"
end end
test "Birth date is optional but must not be in the future" do
attrs = Map.put(@valid_attrs, :birth_date, Date.utc_today() |> Date.add(1))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :birth_date) =~ "cannot be in the future"
end
test "Paid is optional but must be boolean if specified" do test "Paid is optional but must be boolean if specified" do
attrs = Map.put(@valid_attrs, :paid, nil) attrs = Map.put(@valid_attrs, :paid, nil)
attrs2 = Map.put(@valid_attrs, :paid, "yes") attrs2 = Map.put(@valid_attrs, :paid, "yes")

View file

@ -0,0 +1,363 @@
defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
@moduledoc """
Tests for FieldVisibilityDropdownComponent LiveComponent.
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias MvWeb.Components.FieldVisibilityDropdownComponent
# Helper to create test assigns
defp create_assigns(overrides \\ %{}) do
default_assigns = %{
id: "test-dropdown",
all_fields: [:first_name, :email, :street, "custom_field_123"],
custom_fields: [
%{id: "123", name: "Custom Field 1"}
],
selected_fields: %{
"first_name" => true,
"email" => true,
"street" => false,
"custom_field_123" => true
}
}
Map.merge(default_assigns, overrides)
end
describe "update/2" do
test "initializes with default values" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
assert socket.assigns.id == "test-dropdown"
assert socket.assigns.open == false
assert socket.assigns.all_fields == assigns.all_fields
assert socket.assigns.selected_fields == assigns.selected_fields
end
test "preserves existing open state" do
assigns = create_assigns()
existing_socket = %{assigns: %{open: true}}
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, existing_socket)
assert socket.assigns.open == true
end
test "handles missing optional assigns" do
minimal_assigns = %{id: "test"}
{:ok, socket} = FieldVisibilityDropdownComponent.update(minimal_assigns, %{})
assert socket.assigns.all_fields == []
assert socket.assigns.custom_fields == []
assert socket.assigns.selected_fields == %{}
end
end
describe "render/1" do
test "renders dropdown button" do
assigns = create_assigns()
html = render_component(FieldVisibilityDropdownComponent, assigns)
assert html =~ "Columns"
assert html =~ "hero-adjustments-horizontal"
assert has_element?(html, "button[aria-controls='field-visibility-menu']")
end
test "renders dropdown menu when open" do
assigns = create_assigns() |> Map.put(:open, true)
html = render_component(FieldVisibilityDropdownComponent, assigns)
assert has_element?(html, "ul#field-visibility-menu")
assert html =~ "All"
assert html =~ "None"
end
test "does not render menu when closed" do
assigns = create_assigns() |> Map.put(:open, false)
html = render_component(FieldVisibilityDropdownComponent, assigns)
refute has_element?(html, "ul#field-visibility-menu")
end
test "renders member fields" do
assigns = create_assigns() |> Map.put(:open, true)
html = render_component(FieldVisibilityDropdownComponent, assigns)
# Field names should be formatted (first_name -> First Name)
assert html =~ "First Name" or html =~ "first_name"
assert html =~ "Email" or html =~ "email"
assert html =~ "Street" or html =~ "street"
end
test "renders custom fields when custom fields exist" do
assigns = create_assigns() |> Map.put(:open, true)
html = render_component(FieldVisibilityDropdownComponent, assigns)
# Custom field name
assert html =~ "Custom Field 1"
end
test "renders checkboxes with correct checked state" do
assigns = create_assigns() |> Map.put(:open, true)
html = render_component(FieldVisibilityDropdownComponent, assigns)
# first_name should be checked (aria-checked="true")
assert html =~ ~s(aria-checked="true")
assert html =~ ~s(phx-value-item="first_name")
# street should not be checked (aria-checked="false")
assert html =~ ~s(phx-value-item="street")
# Note: The visual checkbox state is handled by CSS classes and aria-checked attribute
end
test "includes accessibility attributes" do
assigns = create_assigns() |> Map.put(:open, true)
html = render_component(FieldVisibilityDropdownComponent, assigns)
assert html =~ ~s(aria-controls="field-visibility-menu")
assert html =~ ~s(aria-haspopup="menu")
assert html =~ ~s(role="button")
assert html =~ ~s(role="menu")
assert html =~ ~s(role="menuitemcheckbox")
end
test "formats member field labels correctly" do
assigns = create_assigns() |> Map.put(:open, true)
html = render_component(FieldVisibilityDropdownComponent, assigns)
# Field names should be formatted (first_name -> First Name)
assert html =~ "First Name" or html =~ "first_name"
end
test "uses custom field names from custom_fields prop" do
assigns =
create_assigns()
|> Map.put(:open, true)
|> Map.put(:custom_fields, [
%{id: "123", name: "Membership Number"}
])
html = render_component(FieldVisibilityDropdownComponent, assigns)
assert html =~ "Membership Number"
end
test "falls back to ID when custom field not found" do
assigns =
create_assigns()
|> Map.put(:open, true)
# Empty custom fields list
|> Map.put(:custom_fields, [])
html = render_component(FieldVisibilityDropdownComponent, assigns)
# Should show something like "Custom Field 123"
assert html =~ "custom_field_123" or html =~ "Custom Field"
end
end
describe "handle_event/2" do
test "toggle_dropdown toggles open state" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
assert socket.assigns.open == false
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event("toggle_dropdown", %{}, socket)
assert socket.assigns.open == true
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event("toggle_dropdown", %{}, socket)
assert socket.assigns.open == false
end
test "close_dropdown sets open to false" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
socket = assign(socket, :open, true)
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event("close_dropdown", %{}, socket)
assert socket.assigns.open == false
end
test "select_item toggles field visibility" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
assert socket.assigns.selected_fields["first_name"] == true
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event(
"select_item",
%{"item" => "first_name"},
socket
)
assert socket.assigns.selected_fields["first_name"] == false
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event(
"select_item",
%{"item" => "first_name"},
socket
)
assert socket.assigns.selected_fields["first_name"] == true
end
test "select_item defaults to true for missing fields" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event(
"select_item",
%{"item" => "new_field"},
socket
)
# Toggled from default true
assert socket.assigns.selected_fields["new_field"] == false
end
test "select_item sends message to parent" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
FieldVisibilityDropdownComponent.handle_event(
"select_item",
%{"item" => "first_name"},
socket
)
# Check that message was sent (would be verified in integration test)
# For unit test, we just verify the state change
assert_receive {:field_toggled, "first_name", false}
end
test "select_all sets all fields to true" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event("select_all", %{}, socket)
assert socket.assigns.selected_fields["first_name"] == true
assert socket.assigns.selected_fields["email"] == true
assert socket.assigns.selected_fields["street"] == true
assert socket.assigns.selected_fields["custom_field_123"] == true
end
test "select_all sends message to parent" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
FieldVisibilityDropdownComponent.handle_event("select_all", %{}, socket)
assert_receive {:fields_selected, selection}
assert selection["first_name"] == true
assert selection["email"] == true
end
test "select_none sets all fields to false" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event("select_none", %{}, socket)
assert socket.assigns.selected_fields["first_name"] == false
assert socket.assigns.selected_fields["email"] == false
assert socket.assigns.selected_fields["street"] == false
assert socket.assigns.selected_fields["custom_field_123"] == false
end
test "select_none sends message to parent" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
FieldVisibilityDropdownComponent.handle_event("select_none", %{}, socket)
assert_receive {:fields_selected, selection}
assert selection["first_name"] == false
assert selection["email"] == false
end
test "handles custom field toggle" do
assigns = create_assigns()
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
{:noreply, socket} =
FieldVisibilityDropdownComponent.handle_event(
"select_item",
%{"item" => "custom_field_123"},
socket
)
assert socket.assigns.selected_fields["custom_field_123"] == false
end
end
describe "integration with LiveView" do
test "component can be rendered in LiveView" do
conn = conn_with_oidc_user(build_conn())
{:ok, view, _html} = live(conn, "/members")
# Check that component is rendered
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
end
test "clicking button opens dropdown" do
conn = conn_with_oidc_user(build_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 "toggling field updates selection" do
conn = conn_with_oidc_user(build_conn())
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Toggle a field
view
|> element("button[phx-click='select_item'][phx-value-item='first_name']")
|> render_click()
# Component should update (verified by state change)
# In a real scenario, this would trigger a reload of members
end
end
end

View file

@ -0,0 +1,183 @@
defmodule MvWeb.Components.PaymentFilterComponentTest do
@moduledoc """
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :not_paid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
"""
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with no filter active (nil)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Should show "All" text and no badge
assert has_element?(view, "#payment-filter")
refute has_element?(view, "#payment-filter .badge")
end
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
test "renders with not_paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
end
describe "dropdown behavior" do
test "dropdown opens on button click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially dropdown is closed
refute has_element?(view, "#payment-filter ul[role='menu']")
# Click to open
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Dropdown should be visible
assert has_element?(view, "#payment-filter ul[role='menu']")
end
test "dropdown closes after selecting an option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
assert has_element?(view, "#payment-filter ul[role='menu']")
# Select an option - this should close the dropdown
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# After selection, dropdown should be closed
# Note: The dropdown closes via assign, which is reflected in the next render
refute has_element?(view, "#payment-filter ul[role='menu']")
end
end
describe "filter selection" do
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "All" option
view
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
# URL should not contain paid_filter param - wait for patch
assert_patch(view)
end
test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=paid
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Not paid" option
view
|> element("#payment-filter button[phx-value-filter='not_paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=not_paid
path = assert_patch(view)
assert path =~ "paid_filter=not_paid"
end
end
describe "accessibility" do
test "has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Main button should have aria-haspopup and aria-expanded
assert html =~ ~s(aria-haspopup="true")
assert html =~ ~s(aria-expanded="false")
assert html =~ ~s(aria-label=)
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Check aria-expanded is now true
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
# Options should have role="menuitemradio"
assert html =~ ~s(role="menuitemradio")
end
test "has aria-checked on selected option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# "Paid" option should have aria-checked="true"
# Check both possible orderings of attributes
assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\""
end
end
end

View file

@ -0,0 +1,346 @@
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 is missing" 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
json = Jason.encode!(%{"first_name" => true, "email" => false})
conn = Plug.Conn.put_req_cookie(%Plug.Conn{}, "member_field_selection", json)
result = FieldSelection.get_from_cookie(conn)
assert result == %{"first_name" => true, "email" => false}
end
test "handles invalid JSON in cookie gracefully" do
conn = Plug.Conn.put_req_cookie(%Plug.Conn{}, "member_field_selection", "invalid{[")
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
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
assert result == "first_name,email"
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

@ -0,0 +1,336 @@
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

@ -9,7 +9,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
- Custom field values are correctly formatted for different types - Custom field values are correctly formatted for different types
- Members without custom field values show empty cell or "-" - Members without custom field values show empty cell or "-"
""" """
use MvWeb.ConnCase, async: true # async: false to prevent PostgreSQL deadlocks when creating members and custom fields
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query

View file

@ -0,0 +1,509 @@
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 "showing a hidden field adds it to display", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with only first_name and street explicitly set in URL
# Note: Other fields may still be visible due to global settings
{:ok, view, _html} = live(conn, "/members?fields=first_name,street")
# Verify first_name and street are visible
html = render(view)
assert html =~ "Alice"
assert html =~ "Main St"
# Open dropdown and toggle email (to ensure it's visible)
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# If email is not visible, toggle it to make it visible
# If it's already visible, toggle it off and on again
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Email should now be visible
html = render(view)
assert html =~ "alice@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 Space")
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("Enter")
# Wait for update
:timer.sleep(100)
# Email should no longer be visible
html = render(view)
refute html =~ "alice@example.com"
end
test "keyboard activation with Space 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 Space key press on email field button
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_keydown(" ")
# Wait for update
:timer.sleep(100)
# Email should no longer be visible
html = render(view)
refute html =~ "alice@example.com"
end
end
end

View file

@ -0,0 +1,64 @@
defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.Member
setup do
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main Street",
house_number: "123",
postal_code: "12345",
city: "Berlin",
phone_number: "+49123456789",
join_date: ~D[2020-01-15]
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
%{
member1: member1,
member2: member2
}
end
test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
assert html =~ field
end
end
test "respects show_in_overview config", %{conn: conn, member1: m} do
{:ok, settings} = Mv.Membership.get_settings()
fields_to_hide = [:street, :house_number]
{:ok, _} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
})
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Email"
assert html =~ m.email
refute html =~ m.street
end
end

View file

@ -249,4 +249,441 @@ defmodule MvWeb.MemberLive.IndexTest do
# Verify the member was actually deleted from the database # Verify the member was actually deleted from the database
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?()) assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
end end
describe "copy_emails feature" do
setup do
# Create test members
{:ok, member1} =
Mv.Membership.create_member(%{
first_name: "Max",
last_name: "Mustermann",
email: "max@example.com"
})
{:ok, member2} =
Mv.Membership.create_member(%{
first_name: "Erika",
last_name: "Musterfrau",
email: "erika@example.com"
})
{:ok, member3} =
Mv.Membership.create_member(%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
email: "hans@example.com"
})
%{member1: member1, member2: member2, member3: member3}
end
test "copy_emails event formats selected members correctly", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
view
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|> render_click()
# Trigger copy_emails event
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count
assert render(view) =~ "2"
end
test "copy_emails event with no selection shows error flash", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Trigger copy_emails event directly (button not visible when no selection)
# This tests the edge case where event is triggered without selection
result = render_hook(view, "copy_emails", %{})
# Should show error flash
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
end
test "copy_emails event with all members selected formats all emails", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select all members via select_all
view |> element("[phx-click='select_all']") |> render_click()
# Trigger copy_emails event
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count (3 members)
assert render(view) =~ "3"
end
test "copy_emails handles members with special characters in names", %{
conn: conn,
member3: member3
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select member with umlauts
view
|> element("[phx-click='select_member'][phx-value-id='#{member3.id}']")
|> render_click()
# Trigger copy_emails event - should not crash
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows success
assert render(view) =~ "1"
end
test "copy_emails handles case where selected member is deleted before copy", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Delete the member from the database
Ash.destroy!(member1)
# Trigger copy_emails event directly - selection still contains the deleted ID
# but the member is no longer in @members list after reload
result = render_hook(view, "copy_emails", %{})
# Should show error since no visible members match selection
assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0"
end
test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
view
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|> render_click()
# Get the socket state to verify the formatted email string
state = :sys.get_state(view.pid)
selected_members = state.socket.assigns.selected_members
# Verify MapSet is used
assert %MapSet{} = selected_members
assert MapSet.size(selected_members) == 2
end
test "email format is 'First Last <email>' with comma separator", %{
conn: conn,
member1: _member1
} do
# Test the format_member_email function indirectly
# by checking the push_event payload structure
conn = conn_with_oidc_user(conn)
# Create a member with known data
{:ok, test_member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "Format",
email: "test.format@example.com"
})
{:ok, view, _html} = live(conn, "/members")
# Select the test member
view
|> element("[phx-click='select_member'][phx-value-id='#{test_member.id}']")
|> render_click()
# The format should be "Test Format <test.format@example.com>"
# We verify this by checking the flash shows 1 email was copied
view |> element("#copy-emails-btn") |> render_click()
assert render(view) =~ "1"
end
test "copy button is not visible when no members are selected", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Ensure no members are selected (default state)
refute has_element?(view, "#copy-emails-btn")
end
test "copy button is visible when members are selected", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Button should now be visible
assert has_element?(view, "#copy-emails-btn")
end
test "copy button click triggers event and shows flash", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Click copy button
view |> element("#copy-emails-btn") |> render_click()
# Flash message should appear
assert has_element?(view, "#flash-group")
end
end
describe "payment filter integration" do
setup do
# Create members with different payment status
# Use unique names that won't appear elsewhere in the HTML
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Zahler",
last_name: "Mitglied",
email: "zahler@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Nichtzahler",
last_name: "Mitglied",
email: "nichtzahler@example.com",
paid: false
})
{:ok, nil_paid_member} =
Mv.Membership.create_member(%{
first_name: "Unbestimmt",
last_name: "Mitglied",
email: "unbestimmt@example.com"
# paid is nil by default
})
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
end
test "filter shows all members when no filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter shows only paid members when paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
assert html =~ paid_member.first_name
refute html =~ unpaid_member.first_name
refute html =~ nil_paid_member.first_name
end
test "filter shows only unpaid members (including nil) when not_paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
refute html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter combines with search query (AND)", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
assert html =~ paid_member.first_name
end
test "filter combines with sorting", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
# Click on email sort header
view
|> element("[data-testid='email']")
|> render_click()
# Filter should be preserved in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
assert path =~ "sort_field=email"
end
test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open filter dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "URL parameter is correctly read on page load", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
# Only paid member should be visible
assert html =~ paid_member.first_name
# Filter badge should be visible
assert html =~ "badge"
end
test "invalid URL parameter is ignored", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value")
# All members should be visible (filter not applied)
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
end
test "search maintains filter state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{"query" => "test"})
# Filter state should be maintained in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
end
describe "paid column in table" do
setup do
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Paid",
last_name: "Member",
email: "paid.column@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Unpaid",
last_name: "Member",
email: "unpaid.column@example.com",
paid: false
})
%{paid_member: paid_member, unpaid_member: unpaid_member}
end
test "paid column shows green badge for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for success badge (green)
assert html =~ "badge-success"
end
test "paid column shows red badge for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for error badge (red)
assert html =~ "badge-error"
end
test "paid column shows 'Yes' for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "Yes" text inside badge
assert html =~ "badge-success"
assert html =~ "Yes"
end
test "paid column shows 'No' for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "No" text inside badge
assert html =~ "badge-error"
assert html =~ "No"
end
end
end end