From 7d38a76e787297af44197d6461ea3715679ffd77 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 27 Nov 2025 21:30:06 +0100 Subject: [PATCH 01/88] docs: payment concept --- docs/contributions-architecture.md | 651 +++++++++++++++++++++++++++++ docs/contributions-overview.md | 527 +++++++++++++++++++++++ 2 files changed, 1178 insertions(+) create mode 100644 docs/contributions-architecture.md create mode 100644 docs/contributions-overview.md diff --git a/docs/contributions-architecture.md b/docs/contributions-architecture.md new file mode 100644 index 0000000..b01ad30 --- /dev/null +++ b/docs/contributions-architecture.md @@ -0,0 +1,651 @@ +# Membership Contributions - Technical Architecture + +**Project:** Mila - Membership Management System +**Feature:** Membership Contribution Management +**Version:** 1.0 +**Last Updated:** 2025-11-27 +**Status:** Architecture Design - Ready for Implementation + +--- + +## Purpose + +This document defines the technical architecture for the Membership Contributions system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. + +**Related Documents:** +- [contributions-overview.md](./contributions-overview.md) - Business logic and requirements +- [database-schema-readme.md](./database-schema-readme.md) - Database documentation +- [database_schema.dbml](./database_schema.dbml) - Database schema definition + +--- + +## Table of Contents + +1. [Architecture Principles](#architecture-principles) +2. [Domain Structure](#domain-structure) +3. [Data Architecture](#data-architecture) +4. [Business Logic Architecture](#business-logic-architecture) +5. [Integration Points](#integration-points) +6. [Acceptance Criteria](#acceptance-criteria) +7. [Testing Strategy](#testing-strategy) +8. [Security Considerations](#security-considerations) +9. [Performance Considerations](#performance-considerations) + +--- + +## Architecture Principles + +### Core Design Decisions + +1. **Single Responsibility:** + - Each module has one clear responsibility + - Period generation separated from status management + - Calendar logic isolated in dedicated module + +2. **No Redundancy:** + - No `period_end` field (calculated from `period_start` + `interval`) + - No `interval_type` field (read from `contribution_type.interval`) + - Eliminates data inconsistencies + +3. **Immutability Where Important:** + - `contribution_type.interval` cannot be changed after creation + - Prevents complex migration scenarios + - Enforced via Ash change validation + +4. **Historical Accuracy:** + - `amount` stored per period for audit trail + - Enables tracking of contribution changes over time + - Old periods retain original amounts + +5. **Calendar-Based Periods:** + - All periods aligned to calendar boundaries + - Simplifies date calculations + - Predictable period generation + +--- + +## Domain Structure + +### Ash Domain: `Mv.Contributions` + +**Purpose:** Encapsulates all contribution-related resources and logic + +**Resources:** +- `ContributionType` - Contribution type definitions (admin-managed) +- `ContributionPeriod` - Individual contribution periods per member + +**Extensions:** +- Member resource extended with contribution fields + +### Module Organization + +``` +lib/ +├── contributions/ +│ ├── contributions.ex # Ash domain definition +│ ├── contribution_type.ex # ContributionType resource +│ ├── contribution_period.ex # ContributionPeriod resource +│ └── changes/ +│ ├── prevent_interval_change.ex # Validates interval immutability +│ ├── set_contribution_start_date.ex # Auto-sets start date +│ └── validate_same_interval.ex # Validates interval match on type change +├── mv/ +│ └── contributions/ +│ ├── period_generator.ex # Period generation algorithm +│ └── calendar_periods.ex # Calendar period calculations +└── membership/ + └── member.ex # Extended with contribution relationships +``` + +### Separation of Concerns + +**Domain Layer (Ash Resources):** +- Data validation +- Relationship management +- Policy enforcement +- Action definitions + +**Business Logic Layer (`Mv.Contributions`):** +- Period generation algorithm +- Calendar calculations +- Date boundary handling +- Status transitions + +**UI Layer (LiveView):** +- User interaction +- Display logic +- Authorization checks +- Form handling + +--- + +## Data Architecture + +### Database Schema Extensions + +**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation. + +### New Tables + +1. **`contribution_types`** + - Purpose: Define contribution types with fixed intervals + - Key Constraint: `interval` field immutable after creation + - Relationships: has_many members, has_many contribution_periods + +2. **`contribution_periods`** + - Purpose: Individual contribution periods for members + - Key Design: NO `period_end` or `interval_type` fields (calculated) + - Relationships: belongs_to member, belongs_to contribution_type + - Composite uniqueness: One period per member per period_start + +### Member Table Extensions + +**Fields Added:** +- `contribution_type_id` (FK, NOT NULL with default from settings) +- `contribution_start_date` (Date, nullable) + +**Existing Fields Used:** +- `joined_at` - For calculating contribution start +- `left_at` - For limiting period generation + +### Settings Integration + +**Global Settings:** +- `contributions.include_joining_period` (Boolean) +- `contributions.default_contribution_type_id` (UUID) + +**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource) + +### Foreign Key Behaviors + +| Relationship | On Delete | Rationale | +|--------------|-----------|-----------| +| `contribution_periods.member_id → members.id` | CASCADE | Remove periods when member deleted | +| `contribution_periods.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if periods exist | +| `members.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if assigned to members | + +--- + +## Business Logic Architecture + +### Period Generation System + +**Component:** `Mv.Contributions.PeriodGenerator` + +**Responsibilities:** +- Calculate which periods should exist for a member +- Generate missing periods +- Respect contribution_start_date and left_at boundaries +- Skip existing periods (idempotent) + +**Triggers:** +1. Member contribution type assigned (via Ash change) +2. Member created with contribution type (via Ash change) +3. Scheduled job runs (daily/weekly cron) +4. Admin manual regeneration (UI action) + +**Algorithm Steps:** +1. Retrieve member with contribution_type and dates +2. Determine first period start (based on contribution_start_date) +3. Calculate all period starts from first to today (or left_at) +4. Query existing periods for member +5. Generate missing periods with current contribution_type.amount +6. Insert new periods (batch operation) + +**Edge Case Handling:** +- If contribution_start_date is NULL: Calculate from joined_at + global setting +- If left_at is set: Stop generation at left_at +- If contribution_type changes: Handled separately by regeneration logic + +### Calendar Period Calculations + +**Component:** `Mv.Contributions.CalendarPeriods` + +**Responsibilities:** +- Calculate period boundaries based on interval type +- Determine current period +- Determine last completed period +- Calculate period_end from period_start + interval + +**Functions (high-level):** +- `calculate_period_start/3` - Given date and interval, find period start +- `calculate_period_end/2` - Given period_start and interval, calculate end +- `next_period_start/2` - Given period_start and interval, find next +- `is_current_period?/2` - Check if period contains today +- `is_last_completed_period?/2` - Check if period just ended + +**Interval Logic:** +- **Monthly:** Start = 1st of month, End = last day of month +- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter +- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half +- **Yearly:** Start = Jan 1st, End = Dec 31st + +### Status Management + +**Component:** Ash actions on `ContributionPeriod` + +**Status Transitions:** +- Simple state machine: unpaid ↔ paid ↔ suspended +- No complex validation (all transitions allowed) +- Permissions checked via Ash policies + +**Actions Required:** +- `mark_as_paid` - Set status to :paid +- `mark_as_suspended` - Set status to :suspended +- `mark_as_unpaid` - Set status to :unpaid (error correction) + +**Bulk Operations:** +- `bulk_mark_as_paid` - Mark multiple periods as paid (efficiency) + +### Contribution Type Change Handling + +**Component:** Ash change on `Member.contribution_type_id` + +**Validation:** +- Check if new type has same interval as old type +- If different: Reject change (MVP constraint) +- If same: Allow change + +**Side Effects on Allowed Change:** +1. Keep all existing periods unchanged +2. Find future unpaid periods +3. Delete future unpaid periods +4. Regenerate periods with new contribution_type_id and amount + +**Implementation Pattern:** +- Use Ash change module to validate +- Use after_action hook to trigger regeneration +- Use transaction to ensure atomicity + +--- + +## Integration Points + +### Member Resource Integration + +**Extension Points:** +1. Add fields via migration +2. Add relationships (belongs_to, has_many) +3. Add calculations (current_period_status, overdue_count) +4. Add changes (auto-set contribution_start_date, validate interval) + +**Backward Compatibility:** +- New fields nullable or with defaults +- Existing members get default contribution type from settings +- No breaking changes to existing member functionality + +### Settings System Integration + +**Requirements:** +- Store two global settings +- Provide UI for admin to modify +- Default values if not set +- Validation (e.g., default_contribution_type_id must exist) + +**Access Pattern:** +- Read settings during period generation +- Read settings during member creation +- Write settings only via admin UI + +### Permission System Integration + +**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) + +**Required Permissions:** +- `ContributionType.create/update/destroy` - Admin only +- `ContributionType.read` - Admin, Treasurer, Board +- `ContributionPeriod.update` (status changes) - Admin, Treasurer +- `ContributionPeriod.read` - Admin, Treasurer, Board, Own member + +**Policy Patterns:** +- Use existing HasPermission check +- Leverage existing roles (Admin, Kassenwart) +- Member can read own periods (linked via member_id) + +### LiveView Integration + +**New LiveViews Required:** +1. ContributionType index/form (admin) +2. ContributionPeriod table component (member detail view) +3. Settings form section (admin) +4. Member list column (contribution status) + +**Existing LiveViews to Extend:** +- Member detail view: Add contributions section +- Member list view: Add status column +- Settings page: Add contributions section + +**Authorization Helpers:** +- Use existing `can?/3` helper for UI conditionals +- Check permissions before showing actions + +--- + +## Acceptance Criteria + +### ContributionType Resource + +**AC-CT-1:** Admin can create contribution type with name, amount, interval, description +**AC-CT-2:** Interval field is immutable after creation (validation error on change attempt) +**AC-CT-3:** Admin can update name, amount, description (but not interval) +**AC-CT-4:** Cannot delete contribution type if assigned to members +**AC-CT-5:** Cannot delete contribution type if periods exist referencing it +**AC-CT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly + +### ContributionPeriod Resource + +**AC-CP-1:** Period has period_start, status, amount, notes, member_id, contribution_type_id +**AC-CP-2:** Period_end is calculated, not stored +**AC-CP-3:** Status defaults to :unpaid +**AC-CP-4:** One period per member per period_start (uniqueness constraint) +**AC-CP-5:** Amount is set at generation time from contribution_type.amount +**AC-CP-6:** Periods cascade delete when member deleted +**AC-CP-7:** Admin/Treasurer can change status +**AC-CP-8:** Member can read own periods + +### Member Extensions + +**AC-M-1:** Member has contribution_type_id field (NOT NULL with default) +**AC-M-2:** Member has contribution_start_date field (nullable) +**AC-M-3:** New members get default contribution type from global setting +**AC-M-4:** contribution_start_date auto-set based on joined_at and global setting +**AC-M-5:** Admin can manually override contribution_start_date +**AC-M-6:** Cannot change to contribution type with different interval (MVP) + +### Period Generation + +**AC-PG-1:** Periods generated when member gets contribution type +**AC-PG-2:** Periods generated when member created (via change hook) +**AC-PG-3:** Scheduled job generates missing periods daily +**AC-PG-4:** Generation respects contribution_start_date +**AC-PG-5:** Generation stops at left_at if member exited +**AC-PG-6:** Generation is idempotent (skips existing periods) +**AC-PG-7:** Periods align to calendar boundaries (1st of month/quarter/half/year) +**AC-PG-8:** Amount comes from contribution_type at generation time + +### Calendar Logic + +**AC-CL-1:** Monthly periods: 1st to last day of month +**AC-CL-2:** Quarterly periods: 1st of Jan/Apr/Jul/Oct to last day of quarter +**AC-CL-3:** Half-yearly periods: 1st of Jan/Jul to last day of half +**AC-CL-4:** Yearly periods: Jan 1 to Dec 31 +**AC-CL-5:** Period_end calculated correctly for all interval types +**AC-CL-6:** Current period determined correctly based on today's date +**AC-CL-7:** Last completed period determined correctly + +### Contribution Type Change + +**AC-TC-1:** Can change to type with same interval +**AC-TC-2:** Cannot change to type with different interval (error message) +**AC-TC-3:** On allowed change: future unpaid periods regenerated +**AC-TC-4:** On allowed change: paid/suspended periods unchanged +**AC-TC-5:** On allowed change: amount updated to new type's amount +**AC-TC-6:** Change is atomic (transaction) + +### Settings + +**AC-S-1:** Global setting: include_joining_period (boolean, default true) +**AC-S-2:** Global setting: default_contribution_type_id (UUID, required) +**AC-S-3:** Admin can modify settings via UI +**AC-S-4:** Settings validated (e.g., default type must exist) +**AC-S-5:** Settings applied to new members immediately + +### UI - Member List + +**AC-UI-ML-1:** New column shows contribution status +**AC-UI-ML-2:** Default: Shows last completed period status +**AC-UI-ML-3:** Optional: Toggle to show current period status +**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended) +**AC-UI-ML-5:** Filter: Unpaid in last period +**AC-UI-ML-6:** Filter: Unpaid in current period + +### UI - Member Detail + +**AC-UI-MD-1:** Contributions section shows all periods +**AC-UI-MD-2:** Table columns: Period, Interval, Amount, Status, Actions +**AC-UI-MD-3:** Checkbox per period for bulk marking +**AC-UI-MD-4:** "Mark selected as paid" button +**AC-UI-MD-5:** Dropdown to change contribution type (same interval only) +**AC-UI-MD-6:** Warning if different interval selected +**AC-UI-MD-7:** Only show actions if user has permission + +### UI - Contribution Types Admin + +**AC-UI-CTA-1:** List all contribution types +**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count +**AC-UI-CTA-3:** Create new contribution type form +**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable +**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable) +**AC-UI-CTA-6:** Warning on amount change (explain impact) +**AC-UI-CTA-7:** Cannot delete if members assigned +**AC-UI-CTA-8:** Only admin can access + +### UI - Settings Admin + +**AC-UI-SA-1:** Contributions section in settings +**AC-UI-SA-2:** Dropdown to select default contribution type +**AC-UI-SA-3:** Checkbox: Include joining period +**AC-UI-SA-4:** Explanatory text with examples +**AC-UI-SA-5:** Save button with validation + +--- + +## Testing Strategy + +### Unit Testing + +**Period Generator Tests:** +- Correct period_start calculation for all interval types +- Correct period count from start to end date +- Respects contribution_start_date boundary +- Respects left_at boundary +- Skips existing periods (idempotent) +- Handles edge dates (year boundaries, leap years) + +**Calendar Periods Tests:** +- Period boundaries correct for all intervals +- Period_end calculation correct +- Current period detection +- Last completed period detection +- Next period calculation + +**Validation Tests:** +- Interval immutability enforced +- Same interval validation on type change +- Status transitions allowed +- Uniqueness constraints enforced + +### Integration Testing + +**Period Generation Flow:** +- Member creation triggers generation +- Type assignment triggers generation +- Type change regenerates future periods +- Scheduled job generates missing periods +- Left member stops generation + +**Status Management Flow:** +- Mark single period as paid +- Bulk mark multiple periods +- Status transitions work +- Permissions enforced + +**Contribution Type Management:** +- Create type +- Update amount (regeneration triggered) +- Cannot update interval +- Cannot delete if in use + +### LiveView Testing + +**Member List:** +- Status column displays correctly +- Toggle between last/current works +- Filters work correctly +- Color coding applied + +**Member Detail:** +- Periods table displays all periods +- Checkboxes work +- Bulk marking works +- Type change validation works +- Actions only shown with permission + +**Admin UI:** +- Type CRUD works +- Settings save correctly +- Validations display errors +- Only authorized users can access + +### Edge Case Testing + +**Interval Change Attempt:** +- Error message displayed +- No data modified +- User can cancel/choose different type + +**Exit with Unpaid:** +- Warning shown +- Option to suspend offered +- Exit completes correctly + +**Amount Change:** +- Warning displayed +- Only future unpaid regenerated +- Historical periods unchanged + +**Date Boundaries:** +- Today = period start handled +- Today = period end handled +- Leap year handled + +### Performance Testing + +**Period Generation:** +- Generate 10 years of monthly periods: < 100ms +- Generate for 1000 members: < 5 seconds +- Idempotent check efficient (no full scan) + +**Member List Query:** +- With status column: < 200ms for 1000 members +- Filters applied efficiently +- No N+1 queries + +--- + +## Security Considerations + +### Authorization + +**Permissions Required:** +- ContributionType management: Admin only +- ContributionPeriod status changes: Admin + Treasurer +- View all periods: Admin + Treasurer + Board +- View own periods: All authenticated users + +**Policy Enforcement:** +- All actions protected by Ash policies +- UI shows/hides based on permissions +- Backend validates permissions (never trust UI alone) + +### Data Integrity + +**Validation Layers:** +1. Database constraints (NOT NULL, UNIQUE, CHECK) +2. Ash validations (business rules) +3. UI validations (user experience) + +**Immutability Protection:** +- Interval change prevented at multiple layers +- Period amounts immutable (audit trail) +- Settings changes logged (future) + +### Audit Trail + +**Tracked Information:** +- Period status changes (who, when) - future enhancement +- Type amount changes (implicit via period amounts) +- Member type assignments (via timestamps) + +--- + +## Performance Considerations + +### Database Indexes + +**Required Indexes:** +- `contribution_periods(member_id)` - For member period lookups +- `contribution_periods(contribution_type_id)` - For type queries +- `contribution_periods(status)` - For unpaid filters +- `contribution_periods(period_start)` - For date range queries +- `contribution_periods(member_id, period_start)` - Composite unique index +- `members(contribution_type_id)` - For type membership count + +### Query Optimization + +**Preloading:** +- Load contribution_type with periods (avoid N+1) +- Load periods when displaying member detail +- Use Ash's load for efficient preloading + +**Calculated Fields:** +- period_end calculated on-demand (not stored) +- current_period_status calculated when needed +- Use Ash calculations for lazy evaluation + +**Pagination:** +- Period list paginated if > 50 periods +- Member list already paginated + +### Caching Strategy + +**No caching needed in MVP:** +- Contribution types rarely change +- Period queries are fast +- Settings read infrequently + +**Future caching if needed:** +- Cache settings in application memory +- Cache contribution types list +- Invalidate on change + +### Scheduled Job Performance + +**Period Generation Job:** +- Run daily or weekly (not hourly) +- Batch members (process 100 at a time) +- Skip members with no changes +- Log failures for retry + +--- + +## Future Enhancements + +### Phase 2: Interval Change Support + +**Architecture Changes:** +- Add logic to handle period overlaps +- Calculate prorata amounts if needed +- More complex validation +- Migration path for existing periods + +### Phase 3: Payment Details + +**Architecture Changes:** +- Add PaymentTransaction resource +- Link transactions to periods +- Support multiple payments per period +- Reconciliation logic + +### Phase 4: vereinfacht.digital Integration + +**Architecture Changes:** +- External API client module +- Webhook handling for transactions +- Automatic matching logic +- Manual review interface + +--- + +**End of Architecture Document** + diff --git a/docs/contributions-overview.md b/docs/contributions-overview.md new file mode 100644 index 0000000..03f347b --- /dev/null +++ b/docs/contributions-overview.md @@ -0,0 +1,527 @@ +# Membership Contributions - Overview + +**Project:** Mila - Membership Management System +**Feature:** Membership Contribution Management +**Version:** 1.0 +**Last Updated:** 2025-11-27 +**Status:** Concept - Ready for Review + +--- + +## Purpose + +This document provides a comprehensive overview of the Membership Contributions system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. + +**For detailed implementation:** See [contributions-implementation-plan.md](./contributions-implementation-plan.md) (created after concept iterations) + +--- + +## Table of Contents + +1. [Core Principle](#core-principle) +2. [Terminology](#terminology) +3. [Data Model](#data-model) +4. [Business Logic](#business-logic) +5. [UI/UX Design](#uiux-design) +6. [Edge Cases](#edge-cases) +7. [Technical Integration](#technical-integration) +8. [Implementation Scope](#implementation-scope) + +--- + +## Core Principle + +**Maximum Simplicity:** + +- Minimal complexity +- Clear data model without redundancies +- Intuitive operation +- Calendar period-based (Month/Quarter/Half-Year/Year) + +--- + +## Terminology + +### German ↔ English + +**Core Entities:** + +- Beitragsart ↔ Contribution Type / Membership Fee Type +- Beitragsintervall ↔ Contribution Period +- Mitgliedsbeitrag ↔ Membership Fee / Contribution + +**Status:** + +- bezahlt ↔ paid +- unbezahlt ↔ unpaid +- ausgesetzt ↔ suspended / waived + +**Intervals:** + +- monatlich ↔ monthly +- quartalsweise ↔ quarterly +- halbjährlich ↔ half-yearly / semi-annually +- jährlich ↔ yearly / annually + +**UI Elements:** + +- "Letztes Intervall" ↔ "Last Period" (e.g., 2023 when in 2024) +- "Aktuelles Intervall" ↔ "Current Period" (e.g., 2024) +- "Als bezahlt markieren" ↔ "Mark as paid" +- "Aussetzen" ↔ "Suspend" / "Waive" + +--- + +## Data Model + +### Contribution Type (ContributionType) + +``` +- id (UUID) +- name (String) - e.g., "Regular", "Reduced", "Student" +- amount (Decimal) - Contribution amount in Euro +- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly +- description (Text, optional) +- timestamps +``` + +**Important:** + +- `interval` is **IMMUTABLE** after creation! +- Admin can only change `name`, `amount`, `description` +- On change: Future unpaid periods regenerated with new amount + +### Contribution Period (ContributionPeriod) + +``` +- id (UUID) +- member_id (FK → members.id) +- contribution_type_id (FK → contribution_types.id) +- period_start (Date) - Calendar period start (01.01., 01.04., 01.07., 01.10., etc.) +- status (Enum) - :unpaid (default), :paid, :suspended +- amount (Decimal) - Amount at generation time (history when type changes) +- notes (Text, optional) - Admin notes +- timestamps +``` + +**Important:** + +- **NO** `period_end` - calculated from `period_start` + `interval` +- **NO** `interval_type` - read from `contribution_type.interval` +- Avoids redundancy and inconsistencies! + +**Calendar Period Logic:** + +- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc. +- Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12. +- Half-yearly: 01.01. - 30.06., 01.07. - 31.12. +- Yearly: 01.01. - 31.12. + +### Member (Extensions) + +``` +- contribution_type_id (FK → contribution_types.id, NOT NULL, default from settings) +- contribution_start_date (Date, nullable) - When to start generating contributions +- left_at (Date, nullable) - Exit date (existing) +``` + +**Logic for contribution_start_date:** + +- Auto-set based on global setting `include_joining_period` +- If `include_joining_period = true`: First day of joining month/quarter/year +- If `include_joining_period = false`: First day of NEXT period after joining +- Can be manually overridden by admin + +**NO** `include_joining_period` field on Member - unnecessary due to `contribution_start_date`! + +### Global Settings + +``` +key: "contributions.include_joining_period" +value: Boolean (Default: true) + +key: "contributions.default_contribution_type_id" +value: UUID (Required) - Default contribution type for new members +``` + +**Meaning include_joining_period:** + +- `true`: Joining period is included (member pays from joining period) +- `false`: Only from next full period after joining + +**Meaning default_contribution_type_id:** + +- Every new member automatically gets this contribution type +- Must be configured in admin settings +- Prevents: Members without contribution type + +--- + +## Business Logic + +### Period Generation + +**Triggers:** + +- Member gets contribution type assigned (also during member creation) +- New period begins (Cron job daily/weekly) +- Admin requests manual regeneration + +**Algorithm:** + +1. Get `member.contribution_start_date` and `member.contribution_type` +2. Calculate first period based on `contribution_start_date` +3. Generate all periods from start to today (or `left_at` if present) +4. Skip existing periods +5. Set `amount` to current `contribution_type.amount` + +**Example (Yearly):** + +``` +Joining date: 15.03.2023 +include_joining_period: true +→ contribution_start_date: 01.01.2023 + +Generated periods: +- 01.01.2023 - 31.12.2023 (joining period) +- 01.01.2024 - 31.12.2024 +- 01.01.2025 - 31.12.2025 (current year) +``` + +**Example (Quarterly):** + +``` +Joining date: 15.03.2023 +include_joining_period: false +→ contribution_start_date: 01.04.2023 + +Generated periods: +- 01.04.2023 - 30.06.2023 (first full quarter) +- 01.07.2023 - 30.09.2023 +- 01.10.2023 - 31.12.2023 +- 01.01.2024 - 31.03.2024 +- ... +``` + +### Status Transitions + +``` +unpaid → paid +unpaid → suspended +paid → unpaid +suspended → paid +suspended → unpaid +``` + +**Permissions:** + +- Admin + Treasurer (Kassenwart) can change status +- Uses existing permission system + +### Contribution Type Change + +**MVP - Same Interval Only:** + +- Member can only choose contribution type with **same interval** +- Example: From "Regular (yearly)" to "Reduced (yearly)" ✓ +- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗ + +**Logic on Change:** + +1. Check: New contribution type has same interval +2. If yes: Set `member.contribution_type_id` +3. Future **unpaid** periods: Delete and regenerate with new amount +4. Paid/suspended periods: Remain unchanged (historical amount) + +**Future - Different Intervals:** + +- Enable interval switching (e.g., yearly → monthly) +- More complex logic for period overlaps +- Needs additional validation + +### Member Exit + +**Logic:** + +- Periods only generated until `member.left_at` +- Existing periods remain visible +- Unpaid exit period can be marked as "suspended" + +**Example:** + +``` +Exit: 15.08.2024 +Yearly period: 01.01.2024 - 31.12.2024 + +→ Period 2024 is shown (Status: unpaid) +→ Admin can set to "suspended" +→ No periods for 2025+ generated +``` + +--- + +## UI/UX Design + +### Member List View + +**New Column: "Contribution Status"** + +**Default Display (Last Period):** + +- Shows status of **last completed** period +- Example in 2024: Shows contribution for 2023 +- Color coding: + - Green: paid ✓ + - Red: unpaid ✗ + - Gray: suspended ⊘ + +**Optional: Show Current Period** + +- Toggle: "Show current period" (2024) +- Admin decides what to display + +**Filters:** + +- "Unpaid contributions in last period" +- "Unpaid contributions in current period" + +### Member Detail View + +**Section: "Contributions"** + +**Contribution Type Assignment:** + +``` +┌─────────────────────────────────────┐ +│ Contribution Type: [Dropdown] │ +│ ⚠ Only types with same interval │ +│ can be selected │ +└─────────────────────────────────────┘ +``` + +**Period Table:** + +``` +┌───────────────┬──────────┬────────┬──────────┬─────────┐ +│ Period │ Interval │ Amount │ Status │ Action │ +├───────────────┼──────────┼────────┼──────────┼─────────┤ +│ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │ +│ 31.12.2023 │ │ │ │ │ +├───────────────┼──────────┼────────┼──────────┼─────────┤ +│ 01.01.2024- │ Yearly │ 60 € │ ☐ Open │ [Mark │ +│ 31.12.2024 │ │ │ │ as paid]│ +├───────────────┼──────────┼────────┼──────────┼─────────┤ +│ 01.01.2025- │ Yearly │ 60 € │ ☐ Open │ [Mark │ +│ 31.12.2025 │ │ │ │ as paid]│ +└───────────────┴──────────┴────────┴──────────┴─────────┘ + +Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended +``` + +**Quick Marking:** + +- Checkbox in each row for fast marking +- Button: "Mark selected as paid" +- Bulk action for multiple periods + +### Admin: Contribution Types Management + +**List:** + +``` +┌────────────┬──────────┬──────────┬────────────┬─────────┐ +│ Name │ Amount │ Interval │ Members │ Actions │ +├────────────┼──────────┼──────────┼────────────┼─────────┤ +│ Regular │ 60 € │ Yearly │ 45 │ [Edit] │ +│ Reduced │ 30 € │ Yearly │ 12 │ [Edit] │ +│ Student │ 20 € │ Monthly │ 8 │ [Edit] │ +└────────────┴──────────┴──────────┴────────────┴─────────┘ +``` + +**Edit:** + +- Name: ✓ editable +- Amount: ✓ editable +- Description: ✓ editable +- Interval: ✗ **NOT** editable (grayed out) + +**Warning on Amount Change:** + +``` +⚠ Change amount to 65 €? + +Impact: +- 45 members affected +- Future unpaid periods will be generated with 65 € +- Already paid periods remain with old amount + +[Cancel] [Confirm] +``` + +### Admin: Settings + +**Contribution Configuration:** + +``` +Default Contribution Type: [Dropdown: Contribution Types] + +Selected: "Regular (60 €, Yearly)" + +This contribution type is automatically assigned to all new members. +Can be changed individually per member. + +--- + +☐ Include joining period + +When active: +Members pay from the period of their joining. + +Example (Yearly): +Joining: 15.03.2023 +→ Pays from 2023 + +When inactive: +Members pay from the next full period. + +Example (Yearly): +Joining: 15.03.2023 +→ Pays from 2024 +``` + +--- + +## Edge Cases + +### 1. Contribution Type Change with Different Interval + +**MVP:** Blocked (only same interval allowed) + +**UI:** + +``` +Error: Interval change not possible + +Current contribution type: "Regular (Yearly)" +Selected contribution type: "Student (Monthly)" + +Changing the interval is currently not possible. +Please select a contribution type with interval "Yearly". + +[OK] +``` + +**Future:** + +- Allow interval switching +- Calculate overlaps +- Generate new periods without duplicates + +### 2. Exit with Unpaid Contributions + +**Scenario:** + +``` +Member exits: 15.08.2024 +Yearly period 2024: unpaid +``` + +**UI Notice on Exit:** + +``` +⚠ Unpaid contributions present + +This member has 1 unpaid period(s): +- 2024: 60 € (unpaid) + +Do you want to continue? + +[ ] Mark contribution as "suspended" +[Cancel] [Confirm Exit] +``` + +### 3. Multiple Unpaid Periods + +**Scenario:** Member hasn't paid for 2 years + +**Display:** + +``` +┌───────────────┬──────────┬────────┬──────────┬─────────┐ +│ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │ +│ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │ +│ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │ +└───────────────┴──────────┴────────┴──────────┴─────────┘ + +[Mark selected as paid] (2 selected) +``` + +### 4. Amount Changes + +**Scenario:** + +``` +2023: Regular = 50 € +2024: Regular = 60 € (increase) +``` + +**Result:** + +- Period 2023: Saved with 50 € (history) +- Period 2024: Generated with 60 € (current) +- Both periods show correct historical amount + +### 5. Date Boundaries + +**Problem:** What if today = 01.01.2025? + +**Solution:** + +- Current period (2025) is generated +- Status: unpaid (open) +- Shown in overview + +--- + +## Implementation Scope + +### MVP (Phase 1) + +**Included:** + +- ✓ Contribution types (CRUD) +- ✓ Automatic period generation +- ✓ Status management (paid/unpaid/suspended) +- ✓ Member overview with contribution status +- ✓ Period view per member +- ✓ Quick checkbox marking +- ✓ Bulk actions +- ✓ Amount history +- ✓ Same-interval type change +- ✓ Default contribution type +- ✓ Joining period configuration + +**NOT Included:** + +- ✗ Interval change (only same interval) +- ✗ Payment details (date, method) +- ✗ Automatic integration (vereinfacht.digital) +- ✗ Prorata calculation +- ✗ Reports/statistics +- ✗ Reminders/dunning (manual via filters) + +### Future Enhancements + +**Phase 2:** + +- Payment details (date, amount, method) +- Interval change for future unpaid periods +- Manual vereinfacht.digital links per member +- Extended filter options + +**Phase 3:** + +- Automated vereinfacht.digital integration +- Automatic payment matching +- SEPA integration +- Advanced reports From b509dc4ea37134c6664a4ecc1d713a0a85e87d6d Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 19 Nov 2025 17:27:26 +0100 Subject: [PATCH 02/88] chore: add migration for show in overview flag --- ..._add_show_in_overview_to_custom_fields.exs | 21 ++++ .../repo/custom_fields/20251119160509.json | 118 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251119160509.json diff --git a/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs b/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs new file mode 100644 index 0000000..32b4801 --- /dev/null +++ b/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields 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(:custom_fields) do + add :show_in_overview, :boolean, null: false, default: true + end + end + + def down do + alter table(:custom_fields) do + remove :show_in_overview + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20251119160509.json b/priv/resource_snapshots/repo/custom_fields/20251119160509.json new file mode 100644 index 0000000..718fe51 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251119160509.json @@ -0,0 +1,118 @@ +{ + "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": "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": "false", + "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": "9FBFC42DA896058F88DEDAE774614919222BF2EF2F8CB27386D02C2CE67F03DE", + "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 + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file From 4313703538315041f7e8a4612eada1d1b0c581e1 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:14:29 +0100 Subject: [PATCH 03/88] test: added tests --- .../custom_field_show_in_overview_test.exs | 77 +++ ...index_custom_fields_accessibility_test.exs | 109 +++++ .../index_custom_fields_display_test.exs | 262 ++++++++++ .../index_custom_fields_edge_cases_test.exs | 174 +++++++ .../index_custom_fields_sorting_test.exs | 446 ++++++++++++++++++ 5 files changed, 1068 insertions(+) create mode 100644 test/membership/custom_field_show_in_overview_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_accessibility_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_display_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_edge_cases_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_sorting_test.exs diff --git a/test/membership/custom_field_show_in_overview_test.exs b/test/membership/custom_field_show_in_overview_test.exs new file mode 100644 index 0000000..adac600 --- /dev/null +++ b/test/membership/custom_field_show_in_overview_test.exs @@ -0,0 +1,77 @@ +defmodule Mv.Membership.CustomFieldShowInOverviewTest do + @moduledoc """ + Tests for CustomField show_in_overview attribute. + + Tests cover: + - Creating custom fields with show_in_overview: true + - Creating custom fields with show_in_overview: false (default) + - Updating show_in_overview to true + - Updating show_in_overview to false + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "show_in_overview attribute" do + test "creates custom field with show_in_overview: true" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_show", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "creates custom field with show_in_overview: true (default)" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_hide", + value_type: :string + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "updates show_in_overview to true" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: true}) + |> Ash.update() + + assert updated_field.show_in_overview == true + end + + test "updates show_in_overview to false" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: false}) + |> Ash.update() + + assert updated_field.show_in_overview == false + end + end +end diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs new file mode 100644 index 0000000..e4d174f --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -0,0 +1,109 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do + @moduledoc """ + Accessibility tests for custom field columns in the member overview. + + Tests cover: + - SortHeaderComponent for custom fields has correct ARIA labels + - Tab navigation works for custom field columns + - Screen reader announcements for sorting + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + %{member: member, field: field} + end + + test "sort header component for custom fields has correct ARIA labels", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button has aria-label + assert html =~ ~r/aria-label=["']Click to sort["']/i or + html =~ ~r/aria-label=["'].*sort.*["']/i + + # Check that data-testid is present for testing + assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/ + end + + test "sort header component shows correct ARIA label when sorted ascending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Check that aria-label indicates ascending sort + assert html =~ ~r/aria-label=["'].*ascending.*["']/i + end + + test "sort header component shows correct ARIA label when sorted descending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Check that aria-label indicates descending sort + assert html =~ ~r/aria-label=["'].*descending.*["']/i + end + + test "custom field column header is keyboard accessible", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button is a button element (keyboard accessible) + assert html =~ ~r/]*data-testid=["']custom_field_#{field.id}["']/ + + # Button should not have tabindex="-1" (which would remove from tab order) + refute html =~ ~r/tabindex=["']-1["']/ + end + + test "custom field column header has proper semantic structure", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that custom field name is displayed in the header + assert html =~ field.name + end +end diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs new file mode 100644 index 0000000..7788c60 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -0,0 +1,262 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do + @moduledoc """ + Tests for displaying custom fields in the member overview. + + Tests cover: + - Custom fields with show_in_overview: true are displayed + - Custom fields with show_in_overview: false are not displayed + - Multiple custom fields with show_in_overview: true are all displayed + - Custom field values are correctly formatted for different types + - Members without custom field values show empty cell or "-" + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom fields + {:ok, field_show_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "phone_mobile", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_hide} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "internal_note", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + {:ok, field_show_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :integer, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_boolean} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_date} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday", + value_type: :date, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_email} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "secondary_email", + value_type: :email, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field values for member1 + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_string.id, + value: %{"_union_type" => "string", "_union_value" => "+49123456789"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 12345} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_boolean.id, + value: %{"_union_type" => "boolean", "_union_value" => true} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_date.id, + value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_email.id, + value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"} + }) + |> Ash.create() + + # Create hidden custom field value (should not be displayed) + {:ok, _cfv_hidden} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_hide.id, + value: %{"_union_type" => "string", "_union_value" => "Internal note"} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + field_show_string: field_show_string, + field_hide: field_hide, + field_show_integer: field_show_integer, + field_show_boolean: field_show_boolean, + field_show_date: field_show_date, + field_show_email: field_show_email + } + end + + test "displays custom field with show_in_overview: true", %{ + conn: conn, + member1: _member1, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is displayed + assert html =~ field.name + + # Check that the value is displayed + assert html =~ "+49123456789" + end + + test "does not display custom field with show_in_overview: false", %{ + conn: conn, + member1: _member1, + field_hide: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the hidden custom field column header is NOT displayed + refute html =~ field.name + + # Check that the value is NOT displayed + refute html =~ "Internal note" + end + + test "displays multiple custom fields with show_in_overview: true", %{ + conn: conn, + field_show_string: field_string, + field_show_integer: field_integer, + field_show_boolean: field_boolean + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all visible custom field column headers are displayed + assert html =~ field_string.name + assert html =~ field_integer.name + assert html =~ field_boolean.name + end + + test "formats string custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "+49123456789" + end + + test "formats integer custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "12345" + end + + test "formats boolean custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Boolean should be displayed as "Yes" or "No" or similar + # Check for true representation + assert html =~ "true" or html =~ "Yes" or html =~ "Ja" + end + + test "formats date custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Date should be displayed in readable format + assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990" + end + + test "formats email custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "alice.private@example.com" + end + + test "shows empty cell or placeholder for members without custom field values", %{ + conn: conn, + member2: _member2, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # The custom field column should exist + assert html =~ field.name + + # Member2 should have an empty cell for this field + # We check that member2's row exists but doesn't have the value + assert html =~ "Bob Brown" + # The value should not appear for member2 (only for member1) + # We check that the value appears somewhere (for member1) but member2 row should have "-" + assert html =~ "+49123456789" + end +end diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs new file mode 100644 index 0000000..9d44c40 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -0,0 +1,174 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do + @moduledoc """ + Edge case tests for custom fields in the member overview. + + Tests cover: + - Custom field without values (all members have no value) + - Very long custom field values are correctly displayed + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, Member} + + test "displays custom field column even when no members have values", %{conn: conn} do + # Create test members without custom field values + {:ok, _member1} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + {:ok, _member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true but no values + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is still displayed + assert html =~ field.name + end + + test "displays very long custom field values correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "long_note", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create very long value (but within limits) + long_value = String.duplicate("A", 500) + + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => long_value} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the value is displayed (may be truncated in UI, but should be present) + # We check for at least part of the value + assert html =~ "A" or html =~ long_value + end + + test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create multiple custom fields with show_in_overview: true + {:ok, field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field1", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field2} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field3} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field3", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values for all fields + {:ok, _cfv1} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field1.id, + value: %{"_union_type" => "string", "_union_value" => "Value1"} + }) + |> Ash.create() + + {:ok, _cfv2} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field2.id, + value: %{"_union_type" => "string", "_union_value" => "Value2"} + }) + |> Ash.create() + + {:ok, _cfv3} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field3.id, + value: %{"_union_type" => "string", "_union_value" => "Value3"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all custom field columns are displayed + assert html =~ field1.name + assert html =~ field2.name + assert html =~ field3.name + + # Check that all values are displayed + assert html =~ "Value1" + assert html =~ "Value2" + assert html =~ "Value3" + end +end + diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs new file mode 100644 index 0000000..e1c99b2 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -0,0 +1,446 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do + @moduledoc """ + Tests for sorting by custom fields in the member overview. + + Tests cover: + - Sorting by custom field (ascending) + - Sorting by custom field (descending) + - Sorting by custom field works with search + - Sorting by custom field works with URL parameters + - Sorting by custom field works with other columns + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + {:ok, member3} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Charlie", + last_name: "Clark", + email: "charlie@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "priority", + value_type: :integer, + 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: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "C003"} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "B002"} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 10} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 30} + }) + |> Ash.create() + + {:ok, _cfv6} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 20} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + member3: member3, + field_string: field_string, + field_integer: field_integer + } + end + + test "sorts by custom field ascending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']") + end + + test "sorts by custom field descending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?sort_field=custom_field_#{field.id}&sort_order=asc") + + # Click again to toggle to descending + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "sorting by custom field works with search", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=Alice") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL maintains search query + assert_patch(view, "/members?query=Alice&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Check that the sort state is correctly applied + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "clicking different custom field column resets order to ascending", %{ + conn: conn, + field_string: field_string, + field_integer: field_integer + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") + + # Click on a different custom field column + view + |> element("[data-testid='custom_field_#{field_integer.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc") + end + + test "clicking regular column after custom field column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Click on email column + view + |> element("[data-testid='email']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + end + + test "clicking custom field column after regular column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + # Click on custom field column + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_another_value.first_name) + zebra_pos = :binary.match(html, member_with_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "AnotherValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "WithValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In ASC order: Apple should come before Zebra + assert apple_idx < zebra_idx, "Apple should come before Zebra in ASC order" + + # NULL and empty should come after all values + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + end + + test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_value.first_name) + zebra_pos = :binary.match(html, member_with_another_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "WithValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "AnotherValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In DESC order: Zebra should come before Apple + assert zebra_idx < apple_idx, "Zebra should come before Apple in DESC order" + + # NULL and empty should come after all values + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + end +end From 11179e51f0d54242f6d4a2e6b555477d082f30cd Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:15:14 +0100 Subject: [PATCH 04/88] chore: show in overview attribute to custom field --- lib/membership/custom_field.ex | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index e1cf397..5b7514c 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -14,6 +14,7 @@ defmodule Mv.Membership.CustomField do - `description` - Optional human-readable description - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) + - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted ## Supported Value Types - `:string` - Text data (max 10,000 characters) @@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required] + default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] create :create do - accept [:name, :value_type, :description, :immutable, :required] + accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -119,6 +120,12 @@ defmodule Mv.Membership.CustomField do attribute :required, :boolean, default: false, allow_nil?: false + + attribute :show_in_overview, :boolean, + default: true, + allow_nil?: false, + public?: true, + description: "If true, this custom field will be displayed in the member overview table" end relationships do From 100ed96493ddfd669cfc0aad2c22e6d940c0653e Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:18:27 +0100 Subject: [PATCH 05/88] feat: adds dynamic cols to table core component --- lib/mv_web/components/core_components.ex | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 656d3c0..24e5ffe 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -318,6 +318,13 @@ defmodule MvWeb.CoreComponents do default: &Function.identity/1, doc: "the function for mapping each row before calling the :col and :action slots" + attr :dynamic_cols, :list, + default: [], + doc: "list of dynamic column definitions with :custom_field and :render functions" + + attr :sort_field, :any, default: nil, doc: "current sort field" + attr :sort_order, :atom, default: nil, doc: "current sort order" + slot :col, required: true do attr :label, :string end @@ -335,6 +342,16 @@ defmodule MvWeb.CoreComponents do {col[:label]} + + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} + field={"custom_field_#{dyn_col[:custom_field].id}"} + label={dyn_col[:custom_field].name} + sort_field={@sort_field} + sort_order={@sort_order} + /> + {gettext("Actions")} @@ -349,6 +366,22 @@ defmodule MvWeb.CoreComponents do > {render_slot(col, @row_item.(row))} + + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) + if rendered == "" do + "" + else + rendered + end + else + "" + end} +
<%= for action <- @action do %> From e7c4a4f62fc9667401719954b7d33c32400c78dc Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:18:58 +0100 Subject: [PATCH 06/88] feat: add dynamic cols to member overview and checkbox to form --- docs/feature-roadmap.md | 7 +- lib/mv_web/live/custom_field_live/form.ex | 2 + lib/mv_web/live/member_live/index.ex | 407 ++++++++++++++++-- lib/mv_web/live/member_live/index.html.heex | 4 +- .../live/member_live/index/formatter.ex | 78 ++++ 5 files changed, 460 insertions(+), 38 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/formatter.ex diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 9a6517d..2313fd7 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -94,15 +94,18 @@ - ✅ CustomFieldValue type management - ✅ Dynamic custom field value assignment to members - ✅ Union type storage (JSONB) +- ✅ Default field visibility configuration + +**Closed Issues:** +- [#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) **Open Issues:** -- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 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) **Missing Features:** -- ❌ Default field visibility configuration - ❌ Field groups/categories - ❌ Conditional fields (show field X if field Y = value) - ❌ Field validation rules (min/max, regex patterns) diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index ab8f104..99317a9 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -18,6 +18,7 @@ defmodule MvWeb.CustomFieldLive.Form do - description - Human-readable explanation - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) + - show_in_overview - If true, this custom field will be displayed in the member overview table (default: true) ## Value Type Selection - `:string` - Text data (unlimited length) @@ -60,6 +61,7 @@ defmodule MvWeb.CustomFieldLive.Form do <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> + <.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} /> <.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save Custom field")} diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c933133..4a134f2 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -26,6 +26,11 @@ defmodule MvWeb.MemberLive.Index do """ use MvWeb, :live_view + alias MvWeb.MemberLive.Index.Formatter + + # Prefix used in sort field names for custom fields (e.g., "custom_field_") + @custom_field_prefix "custom_field_" + @doc """ Initializes the LiveView state. @@ -34,6 +39,19 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def mount(_params, _session, socket) do + # Load custom fields that should be shown in overview + require Ash.Query + import Ash.Expr + + # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView + # and result in a 500 error page. This is appropriate for LiveViews where errors + # should be visible to the user rather than silently failing. + custom_fields_visible = + Mv.Membership.CustomField + |> Ash.Query.filter(expr(show_in_overview == true)) + |> Ash.Query.sort(name: :asc) + |> Ash.read!() + socket = socket |> assign(:page_title, gettext("Members")) @@ -41,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, []) + |> assign(:custom_fields_visible, custom_fields_visible) # We call handle params to use the query from the URL {:ok, socket} @@ -60,6 +79,8 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_event("delete", %{"id" => id}, socket) do + # Note: Using bang versions (!) - errors will be handled by Phoenix LiveView + # This ensures users see error messages if deletion fails (e.g., permission denied) member = Ash.get!(Mv.Membership.Member, id) Ash.destroy!(member) @@ -108,7 +129,14 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_info({:sort, field_str}, socket) do - field = String.to_existing_atom(field_str) + # Handle both atom and string field names (for custom fields) + field = + try do + String.to_existing_atom(field_str) + rescue + ArgumentError -> field_str + end + {new_field, new_order} = determine_new_sort(field, socket) socket @@ -158,10 +186,37 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_search(params) |> maybe_update_sort(params) |> load_members(params["query"]) + |> prepare_dynamic_cols() {:noreply, socket} end + # Prepares dynamic column definitions for custom fields that should be shown in the overview. + # + # Creates a list of column definitions, each containing: + # - `:custom_field` - The CustomField resource + # - `:render` - A function that formats the custom field value for a given member + # + # Returns the socket with `:dynamic_cols` assigned. + defp prepare_dynamic_cols(socket) do + dynamic_cols = + Enum.map(socket.assigns.custom_fields_visible, fn custom_field -> + %{ + custom_field: custom_field, + render: fn member -> + case get_custom_field_value(member, custom_field) do + nil -> "" + cfv -> + formatted = Formatter.format_custom_field_value(cfv.value, custom_field) + if formatted == "", do: "", else: formatted + end + end + } + end) + + assign(socket, :dynamic_cols, dynamic_cols) + end + # ------------------------------------------------------------- # FUNCTIONS # ------------------------------------------------------------- @@ -177,8 +232,8 @@ defmodule MvWeb.MemberLive.Index do # Updates both the active and old SortHeader components defp update_sort_components(socket, old_field, new_field, new_order) do - active_id = :"sort_#{new_field}" - old_id = :"sort_#{old_field}" + active_id = to_sort_id(new_field) + old_id = to_sort_id(old_field) # Update the new SortHeader send_update(MvWeb.Components.SortHeaderComponent, @@ -197,11 +252,32 @@ defmodule MvWeb.MemberLive.Index do socket end + # Converts a field (atom or string) to a sort component ID atom + # Handles both existing atoms and strings that need to be converted + defp to_sort_id(field) when is_binary(field) do + try do + String.to_existing_atom("sort_#{field}") + rescue + ArgumentError -> :"sort_#{field}" + end + end + + defp to_sort_id(field) when is_atom(field) do + :"sort_#{field}" + end + # Builds sort URL and pushes navigation patch defp push_sort_url(socket, field, order) do + field_str = + if is_atom(field) do + Atom.to_string(field) + else + field + end + query_params = %{ "query" => socket.assigns.query, - "sort_field" => Atom.to_string(field), + "sort_field" => field_str, "sort_order" => Atom.to_string(order) } @@ -214,7 +290,25 @@ defmodule MvWeb.MemberLive.Index do )} end - # Load members eg based on a query for sorting + # Loads members from the database with custom field values and applies search/sort filters. + # + # Process: + # 1. Builds base query with selected fields + # 2. Loads custom field values for visible custom fields + # 3. Applies search filter if provided + # 4. Applies sorting (database-level for regular fields, in-memory for custom fields) + # 5. Filters custom field values to only visible ones (reduces memory usage) + # + # Performance Considerations: + # - In-memory sorting: Custom field sorting is done in memory after loading. + # This is suitable for small to medium datasets (<1000 members). + # For larger datasets, consider implementing database-level sorting or pagination. + # - Memory filtering: Custom field values are filtered after loading to reduce + # memory usage, but all members are still loaded into memory. + # - No pagination: All matching members are loaded at once. For large result sets, + # consider implementing pagination (see Issue #165). + # + # Returns the socket with `:members` assigned. defp load_members(socket, search_query) do query = Mv.Membership.Member @@ -232,16 +326,61 @@ defmodule MvWeb.MemberLive.Index do :join_date ]) + # Load custom field values for visible custom fields + custom_field_ids = Enum.map(socket.assigns.custom_fields_visible, & &1.id) + query = load_custom_field_values(query, custom_field_ids) + # Apply the search filter first query = apply_search_filter(query, search_query) # Apply sorting based on current socket state - query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order) + # For custom fields, we sort after loading + {query, sort_after_load} = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + # Note: Using Ash.read! - errors will be handled by Phoenix LiveView + # This is appropriate for data loading in LiveViews members = Ash.read!(query) + + # Filter custom field values to only visible ones (reduces memory usage) + # Performance: This iterates through all members and their custom_field_values. + # For large datasets (>1000 members), this could be optimized by filtering + # at the database level, but requires more complex Ash queries. + custom_field_ids = MapSet.new(Enum.map(socket.assigns.custom_fields_visible, & &1.id)) + members = Enum.map(members, fn member -> + # Only filter if custom_field_values is loaded (is a list, not Ash.NotLoaded) + if is_list(member.custom_field_values) do + filtered_values = Enum.filter(member.custom_field_values, fn cfv -> + cfv.custom_field_id in custom_field_ids + end) + %{member | custom_field_values: filtered_values} + else + member + end + end) + + # Sort in memory if needed (for custom fields) + members = if sort_after_load do + sort_members_in_memory(members, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + else + members + end + assign(socket, :members, members) end + # Load custom field values for the given custom field IDs + defp load_custom_field_values(query, []) do + query + end + + defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do + # Load all custom field values with their custom_field relationship + # Note: We filter to visible custom fields after loading to reduce memory usage + # Ash loads relationships efficiently with JOINs, but we only keep visible ones + query + |> Ash.Query.load(custom_field_values: [custom_field: [:id, :name, :value_type]]) + end + # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- @@ -264,15 +403,24 @@ defmodule MvWeb.MemberLive.Index do defp toggle_order(nil), do: :asc # Function to sort the column if needed - defp maybe_sort(query, nil, _), do: query + # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory + defp maybe_sort(query, nil, _, _), do: {query, false} - defp maybe_sort(query, field, :asc) when not is_nil(field), - do: Ash.Query.sort(query, [{field, :asc}]) + defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do + if custom_field_sort?(field) do + # Custom fields need to be sorted in memory after loading + {query, true} + else + # Only sort by atom fields (regular member fields) in database + if is_atom(field) do + {Ash.Query.sort(query, [{field, order}]), false} + else + {query, false} + end + end + end - defp maybe_sort(query, field, :desc) when not is_nil(field), - do: Ash.Query.sort(query, [{field, :desc}]) - - defp maybe_sort(query, _, _), do: query + defp maybe_sort(query, _, _, _), do: {query, false} # Validate that a field is sortable defp valid_sort_field?(field) when is_atom(field) do @@ -288,12 +436,156 @@ defmodule MvWeb.MemberLive.Index do :join_date ] - field in valid_fields + field in valid_fields or custom_field_sort?(field) + end + + defp valid_sort_field?(field) when is_binary(field) do + custom_field_sort?(field) end defp valid_sort_field?(_), do: false - # Function to maybe update the sort + # Check if field is a custom field sort field (format: custom_field_) + defp custom_field_sort?(field) when is_atom(field) do + field_str = Atom.to_string(field) + String.starts_with?(field_str, @custom_field_prefix) + end + + defp custom_field_sort?(field) when is_binary(field) do + String.starts_with?(field, @custom_field_prefix) + end + + defp custom_field_sort?(_), do: false + + # Extracts the custom field ID from a sort field name. + # + # Sort fields for custom fields use the format: "custom_field_" + # This function extracts the ID part. + # + # Examples: + # extract_custom_field_id("custom_field_123") -> "123" + # extract_custom_field_id(:custom_field_123) -> "123" + # extract_custom_field_id("first_name") -> nil + defp extract_custom_field_id(field) when is_atom(field) do + field_str = Atom.to_string(field) + extract_custom_field_id(field_str) + end + + defp extract_custom_field_id(field) when is_binary(field) do + case String.split(field, @custom_field_prefix) do + ["", id_str] -> id_str + _ -> nil + end + end + + defp extract_custom_field_id(_), do: nil + + # Sorts members in memory by a custom field value. + # + # Process: + # 1. Extracts custom field ID from sort field name + # 2. Finds the corresponding CustomField resource + # 3. Splits members into those with values and those without + # 4. Sorts members with values by the extracted value + # 5. Combines: sorted values first, then NULL/empty values at the end + # + # Performance Note: + # This function sorts in memory, which is suitable for small to medium datasets (<1000 members). + # For larger datasets, consider implementing database-level sorting or pagination. + # + # Parameters: + # - `members` - List of Member resources to sort + # - `field` - Sort field name (format: "custom_field_" or atom) + # - `order` - Sort order (`:asc` or `:desc`) + # - `custom_fields` - List of visible CustomField resources + # + # Returns the sorted list of members. + defp sort_members_in_memory(members, field, order, custom_fields) do + custom_field_id_str = extract_custom_field_id(field) + + case custom_field_id_str do + nil -> + members + + id_str -> + # Find the custom field by matching the ID string + custom_field = + Enum.find(custom_fields, fn cf -> + to_string(cf.id) == id_str + end) + + case custom_field do + nil -> + members + + cf -> + # Split members into those with values and those without (NULL/empty) + {members_with_values, members_without_values} = + Enum.split_with(members, fn member -> + case get_custom_field_value(member, cf) do + nil -> false + cfv -> + extracted = extract_sort_value(cfv.value, cf.value_type) + not is_empty_value(extracted, cf.value_type) + end + end) + + # Sort members with values + sorted_with_values = Enum.sort_by(members_with_values, fn member -> + cfv = get_custom_field_value(member, cf) + extracted = extract_sort_value(cfv.value, cf.value_type) + normalize_sort_value(extracted, order) + end) + + # For DESC, reverse only the members with values + sorted_with_values = if order == :desc do + Enum.reverse(sorted_with_values) + else + sorted_with_values + end + + # Combine: sorted values first, then NULL/empty values at the end + sorted_with_values ++ members_without_values + end + end + end + + # Extracts a sortable value from a custom field value based on its type. + # + # Handles different value formats: + # - `%Ash.Union{}` - Extracts value and type from union + # - Direct values - Returns as-is for primitive types + # + # Returns the extracted value suitable for sorting. + defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do + extract_sort_value(value, type) + end + + defp extract_sort_value(value, :string) when is_binary(value), do: value + defp extract_sort_value(value, :integer) when is_integer(value), do: value + defp extract_sort_value(value, :boolean) when is_boolean(value), do: value + defp extract_sort_value(%Date{} = date, :date), do: date + defp extract_sort_value(value, :email) when is_binary(value), do: value + defp extract_sort_value(value, _type), do: to_string(value) + + # Check if a value is considered empty (NULL or empty string) + defp is_empty_value(value, :string) when is_binary(value) do + String.trim(value) == "" + end + defp is_empty_value(value, :email) when is_binary(value) do + String.trim(value) == "" + end + defp is_empty_value(_value, _type), do: false + + # Normalize sort value for DESC order + # For DESC, we sort ascending first, then reverse the list + # This function is kept for consistency but doesn't need to invert values + defp normalize_sort_value(value, _order), do: value + + + # Updates sort field and order from URL parameters if present. + # + # Validates the sort field and order, falling back to defaults if invalid. defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) @@ -305,33 +597,50 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_sort(socket, _), do: socket - defp determine_field(default, sf) do - case sf do - "" -> - default + # Determine sort field from URL parameter, validating against allowed fields + defp determine_field(default, ""), do: default + defp determine_field(default, nil), do: default - nil -> - default - - sf when is_binary(sf) -> - sf - |> String.to_existing_atom() - |> handle_atom_conversion(default) - - sf when is_atom(sf) -> - handle_atom_conversion(sf, default) - - _ -> - default + # Determines the valid sort field from a URL parameter. + # + # Validates the field against allowed sort fields (regular member fields or custom fields). + # Falls back to default if the field is invalid. + # + # Parameters: + # - `default` - Default field to use if validation fails + # - `sf` - Sort field from URL (can be atom, string, nil, or empty string) + # + # Returns a valid sort field (atom or string for custom fields). + defp determine_field(default, sf) when is_binary(sf) do + # Check if it's a custom field sort (starts with "custom_field_") + if custom_field_sort?(sf) do + if valid_sort_field?(sf), do: sf, else: default + else + # Try to convert to atom for regular fields + try do + atom = String.to_existing_atom(sf) + if valid_sort_field?(atom), do: atom, else: default + rescue + ArgumentError -> default + end end end - defp handle_atom_conversion(val, default) when is_atom(val) do - if valid_sort_field?(val), do: val, else: default + defp determine_field(default, sf) when is_atom(sf) do + if valid_sort_field?(sf), do: sf, else: default end - defp handle_atom_conversion(_, default), do: default + defp determine_field(default, _), do: default + # Determines the valid sort order from a URL parameter. + # + # Validates that the order is either "asc" or "desc", falling back to default if invalid. + # + # Parameters: + # - `default` - Default order to use if validation fails + # - `so` - Sort order from URL (string, atom, nil, or empty string) + # + # Returns `:asc` or `:desc`. defp determine_order(default, so) do case so do "" -> default @@ -350,4 +659,32 @@ defmodule MvWeb.MemberLive.Index do # Keep the previous search query if no new one is provided socket end + + # ------------------------------------------------------------- + # Helper Functions for Custom Field Values + # ------------------------------------------------------------- + + # Retrieves the custom field value for a specific member and custom field. + # + # Searches through the member's `custom_field_values` relationship to find + # the value matching the given custom field. + # + # Returns: + # - `%CustomFieldValue{}` if found + # - `nil` if not found or if member has no custom field values + # + # Examples: + # get_custom_field_value(member, custom_field) -> %CustomFieldValue{...} + # get_custom_field_value(member, non_existent_field) -> nil + def get_custom_field_value(member, custom_field) do + case member.custom_field_values do + nil -> nil + values when is_list(values) -> + Enum.find(values, fn cfv -> + cfv.custom_field_id == custom_field.id or + (cfv.custom_field && cfv.custom_field.id == custom_field.id) + end) + _ -> nil + end + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index cb2ccd8..67fa804 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -19,6 +19,9 @@ id="members" rows={@members} row_click={fn member -> JS.navigate(~p"/members/#{member}") end} + dynamic_cols={@dynamic_cols} + sort_field={@sort_field} + sort_order={@sort_order} > @@ -185,7 +188,6 @@ > {member.join_date} - <:action :let={member}>
<.link navigate={~p"/members/#{member}"}>{gettext("Show")} diff --git a/lib/mv_web/live/member_live/index/formatter.ex b/lib/mv_web/live/member_live/index/formatter.ex new file mode 100644 index 0000000..d97966c --- /dev/null +++ b/lib/mv_web/live/member_live/index/formatter.ex @@ -0,0 +1,78 @@ +defmodule MvWeb.MemberLive.Index.Formatter do + @moduledoc """ + Formats custom field values for display in the member overview table. + + Handles different value types (string, integer, boolean, date, email) and + formats them appropriately for display in the UI. + """ + use Gettext, backend: MvWeb.Gettext + + @doc """ + Formats a custom field value for display. + + Handles different input formats: + - `nil` - Returns empty string + - `%Ash.Union{}` - Extracts value and type from union type + - Map (JSONB format) - Extracts type and value from map keys + - Direct value - Uses custom_field.value_type to determine format + + ## Examples + + iex> format_custom_field_value(nil, %CustomField{value_type: :string}) + "" + + iex> format_custom_field_value("test", %CustomField{value_type: :string}) + "test" + + iex> format_custom_field_value(true, %CustomField{value_type: :boolean}) + "Yes" + """ + def format_custom_field_value(nil, _custom_field), do: "" + + def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do + format_value_by_type(value, type, custom_field) + end + + def format_custom_field_value(value, custom_field) when is_map(value) do + # Handle map format from JSONB + type = Map.get(value, "type") || Map.get(value, "_union_type") + val = Map.get(value, "value") || Map.get(value, "_union_value") + format_value_by_type(val, type, custom_field) + end + + def format_custom_field_value(value, custom_field) do + format_value_by_type(value, custom_field.value_type, custom_field) + end + + # Format value based on type + defp format_value_by_type(value, :string, _) when is_binary(value) do + # Return empty string if value is empty, otherwise return the value + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :string, _), do: to_string(value) + + defp format_value_by_type(value, :integer, _), do: to_string(value) + + defp format_value_by_type(value, :email, _) when is_binary(value) do + # Return empty string if value is empty + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :email, _), do: to_string(value) + + defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes") + defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No") + defp format_value_by_type(value, :boolean, _), do: to_string(value) + + defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date) + + defp format_value_by_type(value, :date, _) when is_binary(value) do + case Date.from_iso8601(value) do + {:ok, date} -> Date.to_string(date) + _ -> value + end + end + + defp format_value_by_type(value, _type, _), do: to_string(value) +end From 82bd5732768b92ab875ccf868a91ed3a388b967d Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:10:27 +0100 Subject: [PATCH 07/88] formatting --- lib/mv_web/components/core_components.ex | 1 + lib/mv_web/live/member_live/index.ex | 179 ++++++++++++------ ...index_custom_fields_accessibility_test.exs | 8 +- .../index_custom_fields_display_test.exs | 2 +- .../index_custom_fields_edge_cases_test.exs | 1 - .../index_custom_fields_sorting_test.exs | 25 ++- 6 files changed, 148 insertions(+), 68 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 24e5ffe..b8fe0fc 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -373,6 +373,7 @@ defmodule MvWeb.CoreComponents do > {if dyn_col[:render] do rendered = dyn_col[:render].(@row_item.(row)) + if rendered == "" do "" else diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 4a134f2..419df17 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -205,7 +205,9 @@ defmodule MvWeb.MemberLive.Index do custom_field: custom_field, render: fn member -> case get_custom_field_value(member, custom_field) do - nil -> "" + nil -> + "" + cfv -> formatted = Formatter.format_custom_field_value(cfv.value, custom_field) if formatted == "", do: "", else: formatted @@ -335,7 +337,13 @@ defmodule MvWeb.MemberLive.Index do # Apply sorting based on current socket state # For custom fields, we sort after loading - {query, sort_after_load} = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + {query, sort_after_load} = + maybe_sort( + query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.custom_fields_visible + ) # Note: Using Ash.read! - errors will be handled by Phoenix LiveView # This is appropriate for data loading in LiveViews @@ -346,24 +354,21 @@ defmodule MvWeb.MemberLive.Index do # For large datasets (>1000 members), this could be optimized by filtering # at the database level, but requires more complex Ash queries. custom_field_ids = MapSet.new(Enum.map(socket.assigns.custom_fields_visible, & &1.id)) - members = Enum.map(members, fn member -> - # Only filter if custom_field_values is loaded (is a list, not Ash.NotLoaded) - if is_list(member.custom_field_values) do - filtered_values = Enum.filter(member.custom_field_values, fn cfv -> - cfv.custom_field_id in custom_field_ids - end) - %{member | custom_field_values: filtered_values} - else - member - end - end) + + members = filter_member_custom_field_values(members, custom_field_ids) # Sort in memory if needed (for custom fields) - members = if sort_after_load do - sort_members_in_memory(members, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) - else - members - end + members = + if sort_after_load do + sort_members_in_memory( + members, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.custom_fields_visible + ) + else + members + end assign(socket, :members, members) end @@ -381,6 +386,28 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.load(custom_field_values: [custom_field: [:id, :name, :value_type]]) end + # Filters custom field values to only visible ones for all members + defp filter_member_custom_field_values(members, custom_field_ids) do + Enum.map(members, fn member -> + filter_single_member_custom_field_values(member, custom_field_ids) + end) + end + + # Filters custom field values for a single member + defp filter_single_member_custom_field_values(member, _custom_field_ids) + when not is_list(member.custom_field_values) do + member + end + + defp filter_single_member_custom_field_values(member, custom_field_ids) do + filtered_values = + Enum.filter(member.custom_field_values, fn cfv -> + cfv.custom_field_id in custom_field_ids + end) + + %{member | custom_field_values: filtered_values} + end + # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- @@ -508,45 +535,76 @@ defmodule MvWeb.MemberLive.Index do members id_str -> - # Find the custom field by matching the ID string - custom_field = - Enum.find(custom_fields, fn cf -> - to_string(cf.id) == id_str - end) + sort_members_by_custom_field(members, id_str, order, custom_fields) + end + end - case custom_field do - nil -> - members + # Sorts members by a specific custom field ID + defp sort_members_by_custom_field(members, id_str, order, custom_fields) do + custom_field = find_custom_field_by_id(custom_fields, id_str) - cf -> - # Split members into those with values and those without (NULL/empty) - {members_with_values, members_without_values} = - Enum.split_with(members, fn member -> - case get_custom_field_value(member, cf) do - nil -> false - cfv -> - extracted = extract_sort_value(cfv.value, cf.value_type) - not is_empty_value(extracted, cf.value_type) - end - end) + case custom_field do + nil -> + members - # Sort members with values - sorted_with_values = Enum.sort_by(members_with_values, fn member -> - cfv = get_custom_field_value(member, cf) - extracted = extract_sort_value(cfv.value, cf.value_type) - normalize_sort_value(extracted, order) - end) + cf -> + sort_members_with_custom_field(members, cf, order) + end + end - # For DESC, reverse only the members with values - sorted_with_values = if order == :desc do - Enum.reverse(sorted_with_values) - else - sorted_with_values - end + # Finds a custom field by matching its ID string + defp find_custom_field_by_id(custom_fields, id_str) do + Enum.find(custom_fields, fn cf -> + to_string(cf.id) == id_str + end) + end - # Combine: sorted values first, then NULL/empty values at the end - sorted_with_values ++ members_without_values - end + # Sorts members that have a specific custom field + defp sort_members_with_custom_field(members, custom_field, order) do + # Split members into those with values and those without (NULL/empty) + {members_with_values, members_without_values} = + split_members_by_value_presence(members, custom_field) + + # Sort members with values + sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) + + # Combine: sorted values first, then NULL/empty values at the end + sorted_with_values ++ members_without_values + end + + # Splits members into those with values and those without + defp split_members_by_value_presence(members, custom_field) do + Enum.split_with(members, fn member -> + has_non_empty_value?(member, custom_field) + end) + end + + # Checks if a member has a non-empty value for the custom field + defp has_non_empty_value?(member, custom_field) do + case get_custom_field_value(member, custom_field) do + nil -> + false + + cfv -> + extracted = extract_sort_value(cfv.value, custom_field.value_type) + not empty_value?(extracted, custom_field.value_type) + end + end + + # Sorts members that have values for the custom field + defp sort_members_with_values(members_with_values, custom_field, order) do + sorted = + Enum.sort_by(members_with_values, fn member -> + cfv = get_custom_field_value(member, custom_field) + extracted = extract_sort_value(cfv.value, custom_field.value_type) + normalize_sort_value(extracted, order) + end) + + # For DESC, reverse only the members with values + if order == :desc do + Enum.reverse(sorted) + else + sorted end end @@ -569,20 +627,21 @@ defmodule MvWeb.MemberLive.Index do defp extract_sort_value(value, _type), do: to_string(value) # Check if a value is considered empty (NULL or empty string) - defp is_empty_value(value, :string) when is_binary(value) do + defp empty_value?(value, :string) when is_binary(value) do String.trim(value) == "" end - defp is_empty_value(value, :email) when is_binary(value) do + + defp empty_value?(value, :email) when is_binary(value) do String.trim(value) == "" end - defp is_empty_value(_value, _type), do: false + + defp empty_value?(_value, _type), do: false # Normalize sort value for DESC order # For DESC, we sort ascending first, then reverse the list # This function is kept for consistency but doesn't need to invert values defp normalize_sort_value(value, _order), do: value - # Updates sort field and order from URL parameters if present. # # Validates the sort field and order, falling back to defaults if invalid. @@ -678,13 +737,17 @@ defmodule MvWeb.MemberLive.Index do # get_custom_field_value(member, non_existent_field) -> nil def get_custom_field_value(member, custom_field) do case member.custom_field_values do - nil -> nil + nil -> + nil + values when is_list(values) -> Enum.find(values, fn cfv -> cfv.custom_field_id == custom_field.id or (cfv.custom_field && cfv.custom_field.id == custom_field.id) end) - _ -> nil + + _ -> + nil end end end diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs index e4d174f..cfe3145 100644 --- a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -67,7 +67,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do field: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") html = render(view) @@ -80,7 +82,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do field: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") html = render(view) diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs index 7788c60..25aefe5 100644 --- a/test/mv_web/member_live/index_custom_fields_display_test.exs +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -105,7 +105,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do |> Ash.Changeset.for_create(:create, %{ member_id: member1.id, custom_field_id: field_show_integer.id, - value: %{"_union_type" => "integer", "_union_value" => 12345} + value: %{"_union_type" => "integer", "_union_value" => 12_345} }) |> Ash.create() diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs index 9d44c40..d526556 100644 --- a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -171,4 +171,3 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do assert html =~ "Value3" end end - diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index e1c99b2..21b0c9f 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -174,7 +174,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") # Check that the sort state is correctly applied assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") @@ -186,14 +188,19 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do field_integer: field_integer } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") # Click on a different custom field column view |> element("[data-testid='custom_field_#{field_integer.id}']") |> render_click() - assert_patch(view, "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc") + assert_patch( + view, + "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc" + ) end test "clicking regular column after custom field column works", %{ @@ -201,7 +208,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do field_string: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") # Click on email column view @@ -305,7 +314,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> Ash.create() conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") html = render(view) @@ -414,7 +425,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> Ash.create() conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") html = render(view) From 2284cd93c472a50c70f2cbd57d000766bfd219b8 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:14:53 +0100 Subject: [PATCH 08/88] translate: add translation --- priv/gettext/de/LC_MESSAGES/default.po | 77 +++++++++------- priv/gettext/default.pot | 117 ++++++++----------------- priv/gettext/en/LC_MESSAGES/default.po | 77 +++++++++------- 3 files changed, 125 insertions(+), 146 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 7b8c86e..27acc80 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -10,13 +10,13 @@ msgid "" msgstr "" "Language: en\n" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" @@ -28,21 +28,21 @@ msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" -#: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeite" @@ -54,7 +54,7 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -70,7 +70,7 @@ msgid "First Name" msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -87,8 +87,8 @@ msgstr "Nachname" msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" @@ -121,7 +121,7 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:111 +#: 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" @@ -140,14 +140,14 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -158,7 +158,7 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: 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/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -167,7 +167,7 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -183,6 +183,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha msgid "Id" msgstr "ID" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -198,19 +199,20 @@ msgstr "Mitglied anzeigen" msgid "This is a member record from your database." msgstr "Dies ist ein Mitglied aus deiner Datenbank." +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: 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/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: 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/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -252,7 +254,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: 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_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -266,7 +268,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -286,7 +288,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -308,13 +310,13 @@ msgid "Member" msgstr "Mitglied" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -357,17 +359,17 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswählen" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswählen" @@ -413,7 +415,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -569,7 +571,7 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" @@ -621,7 +623,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -636,7 +638,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -646,7 +648,7 @@ msgstr "Benutzerdefiniertes Feld speichern" msgid "Save Custom field value" msgstr "Benutzerdefinierten Feldwert speichern" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." @@ -747,3 +749,12 @@ msgstr "Entverknüpfung geplant" #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "" +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "In der Mitglieder-Übersicht anzeigen" + +#~ #: lib/mv_web/live/custom_field_live/index.ex:97 +#~ #, elixir-autogen, elixir-format +#~ msgid "To confirm deletion, please enter the custom field slug:" +#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a1ae484..7cf507b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -11,13 +11,13 @@ msgid "" msgstr "" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -29,21 +29,21 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,8 +88,8 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -122,7 +122,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:111 +#: 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" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: 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/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,6 +184,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -199,19 +200,20 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: 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/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: 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/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +255,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: 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_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -267,7 +269,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -287,7 +289,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -309,13 +311,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -358,17 +360,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -414,7 +416,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -570,7 +572,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "First name" msgstr "" @@ -622,7 +624,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -637,7 +639,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -647,7 +649,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -699,52 +701,7 @@ msgstr "" msgid "To confirm deletion, please enter this text:" msgstr "" -#: lib/mv_web/live/user_live/form.ex:210 +#: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format -msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:185 -#, elixir-autogen, elixir-format -msgid "Available members" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Member will be unlinked when you save. Cannot select new member until saved." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:226 -#, elixir-autogen, elixir-format -msgid "Save to confirm linking." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:169 -#, elixir-autogen, elixir-format -msgid "Search for a member to link..." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:173 -#, elixir-autogen, elixir-format -msgid "Search for member to link" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:223 -#, elixir-autogen, elixir-format -msgid "Selected" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:143 -#, elixir-autogen, elixir-format -msgid "Unlink Member" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Unlinking scheduled" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:342 -#, elixir-autogen, elixir-format -msgid "Failed to link member: %{error}" +msgid "Show in overview" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 28339fc..ed38b0e 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -11,13 +11,13 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -29,21 +29,21 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,8 +88,8 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -122,7 +122,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:111 +#: 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" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: 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/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,6 +184,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -199,19 +200,20 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: 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/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: 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/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +255,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: 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_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -267,7 +269,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -287,7 +289,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -309,13 +311,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -358,17 +360,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -414,7 +416,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -570,7 +572,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" @@ -622,7 +624,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -637,7 +639,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -647,7 +649,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -748,3 +750,12 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "" +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/index.ex:97 +#~ #, elixir-autogen, elixir-format +#~ msgid "To confirm deletion, please enter the custom field slug:" +#~ msgstr "" From b5845811148732a37c982d621babe38e22fee1f8 Mon Sep 17 00:00:00 2001 From: carla Date: Mon, 1 Dec 2025 09:48:29 +0100 Subject: [PATCH 09/88] performance: improvedd ash querying --- docs/development-progress-log.md | 6 ++ lib/mv_web/live/member_live/index.ex | 71 +++++++------------ .../live/member_live/index/formatter.ex | 6 +- 3 files changed, 34 insertions(+), 49 deletions(-) diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 51d0749..5669a19 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -329,6 +329,11 @@ end --- +**PR #208:** *Show custom fields per default in member overview* 🔧 +- added show_in_overview as attribute to custom fields +- show custom fields in member overview per default +- can be set to false in the settings for the specific custom field + ## Implementation Decisions ### Architecture Patterns @@ -390,6 +395,7 @@ defmodule Mv.Membership.CustomField do attribute :value_type, :atom # :string, :integer, :boolean, :date, :email attribute :immutable, :boolean # Can't change after creation attribute :required, :boolean # All members must have this + attribute :show_in_overview, :boolean # "If true, this custom field will be displayed in the member overview table" end # CustomFieldValue stores values diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 419df17..85ee4fb 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -26,6 +26,9 @@ defmodule MvWeb.MemberLive.Index do """ use MvWeb, :live_view + require Ash.Query + import Ash.Expr + alias MvWeb.MemberLive.Index.Formatter # Prefix used in sort field names for custom fields (e.g., "custom_field_") @@ -40,9 +43,6 @@ defmodule MvWeb.MemberLive.Index do @impl true def mount(_params, _session, socket) do # Load custom fields that should be shown in overview - require Ash.Query - import Ash.Expr - # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView # and result in a 500 error page. This is appropriate for LiveViews where errors # should be visible to the user rather than silently failing. @@ -209,8 +209,7 @@ defmodule MvWeb.MemberLive.Index do "" cfv -> - formatted = Formatter.format_custom_field_value(cfv.value, custom_field) - if formatted == "", do: "", else: formatted + Formatter.format_custom_field_value(cfv.value, custom_field) end end } @@ -296,17 +295,16 @@ defmodule MvWeb.MemberLive.Index do # # Process: # 1. Builds base query with selected fields - # 2. Loads custom field values for visible custom fields + # 2. Loads custom field values for visible custom fields (filtered at database level) # 3. Applies search filter if provided # 4. Applies sorting (database-level for regular fields, in-memory for custom fields) - # 5. Filters custom field values to only visible ones (reduces memory usage) # # Performance Considerations: + # - Database-level filtering: Custom field values are filtered directly in the database + # using Ash relationship filters, reducing memory usage and improving performance. # - In-memory sorting: Custom field sorting is done in memory after loading. # This is suitable for small to medium datasets (<1000 members). # For larger datasets, consider implementing database-level sorting or pagination. - # - Memory filtering: Custom field values are filtered after loading to reduce - # memory usage, but all members are still loaded into memory. # - No pagination: All matching members are loaded at once. For large result sets, # consider implementing pagination (see Issue #165). # @@ -329,8 +327,8 @@ defmodule MvWeb.MemberLive.Index do ]) # Load custom field values for visible custom fields - custom_field_ids = Enum.map(socket.assigns.custom_fields_visible, & &1.id) - query = load_custom_field_values(query, custom_field_ids) + custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) + query = load_custom_field_values(query, custom_field_ids_list) # Apply the search filter first query = apply_search_filter(query, search_query) @@ -349,13 +347,8 @@ defmodule MvWeb.MemberLive.Index do # This is appropriate for data loading in LiveViews members = Ash.read!(query) - # Filter custom field values to only visible ones (reduces memory usage) - # Performance: This iterates through all members and their custom_field_values. - # For large datasets (>1000 members), this could be optimized by filtering - # at the database level, but requires more complex Ash queries. - custom_field_ids = MapSet.new(Enum.map(socket.assigns.custom_fields_visible, & &1.id)) - - members = filter_member_custom_field_values(members, custom_field_ids) + # Custom field values are already filtered at the database level in load_custom_field_values/2 + # No need for in-memory filtering anymore # Sort in memory if needed (for custom fields) members = @@ -374,38 +367,28 @@ defmodule MvWeb.MemberLive.Index do end # Load custom field values for the given custom field IDs + # + # Filters custom field values directly in the database using Ash relationship filters. + # This is more efficient than loading all values and filtering in memory. + # + # Performance: Database-level filtering reduces: + # - Memory usage (only visible custom field values are loaded) + # - Network transfer (less data from database to application) + # - Processing time (no need to iterate through all members and filter) defp load_custom_field_values(query, []) do query end defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do - # Load all custom field values with their custom_field relationship - # Note: We filter to visible custom fields after loading to reduce memory usage - # Ash loads relationships efficiently with JOINs, but we only keep visible ones + # Filter custom field values at the database level using Ash relationship query + # This ensures only visible custom field values are loaded + custom_field_values_query = + Mv.Membership.CustomFieldValue + |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids)) + |> Ash.Query.load(custom_field: [:id, :name, :value_type]) + query - |> Ash.Query.load(custom_field_values: [custom_field: [:id, :name, :value_type]]) - end - - # Filters custom field values to only visible ones for all members - defp filter_member_custom_field_values(members, custom_field_ids) do - Enum.map(members, fn member -> - filter_single_member_custom_field_values(member, custom_field_ids) - end) - end - - # Filters custom field values for a single member - defp filter_single_member_custom_field_values(member, _custom_field_ids) - when not is_list(member.custom_field_values) do - member - end - - defp filter_single_member_custom_field_values(member, custom_field_ids) do - filtered_values = - Enum.filter(member.custom_field_values, fn cfv -> - cfv.custom_field_id in custom_field_ids - end) - - %{member | custom_field_values: filtered_values} + |> Ash.Query.load(custom_field_values: custom_field_values_query) end # ------------------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index/formatter.ex b/lib/mv_web/live/member_live/index/formatter.ex index d97966c..2074962 100644 --- a/lib/mv_web/live/member_live/index/formatter.ex +++ b/lib/mv_web/live/member_live/index/formatter.ex @@ -45,16 +45,12 @@ defmodule MvWeb.MemberLive.Index.Formatter do end # Format value based on type - defp format_value_by_type(value, :string, _) when is_binary(value) do - # Return empty string if value is empty, otherwise return the value - if String.trim(value) == "", do: "", else: value - end defp format_value_by_type(value, :string, _), do: to_string(value) defp format_value_by_type(value, :integer, _), do: to_string(value) - defp format_value_by_type(value, :email, _) when is_binary(value) do + defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do # Return empty string if value is empty if String.trim(value) == "", do: "", else: value end From 418b42d35a1cb9e75def6ad3d157e6d5ab9c87e3 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:56:13 +0100 Subject: [PATCH 10/88] adds tests --- test/membership/setting_env_test.exs | 61 +++++++++++++++++ test/membership/setting_test.exs | 53 +++++++++++++++ .../mv_web/components/layouts/navbar_test.exs | 18 +++++ .../mv_web/live/global_settings_live_test.exs | 67 +++++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 test/membership/setting_env_test.exs create mode 100644 test/membership/setting_test.exs create mode 100644 test/mv_web/live/global_settings_live_test.exs diff --git a/test/membership/setting_env_test.exs b/test/membership/setting_env_test.exs new file mode 100644 index 0000000..262f748 --- /dev/null +++ b/test/membership/setting_env_test.exs @@ -0,0 +1,61 @@ +defmodule Mv.Membership.SettingEnvTest do + use Mv.DataCase, async: false + alias Mv.Membership + + describe "Settings with environment variable" do + test "club_name can be set via ASSOCIATION_NAME environment variable" do + # Set environment variable + System.put_env("ASSOCIATION_NAME", "Test Association from Env") + + try do + # Get settings - should use environment variable if no DB value exists + {:ok, settings} = Membership.get_settings() + + # If settings don't have a club_name in DB, it should use the env var + # This depends on implementation - we'll check that the env var is respected + assert settings.club_name != nil + after + # Clean up + System.delete_env("ASSOCIATION_NAME") + end + end + + test "database value takes precedence over environment variable" do + # Set environment variable + System.put_env("ASSOCIATION_NAME", "Env Value") + + try do + # Set a value in the database + {:ok, settings} = Membership.get_settings() + {:ok, _updated} = Membership.update_settings(settings, %{club_name: "DB Value"}) + + # Get settings again - should use DB value, not env var + {:ok, settings_after} = Membership.get_settings() + assert settings_after.club_name == "DB Value" + after + # Clean up + System.delete_env("ASSOCIATION_NAME") + end + end + + test "uses environment variable when database value is not set" do + # Set environment variable + System.put_env("ASSOCIATION_NAME", "Default from Env") + + try do + # Clear database value (if possible) or check that env var is used + {:ok, settings} = Membership.get_settings() + + # If club_name is nil or empty in DB, should use env var + # This test depends on implementation details + # We're testing that the env var fallback works + club_name = settings.club_name || System.get_env("ASSOCIATION_NAME") + assert club_name != nil + assert club_name != "" + after + # Clean up + System.delete_env("ASSOCIATION_NAME") + end + end + end +end diff --git a/test/membership/setting_test.exs b/test/membership/setting_test.exs new file mode 100644 index 0000000..46cf3b9 --- /dev/null +++ b/test/membership/setting_test.exs @@ -0,0 +1,53 @@ +defmodule Mv.Membership.SettingTest do + use Mv.DataCase, async: false + alias Mv.Membership + + describe "Settings Resource" do + test "can read settings" do + # Settings should be a singleton resource + assert {:ok, _settings} = Membership.get_settings() + end + + test "settings have club_name attribute" do + {:ok, settings} = Membership.get_settings() + assert Map.has_key?(settings, :club_name) + end + + test "can update club_name" do + {:ok, settings} = Membership.get_settings() + + assert {:ok, updated_settings} = + Membership.update_settings(settings, %{club_name: "New Club Name"}) + + assert updated_settings.club_name == "New Club Name" + end + + test "club_name is required" do + {:ok, settings} = Membership.get_settings() + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Membership.update_settings(settings, %{club_name: nil}) + + assert error_message(errors, :club_name) =~ "must be present" + end + + test "club_name cannot be empty" do + {:ok, settings} = Membership.get_settings() + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Membership.update_settings(settings, %{club_name: ""}) + + assert error_message(errors, :club_name) =~ "must be present" + end + end + + # Helper function to extract error messages + defp error_message(errors, field) do + errors + |> Enum.find(fn error -> error.field == field end) + |> case do + nil -> "" + error -> List.first(error.message) || "" + end + end +end diff --git a/test/mv_web/components/layouts/navbar_test.exs b/test/mv_web/components/layouts/navbar_test.exs index b6fa556..6a50996 100644 --- a/test/mv_web/components/layouts/navbar_test.exs +++ b/test/mv_web/components/layouts/navbar_test.exs @@ -84,5 +84,23 @@ defmodule MvWeb.Layouts.NavbarTest do # Check for correct logout path assert html =~ ~s(href="/sign-out") end + + test "Settings link navigates to global settings page", %{conn: conn} do + user = create_test_user(%{email: "test@example.com"}) + conn = conn_with_oidc_user(conn, user) + + html = + render_component(&MvWeb.Layouts.Navbar.navbar/1, %{ + current_user: user + }) + + # Check that Settings link exists and points to /settings + assert html =~ "Settings" + assert html =~ ~s(href="/settings") || html =~ ~s(navigate="/settings") + + # Verify the link actually works by navigating to it + {:ok, _view, settings_html} = live(conn, ~p"/settings") + assert settings_html =~ "Vereinsdaten" + end end end diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs new file mode 100644 index 0000000..f06deb1 --- /dev/null +++ b/test/mv_web/live/global_settings_live_test.exs @@ -0,0 +1,67 @@ +defmodule MvWeb.GlobalSettingsLiveTest do + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + alias Mv.Membership + + describe "Global Settings LiveView" do + setup %{conn: conn} do + user = create_test_user(%{email: "admin@example.com"}) + conn = conn_with_oidc_user(conn, user) + {:ok, conn: conn, user: user} + end + + test "renders the global settings page", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/settings") + + assert html =~ "Vereinsdaten" + assert html =~ "Settings" + end + + test "displays current club name", %{conn: conn} do + # Set initial club name + {:ok, settings} = Membership.get_settings() + Membership.update_settings!(settings, %{club_name: "Test Club"}) + + {:ok, _view, html} = live(conn, ~p"/settings") + + assert html =~ "Test Club" + end + + test "can update club name via form", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + + # Submit form with new club name + assert view + |> form("#settings-form", %{setting: %{club_name: "Updated Club Name"}}) + |> render_submit() + + # Check for success message + assert render(view) =~ "Settings updated successfully" + assert render(view) =~ "Updated Club Name" + end + + test "shows error when club_name is empty", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + + # Submit form with empty club name + html = + view + |> form("#settings-form", %{setting: %{club_name: ""}}) + |> render_submit() + + assert html =~ "must be present" + end + + test "shows error when club_name is missing", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + + # Submit form without club_name + html = + view + |> form("#settings-form", %{setting: %{}}) + |> render_submit() + + assert html =~ "must be present" + end + end +end From 193618eacef9baa1101fb39e1d1a12b0681b672d Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:56:50 +0100 Subject: [PATCH 11/88] chore: adds settings ressource and migration --- lib/membership/membership.ex | 77 ++++++++++++++++++ lib/membership/setting.ex | 80 +++++++++++++++++++ .../20251127134451_add_settings_table.exs | 31 +++++++ .../repo/settings/20251127134451.json | 67 ++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 lib/membership/setting.ex create mode 100644 priv/repo/migrations/20251127134451_add_settings_table.exs create mode 100644 priv/resource_snapshots/repo/settings/20251127134451.json diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 7891d2e..c9d0466 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -6,12 +6,14 @@ defmodule Mv.Membership do - `Member` - Club members with personal information and custom field values - `CustomFieldValue` - Dynamic custom field values attached to members - `CustomField` - Schema definitions for custom fields + - `Setting` - Global application settings (singleton) ## Public API The domain exposes these main actions: - Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1` - Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc. - Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc. + - Settings management: `get_settings/0`, `update_settings/2` ## Admin Interface The domain is configured with AshAdmin for management UI. @@ -45,5 +47,80 @@ defmodule Mv.Membership do define :destroy_custom_field, action: :destroy_with_values define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id] end + + resource Mv.Membership.Setting do + # Note: create action exists but is not exposed via code interface + # It's only used internally as fallback in get_settings/0 + # Settings should be created via seed script + define :update_settings, action: :update + end + end + + # Singleton pattern: Get the single settings record + @doc """ + Gets the global settings. + + Settings should normally be created via the seed script (`priv/repo/seeds.exs`). + If no settings exist, this function will create them as a fallback using the + `ASSOCIATION_NAME` environment variable or "Mitgliederverwaltung" as default. + + ## Returns + + - `{:ok, settings}` - The settings record + - `{:ok, nil}` - No settings exist (should not happen if seeds were run) + - `{:error, error}` - Error reading settings + + ## Examples + + iex> {:ok, settings} = Mv.Membership.get_settings() + iex> settings.club_name + "My Club" + + """ + def get_settings do + # Try to get the first (and only) settings record + case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do + {:ok, nil} -> + # No settings exist - create as fallback (should normally be created via seed script) + default_club_name = System.get_env("ASSOCIATION_NAME") || "Mitgliederverwaltung" + + Mv.Membership.Setting + |> Ash.Changeset.for_create(:create, %{club_name: default_club_name}) + |> Ash.create!(domain: __MODULE__) + |> then(fn settings -> {:ok, settings} end) + + {:ok, settings} -> + {:ok, settings} + + {:error, error} -> + {:error, error} + end + end + + @doc """ + Updates the global settings. + + ## Parameters + + - `settings` - The settings record to update + - `attrs` - A map of attributes to update (e.g., `%{club_name: "New Name"}`) + + ## 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_settings(settings, %{club_name: "New Club"}) + iex> updated.club_name + "New Club" + + """ + def update_settings(settings, attrs) do + settings + |> Ash.Changeset.for_update(:update, attrs) + |> Ash.update(domain: __MODULE__) end end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex new file mode 100644 index 0000000..47b9dd8 --- /dev/null +++ b/lib/membership/setting.ex @@ -0,0 +1,80 @@ +defmodule Mv.Membership.Setting do + @moduledoc """ + Ash resource representing global application settings. + + ## Overview + Settings is a singleton resource that stores global configuration for the association, + such as the club name and branding information. There should only ever be one settings + record in the database. + + ## Attributes + - `club_name` - The name of the association/club (required, cannot be empty) + + ## Singleton Pattern + This resource uses a singleton pattern - there should only be one settings record. + The resource is designed to be read and updated, but not created or destroyed + through normal CRUD operations. Initial settings should be seeded. + + ## Environment Variable Support + The `club_name` can be set via the `ASSOCIATION_NAME` environment variable. + If set, the environment variable value is used as a fallback when no database + value exists. Database values always take precedence over environment variables. + + ## Examples + + # Get current settings + {:ok, settings} = Mv.Membership.get_settings() + settings.club_name # => "My Club" + + # Update club name + {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) + """ + use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer + + postgres do + table "settings" + repo Mv.Repo + end + + resource do + description "Global application settings (singleton resource)" + end + + actions do + defaults [:read] + + # Internal create action - not exposed via code interface + # Used only as fallback in get_settings/0 if settings don't exist + # Settings should normally be created via seed script + create :create do + accept [:club_name] + end + + update :update do + primary? true + accept [:club_name] + end + end + + attributes do + uuid_primary_key :id + + attribute :club_name, :string, + allow_nil?: false, + public?: true, + description: "The name of the association/club", + constraints: [ + trim?: true, + min_length: 1 + ] + + timestamps() + end + + validations do + validate present(:club_name), on: [:create, :update] + validate string_length(:club_name, min: 1), on: [:create, :update] + end +end diff --git a/priv/repo/migrations/20251127134451_add_settings_table.exs b/priv/repo/migrations/20251127134451_add_settings_table.exs new file mode 100644 index 0000000..e08ba1d --- /dev/null +++ b/priv/repo/migrations/20251127134451_add_settings_table.exs @@ -0,0 +1,31 @@ +defmodule Mv.Repo.Migrations.AddSettingsTable 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 + create table(:settings, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :club_name, :text, null: false + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + end + + # Note: Singleton pattern is enforced at application level via get_settings/0 + # which creates the record if it doesn't exist and only allows updates + end + + def down do + drop table(:settings) + end +end diff --git a/priv/resource_snapshots/repo/settings/20251127134451.json b/priv/resource_snapshots/repo/settings/20251127134451.json new file mode 100644 index 0000000..fefc223 --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20251127134451.json @@ -0,0 +1,67 @@ +{ + "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?": 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": "353EB39F18B97C596A77A78A060FB9DE075AAD731F74F64AB62D357CBCDEC914", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "settings" +} \ No newline at end of file From 37553d8d6c464e54cec760df1c8fd58e1bec3e8d Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 15:34:10 +0100 Subject: [PATCH 12/88] feat: adds settings live view and updated seeds --- lib/membership/membership.ex | 4 +- lib/membership/setting.ex | 10 +-- lib/mv_web/components/layouts/navbar.ex | 21 +++++- lib/mv_web/live/global_settings_live.ex | 97 +++++++++++++++++++++++++ lib/mv_web/router.ex | 2 + priv/repo/seeds.exs | 19 +++++ 6 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 lib/mv_web/live/global_settings_live.ex diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index c9d0466..cb3691b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -62,7 +62,7 @@ defmodule Mv.Membership do Settings should normally be created via the seed script (`priv/repo/seeds.exs`). If no settings exist, this function will create them as a fallback using the - `ASSOCIATION_NAME` environment variable or "Mitgliederverwaltung" as default. + `ASSOCIATION_NAME` environment variable or "Club Name" as default. ## Returns @@ -82,7 +82,7 @@ defmodule Mv.Membership do case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do {:ok, nil} -> # No settings exist - create as fallback (should normally be created via seed script) - default_club_name = System.get_env("ASSOCIATION_NAME") || "Mitgliederverwaltung" + default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" Mv.Membership.Setting |> Ash.Changeset.for_create(:create, %{club_name: default_club_name}) diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 47b9dd8..38624dc 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -58,6 +58,11 @@ defmodule Mv.Membership.Setting do end end + validations do + validate present(:club_name), on: [:create, :update] + validate string_length(:club_name, min: 1), on: [:create, :update] + end + attributes do uuid_primary_key :id @@ -72,9 +77,4 @@ defmodule Mv.Membership.Setting do timestamps() end - - validations do - validate present(:club_name), on: [:create, :update] - validate string_length(:club_name, min: 1), on: [:create, :update] - end end diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 1de4c7f..7ff7f25 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -6,15 +6,21 @@ defmodule MvWeb.Layouts.Navbar do use Gettext, backend: MvWeb.Gettext use MvWeb, :verified_routes + alias Mv.Membership + attr :current_user, :map, required: true, doc: "The current user - navbar is only shown when user is present" def navbar(assigns) do + club_name = get_club_name() + + assigns = assign(assigns, :club_name, club_name) + ~H""" """ end + + # Helper function to get club name from settings + # Falls back to "Mitgliederverwaltung" if settings can't be loaded + defp get_club_name do + case Membership.get_settings() do + {:ok, settings} -> settings.club_name + _ -> "Mitgliederverwaltung" + end + end end diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex new file mode 100644 index 0000000..0be4559 --- /dev/null +++ b/lib/mv_web/live/global_settings_live.ex @@ -0,0 +1,97 @@ +defmodule MvWeb.GlobalSettingsLive do + @moduledoc """ + LiveView for managing global application settings (Vereinsdaten). + + ## Features + - Edit the association/club name + - Real-time form validation + - Success/error feedback + + ## Settings + - `club_name` - The name of the association/club (required) + + ## Events + - `validate` - Real-time form validation + - `save` - Save settings changes + + ## Note + Settings is a singleton resource - there is only one settings record. + The club_name can also be set via the `ASSOCIATION_NAME` environment variable. + """ + use MvWeb, :live_view + + alias Mv.Membership + + @impl true + def mount(_params, _session, socket) do + {:ok, settings} = Membership.get_settings() + + {:ok, + socket + |> assign(:page_title, gettext("Club Settings")) + |> assign(:settings, settings) + |> assign_form()} + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + {gettext("Club Settings")} + <:subtitle> + {gettext("Manage global settings for the association.")} + + + + <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save"> + <.input + field={@form[:club_name]} + type="text" + label={gettext("Association Name")} + required + /> + + <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Settings")} + + + + """ + end + + @impl true + def handle_event("validate", %{"setting" => setting_params}, socket) do + {:noreply, + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} + end + + def handle_event("save", %{"setting" => setting_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do + {:ok, updated_settings} -> + socket = + socket + |> assign(:settings, updated_settings) + |> put_flash(:info, gettext("Settings updated successfully")) + |> assign_form() + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + defp assign_form(%{assigns: %{settings: settings}} = socket) do + form = + AshPhoenix.Form.for_update( + settings, + :update, + api: Membership, + as: "setting", + forms: [auto?: true] + ) + + assign(socket, form: to_form(form)) + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index d2a63bc..09a2792 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -73,6 +73,8 @@ defmodule MvWeb.Router do live "/users/:id", UserLive.Show, :show live "/users/:id/show/edit", UserLive.Show, :edit + live "/settings", GlobalSettingsLive + post "/set_locale", LocaleController, :set_locale end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 8d3cb6f..00cf657 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -323,8 +323,27 @@ if friedrich = find_member.("friedrich.wagner@example.de") do end) end +# Create or update global settings (singleton) +default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" + +case Membership.get_settings() do + {:ok, existing_settings} -> + # Settings exist, update if club_name is different from env var + if existing_settings.club_name != default_club_name do + {:ok, _updated} = + Membership.update_settings(existing_settings, %{club_name: default_club_name}) + end + + {:ok, nil} -> + # Settings don't exist, create them + Mv.Membership.Setting + |> Ash.Changeset.for_create(:create, %{club_name: default_club_name}) + |> Ash.create!(domain: Mv.Membership) +end + IO.puts("✅ Seeds completed successfully!") IO.puts("📝 Created sample data:") +IO.puts(" - Global settings: club_name = #{default_club_name}") IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)") IO.puts(" - Admin user: admin@mv.local (password: testpassword)") IO.puts(" - Sample members: Hans, Greta, Friedrich") From fdae610da02994e78eba2eda62b4595575f0dc52 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 15:37:42 +0100 Subject: [PATCH 13/88] adds translation --- priv/gettext/de/LC_MESSAGES/default.po | 46 ++++++++++++++++++++------ priv/gettext/default.pot | 45 +++++++++++++++++++------ priv/gettext/en/LC_MESSAGES/default.po | 45 +++++++++++++++++++------ 3 files changed, 106 insertions(+), 30 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 27acc80..f144198 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -160,6 +160,7 @@ msgstr "Mitglied speichern" #: 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/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format @@ -293,7 +294,7 @@ msgstr "ID" msgid "Immutable" msgstr "Unveränderlich" -#: lib/mv_web/components/layouts/navbar.ex:94 +#: lib/mv_web/components/layouts/navbar.ex:102 #, elixir-autogen, elixir-format msgid "Logout" msgstr "Abmelden" @@ -309,8 +310,8 @@ msgstr "Benutzer*innen auflisten" msgid "Member" msgstr "Mitglied" -#: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:57 +#: lib/mv_web/components/layouts/navbar.ex:25 +#: lib/mv_web/live/member_live/index.ex:39 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -354,7 +355,7 @@ msgstr "OIDC ID" msgid "Password Authentication" msgstr "Passwort-Authentifizierung" -#: lib/mv_web/components/layouts/navbar.ex:89 +#: lib/mv_web/components/layouts/navbar.ex:95 #, elixir-autogen, elixir-format msgid "Profil" msgstr "Profil" @@ -374,7 +375,7 @@ msgstr "Alle Mitglieder auswählen" msgid "Select member" msgstr "Mitglied auswählen" -#: lib/mv_web/components/layouts/navbar.ex:92 +#: lib/mv_web/components/layouts/navbar.ex:99 #, elixir-autogen, elixir-format msgid "Settings" msgstr "Einstellungen" @@ -542,14 +543,14 @@ msgstr "Zurück zur Mitgliederliste" msgid "Back to users list" msgstr "Zurück zur Benutzer*innen-Liste" -#: lib/mv_web/components/layouts/navbar.ex:27 #: lib/mv_web/components/layouts/navbar.ex:33 +#: lib/mv_web/components/layouts/navbar.ex:39 #, elixir-autogen, elixir-format msgid "Select language" msgstr "Sprache auswählen" -#: lib/mv_web/components/layouts/navbar.ex:40 -#: lib/mv_web/components/layouts/navbar.ex:60 +#: lib/mv_web/components/layouts/navbar.ex:46 +#: lib/mv_web/components/layouts/navbar.ex:66 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "Dunklen Modus umschalten" @@ -560,7 +561,7 @@ msgstr "Dunklen Modus umschalten" msgid "Search..." msgstr "Suchen..." -#: lib/mv_web/components/layouts/navbar.ex:21 +#: lib/mv_web/components/layouts/navbar.ex:27 #, elixir-autogen, elixir-format msgid "Users" msgstr "Benutzer*innen" @@ -653,7 +654,7 @@ msgstr "Benutzerdefinierten Feldwert speichern" msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." -#: lib/mv_web/components/layouts/navbar.ex:20 +#: lib/mv_web/components/layouts/navbar.ex:26 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" @@ -753,6 +754,31 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Show in overview" msgstr "In der Mitglieder-Übersicht anzeigen" +#: lib/mv_web/live/global_settings_live.ex:51 +#, elixir-autogen, elixir-format +msgid "Association Name" +msgstr "Vereinsname" + +#: lib/mv_web/live/global_settings_live.ex:31 +#: lib/mv_web/live/global_settings_live.ex:41 +#, elixir-autogen, elixir-format, fuzzy +msgid "Club Settings" +msgstr "Vereinsdaten" + +#: lib/mv_web/live/global_settings_live.ex:43 +#, elixir-autogen, elixir-format +msgid "Manage global settings for the association." +msgstr "Passe übergreifende Einstellungen für den Verein an." + +#: lib/mv_web/live/global_settings_live.ex:56 +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Settings" +msgstr "Einstellungen speichern" + +#: lib/mv_web/live/global_settings_live.ex:75 +#, elixir-autogen, elixir-format +msgid "Settings updated successfully" +msgstr "Einstellungen erfolgreich gespeichert" #~ #: lib/mv_web/live/custom_field_live/index.ex:97 #~ #, elixir-autogen, elixir-format diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 7cf507b..a5e9aa9 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -161,6 +161,7 @@ msgstr "" #: 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/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format @@ -294,7 +295,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:94 +#: lib/mv_web/components/layouts/navbar.ex:102 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -310,8 +311,8 @@ msgstr "" msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:57 +#: lib/mv_web/components/layouts/navbar.ex:25 +#: lib/mv_web/live/member_live/index.ex:39 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -355,7 +356,7 @@ msgstr "" msgid "Password Authentication" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:89 +#: lib/mv_web/components/layouts/navbar.ex:95 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -375,7 +376,7 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:92 +#: lib/mv_web/components/layouts/navbar.ex:99 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -543,14 +544,14 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:27 #: lib/mv_web/components/layouts/navbar.ex:33 +#: lib/mv_web/components/layouts/navbar.ex:39 #, elixir-autogen, elixir-format msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:40 -#: lib/mv_web/components/layouts/navbar.ex:60 +#: lib/mv_web/components/layouts/navbar.ex:46 +#: lib/mv_web/components/layouts/navbar.ex:66 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" @@ -561,7 +562,7 @@ msgstr "" msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:21 +#: lib/mv_web/components/layouts/navbar.ex:27 #, elixir-autogen, elixir-format msgid "Users" msgstr "" @@ -654,7 +655,7 @@ msgstr "" msgid "Use this form to manage custom_field records in your database." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:20 +#: lib/mv_web/components/layouts/navbar.ex:26 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "" @@ -704,4 +705,28 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Show in overview" +#: lib/mv_web/live/global_settings_live.ex:51 +#, elixir-autogen, elixir-format +msgid "Association Name" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex:31 +#: lib/mv_web/live/global_settings_live.ex:41 +#, elixir-autogen, elixir-format +msgid "Club Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex:43 +#, elixir-autogen, elixir-format +msgid "Manage global settings for the association." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex:56 +#, elixir-autogen, elixir-format +msgid "Save Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex:75 +#, elixir-autogen, elixir-format +msgid "Settings updated successfully" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index ed38b0e..19be444 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -161,6 +161,7 @@ msgstr "" #: 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/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format @@ -294,7 +295,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:94 +#: lib/mv_web/components/layouts/navbar.ex:102 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -310,8 +311,8 @@ msgstr "" msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:57 +#: lib/mv_web/components/layouts/navbar.ex:25 +#: lib/mv_web/live/member_live/index.ex:39 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -355,7 +356,7 @@ msgstr "" msgid "Password Authentication" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:89 +#: lib/mv_web/components/layouts/navbar.ex:95 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -375,7 +376,7 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:92 +#: lib/mv_web/components/layouts/navbar.ex:99 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -543,14 +544,14 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:27 #: lib/mv_web/components/layouts/navbar.ex:33 +#: lib/mv_web/components/layouts/navbar.ex:39 #, elixir-autogen, elixir-format, fuzzy msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:40 -#: lib/mv_web/components/layouts/navbar.ex:60 +#: lib/mv_web/components/layouts/navbar.ex:46 +#: lib/mv_web/components/layouts/navbar.ex:66 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" @@ -561,7 +562,7 @@ msgstr "" msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:21 +#: lib/mv_web/components/layouts/navbar.ex:27 #, elixir-autogen, elixir-format, fuzzy msgid "Users" msgstr "" @@ -654,7 +655,7 @@ msgstr "" msgid "Use this form to manage custom_field records in your database." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:20 +#: lib/mv_web/components/layouts/navbar.ex:26 #, elixir-autogen, elixir-format, fuzzy msgid "Custom Fields" msgstr "" @@ -753,6 +754,30 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Show in overview" +#: lib/mv_web/live/global_settings_live.ex:51 +#, elixir-autogen, elixir-format +msgid "Association Name" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex:31 +#: lib/mv_web/live/global_settings_live.ex:41 +#, elixir-autogen, elixir-format, fuzzy +msgid "Club Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex:43 +#, elixir-autogen, elixir-format +msgid "Manage global settings for the association." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex:56 +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex:75 +#, elixir-autogen, elixir-format +msgid "Settings updated successfully" msgstr "" #~ #: lib/mv_web/live/custom_field_live/index.ex:97 From cf354bcf2513d37a5f9b32d028b214ad697c7874 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 15:49:29 +0100 Subject: [PATCH 14/88] test updated --- test/membership/setting_test.exs | 8 +++----- test/mv_web/components/layouts/navbar_test.exs | 2 +- test/mv_web/controllers/page_controller_test.exs | 11 ++++++----- test/mv_web/live/global_settings_live_test.exs | 11 ++++++----- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/test/membership/setting_test.exs b/test/membership/setting_test.exs index 46cf3b9..531ab88 100644 --- a/test/membership/setting_test.exs +++ b/test/membership/setting_test.exs @@ -44,10 +44,8 @@ defmodule Mv.Membership.SettingTest do # Helper function to extract error messages defp error_message(errors, field) do errors - |> Enum.find(fn error -> error.field == field end) - |> case do - nil -> "" - error -> List.first(error.message) || "" - end + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map(&Map.get(&1, :message, "")) + |> List.first() || "" end end diff --git a/test/mv_web/components/layouts/navbar_test.exs b/test/mv_web/components/layouts/navbar_test.exs index 6a50996..7836ee6 100644 --- a/test/mv_web/components/layouts/navbar_test.exs +++ b/test/mv_web/components/layouts/navbar_test.exs @@ -100,7 +100,7 @@ defmodule MvWeb.Layouts.NavbarTest do # Verify the link actually works by navigating to it {:ok, _view, settings_html} = live(conn, ~p"/settings") - assert settings_html =~ "Vereinsdaten" + assert settings_html =~ "Club Settings" end end end diff --git a/test/mv_web/controllers/page_controller_test.exs b/test/mv_web/controllers/page_controller_test.exs index ce3195b..1dfcf2b 100644 --- a/test/mv_web/controllers/page_controller_test.exs +++ b/test/mv_web/controllers/page_controller_test.exs @@ -1,10 +1,11 @@ defmodule MvWeb.PageControllerTest do - use MvWeb.ConnCase + use MvWeb.ConnCase, async: true - test "GET /", %{conn: conn} do - conn = conn_with_oidc_user(conn) + test "renders home template successfully with authenticated user", %{conn: conn} do + user = create_test_user(%{email: "test@example.com"}) + conn = conn_with_oidc_user(conn, user) + conn = get(conn, "/") - conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Mitgliederverwaltung" + assert html_response(conn, 200) end end diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index f06deb1..6a739b5 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -11,16 +11,16 @@ defmodule MvWeb.GlobalSettingsLiveTest do end test "renders the global settings page", %{conn: conn} do - {:ok, view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/settings") - assert html =~ "Vereinsdaten" + assert html =~ "Club Settings" assert html =~ "Settings" end test "displays current club name", %{conn: conn} do # Set initial club name {:ok, settings} = Membership.get_settings() - Membership.update_settings!(settings, %{club_name: "Test Club"}) + {:ok, _updated} = Membership.update_settings(settings, %{club_name: "Test Club"}) {:ok, _view, html} = live(conn, ~p"/settings") @@ -55,10 +55,11 @@ defmodule MvWeb.GlobalSettingsLiveTest do test "shows error when club_name is missing", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/settings") - # Submit form without club_name + # Submit form with club_name explicitly set to empty string + # (Phoenix forms will keep existing value if field is omitted) html = view - |> form("#settings-form", %{setting: %{}}) + |> form("#settings-form", %{setting: %{club_name: ""}}) |> render_submit() assert html =~ "must be present" From dfdf4c980b29eeddb55f5d7056f9d5c2def7b480 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 15:56:20 +0100 Subject: [PATCH 15/88] chore: updated env example --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 7559b0a..13154f3 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ TOKEN_SIGNING_SECRET=changeme-run-mix-phx.gen.secret # Required: Hostname for URL generation PHX_HOST=localhost +# Recommended: Association settings +ASSOCIATION_NAME="Sportsclub XYZ" + # Optional: OIDC Configuration # These have defaults in docker-compose.prod.yml, only override if needed # OIDC_CLIENT_ID=mv From f9ff6d3d2dc7ae70968e75381a07976b5a3da206 Mon Sep 17 00:00:00 2001 From: carla Date: Mon, 1 Dec 2025 10:54:12 +0100 Subject: [PATCH 16/88] fix: remove unused branch in seeds and fixed translations --- priv/gettext/de/LC_MESSAGES/default.po | 128 ++++++++++++------------ priv/gettext/default.pot | 80 ++++++++++++--- priv/gettext/en/LC_MESSAGES/default.po | 129 +++++++++++++------------ priv/repo/seeds.exs | 6 -- 4 files changed, 197 insertions(+), 146 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f144198..e9214fc 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -16,7 +16,7 @@ msgid "Actions" msgstr "Aktionen" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" @@ -35,14 +35,14 @@ msgid "City" msgstr "Stadt" #: lib/mv_web/live/member_live/index.html.heex:204 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" #: lib/mv_web/live/member_live/index.html.heex:196 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:265 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeite" @@ -88,7 +88,7 @@ msgid "New Member" msgstr "Neues Mitglied" #: lib/mv_web/live/member_live/index.html.heex:193 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" @@ -162,7 +162,7 @@ msgstr "Mitglied speichern" #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:234 +#: lib/mv_web/live/user_live/form.ex:248 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "Speichern..." @@ -184,7 +184,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha msgid "Id" msgstr "ID" -#: lib/mv_web/live/member_live/index/formatter.ex:65 +#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -200,7 +200,7 @@ msgstr "Mitglied anzeigen" msgid "This is a member record from your database." msgstr "Dies ist ein Mitglied aus deiner Datenbank." -#: lib/mv_web/live/member_live/index/formatter.ex:64 +#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" @@ -259,7 +259,7 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" #: 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/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:237 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" @@ -311,7 +311,7 @@ msgid "Member" msgstr "Mitglied" #: lib/mv_web/components/layouts/navbar.ex:25 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -339,7 +339,7 @@ msgstr "Nicht gesetzt" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:210 +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format msgid "Note" msgstr "Hinweis" @@ -380,7 +380,7 @@ msgstr "Mitglied auswählen" msgid "Settings" msgstr "Einstellungen" -#: lib/mv_web/live/user_live/form.ex:235 +#: lib/mv_web/live/user_live/form.ex:249 #, elixir-autogen, elixir-format msgid "Save User" msgstr "Benutzer*in speichern" @@ -405,7 +405,7 @@ msgstr "Nicht unterstützter Wertetyp: %{type}" msgid "Use this form to manage user records in your database." msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten." -#: lib/mv_web/live/user_live/form.ex:252 +#: lib/mv_web/live/user_live/form.ex:266 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -433,7 +433,7 @@ msgstr "aufsteigend" msgid "descending" msgstr "absteigend" -#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/form.ex:265 #, elixir-autogen, elixir-format msgid "New" msgstr "Neue*r" @@ -701,59 +701,11 @@ msgstr "Obigen Text zur Bestätigung eingeben" msgid "To confirm deletion, please enter this text:" msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:" -#: lib/mv_web/live/user_live/form.ex:210 -#, elixir-autogen, elixir-format -msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." -msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen." - -#: lib/mv_web/live/user_live/form.ex:185 -#, elixir-autogen, elixir-format -msgid "Available members" -msgstr "Verfügbare Mitglieder" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Member will be unlinked when you save. Cannot select new member until saved." -msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden." - -#: lib/mv_web/live/user_live/form.ex:226 -#, elixir-autogen, elixir-format -msgid "Save to confirm linking." -msgstr "Speichern, um die Verknüpfung zu bestätigen." - -#: lib/mv_web/live/user_live/form.ex:169 -#, elixir-autogen, elixir-format -msgid "Search for a member to link..." -msgstr "Nach einem Mitglied zum Verknüpfen suchen..." - -#: lib/mv_web/live/user_live/form.ex:173 -#, elixir-autogen, elixir-format -msgid "Search for member to link" -msgstr "Nach Mitglied zum Verknüpfen suchen" - -#: lib/mv_web/live/user_live/form.ex:223 -#, elixir-autogen, elixir-format -msgid "Selected" -msgstr "Ausgewählt" - -#: lib/mv_web/live/user_live/form.ex:143 -#, elixir-autogen, elixir-format -msgid "Unlink Member" -msgstr "Mitglied entverknüpfen" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Unlinking scheduled" -msgstr "Entverknüpfung geplant" - -#: lib/mv_web/live/user_live/form.ex:342 -#, elixir-autogen, elixir-format -msgid "Failed to link member: %{error}" -msgstr "" #: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Show in overview" msgstr "In der Mitglieder-Übersicht anzeigen" + #: lib/mv_web/live/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" @@ -780,6 +732,56 @@ msgstr "Einstellungen speichern" msgid "Settings updated successfully" msgstr "Einstellungen erfolgreich gespeichert" +#: lib/mv_web/live/user_live/form.ex:224 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen." + +#: lib/mv_web/live/user_live/form.ex:192 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "Verfügbare Mitglieder" + +#: lib/mv_web/live/user_live/form.ex:357 +#, elixir-autogen, elixir-format +msgid "Failed to link member: %{error}" +msgstr "Fehler beim Verlinken des Mitglieds: %{error}" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden." + +#: lib/mv_web/live/user_live/form.ex:240 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "Speichern, um die Verknüpfung zu bestätigen." + +#: lib/mv_web/live/user_live/form.ex:171 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "Nach einem Mitglied zum Verknüpfen suchen..." + +#: lib/mv_web/live/user_live/form.ex:175 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "Nach Mitglied zum Verknüpfen suchen" + +#: lib/mv_web/live/user_live/form.ex:237 +#, elixir-autogen, elixir-format +msgid "Selected" +msgstr "Ausgewählt" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "Mitglied entverknüpfen" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "Entverknüpfung geplant" + #~ #: lib/mv_web/live/custom_field_live/index.ex:97 #~ #, elixir-autogen, elixir-format #~ msgid "To confirm deletion, please enter the custom field slug:" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a5e9aa9..47fe4dd 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -17,7 +17,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -36,14 +36,14 @@ msgid "City" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:204 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:196 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:265 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -89,7 +89,7 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:193 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -163,7 +163,7 @@ msgstr "" #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:234 +#: lib/mv_web/live/user_live/form.ex:248 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -185,7 +185,7 @@ msgstr "" msgid "Id" msgstr "" -#: lib/mv_web/live/member_live/index/formatter.ex:65 +#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -201,7 +201,7 @@ msgstr "" msgid "This is a member record from your database." msgstr "" -#: lib/mv_web/live/member_live/index/formatter.ex:64 +#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" @@ -260,7 +260,7 @@ msgstr "" #: 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/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:237 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -312,7 +312,7 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:25 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -340,7 +340,7 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:210 +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format msgid "Note" msgstr "" @@ -381,7 +381,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:235 +#: lib/mv_web/live/user_live/form.ex:249 #, elixir-autogen, elixir-format msgid "Save User" msgstr "" @@ -406,7 +406,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:252 +#: lib/mv_web/live/user_live/form.ex:266 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -434,7 +434,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/form.ex:265 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -705,6 +705,8 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Show in overview" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" @@ -730,3 +732,53 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Settings updated successfully" msgstr "" + +#: lib/mv_web/live/user_live/form.ex:224 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:192 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:357 +#, elixir-autogen, elixir-format +msgid "Failed to link member: %{error}" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:240 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:171 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:175 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:237 +#, elixir-autogen, elixir-format +msgid "Selected" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 19be444..a9e59e8 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -17,7 +17,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -36,14 +36,14 @@ msgid "City" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:204 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:196 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:265 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -89,7 +89,7 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:193 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -163,7 +163,7 @@ msgstr "" #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:234 +#: lib/mv_web/live/user_live/form.ex:248 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -185,7 +185,7 @@ msgstr "" msgid "Id" msgstr "" -#: lib/mv_web/live/member_live/index/formatter.ex:65 +#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -201,7 +201,7 @@ msgstr "" msgid "This is a member record from your database." msgstr "" -#: lib/mv_web/live/member_live/index/formatter.ex:64 +#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" @@ -260,7 +260,7 @@ msgstr "" #: 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/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:237 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -312,7 +312,7 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:25 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -340,7 +340,7 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:210 +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format, fuzzy msgid "Note" msgstr "" @@ -381,7 +381,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:235 +#: lib/mv_web/live/user_live/form.ex:249 #, elixir-autogen, elixir-format, fuzzy msgid "Save User" msgstr "" @@ -406,7 +406,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:252 +#: lib/mv_web/live/user_live/form.ex:266 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -434,7 +434,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/form.ex:265 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -702,58 +702,11 @@ msgstr "" msgid "To confirm deletion, please enter this text:" msgstr "" -#: lib/mv_web/live/user_live/form.ex:210 -#, elixir-autogen, elixir-format -msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:185 -#, elixir-autogen, elixir-format -msgid "Available members" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Member will be unlinked when you save. Cannot select new member until saved." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:226 -#, elixir-autogen, elixir-format -msgid "Save to confirm linking." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:169 -#, elixir-autogen, elixir-format -msgid "Search for a member to link..." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:173 -#, elixir-autogen, elixir-format -msgid "Search for member to link" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:223 -#, elixir-autogen, elixir-format, fuzzy -msgid "Selected" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:143 -#, elixir-autogen, elixir-format -msgid "Unlink Member" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Unlinking scheduled" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:342 -#, elixir-autogen, elixir-format -msgid "Failed to link member: %{error}" -msgstr "" #: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Show in overview" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" @@ -780,6 +733,56 @@ msgstr "" msgid "Settings updated successfully" msgstr "" +#: lib/mv_web/live/user_live/form.ex:224 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:192 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:357 +#, elixir-autogen, elixir-format +msgid "Failed to link member: %{error}" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:240 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:171 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:175 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:237 +#, elixir-autogen, elixir-format, fuzzy +msgid "Selected" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "" + #~ #: lib/mv_web/live/custom_field_live/index.ex:97 #~ #, elixir-autogen, elixir-format #~ msgid "To confirm deletion, please enter the custom field slug:" diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 00cf657..542e559 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -333,12 +333,6 @@ case Membership.get_settings() do {:ok, _updated} = Membership.update_settings(existing_settings, %{club_name: default_club_name}) end - - {:ok, nil} -> - # Settings don't exist, create them - Mv.Membership.Setting - |> Ash.Changeset.for_create(:create, %{club_name: default_club_name}) - |> Ash.create!(domain: Mv.Membership) end IO.puts("✅ Seeds completed successfully!") From d79205e75dccb89407ff73e58da39aa0816618d4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 1 Dec 2025 15:43:04 +0100 Subject: [PATCH 17/88] resolve review issues --- docs/contributions-architecture.md | 8 +++++--- docs/contributions-overview.md | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/contributions-architecture.md b/docs/contributions-architecture.md index b01ad30..3718a3b 100644 --- a/docs/contributions-architecture.md +++ b/docs/contributions-architecture.md @@ -147,6 +147,7 @@ lib/ **Existing Fields Used:** - `joined_at` - For calculating contribution start - `left_at` - For limiting period generation +- These fields must remain member fields and should not be replaced by custom fields in the future ### Settings Integration @@ -236,6 +237,7 @@ lib/ **Bulk Operations:** - `bulk_mark_as_paid` - Mark multiple periods as paid (efficiency) + - low priority, can be a future issue ### Contribution Type Change Handling @@ -403,7 +405,7 @@ lib/ **AC-UI-MD-1:** Contributions section shows all periods **AC-UI-MD-2:** Table columns: Period, Interval, Amount, Status, Actions -**AC-UI-MD-3:** Checkbox per period for bulk marking +**AC-UI-MD-3:** Checkbox per period for bulk marking (low prio) **AC-UI-MD-4:** "Mark selected as paid" button **AC-UI-MD-5:** Dropdown to change contribution type (same interval only) **AC-UI-MD-6:** Warning if different interval selected @@ -466,7 +468,7 @@ lib/ **Status Management Flow:** - Mark single period as paid -- Bulk mark multiple periods +- Bulk mark multiple periods (low prio) - Status transitions work - Permissions enforced @@ -487,7 +489,7 @@ lib/ **Member Detail:** - Periods table displays all periods - Checkboxes work -- Bulk marking works +- Bulk marking works (low prio) - Type change validation works - Actions only shown with permission diff --git a/docs/contributions-overview.md b/docs/contributions-overview.md index 03f347b..e0c4bc8 100644 --- a/docs/contributions-overview.md +++ b/docs/contributions-overview.md @@ -321,7 +321,7 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended **Quick Marking:** - Checkbox in each row for fast marking -- Button: "Mark selected as paid" +- Button: "Mark selected as paid/unpaid/suspended" - Bulk action for multiple periods ### Admin: Contribution Types Management @@ -426,7 +426,7 @@ Member exits: 15.08.2024 Yearly period 2024: unpaid ``` -**UI Notice on Exit:** +**UI Notice on Exit: (Low Prio)** ``` ⚠ Unpaid contributions present @@ -453,7 +453,7 @@ Do you want to continue? │ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │ └───────────────┴──────────┴────────┴──────────┴─────────┘ -[Mark selected as paid] (2 selected) +[Mark selected as paid/unpaid/suspended] (2 selected) ``` ### 4. Amount Changes From cf957563bb8295ae0bc2946860ac752242358b6b Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 08:45:18 +0100 Subject: [PATCH 18/88] chore: adds migration for member field visibility --- ...dd_member_field_visibility_to_settings.exs | 21 +++ .../repo/custom_fields/20251201115939.json | 144 ++++++++++++++++++ .../repo/settings/20251201115939.json | 79 ++++++++++ 3 files changed, 244 insertions(+) create mode 100644 priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251201115939.json create mode 100644 priv/resource_snapshots/repo/settings/20251201115939.json diff --git a/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs new file mode 100644 index 0000000..6d278fb --- /dev/null +++ b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs @@ -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 diff --git a/priv/resource_snapshots/repo/custom_fields/20251201115939.json b/priv/resource_snapshots/repo/custom_fields/20251201115939.json new file mode 100644 index 0000000..fabd84b --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251201115939.json @@ -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" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/settings/20251201115939.json b/priv/resource_snapshots/repo/settings/20251201115939.json new file mode 100644 index 0000000..4e635c4 --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20251201115939.json @@ -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" +} \ No newline at end of file From f24d4985fc43a816f6fff4faaa0ebbf0c9ddf301 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 09:22:26 +0100 Subject: [PATCH 19/88] tests: adds tests --- .../member_field_visibility_test.exs | 80 +++++++++++++++++++ .../index_member_fields_display_test.exs | 75 +++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 test/membership/member_field_visibility_test.exs create mode 100644 test/mv_web/member_live/index_member_fields_display_test.exs diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs new file mode 100644 index 0000000..46bdb74 --- /dev/null +++ b/test/membership/member_field_visibility_test.exs @@ -0,0 +1,80 @@ +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 + + describe "show_in_overview?/1" do + test "returns true for all member fields by default" do + # When no settings exist or member_field_visibility is not configured + # Test with fields from constants + member_fields = Mv.Constants.member_fields() + + Enum.each(member_fields, fn field -> + assert Member.show_in_overview?(field) == true, + "Field #{field} should be visible by default" + end) + end + + test "returns false for fields with show_in_overview: false in settings" do + # Get or create settings + {:ok, settings} = Mv.Membership.get_settings() + + # Use a field that exists in member fields + member_fields = Mv.Constants.member_fields() + field_to_hide = List.first(member_fields) + field_to_show = List.last(member_fields) + + # Update settings to hide a field + {:ok, _updated_settings} = + Mv.Membership.update_settings(settings, %{ + member_field_visibility: %{field_to_hide => false} + }) + + # JSONB may convert atom keys to string keys, so we check via show_in_overview? instead + assert Member.show_in_overview?(field_to_hide) == false + assert Member.show_in_overview?(field_to_show) == true + end + + test "returns true for non-configured fields (default)" do + # Get or create settings + {:ok, settings} = Mv.Membership.get_settings() + + # Use fields that exist in member fields + member_fields = Mv.Constants.member_fields() + fields_to_hide = Enum.take(member_fields, 2) + fields_to_show = Enum.take(member_fields, -2) + + # Update settings to hide some fields + visibility_config = + Enum.reduce(fields_to_hide, %{}, fn field, acc -> + Map.put(acc, field, false) + end) + + {:ok, _updated_settings} = + Mv.Membership.update_settings(settings, %{ + member_field_visibility: visibility_config + }) + + # Hidden fields should be false + Enum.each(fields_to_hide, fn field -> + assert Member.show_in_overview?(field) == false, + "Field #{field} should be hidden" + end) + + # Unconfigured fields should still be true (default) + Enum.each(fields_to_show, fn field -> + assert Member.show_in_overview?(field) == true, + "Field #{field} should be visible by default" + end) + end + end +end diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs new file mode 100644 index 0000000..a0e519a --- /dev/null +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -0,0 +1,75 @@ +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, &{&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 + + defp get_field_label(:street), do: "Street" + defp get_field_label(:house_number), do: "House Number" + defp get_field_label(:postal_code), do: "Postal Code" + defp get_field_label(:city), do: "City" + defp get_field_label(:phone_number), do: "Phone Number" + defp get_field_label(:join_date), do: "Join Date" + defp get_field_label(:email), do: "Email" + defp get_field_label(:first_name), do: "First name" + defp get_field_label(:last_name), do: "Last name" +end From a022d8cd02fe40c37e5aff5a13cf9afa4ad7682f Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 09:22:49 +0100 Subject: [PATCH 20/88] chore: adds constant for member_fields --- lib/mv/constants.ex | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 lib/mv/constants.ex diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex new file mode 100644 index 0000000..0725d60 --- /dev/null +++ b/lib/mv/constants.ex @@ -0,0 +1,9 @@ +defmodule Mv.Constants do + @moduledoc """ + Module for defining constants and atoms. + """ + + @member_fields [:first_name, :last_name, :email, :birth_date, :paid, :phone_number, :join_date, :exit_date, :notes, :city, :street, :house_number, :postal_code] + + def member_fields, do: @member_fields +end From 82e41916d27935d826093ec870037f21935b5b46 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 09:23:23 +0100 Subject: [PATCH 21/88] feat: adds member visibility settings --- lib/membership/member.ex | 64 ++++++++++++++++++++++++++++ lib/membership/membership.ex | 34 +++++++++++++++ lib/membership/setting.ex | 82 +++++++++++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index da69861..f91cb0b 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -434,6 +434,70 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end + @doc """ + Checks if a member field should be shown in the overview. + + Reads the visibility configuration from Settings resource. If a field is not + configured in settings, it defaults to `true` (visible). + + ## Parameters + - `field` - Atom representing the member field name (e.g., `:email`, `:street`) + + ## Returns + - `true` if the field should be shown in overview (default) + - `false` if the field is configured as hidden in settings + + ## Examples + + iex> Member.show_in_overview?(:email) + true + + iex> Member.show_in_overview?(:street) + true # or false if configured in settings + + """ + @spec show_in_overview?(atom()) :: boolean() + def show_in_overview?(field) when is_atom(field) do + case Mv.Membership.get_settings() do + {:ok, settings} -> + visibility_config = settings.member_field_visibility || %{} + # Normalize map keys to atoms (JSONB may return string keys) + normalized_config = normalize_visibility_config(visibility_config) + + # Get value from normalized config, default to true + Map.get(normalized_config, field, true) + + {:error, _} -> + # If settings can't be loaded, default to visible + true + end + end + + def show_in_overview?(_), do: true + + # Normalizes visibility config map keys from strings to atoms. + # JSONB in PostgreSQL converts atom keys to string keys when storing. + defp normalize_visibility_config(config) when is_map(config) do + Enum.reduce(config, %{}, fn + {key, value}, acc when is_atom(key) -> + Map.put(acc, key, value) + + {key, value}, acc when is_binary(key) -> + try do + atom_key = String.to_existing_atom(key) + Map.put(acc, atom_key, value) + rescue + ArgumentError -> + acc + end + + _, acc -> + acc + end) + end + + defp normalize_visibility_config(_), do: %{} + @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index cb3691b..516448c 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -53,6 +53,7 @@ defmodule Mv.Membership do # It's only used internally as fallback in get_settings/0 # Settings should be created via seed script define :update_settings, action: :update + define :update_member_field_visibility, action: :update_member_field_visibility end end @@ -123,4 +124,37 @@ defmodule Mv.Membership do |> Ash.Changeset.for_update(:update, attrs) |> Ash.update(domain: __MODULE__) 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 (atoms) 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 diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 38624dc..0bd9212 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do ## Attributes - `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 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 {: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, domain: Mv.Membership, @@ -49,18 +54,86 @@ defmodule Mv.Membership.Setting do # Used only as fallback in get_settings/0 if settings don't exist # Settings should normally be created via seed script create :create do - accept [:club_name] + accept [:club_name, :member_field_visibility] end update :update do 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] + + change fn changeset, _context -> + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + + if visibility && is_map(visibility) do + valid_fields = Mv.Constants.member_fields() + # Normalize keys to atoms (JSONB may return string keys) + invalid_keys = + Enum.filter(visibility, fn {key, _value} -> + atom_key = + if is_atom(key) do + key + else + try do + String.to_existing_atom(key) + rescue + ArgumentError -> nil + end + end + + atom_key && atom_key not in valid_fields + end) + |> Enum.map(fn {key, _value} -> key end) + + if Enum.empty?(invalid_keys) do + changeset + else + Ash.Changeset.add_error( + changeset, + field: :member_field_visibility, + message: "Invalid member field keys: #{inspect(invalid_keys)}" + ) + end + else + changeset + end + end end end validations do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] + + # Validate that member_field_visibility map contains only boolean values + # This allows dynamic fields without hardcoding specific field names + validate fn changeset, _context -> + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + + if visibility && is_map(visibility) do + invalid_entries = + Enum.filter(visibility, fn {_key, value} -> + not is_boolean(value) + end) + + if Enum.empty?(invalid_entries) do + :ok + else + {:error, + field: :member_field_visibility, + message: "All values in member_field_visibility must be booleans"} + end + else + :ok + end + end, + on: [:create, :update] end attributes do @@ -75,6 +148,11 @@ defmodule Mv.Membership.Setting do 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() end end From 7f0da693eeb798fcb097bc4f17f3b400ef286c5f Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 09:23:37 +0100 Subject: [PATCH 22/88] feat: adds member visibility to live view --- lib/mv_web/live/member_live/index.ex | 37 ++++++++++++++++ lib/mv_web/live/member_live/index.html.heex | 49 ++++++--------------- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 85ee4fb..1f8acb5 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -60,6 +60,8 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, []) |> assign(:custom_fields_visible, custom_fields_visible) + |> assign(:member_field_configurations, get_member_field_configurations()) + |> assign(:member_fields_visible, get_visible_member_fields()) # We call handle params to use the query from the URL {:ok, socket} @@ -733,4 +735,39 @@ defmodule MvWeb.MemberLive.Index do nil end end + + # Gets the configuration for all member fields with their show_in_overview values. + # + # Reads the visibility configuration from Settings and returns a map with all member fields + # and their show_in_overview values (true or false). Fields not configured in settings + # default to true. + # + # Returns a map: %{field_name => show_in_overview} + # + # This can be used for: + # - Rendering the overview (filtering visible fields) + # - UI configuration dropdowns (showing all fields with their current state) + # - Dynamic field management + # + # Fields are read from the global Constants module. + defp get_member_field_configurations do + # Get all eligible fields from the global constants + all_fields = Mv.Constants.member_fields() + + Enum.reduce(all_fields, %{}, fn field, acc -> + show_in_overview = Mv.Membership.Member.show_in_overview?(field) + Map.put(acc, field, show_in_overview) + 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. + # + # Returns a list of atoms representing visible member field names. + defp get_visible_member_fields do + get_member_field_configurations() + |> Enum.filter(fn {_field, show_in_overview} -> show_in_overview end) + |> Enum.map(fn {field, _show_in_overview} -> field end) + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 67fa804..0fa9f05 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -69,9 +69,7 @@ > {member.first_name} {member.last_name} - <:col - :let={member} - label={ + <:col :if={:email in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -82,13 +80,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.email} - <:col - :let={member} - label={ + <:col :if={:street in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -99,13 +94,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.street} - <:col - :let={member} - label={ + <:col :if={:house_number in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -116,13 +108,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.house_number} - <:col - :let={member} - label={ + <:col :if={:postal_code in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -133,13 +122,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.postal_code} - <:col - :let={member} - label={ + <:col :if={:city in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -150,13 +136,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.city} - <:col - :let={member} - label={ + <:col :if={:phone_number in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -167,13 +150,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.phone_number} - <:col - :let={member} - label={ + <:col :if={:join_date in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -184,8 +164,7 @@ sort_order={@sort_order} /> """ - } - > + }> {member.join_date} <:action :let={member}> From d039e4bb7d853ad50b9f141aa616679782f648c8 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 10:02:52 +0100 Subject: [PATCH 23/88] formatting and refactor member fields constant --- lib/membership/member.ex | 36 ++----- lib/membership/setting.ex | 39 ++++---- lib/mv/constants.ex | 16 +++- lib/mv_web/live/member_live/index.ex | 96 +++++++++++++------ lib/mv_web/live/member_live/index.html.heex | 56 ++++++++--- .../index_member_fields_display_test.exs | 11 --- 6 files changed, 150 insertions(+), 104 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index f91cb0b..31a825b 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do @member_search_limit 10 @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 table "members" 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 argument :user, :map, allow_nil?: true - accept [ - :first_name, - :last_name, - :email, - :birth_date, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] + accept @member_fields 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 argument :user, :map, allow_nil?: true - accept [ - :first_name, - :last_name, - :email, - :birth_date, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] + accept @member_fields change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 0bd9212..3405a3f 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -114,26 +114,26 @@ defmodule Mv.Membership.Setting do # Validate that member_field_visibility map contains only boolean values # This allows dynamic fields without hardcoding specific field names validate fn changeset, _context -> - visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) - if visibility && is_map(visibility) do - invalid_entries = - Enum.filter(visibility, fn {_key, value} -> - not is_boolean(value) - end) + if visibility && is_map(visibility) do + invalid_entries = + Enum.filter(visibility, fn {_key, value} -> + not is_boolean(value) + end) - if Enum.empty?(invalid_entries) do - :ok - else - {:error, - field: :member_field_visibility, - message: "All values in member_field_visibility must be booleans"} - end - else - :ok - end - end, - on: [:create, :update] + if Enum.empty?(invalid_entries) do + :ok + else + {:error, + field: :member_field_visibility, + message: "All values in member_field_visibility must be booleans"} + end + else + :ok + end + end, + on: [:create, :update] end attributes do @@ -151,7 +151,8 @@ defmodule Mv.Membership.Setting do 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." + description: + "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." timestamps() end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 0725d60..cd8d3a4 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -3,7 +3,21 @@ defmodule Mv.Constants do Module for defining constants and atoms. """ - @member_fields [:first_name, :last_name, :email, :birth_date, :paid, :phone_number, :join_date, :exit_date, :notes, :city, :street, :house_number, :postal_code] + @member_fields [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] def member_fields, do: @member_fields end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 1f8acb5..6bce495 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -29,11 +29,18 @@ defmodule MvWeb.MemberLive.Index do require Ash.Query import Ash.Expr + alias Mv.Membership alias MvWeb.MemberLive.Index.Formatter # Prefix used in sort field names for custom fields (e.g., "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 """ Initializes the LiveView state. @@ -52,6 +59,14 @@ defmodule MvWeb.MemberLive.Index do |> 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 + socket = socket |> assign(:page_title, gettext("Members")) @@ -59,9 +74,10 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, []) + |> assign(:settings, settings) |> assign(:custom_fields_visible, custom_fields_visible) - |> assign(:member_field_configurations, get_member_field_configurations()) - |> assign(:member_fields_visible, get_visible_member_fields()) + |> assign(:member_field_configurations, get_member_field_configurations(settings)) + |> assign(:member_fields_visible, get_visible_member_fields(settings)) # We call handle params to use the query from the URL {:ok, socket} @@ -315,18 +331,7 @@ defmodule MvWeb.MemberLive.Index do query = Mv.Membership.Member |> Ash.Query.new() - |> Ash.Query.select([ - :id, - :first_name, - :last_name, - :email, - :street, - :house_number, - :postal_code, - :city, - :phone_number, - :join_date - ]) + |> Ash.Query.select(@overview_fields) # Load custom field values for visible custom fields custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) @@ -435,18 +440,13 @@ defmodule MvWeb.MemberLive.Index do defp maybe_sort(query, _, _, _), do: {query, false} # 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 - valid_fields = [ - :first_name, - :last_name, - :email, - :street, - :house_number, - :postal_code, - :city, - :phone_number, - :join_date - ] + # All member fields are sortable, but we exclude some that don't make sense + # :id is not in member_fields, but we don't want to sort by it anyway + non_sortable_fields = [:notes, :paid] + valid_fields = Mv.Constants.member_fields() -- non_sortable_fields field in valid_fields or custom_field_sort?(field) end @@ -742,6 +742,12 @@ defmodule MvWeb.MemberLive.Index do # and their show_in_overview values (true or false). Fields not configured in settings # default to true. # + # Performance: This function uses the already-loaded settings to avoid N+1 queries. + # Settings should be loaded once in mount/3 and passed to this function. + # + # Parameters: + # - `settings` - The settings struct loaded from the database + # # Returns a map: %{field_name => show_in_overview} # # This can be used for: @@ -750,12 +756,16 @@ defmodule MvWeb.MemberLive.Index do # - Dynamic field management # # Fields are read from the global Constants module. - defp get_member_field_configurations do + @spec get_member_field_configurations(map()) :: %{atom() => boolean()} + defp get_member_field_configurations(settings) do # Get all eligible fields from the global constants all_fields = Mv.Constants.member_fields() + # Normalize visibility config (JSONB may return string keys) + visibility_config = normalize_visibility_config(settings.member_field_visibility || %{}) + Enum.reduce(all_fields, %{}, fn field, acc -> - show_in_overview = Mv.Membership.Member.show_in_overview?(field) + show_in_overview = Map.get(visibility_config, field, true) Map.put(acc, field, show_in_overview) end) end @@ -764,10 +774,38 @@ defmodule MvWeb.MemberLive.Index do # # 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. - defp get_visible_member_fields do - get_member_field_configurations() + @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 diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 0fa9f05..594f2d8 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -69,7 +69,10 @@ > {member.first_name} {member.last_name} - <:col :if={:email in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:email in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -80,10 +83,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.email} - <:col :if={:street in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:street in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -94,10 +101,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.street} - <:col :if={:house_number in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:house_number in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -108,10 +119,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.house_number} - <:col :if={:postal_code in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -122,10 +137,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.postal_code} - <:col :if={:city in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:city in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -136,10 +155,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.city} - <:col :if={:phone_number in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:phone_number in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -150,10 +173,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.phone_number} - <:col :if={:join_date in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:join_date in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -164,7 +191,8 @@ sort_order={@sort_order} /> """ - }> + } + > {member.join_date} <:action :let={member}> diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index a0e519a..c4a5b9f 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -36,7 +36,6 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do } 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") @@ -62,14 +61,4 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do assert html =~ m.email refute html =~ m.street end - - defp get_field_label(:street), do: "Street" - defp get_field_label(:house_number), do: "House Number" - defp get_field_label(:postal_code), do: "Postal Code" - defp get_field_label(:city), do: "City" - defp get_field_label(:phone_number), do: "Phone Number" - defp get_field_label(:join_date), do: "Join Date" - defp get_field_label(:email), do: "Email" - defp get_field_label(:first_name), do: "First name" - defp get_field_label(:last_name), do: "Last name" end From e2ace3d2a8b53fb87b67f083f1e83cdfaa782463 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Dec 2025 10:02:58 +0100 Subject: [PATCH 24/88] feat: add bulk email copy for selected members (#230) Copy selected members' emails to clipboard in 'First Last ' format --- CHANGELOG.md | 6 + assets/js/app.js | 27 +++ docs/development-progress-log.md | 31 ++- docs/feature-roadmap.md | 1 + email-copy-feature.plan.md | 235 ++++++++++++++++++++ lib/mv_web/live/member_live/index.ex | 62 ++++++ lib/mv_web/live/member_live/index.html.heex | 10 + priv/gettext/de/LC_MESSAGES/default.po | 64 ++++-- priv/gettext/default.pot | 61 +++-- priv/gettext/en/LC_MESSAGES/default.po | 64 ++++-- test/mv_web/member_live/index_test.exs | 161 ++++++++++++++ 11 files changed, 661 insertions(+), 61 deletions(-) create mode 100644 email-copy-feature.plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 74df997..71d9147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - WCAG 2.1 AA compliant autocomplete dropdown with ARIA support - 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 " 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 - Email validation false positive when linking user and member with identical emails (#168 Problem #4) - Relationship data extraction from Ash manage_relationship during validation +- Copy button count now shows only visible selected members when filtering diff --git a/assets/js/app.js b/assets/js/app.js index e55a06d..883ca30 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,6 +27,33 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute(" // Hooks for LiveView components 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 Hooks.ComboBox = { mounted() { diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 5669a19..629987e 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -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 ` 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 " 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) ### Feature Summary @@ -1559,8 +1586,8 @@ This project demonstrates a modern Phoenix application built with: --- -**Document Version:** 1.2 -**Last Updated:** 2025-11-27 +**Document Version:** 1.3 +**Last Updated:** 2025-12-02 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 2313fd7..60432d0 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -65,6 +65,7 @@ - ✅ Sorting by basic fields - ✅ User-Member linking (optional 1:1) - ✅ Email synchronization between User and Member +- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230) **Closed Issues:** - ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12) diff --git a/email-copy-feature.plan.md b/email-copy-feature.plan.md new file mode 100644 index 0000000..7895798 --- /dev/null +++ b/email-copy-feature.plan.md @@ -0,0 +1,235 @@ +# Bulk Email Copy Feature - Detaillierter Implementierungsplan + +## Aktueller Stand + +Die Checkbox-Funktionalität existiert bereits vollständig: + +- `select_member` und `select_all` Events in [`lib/mv_web/live/member_live/index.ex`](lib/mv_web/live/member_live/index.ex) (Zeilen 91-117) +- Checkboxen im Template [`lib/mv_web/live/member_live/index.html.heex`](lib/mv_web/live/member_live/index.html.heex) (Zeilen 28-54) +- `@selected_members` enthält die UUIDs der ausgewählten Mitglieder als Liste + +## Gewählte Implementierung: JavaScript Hook mit LiveView Event + +**Ablauf:** + +1. User wählt Mitglieder über Checkboxen aus +2. User klickt "E-Mail-Adressen kopieren" Button +3. LiveView Event `copy_emails` wird ausgelöst +4. Server filtert Member aus `@members` nach `@selected_members` +5. Server formatiert E-Mails im Format `Vorname Nachname ` +6. Server pusht `copy_to_clipboard` Event mit formatiertem String an Client +7. JavaScript Hook empfängt Event und kopiert via `navigator.clipboard.writeText()` +8. Server zeigt Flash-Nachricht mit Erfolgsbestätigung + +--- + +## Implementierungsschritte + +### Schritt 1: JavaScript Hook erstellen + +**Datei:** `assets/js/app.js` + +- Neuen Hook `CopyToClipboard` zum bestehenden `Hooks` Objekt hinzufügen +- Hook lauscht auf `copy_to_clipboard` Event vom Server +- Nutzt `navigator.clipboard.writeText()` API für das Kopieren +- Fallback-Behandlung für Browser ohne Clipboard API (ältere Browser) +- Fehlerbehandlung bei fehlgeschlagenem Kopieren + +### Schritt 2: LiveView Event Handler implementieren + +**Datei:** `lib/mv_web/live/member_live/index.ex` + +- Neuen `handle_event("copy_emails", ...)` Callback hinzufügen +- Member aus `@members` filtern, deren ID in `@selected_members` enthalten ist +- Jeden Member im Format `"Vorname Nachname "` formatieren +- Formatierte Strings mit `"; "` (Semikolon + Leerzeichen) verbinden +- `push_event/3` nutzen um `copy_to_clipboard` Event zu senden +- `put_flash/3` für Erfolgsbestätigung mit Anzahl der kopierten Adressen +- Private Helper-Funktion für die E-Mail-Formatierung + +### Schritt 3: UI Button hinzufügen + +**Datei:** `lib/mv_web/live/member_live/index.html.heex` + +- Button im Header-Bereich neben "New Member" Button platzieren +- Button nur anzeigen wenn mindestens ein Mitglied ausgewählt ist (`:if` Bedingung) +- `phx-hook="CopyToClipboard"` Attribut für JavaScript Hook Anbindung +- `phx-click="copy_emails"` für Event-Auslösung +- Icon: `hero-clipboard-document` oder `hero-envelope` +- Button-Text mit Anzahl der ausgewählten Mitglieder anzeigen +- Accessibility: `aria-label` für Screen Reader + +### Schritt 4: Gettext Übersetzungen hinzufügen + +**Dateien:** + +- `priv/gettext/default.pot` - Template aktualisieren via `mix gettext.extract` +- `priv/gettext/de/LC_MESSAGES/default.po` - Deutsche Übersetzungen +- `priv/gettext/en/LC_MESSAGES/default.po` - Englische Übersetzungen (falls vorhanden) + +**Zu übersetzende Strings:** + +- Button-Text: "Copy Email Addresses" +- Flash-Nachricht Erfolg: "Copied %{count} email address(es) to clipboard" +- Flash-Nachricht Fehler: "No members selected" + +### Schritt 5: Moduledoc aktualisieren + +**Datei:** `lib/mv_web/live/member_live/index.ex` + +- `@moduledoc` um neues Event `copy_emails` erweitern +- Dokumentation der Funktionalität hinzufügen + +--- + +## Edge Cases + +### E1: Keine Mitglieder ausgewählt + +- Button wird nicht angezeigt (UI-seitig gelöst) +- Falls Event dennoch ausgelöst wird: Error-Flash anzeigen, nichts kopieren + +### E2: Ausgewählte Mitglieder nicht mehr in `@members` Liste + +- Kann passieren wenn Member zwischenzeitlich gelöscht wurde +- Nur vorhandene Member verarbeiten, keine Fehler werfen +- Flash zeigt tatsächliche Anzahl kopierter Adressen + +### E3: Member ohne E-Mail-Adresse + +- Defensive Programmierung: Member ohne E-Mail überspringen + +### E4: Member mit leerem Vor- oder Nachnamen + +- Defensive Programmierung: Leere Namen graceful behandeln + +### E5: Sonderzeichen in Namen + +- Namen können Umlaute, Akzente, etc. enthalten +- Keine Escaping nötig, da Text direkt in Zwischenablage kopiert wird +- E-Mail-Clients verarbeiten Unicode korrekt + +### E6: Sehr lange Liste (100+ Mitglieder) + +- String kann sehr lang werden +- Clipboard API hat kein praktisches Limit +- Kein spezielles Handling nötig + +### E7: Browser unterstützt Clipboard API nicht + +- `navigator.clipboard` ist nicht in allen Browsern verfügbar +- Fallback: `document.execCommand('copy')` (deprecated aber breit unterstützt) +- Oder: Fehler-Flash anzeigen + +### E8: Clipboard-Zugriff vom Browser blockiert + +- Moderne Browser können Clipboard-Zugriff einschränken +- HTTPS erforderlich (in Produktion gegeben) +- User muss ggf. Berechtigung erteilen +- Fehlerbehandlung im Hook nötig + +### E9: Parallel laufende Suche/Filter ändert `@members` + +- User wählt Mitglieder, dann ändert Suche die Liste +- `@selected_members` bleibt erhalten, aber IDs passen nicht mehr zu `@members` +- Nur noch vorhandene (angezeigte) Members werden kopiert +- Entscheidung: Selection bei Suche beibehalten? + +### E10: "Select All" nach Filterung + +- Wenn gefiltert und "Select All" geklickt, werden nur sichtbare Members ausgewählt +- Bestehendes Verhalten, kein neues Problem + +--- + +## Testplan + +### Unit Tests (index.ex) + +**T1: copy_emails Event - Erfolgsfall** + +- Setup: 3 Members in `@members`, 2 davon in `@selected_members` +- Assert: `push_event` wird mit korrektem String aufgerufen +- Assert: Flash-Nachricht mit count=2 + +**T2: copy_emails Event - Keine Auswahl** + +- Setup: `@selected_members` ist leer +- Assert: Kein `push_event` +- Assert: Error-Flash oder keine Aktion + +**T3: copy_emails Event - Alle ausgewählt** + +- Setup: Alle Members in `@selected_members` +- Assert: Alle E-Mails im Output-String + +**T4: E-Mail Formatierung** + +- Assert: Format ist `"Vorname Nachname "` +- Assert: Mehrere E-Mails mit `"; "` getrennt + +**T5: Member mit Sonderzeichen im Namen** + +- Setup: Member mit Name "Müller-Lüdenscheidt" +- Assert: Name wird korrekt übernommen + +**T6: Teilweise nicht vorhandene Member** + +- Setup: `@selected_members` enthält ID die nicht in `@members` ist +- Assert: Nur vorhandene Members werden verarbeitet, kein Crash + +### LiveView Integration Tests + +**T7: Button Sichtbarkeit** + +- Assert: Button nicht sichtbar wenn `@selected_members` leer +- Assert: Button sichtbar wenn mindestens 1 Member ausgewählt + +**T8: Button zeigt korrekte Anzahl** + +- Setup: 3 Members ausgewählt +- Assert: Button-Text enthält "(3)" + +**T9: Click löst Event aus** + +- Action: Click auf Copy-Button +- Assert: `copy_emails` Event wird gesendet + +**T10: Vollständiger Flow** + +- Action: Member auswählen, Button klicken +- Assert: Flash-Nachricht erscheint + +## Zu ändernde Dateien + +| Datei | Änderungstyp | + +|-------|--------------| + +| `assets/js/app.js` | Hook hinzufügen | + +| `lib/mv_web/live/member_live/index.ex` | Event Handler + Helper | + +| `lib/mv_web/live/member_live/index.html.heex` | Button UI | + +| `priv/gettext/de/LC_MESSAGES/default.po` | Übersetzungen | + +| `test/mv_web/member_live/index_test.exs` | Tests | + +--- + +## E-Mail Output Format + +**Einzelne E-Mail:** + +``` +Max Mustermann +``` + +**Mehrere E-Mails:** + +``` +Max Mustermann ; Erika Musterfrau ; Hans Müller +``` + +**Hinweis:** Semikolon als Trennzeichen ist Standard für E-Mail-Clients (Outlook, Thunderbird, etc.) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 85ee4fb..3087d7e 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index do - `delete` - Remove a member from the database - `select_member` - Toggle individual member selection - `select_all` - Toggle selection of all visible members + - `copy_emails` - Copy email addresses of selected members to clipboard ## Implementation Notes - Search uses PostgreSQL full-text search (plainto_tsquery) @@ -116,6 +117,49 @@ defmodule MvWeb.MemberLive.Index do {:noreply, assign(socket, :selected_members, selected)} end + @impl true + def handle_event("copy_emails", _params, socket) do + selected_ids = socket.assigns.selected_members + + if selected_ids == [] do + {:noreply, put_flash(socket, :error, gettext("No members selected"))} + else + # Filter members that are in the selection + selected_members = + socket.assigns.members + |> Enum.filter(fn member -> member.id in selected_ids end) + + # Format emails and filter out members without email + formatted_emails = + selected_members + |> Enum.filter(fn member -> member.email && member.email != "" end) + |> Enum.map(&format_member_email/1) + + email_count = length(formatted_emails) + + if email_count == 0 do + {:noreply, put_flash(socket, :error, gettext("No email addresses found"))} + else + email_string = Enum.join(formatted_emails, "; ") + + socket = + socket + |> push_event("copy_to_clipboard", %{text: email_string}) + |> put_flash( + :info, + ngettext( + "Copied %{count} email address to clipboard", + "Copied %{count} email addresses to clipboard", + email_count, + count: email_count + ) + ) + + {:noreply, socket} + end + end + end + # ----------------------------------------------------------------- # Handle Infos from Child Components # ----------------------------------------------------------------- @@ -733,4 +777,22 @@ defmodule MvWeb.MemberLive.Index do nil end end + + # Formats a member's email in the format "First Last " + # 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 end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 67fa804..1ab9b3d 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -2,6 +2,16 @@ <.header> {gettext("Members")} <:actions> + <.button + :if={Enum.any?(@members, &(&1.id in @selected_members))} + 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, &(&1.id in @selected_members))}) + <.button variant="primary" navigate={~p"/members/new"}> <.icon name="hero-plus" /> {gettext("New Member")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index e9214fc..d75ec52 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -15,7 +15,7 @@ msgstr "" msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/index.html.heex:212 #: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -28,19 +28,19 @@ msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:148 +#: lib/mv_web/live/member_live/index.html.heex:158 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" -#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/member_live/index.html.heex:214 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/member_live/index.html.heex:206 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -54,7 +54,7 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: 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:90 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -70,7 +70,7 @@ msgid "First Name" msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:182 +#: lib/mv_web/live/member_live/index.html.heex:192 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -82,12 +82,12 @@ msgstr "Beitrittsdatum" msgid "Last Name" msgstr "Nachname" -#: lib/mv_web/live/member_live/index.html.heex:6 +#: lib/mv_web/live/member_live/index.html.heex:16 #, elixir-autogen, elixir-format msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/member_live/index.html.heex:203 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" @@ -121,7 +121,7 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: 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/index.html.heex:124 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -140,14 +140,14 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:165 +#: lib/mv_web/live/member_live/index.html.heex:175 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:131 +#: lib/mv_web/live/member_live/index.html.heex:141 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:97 +#: lib/mv_web/live/member_live/index.html.heex:107 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -311,7 +311,7 @@ msgid "Member" msgstr "Mitglied" #: 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:58 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -365,12 +365,12 @@ msgstr "Profil" msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex:37 +#: lib/mv_web/live/member_live/index.html.heex:47 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswählen" -#: lib/mv_web/live/member_live/index.html.heex:51 +#: lib/mv_web/live/member_live/index.html.heex:61 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswählen" @@ -556,7 +556,7 @@ msgid "Toggle dark mode" msgstr "Dunklen Modus umschalten" #: 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:25 #, elixir-autogen, elixir-format msgid "Search..." msgstr "Suchen..." @@ -572,7 +572,7 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:73 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" @@ -782,7 +782,29 @@ msgstr "Mitglied entverknüpfen" msgid "Unlinking scheduled" msgstr "Entverknüpfung geplant" -#~ #: lib/mv_web/live/custom_field_live/index.ex:97 -#~ #, elixir-autogen, elixir-format -#~ msgid "To confirm deletion, please enter the custom field slug:" -#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:" +#: lib/mv_web/live/member_live/index.ex:150 +#, 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:141 +#, elixir-autogen, elixir-format +msgid "No email addresses found" +msgstr "Keine E-Mail-Adressen gefunden" + +#: lib/mv_web/live/member_live/index.ex:125 +#, elixir-autogen, elixir-format +msgid "No members selected" +msgstr "Keine Mitglieder ausgewählt" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 47fe4dd..ca8bd14 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -16,7 +16,7 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/index.html.heex:212 #: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -29,19 +29,19 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:148 +#: lib/mv_web/live/member_live/index.html.heex:158 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/member_live/index.html.heex:214 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/member_live/index.html.heex:206 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: 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:90 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:182 +#: lib/mv_web/live/member_live/index.html.heex:192 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -83,12 +83,12 @@ msgstr "" msgid "Last Name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:6 +#: lib/mv_web/live/member_live/index.html.heex:16 #, elixir-autogen, elixir-format msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/member_live/index.html.heex:203 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" @@ -122,7 +122,7 @@ 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/index.html.heex:124 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:165 +#: lib/mv_web/live/member_live/index.html.heex:175 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:131 +#: lib/mv_web/live/member_live/index.html.heex:141 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -169,7 +169,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:97 +#: lib/mv_web/live/member_live/index.html.heex:107 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -312,7 +312,7 @@ msgid "Member" msgstr "" #: 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:58 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -366,12 +366,12 @@ msgstr "" msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:37 +#: lib/mv_web/live/member_live/index.html.heex:47 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:51 +#: lib/mv_web/live/member_live/index.html.heex:61 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -557,7 +557,7 @@ msgid "Toggle dark mode" msgstr "" #: 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:25 #, elixir-autogen, elixir-format msgid "Search..." msgstr "" @@ -573,7 +573,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:73 #, elixir-autogen, elixir-format msgid "First name" msgstr "" @@ -782,3 +782,30 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "" + +#: lib/mv_web/live/member_live/index.ex:150 +#, 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:141 +#, elixir-autogen, elixir-format +msgid "No email addresses found" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex:125 +#, elixir-autogen, elixir-format +msgid "No members selected" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a9e59e8..e9158d9 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -16,7 +16,7 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/index.html.heex:212 #: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -29,19 +29,19 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:148 +#: lib/mv_web/live/member_live/index.html.heex:158 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/member_live/index.html.heex:214 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/member_live/index.html.heex:206 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: 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:90 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:182 +#: lib/mv_web/live/member_live/index.html.heex:192 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -83,12 +83,12 @@ msgstr "" msgid "Last Name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:6 +#: lib/mv_web/live/member_live/index.html.heex:16 #, elixir-autogen, elixir-format msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/member_live/index.html.heex:203 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" @@ -122,7 +122,7 @@ 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/index.html.heex:124 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:165 +#: lib/mv_web/live/member_live/index.html.heex:175 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:131 +#: lib/mv_web/live/member_live/index.html.heex:141 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -169,7 +169,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:97 +#: lib/mv_web/live/member_live/index.html.heex:107 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -312,7 +312,7 @@ msgid "Member" msgstr "" #: 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:58 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -366,12 +366,12 @@ msgstr "" msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:37 +#: lib/mv_web/live/member_live/index.html.heex:47 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:51 +#: lib/mv_web/live/member_live/index.html.heex:61 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -557,7 +557,7 @@ msgid "Toggle dark mode" msgstr "" #: 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:25 #, elixir-autogen, elixir-format msgid "Search..." msgstr "" @@ -573,7 +573,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:73 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" @@ -783,7 +783,29 @@ msgstr "" msgid "Unlinking scheduled" msgstr "" -#~ #: lib/mv_web/live/custom_field_live/index.ex:97 -#~ #, elixir-autogen, elixir-format -#~ msgid "To confirm deletion, please enter the custom field slug:" -#~ msgstr "" +#: lib/mv_web/live/member_live/index.ex:150 +#, 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:141 +#, elixir-autogen, elixir-format +msgid "No email addresses found" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex:125 +#, elixir-autogen, elixir-format, fuzzy +msgid "No members selected" +msgstr "" diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 0668202..6e91b4c 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -249,4 +249,165 @@ defmodule MvWeb.MemberLive.IndexTest do # Verify the member was actually deleted from the database assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?()) 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 members are deleted", %{ + 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 - should work correctly + view |> element("#copy-emails-btn") |> render_click() + + # Should show count of actual members found (1) + 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 end From ba78a6ac7af31b5c9f8295516f4fbcdd80a063c8 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Dec 2025 11:42:11 +0100 Subject: [PATCH 25/88] feat: improve email copy UX with colored alerts and mailto button - Green success alert for copied confirmation - Blue info alert with BCC privacy tip - Mailto button opens email program with BCC recipients - Alerts stack vertically instead of overlapping --- lib/mv_web/components/core_components.ex | 40 +++++++------ lib/mv_web/components/layouts.ex | 4 +- lib/mv_web/live/member_live/index.ex | 6 +- lib/mv_web/live/member_live/index.html.heex | 8 +++ priv/gettext/de/LC_MESSAGES/default.po | 65 +++++++++++++-------- priv/gettext/default.pot | 65 +++++++++++++-------- priv/gettext/en/LC_MESSAGES/default.po | 65 +++++++++++++-------- 7 files changed, 159 insertions(+), 94 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index b8fe0fc..ae50ecb 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do attr :id, :string, doc: "the optional id of flash container" attr :flash, :map, default: %{}, doc: "the map of flash messages to display" 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" slot :inner_block, doc: "the optional inner block that renders the flash message" @@ -56,25 +60,27 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="toast toast-top toast-end z-50" - {@rest} - > -
- <.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" /> -
-

{@title}

-

{msg}

-
-
- + @kind == :error && "alert-error", + @kind == :success && "bg-green-500 text-white", + @kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300" + ]} + {@rest} + > + <.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 == :success} name="hero-check-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" /> +
+

{@title}

+

{msg}

+
+
""" end diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index b7f7568..487a01f 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -65,7 +65,9 @@ defmodule MvWeb.Layouts do def flash_group(assigns) do ~H""" -
+
+ <.flash kind={:success} flash={@flash} /> + <.flash kind={:warning} flash={@flash} /> <.flash kind={:info} flash={@flash} /> <.flash kind={:error} flash={@flash} /> diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 3087d7e..ad867ab 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -146,7 +146,7 @@ defmodule MvWeb.MemberLive.Index do socket |> push_event("copy_to_clipboard", %{text: email_string}) |> put_flash( - :info, + :success, ngettext( "Copied %{count} email address to clipboard", "Copied %{count} email addresses to clipboard", @@ -154,6 +154,10 @@ defmodule MvWeb.MemberLive.Index do count: email_count ) ) + |> put_flash( + :warning, + gettext("Tip: Paste email addresses into the BCC field for privacy compliance") + ) {:noreply, socket} end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 1ab9b3d..0dabbaf 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -12,6 +12,14 @@ <.icon name="hero-clipboard-document" /> {gettext("Copy emails")} ({Enum.count(@members, &(&1.id in @selected_members))}) + <.button + :if={Enum.any?(@members, &(&1.id in @selected_members))} + href={"mailto:?bcc=#{@members |> Enum.filter(&(&1.id in @selected_members and &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 variant="primary" navigate={~p"/members/new"}> <.icon name="hero-plus" /> {gettext("New Member")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index d75ec52..770cc09 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -10,37 +10,37 @@ msgid "" msgstr "" "Language: en\n" -#: lib/mv_web/components/core_components.ex:356 +#: lib/mv_web/components/core_components.ex:360 #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:212 +#: lib/mv_web/live/member_live/index.html.heex:220 #: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" -#: lib/mv_web/components/layouts.ex:80 -#: lib/mv_web/components/layouts.ex:92 +#: lib/mv_web/components/layouts.ex:82 +#: lib/mv_web/components/layouts.ex:94 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:158 +#: lib/mv_web/live/member_live/index.html.heex:166 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" -#: lib/mv_web/live/member_live/index.html.heex:214 +#: lib/mv_web/live/member_live/index.html.heex:222 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:206 +#: lib/mv_web/live/member_live/index.html.heex:214 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -54,7 +54,7 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:90 +#: lib/mv_web/live/member_live/index.html.heex:98 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -70,7 +70,7 @@ msgid "First Name" msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:192 +#: lib/mv_web/live/member_live/index.html.heex:200 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -82,28 +82,28 @@ msgstr "Beitrittsdatum" msgid "Last Name" msgstr "Nachname" -#: lib/mv_web/live/member_live/index.html.heex:16 +#: lib/mv_web/live/member_live/index.html.heex:24 #, elixir-autogen, elixir-format msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:203 +#: lib/mv_web/live/member_live/index.html.heex:211 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" -#: lib/mv_web/components/layouts.ex:87 +#: lib/mv_web/components/layouts.ex:89 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "Etwas ist schiefgelaufen!" -#: lib/mv_web/components/layouts.ex:75 +#: lib/mv_web/components/layouts.ex:77 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "Keine Internetverbindung gefunden" -#: lib/mv_web/components/core_components.ex:74 +#: lib/mv_web/components/core_components.ex:78 #, elixir-autogen, elixir-format msgid "close" msgstr "schließen" @@ -121,7 +121,7 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:124 +#: lib/mv_web/live/member_live/index.html.heex:132 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -140,14 +140,14 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:175 +#: lib/mv_web/live/member_live/index.html.heex:183 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:141 +#: lib/mv_web/live/member_live/index.html.heex:149 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:107 +#: lib/mv_web/live/member_live/index.html.heex:115 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -365,12 +365,12 @@ msgstr "Profil" msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex:47 +#: lib/mv_web/live/member_live/index.html.heex:55 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswählen" -#: lib/mv_web/live/member_live/index.html.heex:61 +#: lib/mv_web/live/member_live/index.html.heex:69 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswählen" @@ -556,7 +556,7 @@ msgid "Toggle dark mode" msgstr "Dunklen Modus umschalten" #: lib/mv_web/live/components/search_bar_component.ex:15 -#: lib/mv_web/live/member_live/index.html.heex:25 +#: lib/mv_web/live/member_live/index.html.heex:33 #, elixir-autogen, elixir-format msgid "Search..." msgstr "Suchen..." @@ -572,7 +572,7 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:73 +#: lib/mv_web/live/member_live/index.html.heex:81 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" @@ -782,7 +782,7 @@ msgstr "Mitglied entverknüpfen" msgid "Unlinking scheduled" msgstr "Entverknüpfung geplant" -#: lib/mv_web/live/member_live/index.ex:150 +#: lib/mv_web/live/member_live/index.ex:159 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" @@ -799,12 +799,27 @@ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren" msgid "Copy emails" msgstr "E-Mails kopieren" -#: lib/mv_web/live/member_live/index.ex:141 +#: lib/mv_web/live/member_live/index.ex:142 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "Keine E-Mail-Adressen gefunden" -#: lib/mv_web/live/member_live/index.ex:125 +#: lib/mv_web/live/member_live/index.ex:126 #, 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:168 +#, 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" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ca8bd14..682b780 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -11,37 +11,37 @@ msgid "" msgstr "" -#: lib/mv_web/components/core_components.ex:356 +#: lib/mv_web/components/core_components.ex:360 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:212 +#: lib/mv_web/live/member_live/index.html.heex:220 #: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" -#: lib/mv_web/components/layouts.ex:80 -#: lib/mv_web/components/layouts.ex:92 +#: lib/mv_web/components/layouts.ex:82 +#: lib/mv_web/components/layouts.ex:94 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:158 +#: lib/mv_web/live/member_live/index.html.heex:166 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:214 +#: lib/mv_web/live/member_live/index.html.heex:222 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:206 +#: lib/mv_web/live/member_live/index.html.heex:214 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:90 +#: lib/mv_web/live/member_live/index.html.heex:98 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:192 +#: lib/mv_web/live/member_live/index.html.heex:200 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -83,28 +83,28 @@ msgstr "" msgid "Last Name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:16 +#: lib/mv_web/live/member_live/index.html.heex:24 #, elixir-autogen, elixir-format msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:203 +#: lib/mv_web/live/member_live/index.html.heex:211 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" -#: lib/mv_web/components/layouts.ex:87 +#: lib/mv_web/components/layouts.ex:89 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" -#: lib/mv_web/components/layouts.ex:75 +#: lib/mv_web/components/layouts.ex:77 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" -#: lib/mv_web/components/core_components.ex:74 +#: lib/mv_web/components/core_components.ex:78 #, elixir-autogen, elixir-format msgid "close" msgstr "" @@ -122,7 +122,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:124 +#: lib/mv_web/live/member_live/index.html.heex:132 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:175 +#: lib/mv_web/live/member_live/index.html.heex:183 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:141 +#: lib/mv_web/live/member_live/index.html.heex:149 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -169,7 +169,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:107 +#: lib/mv_web/live/member_live/index.html.heex:115 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -366,12 +366,12 @@ msgstr "" msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:47 +#: lib/mv_web/live/member_live/index.html.heex:55 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:61 +#: lib/mv_web/live/member_live/index.html.heex:69 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -557,7 +557,7 @@ msgid "Toggle dark mode" msgstr "" #: lib/mv_web/live/components/search_bar_component.ex:15 -#: lib/mv_web/live/member_live/index.html.heex:25 +#: lib/mv_web/live/member_live/index.html.heex:33 #, elixir-autogen, elixir-format msgid "Search..." msgstr "" @@ -573,7 +573,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:73 +#: lib/mv_web/live/member_live/index.html.heex:81 #, elixir-autogen, elixir-format msgid "First name" msgstr "" @@ -783,7 +783,7 @@ msgstr "" msgid "Unlinking scheduled" msgstr "" -#: lib/mv_web/live/member_live/index.ex:150 +#: lib/mv_web/live/member_live/index.ex:159 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" @@ -800,12 +800,27 @@ msgstr "" msgid "Copy emails" msgstr "" -#: lib/mv_web/live/member_live/index.ex:141 +#: lib/mv_web/live/member_live/index.ex:142 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "" -#: lib/mv_web/live/member_live/index.ex:125 +#: lib/mv_web/live/member_live/index.ex:126 #, 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:168 +#, elixir-autogen, elixir-format +msgid "Tip: Paste email addresses into the BCC field for privacy compliance" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e9158d9..a3fdfa4 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -11,37 +11,37 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/mv_web/components/core_components.ex:356 +#: lib/mv_web/components/core_components.ex:360 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:212 +#: lib/mv_web/live/member_live/index.html.heex:220 #: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" -#: lib/mv_web/components/layouts.ex:80 -#: lib/mv_web/components/layouts.ex:92 +#: lib/mv_web/components/layouts.ex:82 +#: lib/mv_web/components/layouts.ex:94 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:158 +#: lib/mv_web/live/member_live/index.html.heex:166 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:214 +#: lib/mv_web/live/member_live/index.html.heex:222 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:206 +#: lib/mv_web/live/member_live/index.html.heex:214 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:90 +#: lib/mv_web/live/member_live/index.html.heex:98 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:192 +#: lib/mv_web/live/member_live/index.html.heex:200 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -83,28 +83,28 @@ msgstr "" msgid "Last Name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:16 +#: lib/mv_web/live/member_live/index.html.heex:24 #, elixir-autogen, elixir-format msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:203 +#: lib/mv_web/live/member_live/index.html.heex:211 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" -#: lib/mv_web/components/layouts.ex:87 +#: lib/mv_web/components/layouts.ex:89 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" -#: lib/mv_web/components/layouts.ex:75 +#: lib/mv_web/components/layouts.ex:77 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" -#: lib/mv_web/components/core_components.ex:74 +#: lib/mv_web/components/core_components.ex:78 #, elixir-autogen, elixir-format msgid "close" msgstr "" @@ -122,7 +122,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:124 +#: lib/mv_web/live/member_live/index.html.heex:132 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:175 +#: lib/mv_web/live/member_live/index.html.heex:183 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:141 +#: lib/mv_web/live/member_live/index.html.heex:149 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -169,7 +169,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:107 +#: lib/mv_web/live/member_live/index.html.heex:115 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -366,12 +366,12 @@ msgstr "" msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:47 +#: lib/mv_web/live/member_live/index.html.heex:55 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:61 +#: lib/mv_web/live/member_live/index.html.heex:69 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -557,7 +557,7 @@ msgid "Toggle dark mode" msgstr "" #: lib/mv_web/live/components/search_bar_component.ex:15 -#: lib/mv_web/live/member_live/index.html.heex:25 +#: lib/mv_web/live/member_live/index.html.heex:33 #, elixir-autogen, elixir-format msgid "Search..." msgstr "" @@ -573,7 +573,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:73 +#: lib/mv_web/live/member_live/index.html.heex:81 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" @@ -783,7 +783,7 @@ msgstr "" msgid "Unlinking scheduled" msgstr "" -#: lib/mv_web/live/member_live/index.ex:150 +#: lib/mv_web/live/member_live/index.ex:159 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" @@ -800,12 +800,27 @@ msgstr "" msgid "Copy emails" msgstr "" -#: lib/mv_web/live/member_live/index.ex:141 +#: lib/mv_web/live/member_live/index.ex:142 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "" -#: lib/mv_web/live/member_live/index.ex:125 +#: lib/mv_web/live/member_live/index.ex:126 #, 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:168 +#, elixir-autogen, elixir-format +msgid "Tip: Paste email addresses into the BCC field for privacy compliance" +msgstr "" From 39d2cb7820a322240ba3573c8a39c934021c0dcf Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Dec 2025 12:10:59 +0100 Subject: [PATCH 26/88] refactor: improve email copy with MapSet, RFC 5322 commas, and cond Performance optimization, RFC-compliant separator, better tests --- email-copy-feature.plan.md | 235 -------------------- lib/mv_web/live/member_live/index.ex | 49 ++-- lib/mv_web/live/member_live/index.html.heex | 12 +- test/mv_web/member_live/index_test.exs | 67 +++++- 4 files changed, 92 insertions(+), 271 deletions(-) delete mode 100644 email-copy-feature.plan.md diff --git a/email-copy-feature.plan.md b/email-copy-feature.plan.md deleted file mode 100644 index 7895798..0000000 --- a/email-copy-feature.plan.md +++ /dev/null @@ -1,235 +0,0 @@ -# Bulk Email Copy Feature - Detaillierter Implementierungsplan - -## Aktueller Stand - -Die Checkbox-Funktionalität existiert bereits vollständig: - -- `select_member` und `select_all` Events in [`lib/mv_web/live/member_live/index.ex`](lib/mv_web/live/member_live/index.ex) (Zeilen 91-117) -- Checkboxen im Template [`lib/mv_web/live/member_live/index.html.heex`](lib/mv_web/live/member_live/index.html.heex) (Zeilen 28-54) -- `@selected_members` enthält die UUIDs der ausgewählten Mitglieder als Liste - -## Gewählte Implementierung: JavaScript Hook mit LiveView Event - -**Ablauf:** - -1. User wählt Mitglieder über Checkboxen aus -2. User klickt "E-Mail-Adressen kopieren" Button -3. LiveView Event `copy_emails` wird ausgelöst -4. Server filtert Member aus `@members` nach `@selected_members` -5. Server formatiert E-Mails im Format `Vorname Nachname ` -6. Server pusht `copy_to_clipboard` Event mit formatiertem String an Client -7. JavaScript Hook empfängt Event und kopiert via `navigator.clipboard.writeText()` -8. Server zeigt Flash-Nachricht mit Erfolgsbestätigung - ---- - -## Implementierungsschritte - -### Schritt 1: JavaScript Hook erstellen - -**Datei:** `assets/js/app.js` - -- Neuen Hook `CopyToClipboard` zum bestehenden `Hooks` Objekt hinzufügen -- Hook lauscht auf `copy_to_clipboard` Event vom Server -- Nutzt `navigator.clipboard.writeText()` API für das Kopieren -- Fallback-Behandlung für Browser ohne Clipboard API (ältere Browser) -- Fehlerbehandlung bei fehlgeschlagenem Kopieren - -### Schritt 2: LiveView Event Handler implementieren - -**Datei:** `lib/mv_web/live/member_live/index.ex` - -- Neuen `handle_event("copy_emails", ...)` Callback hinzufügen -- Member aus `@members` filtern, deren ID in `@selected_members` enthalten ist -- Jeden Member im Format `"Vorname Nachname "` formatieren -- Formatierte Strings mit `"; "` (Semikolon + Leerzeichen) verbinden -- `push_event/3` nutzen um `copy_to_clipboard` Event zu senden -- `put_flash/3` für Erfolgsbestätigung mit Anzahl der kopierten Adressen -- Private Helper-Funktion für die E-Mail-Formatierung - -### Schritt 3: UI Button hinzufügen - -**Datei:** `lib/mv_web/live/member_live/index.html.heex` - -- Button im Header-Bereich neben "New Member" Button platzieren -- Button nur anzeigen wenn mindestens ein Mitglied ausgewählt ist (`:if` Bedingung) -- `phx-hook="CopyToClipboard"` Attribut für JavaScript Hook Anbindung -- `phx-click="copy_emails"` für Event-Auslösung -- Icon: `hero-clipboard-document` oder `hero-envelope` -- Button-Text mit Anzahl der ausgewählten Mitglieder anzeigen -- Accessibility: `aria-label` für Screen Reader - -### Schritt 4: Gettext Übersetzungen hinzufügen - -**Dateien:** - -- `priv/gettext/default.pot` - Template aktualisieren via `mix gettext.extract` -- `priv/gettext/de/LC_MESSAGES/default.po` - Deutsche Übersetzungen -- `priv/gettext/en/LC_MESSAGES/default.po` - Englische Übersetzungen (falls vorhanden) - -**Zu übersetzende Strings:** - -- Button-Text: "Copy Email Addresses" -- Flash-Nachricht Erfolg: "Copied %{count} email address(es) to clipboard" -- Flash-Nachricht Fehler: "No members selected" - -### Schritt 5: Moduledoc aktualisieren - -**Datei:** `lib/mv_web/live/member_live/index.ex` - -- `@moduledoc` um neues Event `copy_emails` erweitern -- Dokumentation der Funktionalität hinzufügen - ---- - -## Edge Cases - -### E1: Keine Mitglieder ausgewählt - -- Button wird nicht angezeigt (UI-seitig gelöst) -- Falls Event dennoch ausgelöst wird: Error-Flash anzeigen, nichts kopieren - -### E2: Ausgewählte Mitglieder nicht mehr in `@members` Liste - -- Kann passieren wenn Member zwischenzeitlich gelöscht wurde -- Nur vorhandene Member verarbeiten, keine Fehler werfen -- Flash zeigt tatsächliche Anzahl kopierter Adressen - -### E3: Member ohne E-Mail-Adresse - -- Defensive Programmierung: Member ohne E-Mail überspringen - -### E4: Member mit leerem Vor- oder Nachnamen - -- Defensive Programmierung: Leere Namen graceful behandeln - -### E5: Sonderzeichen in Namen - -- Namen können Umlaute, Akzente, etc. enthalten -- Keine Escaping nötig, da Text direkt in Zwischenablage kopiert wird -- E-Mail-Clients verarbeiten Unicode korrekt - -### E6: Sehr lange Liste (100+ Mitglieder) - -- String kann sehr lang werden -- Clipboard API hat kein praktisches Limit -- Kein spezielles Handling nötig - -### E7: Browser unterstützt Clipboard API nicht - -- `navigator.clipboard` ist nicht in allen Browsern verfügbar -- Fallback: `document.execCommand('copy')` (deprecated aber breit unterstützt) -- Oder: Fehler-Flash anzeigen - -### E8: Clipboard-Zugriff vom Browser blockiert - -- Moderne Browser können Clipboard-Zugriff einschränken -- HTTPS erforderlich (in Produktion gegeben) -- User muss ggf. Berechtigung erteilen -- Fehlerbehandlung im Hook nötig - -### E9: Parallel laufende Suche/Filter ändert `@members` - -- User wählt Mitglieder, dann ändert Suche die Liste -- `@selected_members` bleibt erhalten, aber IDs passen nicht mehr zu `@members` -- Nur noch vorhandene (angezeigte) Members werden kopiert -- Entscheidung: Selection bei Suche beibehalten? - -### E10: "Select All" nach Filterung - -- Wenn gefiltert und "Select All" geklickt, werden nur sichtbare Members ausgewählt -- Bestehendes Verhalten, kein neues Problem - ---- - -## Testplan - -### Unit Tests (index.ex) - -**T1: copy_emails Event - Erfolgsfall** - -- Setup: 3 Members in `@members`, 2 davon in `@selected_members` -- Assert: `push_event` wird mit korrektem String aufgerufen -- Assert: Flash-Nachricht mit count=2 - -**T2: copy_emails Event - Keine Auswahl** - -- Setup: `@selected_members` ist leer -- Assert: Kein `push_event` -- Assert: Error-Flash oder keine Aktion - -**T3: copy_emails Event - Alle ausgewählt** - -- Setup: Alle Members in `@selected_members` -- Assert: Alle E-Mails im Output-String - -**T4: E-Mail Formatierung** - -- Assert: Format ist `"Vorname Nachname "` -- Assert: Mehrere E-Mails mit `"; "` getrennt - -**T5: Member mit Sonderzeichen im Namen** - -- Setup: Member mit Name "Müller-Lüdenscheidt" -- Assert: Name wird korrekt übernommen - -**T6: Teilweise nicht vorhandene Member** - -- Setup: `@selected_members` enthält ID die nicht in `@members` ist -- Assert: Nur vorhandene Members werden verarbeitet, kein Crash - -### LiveView Integration Tests - -**T7: Button Sichtbarkeit** - -- Assert: Button nicht sichtbar wenn `@selected_members` leer -- Assert: Button sichtbar wenn mindestens 1 Member ausgewählt - -**T8: Button zeigt korrekte Anzahl** - -- Setup: 3 Members ausgewählt -- Assert: Button-Text enthält "(3)" - -**T9: Click löst Event aus** - -- Action: Click auf Copy-Button -- Assert: `copy_emails` Event wird gesendet - -**T10: Vollständiger Flow** - -- Action: Member auswählen, Button klicken -- Assert: Flash-Nachricht erscheint - -## Zu ändernde Dateien - -| Datei | Änderungstyp | - -|-------|--------------| - -| `assets/js/app.js` | Hook hinzufügen | - -| `lib/mv_web/live/member_live/index.ex` | Event Handler + Helper | - -| `lib/mv_web/live/member_live/index.html.heex` | Button UI | - -| `priv/gettext/de/LC_MESSAGES/default.po` | Übersetzungen | - -| `test/mv_web/member_live/index_test.exs` | Tests | - ---- - -## E-Mail Output Format - -**Einzelne E-Mail:** - -``` -Max Mustermann -``` - -**Mehrere E-Mails:** - -``` -Max Mustermann ; Erika Musterfrau ; Hans Müller -``` - -**Hinweis:** Semikolon als Trennzeichen ist Standard für E-Mail-Clients (Outlook, Thunderbird, etc.) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index ad867ab..b0a9bc2 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -59,7 +59,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:query, "") |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) - |> assign(:selected_members, []) + |> assign(:selected_members, MapSet.new()) |> assign(:custom_fields_visible, custom_fields_visible) # We call handle params to use the query from the URL @@ -92,10 +92,10 @@ defmodule MvWeb.MemberLive.Index do @impl true def handle_event("select_member", %{"id" => id}, socket) do selected = - if id in socket.assigns.selected_members do - List.delete(socket.assigns.selected_members, id) + if MapSet.member?(socket.assigns.selected_members, id) do + MapSet.delete(socket.assigns.selected_members, id) else - [id | socket.assigns.selected_members] + MapSet.put(socket.assigns.selected_members, id) end {:noreply, assign(socket, :selected_members, selected)} @@ -103,13 +103,11 @@ defmodule MvWeb.MemberLive.Index do @impl true def handle_event("select_all", _params, socket) do - members = socket.assigns.members - - all_ids = Enum.map(members, & &1.id) + all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new() 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 all_ids end @@ -121,26 +119,26 @@ defmodule MvWeb.MemberLive.Index do def handle_event("copy_emails", _params, socket) do selected_ids = socket.assigns.selected_members - if selected_ids == [] do - {:noreply, put_flash(socket, :error, gettext("No members selected"))} - else - # Filter members that are in the selection - selected_members = - socket.assigns.members - |> Enum.filter(fn member -> member.id in selected_ids end) + # 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) - # Format emails and filter out members without email - formatted_emails = - selected_members - |> Enum.filter(fn member -> member.email && member.email != "" end) - |> Enum.map(&format_member_email/1) + email_count = length(formatted_emails) - email_count = length(formatted_emails) + cond do + MapSet.size(selected_ids) == 0 -> + {:noreply, put_flash(socket, :error, gettext("No members selected"))} - if email_count == 0 do + email_count == 0 -> {:noreply, put_flash(socket, :error, gettext("No email addresses found"))} - else - email_string = Enum.join(formatted_emails, "; ") + + true -> + # RFC 5322 uses comma as separator for email address lists + email_string = Enum.join(formatted_emails, ", ") socket = socket @@ -160,7 +158,6 @@ defmodule MvWeb.MemberLive.Index do ) {:noreply, socket} - end end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 0dabbaf..633dd9c 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -3,18 +3,18 @@ {gettext("Members")} <:actions> <.button - :if={Enum.any?(@members, &(&1.id in @selected_members))} + :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, &(&1.id in @selected_members))}) + {gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))}) <.button - :if={Enum.any?(@members, &(&1.id in @selected_members))} - href={"mailto:?bcc=#{@members |> Enum.filter(&(&1.id in @selected_members and &1.email)) |> Enum.map(& &1.email) |> Enum.join(",")}"} + :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" /> @@ -51,7 +51,7 @@ type="checkbox" name="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")} role="checkbox" /> @@ -63,7 +63,7 @@ name={member.id} phx-click="select_member" phx-value-id={member.id} - checked={member.id in @selected_members} + checked={MapSet.member?(@selected_members, member.id)} phx-capture-click phx-stop-propagation aria-label={gettext("Select member")} diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 6e91b4c..e3ad5bb 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -348,7 +348,7 @@ defmodule MvWeb.MemberLive.IndexTest do assert render(view) =~ "1" end - test "copy_emails handles case where selected members are deleted", %{ + test "copy_emails handles case where selected member is deleted before copy", %{ conn: conn, member1: member1 } do @@ -360,10 +360,69 @@ defmodule MvWeb.MemberLive.IndexTest do |> element("[phx-click='select_member'][phx-value-id='#{member1.id}']") |> render_click() - # Click copy button - should work correctly - view |> element("#copy-emails-btn") |> render_click() + # Delete the member from the database + Ash.destroy!(member1) - # Should show count of actual members found (1) + # 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 ' 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 " + # We verify this by checking the flash shows 1 email was copied + view |> element("#copy-emails-btn") |> render_click() assert render(view) =~ "1" end From d10f2ecc90046b62841d9399c4efc4df8c1492fd Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 08:45:18 +0100 Subject: [PATCH 27/88] chore: adds migration for member field visibility --- ...dd_member_field_visibility_to_settings.exs | 21 +++ .../repo/custom_fields/20251201115939.json | 144 ++++++++++++++++++ .../repo/settings/20251201115939.json | 79 ++++++++++ 3 files changed, 244 insertions(+) create mode 100644 priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251201115939.json create mode 100644 priv/resource_snapshots/repo/settings/20251201115939.json diff --git a/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs new file mode 100644 index 0000000..6d278fb --- /dev/null +++ b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs @@ -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 diff --git a/priv/resource_snapshots/repo/custom_fields/20251201115939.json b/priv/resource_snapshots/repo/custom_fields/20251201115939.json new file mode 100644 index 0000000..fabd84b --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251201115939.json @@ -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" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/settings/20251201115939.json b/priv/resource_snapshots/repo/settings/20251201115939.json new file mode 100644 index 0000000..4e635c4 --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20251201115939.json @@ -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" +} \ No newline at end of file From 944b868478d37892a31c4a36516711cee4630633 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 09:22:26 +0100 Subject: [PATCH 28/88] tests: adds tests --- .../member_field_visibility_test.exs | 80 +++++++++++++++++++ .../index_member_fields_display_test.exs | 75 +++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 test/membership/member_field_visibility_test.exs create mode 100644 test/mv_web/member_live/index_member_fields_display_test.exs diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs new file mode 100644 index 0000000..46bdb74 --- /dev/null +++ b/test/membership/member_field_visibility_test.exs @@ -0,0 +1,80 @@ +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 + + describe "show_in_overview?/1" do + test "returns true for all member fields by default" do + # When no settings exist or member_field_visibility is not configured + # Test with fields from constants + member_fields = Mv.Constants.member_fields() + + Enum.each(member_fields, fn field -> + assert Member.show_in_overview?(field) == true, + "Field #{field} should be visible by default" + end) + end + + test "returns false for fields with show_in_overview: false in settings" do + # Get or create settings + {:ok, settings} = Mv.Membership.get_settings() + + # Use a field that exists in member fields + member_fields = Mv.Constants.member_fields() + field_to_hide = List.first(member_fields) + field_to_show = List.last(member_fields) + + # Update settings to hide a field + {:ok, _updated_settings} = + Mv.Membership.update_settings(settings, %{ + member_field_visibility: %{field_to_hide => false} + }) + + # JSONB may convert atom keys to string keys, so we check via show_in_overview? instead + assert Member.show_in_overview?(field_to_hide) == false + assert Member.show_in_overview?(field_to_show) == true + end + + test "returns true for non-configured fields (default)" do + # Get or create settings + {:ok, settings} = Mv.Membership.get_settings() + + # Use fields that exist in member fields + member_fields = Mv.Constants.member_fields() + fields_to_hide = Enum.take(member_fields, 2) + fields_to_show = Enum.take(member_fields, -2) + + # Update settings to hide some fields + visibility_config = + Enum.reduce(fields_to_hide, %{}, fn field, acc -> + Map.put(acc, field, false) + end) + + {:ok, _updated_settings} = + Mv.Membership.update_settings(settings, %{ + member_field_visibility: visibility_config + }) + + # Hidden fields should be false + Enum.each(fields_to_hide, fn field -> + assert Member.show_in_overview?(field) == false, + "Field #{field} should be hidden" + end) + + # Unconfigured fields should still be true (default) + Enum.each(fields_to_show, fn field -> + assert Member.show_in_overview?(field) == true, + "Field #{field} should be visible by default" + end) + end + end +end diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs new file mode 100644 index 0000000..a0e519a --- /dev/null +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -0,0 +1,75 @@ +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, &{&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 + + defp get_field_label(:street), do: "Street" + defp get_field_label(:house_number), do: "House Number" + defp get_field_label(:postal_code), do: "Postal Code" + defp get_field_label(:city), do: "City" + defp get_field_label(:phone_number), do: "Phone Number" + defp get_field_label(:join_date), do: "Join Date" + defp get_field_label(:email), do: "Email" + defp get_field_label(:first_name), do: "First name" + defp get_field_label(:last_name), do: "Last name" +end From 831149f46331032c27b8497908e647651572538d Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 09:22:49 +0100 Subject: [PATCH 29/88] chore: adds constant for member_fields --- lib/mv/constants.ex | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 lib/mv/constants.ex diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex new file mode 100644 index 0000000..0725d60 --- /dev/null +++ b/lib/mv/constants.ex @@ -0,0 +1,9 @@ +defmodule Mv.Constants do + @moduledoc """ + Module for defining constants and atoms. + """ + + @member_fields [:first_name, :last_name, :email, :birth_date, :paid, :phone_number, :join_date, :exit_date, :notes, :city, :street, :house_number, :postal_code] + + def member_fields, do: @member_fields +end From 397cbde9d6555c665c4ab0e9443eaa19ff885801 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 09:23:23 +0100 Subject: [PATCH 30/88] feat: adds member visibility settings --- lib/membership/member.ex | 64 ++++++++++++++++++++++++++++ lib/membership/membership.ex | 34 +++++++++++++++ lib/membership/setting.ex | 82 +++++++++++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index da69861..f91cb0b 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -434,6 +434,70 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end + @doc """ + Checks if a member field should be shown in the overview. + + Reads the visibility configuration from Settings resource. If a field is not + configured in settings, it defaults to `true` (visible). + + ## Parameters + - `field` - Atom representing the member field name (e.g., `:email`, `:street`) + + ## Returns + - `true` if the field should be shown in overview (default) + - `false` if the field is configured as hidden in settings + + ## Examples + + iex> Member.show_in_overview?(:email) + true + + iex> Member.show_in_overview?(:street) + true # or false if configured in settings + + """ + @spec show_in_overview?(atom()) :: boolean() + def show_in_overview?(field) when is_atom(field) do + case Mv.Membership.get_settings() do + {:ok, settings} -> + visibility_config = settings.member_field_visibility || %{} + # Normalize map keys to atoms (JSONB may return string keys) + normalized_config = normalize_visibility_config(visibility_config) + + # Get value from normalized config, default to true + Map.get(normalized_config, field, true) + + {:error, _} -> + # If settings can't be loaded, default to visible + true + end + end + + def show_in_overview?(_), do: true + + # Normalizes visibility config map keys from strings to atoms. + # JSONB in PostgreSQL converts atom keys to string keys when storing. + defp normalize_visibility_config(config) when is_map(config) do + Enum.reduce(config, %{}, fn + {key, value}, acc when is_atom(key) -> + Map.put(acc, key, value) + + {key, value}, acc when is_binary(key) -> + try do + atom_key = String.to_existing_atom(key) + Map.put(acc, atom_key, value) + rescue + ArgumentError -> + acc + end + + _, acc -> + acc + end) + end + + defp normalize_visibility_config(_), do: %{} + @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index cb3691b..516448c 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -53,6 +53,7 @@ defmodule Mv.Membership do # It's only used internally as fallback in get_settings/0 # Settings should be created via seed script define :update_settings, action: :update + define :update_member_field_visibility, action: :update_member_field_visibility end end @@ -123,4 +124,37 @@ defmodule Mv.Membership do |> Ash.Changeset.for_update(:update, attrs) |> Ash.update(domain: __MODULE__) 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 (atoms) 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 diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 38624dc..0bd9212 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do ## Attributes - `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 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 {: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, domain: Mv.Membership, @@ -49,18 +54,86 @@ defmodule Mv.Membership.Setting do # Used only as fallback in get_settings/0 if settings don't exist # Settings should normally be created via seed script create :create do - accept [:club_name] + accept [:club_name, :member_field_visibility] end update :update do 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] + + change fn changeset, _context -> + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + + if visibility && is_map(visibility) do + valid_fields = Mv.Constants.member_fields() + # Normalize keys to atoms (JSONB may return string keys) + invalid_keys = + Enum.filter(visibility, fn {key, _value} -> + atom_key = + if is_atom(key) do + key + else + try do + String.to_existing_atom(key) + rescue + ArgumentError -> nil + end + end + + atom_key && atom_key not in valid_fields + end) + |> Enum.map(fn {key, _value} -> key end) + + if Enum.empty?(invalid_keys) do + changeset + else + Ash.Changeset.add_error( + changeset, + field: :member_field_visibility, + message: "Invalid member field keys: #{inspect(invalid_keys)}" + ) + end + else + changeset + end + end end end validations do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] + + # Validate that member_field_visibility map contains only boolean values + # This allows dynamic fields without hardcoding specific field names + validate fn changeset, _context -> + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + + if visibility && is_map(visibility) do + invalid_entries = + Enum.filter(visibility, fn {_key, value} -> + not is_boolean(value) + end) + + if Enum.empty?(invalid_entries) do + :ok + else + {:error, + field: :member_field_visibility, + message: "All values in member_field_visibility must be booleans"} + end + else + :ok + end + end, + on: [:create, :update] end attributes do @@ -75,6 +148,11 @@ defmodule Mv.Membership.Setting do 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() end end From e81aecce48a90e6bef4d282f737024c9e8ea2ba1 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 09:23:37 +0100 Subject: [PATCH 31/88] feat: adds member visibility to live view --- lib/mv_web/live/member_live/index.ex | 37 ++++++++++++++++ lib/mv_web/live/member_live/index.html.heex | 49 ++++++--------------- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index b0a9bc2..830cfd9 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -61,6 +61,8 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, MapSet.new()) |> assign(:custom_fields_visible, custom_fields_visible) + |> assign(:member_field_configurations, get_member_field_configurations()) + |> assign(:member_fields_visible, get_visible_member_fields()) # We call handle params to use the query from the URL {:ok, socket} @@ -796,4 +798,39 @@ defmodule MvWeb.MemberLive.Index do "#{name} <#{member.email}>" end end + + # Gets the configuration for all member fields with their show_in_overview values. + # + # Reads the visibility configuration from Settings and returns a map with all member fields + # and their show_in_overview values (true or false). Fields not configured in settings + # default to true. + # + # Returns a map: %{field_name => show_in_overview} + # + # This can be used for: + # - Rendering the overview (filtering visible fields) + # - UI configuration dropdowns (showing all fields with their current state) + # - Dynamic field management + # + # Fields are read from the global Constants module. + defp get_member_field_configurations do + # Get all eligible fields from the global constants + all_fields = Mv.Constants.member_fields() + + Enum.reduce(all_fields, %{}, fn field, acc -> + show_in_overview = Mv.Membership.Member.show_in_overview?(field) + Map.put(acc, field, show_in_overview) + 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. + # + # Returns a list of atoms representing visible member field names. + defp get_visible_member_fields do + get_member_field_configurations() + |> Enum.filter(fn {_field, show_in_overview} -> show_in_overview end) + |> Enum.map(fn {field, _show_in_overview} -> field end) + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 633dd9c..41536e3 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -87,9 +87,7 @@ > {member.first_name} {member.last_name} - <:col - :let={member} - label={ + <:col :if={:email in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -100,13 +98,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.email} - <:col - :let={member} - label={ + <:col :if={:street in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -117,13 +112,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.street} - <:col - :let={member} - label={ + <:col :if={:house_number in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -134,13 +126,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.house_number} - <:col - :let={member} - label={ + <:col :if={:postal_code in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -151,13 +140,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.postal_code} - <:col - :let={member} - label={ + <:col :if={:city in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -168,13 +154,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.city} - <:col - :let={member} - label={ + <:col :if={:phone_number in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -185,13 +168,10 @@ sort_order={@sort_order} /> """ - } - > + }> {member.phone_number} - <:col - :let={member} - label={ + <:col :if={:join_date in @member_fields_visible} :let={member} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -202,8 +182,7 @@ sort_order={@sort_order} /> """ - } - > + }> {member.join_date} <:action :let={member}> From dce2053ce7ca07755f507437a9c83700ea29a3b1 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 10:02:52 +0100 Subject: [PATCH 32/88] formatting and refactor member fields constant --- lib/membership/member.ex | 36 ++----- lib/membership/setting.ex | 39 ++++---- lib/mv/constants.ex | 16 +++- lib/mv_web/live/member_live/index.ex | 95 +++++++++++++------ lib/mv_web/live/member_live/index.html.heex | 56 ++++++++--- .../index_member_fields_display_test.exs | 11 --- 6 files changed, 149 insertions(+), 104 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index f91cb0b..31a825b 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do @member_search_limit 10 @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 table "members" 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 argument :user, :map, allow_nil?: true - accept [ - :first_name, - :last_name, - :email, - :birth_date, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] + accept @member_fields 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 argument :user, :map, allow_nil?: true - accept [ - :first_name, - :last_name, - :email, - :birth_date, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] + accept @member_fields change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 0bd9212..3405a3f 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -114,26 +114,26 @@ defmodule Mv.Membership.Setting do # Validate that member_field_visibility map contains only boolean values # This allows dynamic fields without hardcoding specific field names validate fn changeset, _context -> - visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) - if visibility && is_map(visibility) do - invalid_entries = - Enum.filter(visibility, fn {_key, value} -> - not is_boolean(value) - end) + if visibility && is_map(visibility) do + invalid_entries = + Enum.filter(visibility, fn {_key, value} -> + not is_boolean(value) + end) - if Enum.empty?(invalid_entries) do - :ok - else - {:error, - field: :member_field_visibility, - message: "All values in member_field_visibility must be booleans"} - end - else - :ok - end - end, - on: [:create, :update] + if Enum.empty?(invalid_entries) do + :ok + else + {:error, + field: :member_field_visibility, + message: "All values in member_field_visibility must be booleans"} + end + else + :ok + end + end, + on: [:create, :update] end attributes do @@ -151,7 +151,8 @@ defmodule Mv.Membership.Setting do 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." + description: + "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." timestamps() end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 0725d60..cd8d3a4 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -3,7 +3,21 @@ defmodule Mv.Constants do Module for defining constants and atoms. """ - @member_fields [:first_name, :last_name, :email, :birth_date, :paid, :phone_number, :join_date, :exit_date, :notes, :city, :street, :house_number, :postal_code] + @member_fields [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] def member_fields, do: @member_fields end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 830cfd9..a15063e 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -30,11 +30,18 @@ defmodule MvWeb.MemberLive.Index do require Ash.Query import Ash.Expr + alias Mv.Membership alias MvWeb.MemberLive.Index.Formatter # Prefix used in sort field names for custom fields (e.g., "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 """ Initializes the LiveView state. @@ -53,6 +60,14 @@ defmodule MvWeb.MemberLive.Index do |> 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 + socket = socket |> assign(:page_title, gettext("Members")) @@ -61,8 +76,8 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, MapSet.new()) |> assign(:custom_fields_visible, custom_fields_visible) - |> assign(:member_field_configurations, get_member_field_configurations()) - |> assign(:member_fields_visible, get_visible_member_fields()) + |> assign(:member_field_configurations, get_member_field_configurations(settings)) + |> assign(:member_fields_visible, get_visible_member_fields(settings)) # We call handle params to use the query from the URL {:ok, socket} @@ -360,18 +375,7 @@ defmodule MvWeb.MemberLive.Index do query = Mv.Membership.Member |> Ash.Query.new() - |> Ash.Query.select([ - :id, - :first_name, - :last_name, - :email, - :street, - :house_number, - :postal_code, - :city, - :phone_number, - :join_date - ]) + |> Ash.Query.select(@overview_fields) # Load custom field values for visible custom fields custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) @@ -480,18 +484,13 @@ defmodule MvWeb.MemberLive.Index do defp maybe_sort(query, _, _, _), do: {query, false} # 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 - valid_fields = [ - :first_name, - :last_name, - :email, - :street, - :house_number, - :postal_code, - :city, - :phone_number, - :join_date - ] + # All member fields are sortable, but we exclude some that don't make sense + # :id is not in member_fields, but we don't want to sort by it anyway + non_sortable_fields = [:notes, :paid] + valid_fields = Mv.Constants.member_fields() -- non_sortable_fields field in valid_fields or custom_field_sort?(field) end @@ -805,6 +804,12 @@ defmodule MvWeb.MemberLive.Index do # and their show_in_overview values (true or false). Fields not configured in settings # default to true. # + # Performance: This function uses the already-loaded settings to avoid N+1 queries. + # Settings should be loaded once in mount/3 and passed to this function. + # + # Parameters: + # - `settings` - The settings struct loaded from the database + # # Returns a map: %{field_name => show_in_overview} # # This can be used for: @@ -813,12 +818,16 @@ defmodule MvWeb.MemberLive.Index do # - Dynamic field management # # Fields are read from the global Constants module. - defp get_member_field_configurations do + @spec get_member_field_configurations(map()) :: %{atom() => boolean()} + defp get_member_field_configurations(settings) do # Get all eligible fields from the global constants all_fields = Mv.Constants.member_fields() + # Normalize visibility config (JSONB may return string keys) + visibility_config = normalize_visibility_config(settings.member_field_visibility || %{}) + Enum.reduce(all_fields, %{}, fn field, acc -> - show_in_overview = Mv.Membership.Member.show_in_overview?(field) + show_in_overview = Map.get(visibility_config, field, true) Map.put(acc, field, show_in_overview) end) end @@ -827,10 +836,38 @@ defmodule MvWeb.MemberLive.Index do # # 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. - defp get_visible_member_fields do - get_member_field_configurations() + @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 diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 41536e3..55b0a20 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -87,7 +87,10 @@ > {member.first_name} {member.last_name} - <:col :if={:email in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:email in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -98,10 +101,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.email} - <:col :if={:street in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:street in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -112,10 +119,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.street} - <:col :if={:house_number in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:house_number in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -126,10 +137,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.house_number} - <:col :if={:postal_code in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -140,10 +155,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.postal_code} - <:col :if={:city in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:city in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -154,10 +173,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.city} - <:col :if={:phone_number in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:phone_number in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -168,10 +191,14 @@ sort_order={@sort_order} /> """ - }> + } + > {member.phone_number} - <:col :if={:join_date in @member_fields_visible} :let={member} label={ + <:col + :let={member} + :if={:join_date in @member_fields_visible} + label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -182,7 +209,8 @@ sort_order={@sort_order} /> """ - }> + } + > {member.join_date} <:action :let={member}> diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index a0e519a..c4a5b9f 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -36,7 +36,6 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do } 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") @@ -62,14 +61,4 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do assert html =~ m.email refute html =~ m.street end - - defp get_field_label(:street), do: "Street" - defp get_field_label(:house_number), do: "House Number" - defp get_field_label(:postal_code), do: "Postal Code" - defp get_field_label(:city), do: "City" - defp get_field_label(:phone_number), do: "Phone Number" - defp get_field_label(:join_date), do: "Join Date" - defp get_field_label(:email), do: "Email" - defp get_field_label(:first_name), do: "First name" - defp get_field_label(:last_name), do: "Last name" end From 13f77b5c0ae190dac2ce7de4ee9e4fc74d357b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Eppl=C3=A9e?= Date: Tue, 2 Dec 2025 12:16:02 +0100 Subject: [PATCH 33/88] Refactor column visibility logic --- lib/membership/member.ex | 64 ---------------- lib/membership/membership.ex | 8 +- lib/membership/setting.ex | 75 +++++++------------ lib/mv_web/live/member_live/index.ex | 68 +++-------------- .../member_field_visibility_test.exs | 66 ---------------- .../index_member_fields_display_test.exs | 2 +- 6 files changed, 43 insertions(+), 240 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 31a825b..bcd505e 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -410,70 +410,6 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end - @doc """ - Checks if a member field should be shown in the overview. - - Reads the visibility configuration from Settings resource. If a field is not - configured in settings, it defaults to `true` (visible). - - ## Parameters - - `field` - Atom representing the member field name (e.g., `:email`, `:street`) - - ## Returns - - `true` if the field should be shown in overview (default) - - `false` if the field is configured as hidden in settings - - ## Examples - - iex> Member.show_in_overview?(:email) - true - - iex> Member.show_in_overview?(:street) - true # or false if configured in settings - - """ - @spec show_in_overview?(atom()) :: boolean() - def show_in_overview?(field) when is_atom(field) do - case Mv.Membership.get_settings() do - {:ok, settings} -> - visibility_config = settings.member_field_visibility || %{} - # Normalize map keys to atoms (JSONB may return string keys) - normalized_config = normalize_visibility_config(visibility_config) - - # Get value from normalized config, default to true - Map.get(normalized_config, field, true) - - {:error, _} -> - # If settings can't be loaded, default to visible - true - end - end - - def show_in_overview?(_), do: true - - # Normalizes visibility config map keys from strings to atoms. - # JSONB in PostgreSQL converts atom keys to string keys when storing. - defp normalize_visibility_config(config) when is_map(config) do - Enum.reduce(config, %{}, fn - {key, value}, acc when is_atom(key) -> - Map.put(acc, key, value) - - {key, value}, acc when is_binary(key) -> - try do - atom_key = String.to_existing_atom(key) - Map.put(acc, atom_key, value) - rescue - ArgumentError -> - acc - end - - _, acc -> - acc - end) - end - - defp normalize_visibility_config(_), do: %{} - @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 516448c..f5a708b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -134,8 +134,8 @@ defmodule Mv.Membership do ## Parameters - `settings` - The settings record to update - - `visibility_config` - A map of member field names (atoms) to boolean visibility values - (e.g., `%{street: false, house_number: false}`) + - `visibility_config` - A map of member field names (strings) to boolean visibility values + (e.g., `%{"street" => false, "house_number" => false}`) ## Returns @@ -145,9 +145,9 @@ defmodule Mv.Membership do ## Examples iex> {:ok, settings} = Mv.Membership.get_settings() - iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false}) + 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} + %{"street" => false, "house_number" => false} """ def update_member_field_visibility(settings, visibility_config) do diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 3405a3f..52c0328 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.Setting do ## Attributes - `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`. + (e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`. ## Singleton Pattern This resource uses a singleton pattern - there should only be one settings record. @@ -32,7 +32,7 @@ defmodule Mv.Membership.Setting do {: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}) + {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) """ use Ash.Resource, domain: Mv.Membership, @@ -67,43 +67,6 @@ defmodule Mv.Membership.Setting do description "Updates the visibility configuration for member fields in the overview" require_atomic? false accept [:member_field_visibility] - - change fn changeset, _context -> - visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) - - if visibility && is_map(visibility) do - valid_fields = Mv.Constants.member_fields() - # Normalize keys to atoms (JSONB may return string keys) - invalid_keys = - Enum.filter(visibility, fn {key, _value} -> - atom_key = - if is_atom(key) do - key - else - try do - String.to_existing_atom(key) - rescue - ArgumentError -> nil - end - end - - atom_key && atom_key not in valid_fields - end) - |> Enum.map(fn {key, _value} -> key end) - - if Enum.empty?(invalid_keys) do - changeset - else - Ash.Changeset.add_error( - changeset, - field: :member_field_visibility, - message: "Invalid member field keys: #{inspect(invalid_keys)}" - ) - end - else - changeset - end - end end end @@ -111,23 +74,39 @@ defmodule Mv.Membership.Setting do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] - # Validate that member_field_visibility map contains only boolean values - # This allows dynamic fields without hardcoding specific field names + # 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 - invalid_entries = + # Validate all values are booleans + invalid_values = Enum.filter(visibility, fn {_key, value} -> not is_boolean(value) end) - if Enum.empty?(invalid_entries) do - :ok - else - {:error, - field: :member_field_visibility, - message: "All values in member_field_visibility must be booleans"} + # 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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index a15063e..4d444b9 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -76,7 +76,6 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, MapSet.new()) |> assign(:custom_fields_visible, custom_fields_visible) - |> assign(:member_field_configurations, get_member_field_configurations(settings)) |> assign(:member_fields_visible, get_visible_member_fields(settings)) # We call handle params to use the query from the URL @@ -798,11 +797,10 @@ defmodule MvWeb.MemberLive.Index do end end - # Gets the configuration for all member fields with their show_in_overview values. + # Gets the list of member fields that should be visible in the overview. # - # Reads the visibility configuration from Settings and returns a map with all member fields - # and their show_in_overview values (true or false). Fields not configured in settings - # default to true. + # Reads the visibility configuration from Settings and returns only the fields + # where show_in_overview is true. Fields not configured in settings default to true. # # Performance: This function uses the already-loaded settings to avoid N+1 queries. # Settings should be loaded once in mount/3 and passed to this function. @@ -810,64 +808,20 @@ defmodule MvWeb.MemberLive.Index do # Parameters: # - `settings` - The settings struct loaded from the database # - # Returns a map: %{field_name => show_in_overview} - # - # This can be used for: - # - Rendering the overview (filtering visible fields) - # - UI configuration dropdowns (showing all fields with their current state) - # - Dynamic field management + # Returns a list of atoms representing visible member field names. # # Fields are read from the global Constants module. - @spec get_member_field_configurations(map()) :: %{atom() => boolean()} - defp get_member_field_configurations(settings) do + @spec get_visible_member_fields(map()) :: [atom()] + defp get_visible_member_fields(settings) do # Get all eligible fields from the global constants all_fields = Mv.Constants.member_fields() - # Normalize visibility config (JSONB may return string keys) - visibility_config = normalize_visibility_config(settings.member_field_visibility || %{}) + # JSONB stores keys as strings + visibility_config = settings.member_field_visibility || %{} - Enum.reduce(all_fields, %{}, fn field, acc -> - show_in_overview = Map.get(visibility_config, field, true) - Map.put(acc, field, show_in_overview) + # Filter to only return visible fields + Enum.filter(all_fields, fn field -> + Map.get(visibility_config, Atom.to_string(field), true) 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 diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs index 46bdb74..9963169 100644 --- a/test/membership/member_field_visibility_test.exs +++ b/test/membership/member_field_visibility_test.exs @@ -11,70 +11,4 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do use Mv.DataCase, async: true alias Mv.Membership.Member - - describe "show_in_overview?/1" do - test "returns true for all member fields by default" do - # When no settings exist or member_field_visibility is not configured - # Test with fields from constants - member_fields = Mv.Constants.member_fields() - - Enum.each(member_fields, fn field -> - assert Member.show_in_overview?(field) == true, - "Field #{field} should be visible by default" - end) - end - - test "returns false for fields with show_in_overview: false in settings" do - # Get or create settings - {:ok, settings} = Mv.Membership.get_settings() - - # Use a field that exists in member fields - member_fields = Mv.Constants.member_fields() - field_to_hide = List.first(member_fields) - field_to_show = List.last(member_fields) - - # Update settings to hide a field - {:ok, _updated_settings} = - Mv.Membership.update_settings(settings, %{ - member_field_visibility: %{field_to_hide => false} - }) - - # JSONB may convert atom keys to string keys, so we check via show_in_overview? instead - assert Member.show_in_overview?(field_to_hide) == false - assert Member.show_in_overview?(field_to_show) == true - end - - test "returns true for non-configured fields (default)" do - # Get or create settings - {:ok, settings} = Mv.Membership.get_settings() - - # Use fields that exist in member fields - member_fields = Mv.Constants.member_fields() - fields_to_hide = Enum.take(member_fields, 2) - fields_to_show = Enum.take(member_fields, -2) - - # Update settings to hide some fields - visibility_config = - Enum.reduce(fields_to_hide, %{}, fn field, acc -> - Map.put(acc, field, false) - end) - - {:ok, _updated_settings} = - Mv.Membership.update_settings(settings, %{ - member_field_visibility: visibility_config - }) - - # Hidden fields should be false - Enum.each(fields_to_hide, fn field -> - assert Member.show_in_overview?(field) == false, - "Field #{field} should be hidden" - end) - - # Unconfigured fields should still be true (default) - Enum.each(fields_to_show, fn field -> - assert Member.show_in_overview?(field) == true, - "Field #{field} should be visible by default" - end) - end - end end diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index c4a5b9f..6b4f50c 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -51,7 +51,7 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do {:ok, _} = Mv.Membership.update_settings(settings, %{ - member_field_visibility: Map.new(fields_to_hide, &{&1, false}) + member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false}) }) conn = conn_with_oidc_user(conn) From c8968636a8235d92eec8e38de08f52cc23b9027f Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Dec 2025 14:58:50 +0100 Subject: [PATCH 34/88] feat: remove birth_date field from Member resource Users who need birthday data can use custom fields instead. Closes #161 --- docs/database-schema-readme.md | 5 +- docs/database_schema.dbml | 4 +- docs/feature-roadmap.md | 2 +- lib/membership/member.ex | 11 +-- lib/mv/constants.ex | 1 - lib/mv_web/live/member_live/form.ex | 3 +- lib/mv_web/live/member_live/show.ex | 3 +- ...2145404_remove_birth_date_from_members.exs | 69 +++++++++++++++++++ priv/repo/seeds.exs | 6 -- test/membership/member_test.exs | 7 -- 10 files changed, 76 insertions(+), 35 deletions(-) create mode 100644 priv/repo/migrations/20251202145404_remove_birth_date_from_members.exs diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index d548b82..1644f2a 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -115,7 +115,6 @@ Member (1) → (N) Properties ### Member Constraints - First name and last name required (min 1 char) - Email unique, validated format (5-254 chars) -- Birth date cannot be in future - Join date cannot be in future - Exit date must be after join date - Phone: `+?[0-9\- ]{6,20}` @@ -169,7 +168,7 @@ Member (1) → (N) Properties ### Weighted Fields - **Weight A (highest):** first_name, last_name - **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 ### Usage Example @@ -381,7 +380,7 @@ Install "DBML Language" extension to view/edit DBML files with: - tokens (jti, purpose, extra_data) **Personal Data (GDPR):** -- All member fields (name, email, birth_date, address) +- All member fields (name, email, address) - User email - Token subject diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 33c0647..b620830 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -122,7 +122,6 @@ Table members { first_name text [not null, note: 'Member first 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)'] - birth_date date [null, note: 'Date of birth (cannot be in future)'] paid boolean [null, note: 'Payment status flag'] 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)'] @@ -153,7 +152,7 @@ Table members { **Club Member Master Data** Core entity for membership management containing: - - Personal information (name, birth date, email) + - Personal information (name, email) - Contact details (phone, address) - Membership status (join/exit dates, payment status) - Additional notes @@ -183,7 +182,6 @@ Table members { **Validation Rules:** - first_name, last_name: min 1 character - email: 5-254 characters, valid email format - - birth_date: cannot be in future - join_date: cannot be in future - exit_date: must be after join_date (if both present) - phone_number: matches pattern ^\+?[0-9\- ]{6,20}$ diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 60432d0..609523c 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -100,10 +100,10 @@ **Closed Issues:** - [#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) +- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02 **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] -- [#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) **Missing Features:** diff --git a/lib/membership/member.ex b/lib/membership/member.ex index bcd505e..8d271d7 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do - Email format validation (using EctoCommons.EmailValidator) - Phone number format: international format with 6-20 digits - 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 ## Full-Text Search @@ -284,11 +284,6 @@ defmodule Mv.Membership.Member do 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 validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:join_date)], @@ -351,10 +346,6 @@ defmodule Mv.Membership.Member do constraints min_length: 5, max_length: 254 end - attribute :birth_date, :date do - allow_nil? true - end - attribute :paid, :boolean do allow_nil? true end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index cd8d3a4..334bcc1 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -7,7 +7,6 @@ defmodule Mv.Constants do :first_name, :last_name, :email, - :birth_date, :paid, :phone_number, :join_date, diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index e4c2e7e..97b13f6 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -14,7 +14,7 @@ defmodule MvWeb.MemberLive.Form do - first_name, last_name, email **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 - paid status - notes @@ -45,7 +45,6 @@ defmodule MvWeb.MemberLive.Form do <.input field={@form[:first_name]} label={gettext("First 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[:birth_date]} label={gettext("Birth Date")} type="date" /> <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> <.input field={@form[:phone_number]} label={gettext("Phone Number")} /> <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 7ec24fa..de46a3a 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -10,7 +10,7 @@ defmodule MvWeb.MemberLive.Show do - Return to member list ## Displayed Information - - Basic: name, email, dates (birth, join, exit) + - Basic: name, email, dates (join, exit) - Contact: phone number - Address: street, house number, postal code, city - Status: paid flag @@ -48,7 +48,6 @@ defmodule MvWeb.MemberLive.Show do <:item title={gettext("First Name")}>{@member.first_name} <:item title={gettext("Last Name")}>{@member.last_name} <:item title={gettext("Email")}>{@member.email} - <:item title={gettext("Birth Date")}>{@member.birth_date} <:item title={gettext("Paid")}> {if @member.paid, do: gettext("Yes"), else: gettext("No")} diff --git a/priv/repo/migrations/20251202145404_remove_birth_date_from_members.exs b/priv/repo/migrations/20251202145404_remove_birth_date_from_members.exs new file mode 100644 index 0000000..4a6cf3a --- /dev/null +++ b/priv/repo/migrations/20251202145404_remove_birth_date_from_members.exs @@ -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 diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 542e559..bec9006 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -112,7 +112,6 @@ for member_attrs <- [ first_name: "Hans", last_name: "Müller", email: "hans.mueller@example.de", - birth_date: ~D[1985-06-15], join_date: ~D[2023-01-15], paid: true, phone_number: "+49301234567", @@ -125,7 +124,6 @@ for member_attrs <- [ first_name: "Greta", last_name: "Schmidt", email: "greta.schmidt@example.de", - birth_date: ~D[1990-03-22], join_date: ~D[2023-02-01], paid: false, phone_number: "+49309876543", @@ -139,7 +137,6 @@ for member_attrs <- [ first_name: "Friedrich", last_name: "Wagner", email: "friedrich.wagner@example.de", - birth_date: ~D[1978-11-08], join_date: ~D[2022-11-10], paid: true, phone_number: "+49301122334", @@ -151,7 +148,6 @@ for member_attrs <- [ first_name: "Marianne", last_name: "Wagner", email: "marianne.wagner@example.de", - birth_date: ~D[1978-11-08], join_date: ~D[2022-11-10], paid: true, phone_number: "+49301122334", @@ -186,7 +182,6 @@ linked_members = [ first_name: "Maria", last_name: "Weber", email: "maria.weber@example.de", - birth_date: ~D[1992-07-14], join_date: ~D[2023-03-15], paid: true, phone_number: "+49301357924", @@ -202,7 +197,6 @@ linked_members = [ first_name: "Thomas", last_name: "Klein", email: "thomas.klein@example.de", - birth_date: ~D[1988-12-03], join_date: ~D[2023-04-01], paid: false, phone_number: "+49302468135", diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs index 7015d34..1bf594a 100644 --- a/test/membership/member_test.exs +++ b/test/membership/member_test.exs @@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do @valid_attrs %{ first_name: "John", last_name: "Doe", - birth_date: ~D[1990-01-01], paid: true, email: "john@example.com", phone_number: "+49123456789", @@ -43,12 +42,6 @@ defmodule Mv.Membership.MemberTest do assert error_message(errors, :email) =~ "is not a valid email" 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 attrs = Map.put(@valid_attrs, :paid, nil) attrs2 = Map.put(@valid_attrs, :paid, "yes") From 45a9bc0cc07ca5fd900ac3c494d4a9a016608ea3 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 14:59:10 +0100 Subject: [PATCH 35/88] tests: added tests --- ...eld_visibility_dropdown_component_test.exs | 363 +++++++++++++ .../index/field_selection_test.exs | 346 ++++++++++++ .../index/field_visibility_test.exs | 336 ++++++++++++ .../index_field_visibility_test.exs | 509 ++++++++++++++++++ 4 files changed, 1554 insertions(+) create mode 100644 test/mv_web/components/field_visibility_dropdown_component_test.exs create mode 100644 test/mv_web/live/member_live/index/field_selection_test.exs create mode 100644 test/mv_web/live/member_live/index/field_visibility_test.exs create mode 100644 test/mv_web/member_live/index_field_visibility_test.exs diff --git a/test/mv_web/components/field_visibility_dropdown_component_test.exs b/test/mv_web/components/field_visibility_dropdown_component_test.exs new file mode 100644 index 0000000..81cd73b --- /dev/null +++ b/test/mv_web/components/field_visibility_dropdown_component_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/index/field_selection_test.exs b/test/mv_web/live/member_live/index/field_selection_test.exs new file mode 100644 index 0000000..3c242c7 --- /dev/null +++ b/test/mv_web/live/member_live/index/field_selection_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs new file mode 100644 index 0000000..83ae06d --- /dev/null +++ b/test/mv_web/live/member_live/index/field_visibility_test.exs @@ -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 diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs new file mode 100644 index 0000000..c4241fe --- /dev/null +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -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 From 0fb43a08162ff9e07b63bf2ed8f20c04d69a167d Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 15:00:09 +0100 Subject: [PATCH 36/88] feat: adds field visibility dropdown live component --- lib/mv_web/components/core_components.ex | 120 +++++++++ .../field_visibility_dropdown_component.ex | 172 +++++++++++++ lib/mv_web/live/member_live/index.ex | 227 ++++++++++++++--- lib/mv_web/live/member_live/index.html.heex | 28 ++- .../live/member_live/index/field_selection.ex | 232 +++++++++++++++++ .../member_live/index/field_visibility.ex | 235 ++++++++++++++++++ 6 files changed, 981 insertions(+), 33 deletions(-) create mode 100644 lib/mv_web/components/field_visibility_dropdown_component.ex create mode 100644 lib/mv_web/live/member_live/index/field_selection.ex create mode 100644 lib/mv_web/live/member_live/index/field_visibility.ex diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index b8fe0fc..4f6bf37 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -111,6 +111,126 @@ defmodule MvWeb.CoreComponents do end end + @doc """ + Renders a dropdown menu. + + ## Examples + + <.dropdown_menu items={@items} open={@open} phx-target={@myself} /> + """ + attr :id, :string, default: "dropdown-menu" + attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps" + attr :button_label, :string, default: "Dropdown" + attr :icon, :string, default: nil + attr :checkboxes, :boolean, default: false + attr :selected, :map, default: %{} + attr :open, :boolean, default: false, doc: "Whether the dropdown is open" + attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" + attr :phx_target, :any, 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""" +
+ + + +
+ """ + end + @doc """ Renders an input with label and error messages. diff --git a/lib/mv_web/components/field_visibility_dropdown_component.ex b/lib/mv_web/components/field_visibility_dropdown_component.ex new file mode 100644 index 0000000..1ee0487 --- /dev/null +++ b/lib/mv_web/components/field_visibility_dropdown_component.ex @@ -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""" +
+ <.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} + /> +
+ """ + 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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 6bce495..522dfa1 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -31,6 +31,8 @@ defmodule MvWeb.MemberLive.Index do alias Mv.Membership 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_") @custom_field_prefix "custom_field_" @@ -48,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do and member selection. Actual data loading happens in `handle_params/3`. """ @impl true - def mount(_params, _session, socket) do - # Load custom fields that should be shown in overview + def mount(_params, session, socket) do + # Load custom fields that should be shown in overview (for display) # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView # and result in a 500 error page. This is appropriate for LiveViews where errors # should be visible to the user rather than silently failing. @@ -59,6 +61,12 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.sort(name: :asc) |> Ash.read!() + # Load ALL custom fields for the dropdown (to show all available fields) + all_custom_fields = + Mv.Membership.CustomField + |> Ash.Query.sort(name: :asc) + |> Ash.read!() + # Load settings once to avoid N+1 queries settings = case Membership.get_settings() do @@ -67,6 +75,20 @@ defmodule MvWeb.MemberLive.Index do {:error, _} -> %{member_field_visibility: %{}} end + # Load user field selection from session + session_selection = FieldSelection.get_from_session(session) + + # Get all available fields (for dropdown - includes ALL custom fields) + all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields) + + # Merge session selection with global settings for initial state (use all_custom_fields) + initial_selection = + FieldVisibility.merge_with_global_settings( + session_selection, + settings, + all_custom_fields + ) + socket = socket |> assign(:page_title, gettext("Members")) @@ -76,8 +98,14 @@ defmodule MvWeb.MemberLive.Index do |> assign(:selected_members, []) |> assign(:settings, settings) |> assign(:custom_fields_visible, custom_fields_visible) + |> assign(:all_custom_fields, all_custom_fields) + |> assign(:all_available_fields, all_available_fields) + |> assign(:user_field_selection, initial_selection) |> assign(:member_field_configurations, get_member_field_configurations(settings)) - |> assign(:member_fields_visible, get_visible_member_fields(settings)) + |> assign( + :member_fields_visible, + FieldVisibility.get_visible_member_fields(initial_selection) + ) # We call handle params to use the query from the URL {:ok, socket} @@ -144,6 +172,8 @@ defmodule MvWeb.MemberLive.Index do ## Supported messages: - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL + - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent + - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ @impl true def handle_info({:sort, field_str}, socket) do @@ -170,11 +200,12 @@ defmodule MvWeb.MemberLive.Index do existing_sort_query = socket.assigns.sort_order # Build the URL with queries - query_params = %{ - "query" => q, - "sort_field" => existing_field_query, - "sort_order" => existing_sort_query - } + query_params = + build_query_params(socket, %{ + "query" => q, + "sort_field" => existing_field_query, + "sort_order" => existing_sort_query + }) # Set the new path with params new_path = ~p"/members?#{query_params}" @@ -187,22 +218,109 @@ defmodule MvWeb.MemberLive.Index do )} end + @impl true + def handle_info({:field_toggled, field_string, visible}, socket) do + # Update user field selection + new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) + + # Save to session (cookie will be saved on next page load via handle_params) + socket = update_session_field_selection(socket, new_selection) + + # Merge with global settings + final_selection = + FieldVisibility.merge_with_global_settings( + new_selection, + socket.assigns.settings, + socket.assigns.custom_fields_visible + ) + + # Get visible fields + visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + + socket = + socket + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> load_members(socket.assigns.query) + |> prepare_dynamic_cols() + |> push_field_selection_url() + + {:noreply, socket} + end + + @impl true + def handle_info({:fields_selected, selection}, socket) do + # Save to session + socket = update_session_field_selection(socket, selection) + + # Merge with global settings (use all_custom_fields for merging) + final_selection = + FieldVisibility.merge_with_global_settings( + selection, + socket.assigns.settings, + socket.assigns.all_custom_fields + ) + + # Get visible fields + visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + + socket = + socket + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> load_members(socket.assigns.query) + |> prepare_dynamic_cols() + |> push_field_selection_url() + + {:noreply, socket} + end + # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @doc """ Handles URL parameter changes. - Parses query parameters for search query, sort field, and sort order, + Parses query parameters for search query, sort field, sort order, and field selection, then loads members accordingly. This enables bookmarkable URLs and browser back/forward navigation. """ @impl true def handle_params(params, _url, socket) do + # Parse field selection from URL + url_selection = FieldSelection.parse_from_url(params) + + # Merge with session selection (URL has priority) + merged_selection = + FieldSelection.merge_sources( + url_selection, + socket.assigns.user_field_selection, + %{} + ) + + # Merge with global settings (use all_custom_fields for merging) + final_selection = + FieldVisibility.merge_with_global_settings( + merged_selection, + socket.assigns.settings, + socket.assigns.all_custom_fields + ) + + # Get visible fields + visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members(params["query"]) |> prepare_dynamic_cols() @@ -215,10 +333,16 @@ defmodule MvWeb.MemberLive.Index do # - `:custom_field` - The CustomField resource # - `:render` - A function that formats the custom field value for a given member # + # Only includes custom fields that are visible according to user field selection. + # # Returns the socket with `:dynamic_cols` assigned. defp prepare_dynamic_cols(socket) do + visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] + 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, render: fn member -> @@ -294,11 +418,11 @@ defmodule MvWeb.MemberLive.Index do field end - query_params = %{ - "query" => socket.assigns.query, - "sort_field" => field_str, - "sort_order" => Atom.to_string(order) - } + query_params = + build_query_params(socket, %{ + "sort_field" => field_str, + "sort_order" => Atom.to_string(order) + }) new_path = ~p"/members?#{query_params}" @@ -309,6 +433,47 @@ defmodule MvWeb.MemberLive.Index do )} 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 + # Loads members from the database with custom field values and applies search/sort filters. # # Process: @@ -333,9 +498,9 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.new() |> Ash.Query.select(@overview_fields) - # Load custom field values for visible custom fields - custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) - query = load_custom_field_values(query, custom_field_ids_list) + # Load custom field values for visible custom fields (based on user selection) + visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] + query = load_custom_field_values(query, visible_custom_field_ids) # Apply the search filter first query = apply_search_filter(query, search_query) @@ -770,20 +935,6 @@ defmodule MvWeb.MemberLive.Index do 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. @@ -808,4 +959,16 @@ defmodule MvWeb.MemberLive.Index do end defp normalize_visibility_config(_), do: %{} + + # Extracts custom field IDs from visible custom field strings + # Format: "custom_field_" -> + defp extract_custom_field_ids(visible_custom_fields) do + Enum.map(visible_custom_fields, fn field_string -> + case String.split(field_string, "custom_field_") do + ["", id] -> id + _ -> nil + end + end) + |> Enum.filter(&(&1 != nil)) + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 594f2d8..e6076aa 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -2,6 +2,13 @@ <.header> {gettext("Members")} <: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 variant="primary" navigate={~p"/members/new"}> <.icon name="hero-plus" /> {gettext("New Member")} @@ -54,6 +61,7 @@ <:col :let={member} + :if={:first_name in @member_fields_visible} label={ ~H""" <.live_component @@ -67,7 +75,25 @@ """ } > - {member.first_name} {member.last_name} + {member.first_name} + + <:col + :let={member} + :if={:last_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_last_name} + field={:last_name} + label={gettext("Last name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.last_name} <:col :let={member} diff --git a/lib/mv_web/live/member_live/index/field_selection.ex b/lib/mv_web/live/member_live/index/field_selection.ex new file mode 100644 index 0000000..4b065f0 --- /dev/null +++ b/lib/mv_web/live/member_live/index/field_selection.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex new file mode 100644 index 0000000..8dd36fc --- /dev/null +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -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_"` (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 From a67a91cffa336ef98b9cfcbdc492b6a962b769de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Eppl=C3=A9e?= Date: Tue, 2 Dec 2025 09:56:54 +0100 Subject: [PATCH 37/88] Mark required fields in UI --- lib/mv_web/components/core_components.ex | 72 ++++++++++++++++-------- lib/mv_web/live/member_live/form.ex | 2 +- priv/gettext/de/LC_MESSAGES/default.po | 54 ++++++++++-------- priv/gettext/default.pot | 54 ++++++++++-------- priv/gettext/en/LC_MESSAGES/default.po | 54 ++++++++++-------- 5 files changed, 142 insertions(+), 94 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index ae50ecb..54a5a64 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -60,27 +60,29 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class={[ + class="z-50 toast toast-top toast-end" + {@rest} + > +
- <.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 == :success} name="hero-check-circle" class="size-5 shrink-0" /> - <.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" /> -
-

{@title}

-

{msg}

+ ]}> + <.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 == :success} name="hero-check-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" /> +
+

{@title}

+

{msg}

+
+
+
-
-
""" end @@ -186,7 +188,7 @@ defmodule MvWeb.CoreComponents do end) ~H""" -
+
<.error :for={msg <- @errors}>{msg} @@ -208,9 +214,15 @@ defmodule MvWeb.CoreComponents do def input(%{type: "select"} = assigns) do ~H""" -
+