Compare commits
27 commits
206e733511
...
f62d1fbf51
| Author | SHA1 | Date | |
|---|---|---|---|
| f62d1fbf51 | |||
| 8b445cec48 | |||
| f709edcf6f | |||
| a143c4e243 | |||
| b0c94234a9 | |||
| 780f5f61ea | |||
| ac2ad0a0d5 | |||
| 875c422b7d | |||
| 6d75766dba | |||
| 354029c9cc | |||
| 671e6ce804 | |||
| 386b4c9e65 | |||
| 88c5f3dde0 | |||
| a67a91cffa | |||
| c8968636a8 | |||
| 40835f7a2d | |||
| 13f77b5c0a | |||
| dce2053ce7 | |||
| e81aecce48 | |||
| 397cbde9d6 | |||
| 831149f463 | |||
| 944b868478 | |||
| d10f2ecc90 | |||
| d757d1b9be | |||
| 39d2cb7820 | |||
| ba78a6ac7a | |||
| e2ace3d2a8 |
40 changed files with 4592 additions and 349 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
1
Justfile
1
Justfile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}$
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
22
lib/mv/constants.ex
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
172
lib/mv_web/components/field_visibility_dropdown_component.ex
Normal file
172
lib/mv_web/components/field_visibility_dropdown_component.ex
Normal 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
|
||||||
|
|
@ -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} />
|
||||||
|
|
||||||
|
|
|
||||||
146
lib/mv_web/live/components/payment_filter_component.ex
Normal file
146
lib/mv_web/live/components/payment_filter_component.ex
Normal 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
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
232
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
232
lib/mv_web/live/member_live/index/field_selection.ex
Normal 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
|
||||||
235
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
235
lib/mv_web/live/member_live/index/field_visibility.ex
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
144
priv/resource_snapshots/repo/custom_fields/20251201115939.json
Normal file
144
priv/resource_snapshots/repo/custom_fields/20251201115939.json
Normal 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"
|
||||||
|
}
|
||||||
79
priv/resource_snapshots/repo/settings/20251201115939.json
Normal file
79
priv/resource_snapshots/repo/settings/20251201115939.json
Normal 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"
|
||||||
|
}
|
||||||
14
test/membership/member_field_visibility_test.exs
Normal file
14
test/membership/member_field_visibility_test.exs
Normal 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
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
183
test/mv_web/components/payment_filter_component_test.exs
Normal file
183
test/mv_web/components/payment_filter_component_test.exs
Normal 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
|
||||||
346
test/mv_web/live/member_live/index/field_selection_test.exs
Normal file
346
test/mv_web/live/member_live/index/field_selection_test.exs
Normal 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
|
||||||
336
test/mv_web/live/member_live/index/field_visibility_test.exs
Normal file
336
test/mv_web/live/member_live/index/field_visibility_test.exs
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
509
test/mv_web/member_live/index_field_visibility_test.exs
Normal file
509
test/mv_web/member_live/index_field_visibility_test.exs
Normal 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
|
||||||
64
test/mv_web/member_live/index_member_fields_display_test.exs
Normal file
64
test/mv_web/member_live/index_member_fields_display_test.exs
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue