Compare commits
11 commits
ad51a226f7
...
1819a1e2d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1819a1e2d1 | |||
| 21ec86839a | |||
| efb3e1cc37 | |||
| c246ca59db | |||
| edf8b2b79e | |||
| bc75a5853a | |||
| e259c29224 | |||
| 93916a09f9 | |||
| a273b54c75 | |||
| a19026e430 | |||
| 1084f67f1f |
36 changed files with 7525 additions and 100 deletions
|
|
@ -23,11 +23,21 @@ import {LiveSocket} from "phoenix_live_view"
|
|||
import topbar from "../vendor/topbar"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken}
|
||||
})
|
||||
|
||||
// Listen for custom events from LiveView
|
||||
window.addEventListener("phx:set-input-value", (e) => {
|
||||
const {id, value} = e.detail
|
||||
const input = document.getElementById(id)
|
||||
if (input) {
|
||||
input.value = value
|
||||
}
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
// - https://dbdocs.io
|
||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||
//
|
||||
// Version: 1.1
|
||||
// Version: 1.2
|
||||
// Last Updated: 2025-11-13
|
||||
|
||||
Project mila_membership_management {
|
||||
|
|
@ -236,6 +236,7 @@ Table custom_field_values {
|
|||
Table custom_fields {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")']
|
||||
slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.']
|
||||
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
|
||||
description text [null, note: 'Human-readable description']
|
||||
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
|
||||
|
|
@ -243,6 +244,7 @@ Table custom_fields {
|
|||
|
||||
indexes {
|
||||
name [unique, name: 'custom_fields_unique_name_index']
|
||||
slug [unique, name: 'custom_fields_unique_slug_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
|
|
@ -252,21 +254,32 @@ Table custom_fields {
|
|||
|
||||
**Attributes:**
|
||||
- `name`: Unique identifier for the custom field
|
||||
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
|
||||
- `value_type`: Enforces data type consistency
|
||||
- `description`: Documentation for users/admins
|
||||
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
|
||||
- `required`: Enforces that all members must have this custom field
|
||||
|
||||
**Slug Generation:**
|
||||
- Automatically generated from `name` on creation
|
||||
- Immutable after creation (does not change when name is updated)
|
||||
- Lowercase, spaces replaced with hyphens, special characters removed
|
||||
- UTF-8 support (ä → a, ß → ss, etc.)
|
||||
- Used for human-readable identifiers (CSV export/import, API, etc.)
|
||||
- Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller"
|
||||
|
||||
**Constraints:**
|
||||
- `value_type` must be one of: string, integer, boolean, date, email
|
||||
- `name` must be unique across all custom fields
|
||||
- `slug` must be unique across all custom fields
|
||||
- `slug` cannot be empty (validated on creation)
|
||||
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
|
||||
|
||||
**Examples:**
|
||||
- Membership Number (string, immutable, required)
|
||||
- Emergency Contact (string, mutable, optional)
|
||||
- Certified Trainer (boolean, mutable, optional)
|
||||
- Certification Date (date, immutable, optional)
|
||||
- Membership Number (string, immutable, required) → slug: "membership-number"
|
||||
- Emergency Contact (string, mutable, optional) → slug: "emergency-contact"
|
||||
- Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer"
|
||||
- Certification Date (date, immutable, optional) → slug: "certification-date"
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1321,6 +1321,135 @@ end
|
|||
|
||||
---
|
||||
|
||||
## Session: User-Member Linking UI Enhancement (2025-01-13)
|
||||
|
||||
### Feature Summary
|
||||
Implemented user-member linking functionality in User Edit/Create views with fuzzy search autocomplete, email conflict handling, and accessibility support.
|
||||
|
||||
**Key Features:**
|
||||
- Autocomplete dropdown with PostgreSQL Trigram fuzzy search
|
||||
- Link/unlink members to user accounts
|
||||
- Email synchronization between linked entities
|
||||
- WCAG 2.1 AA compliant (ARIA labels)
|
||||
- Bilingual UI (English/German)
|
||||
|
||||
### Technical Decisions
|
||||
|
||||
**1. Search Priority Logic**
|
||||
Search query takes precedence over email filtering to provide better UX:
|
||||
- User types → fuzzy search across all unlinked members
|
||||
- Email matching only used for post-filtering when no search query present
|
||||
|
||||
**2. JavaScript Hook for Input Value**
|
||||
Used minimal JavaScript (~6 lines) for reliable input field updates:
|
||||
```javascript
|
||||
// assets/js/app.js
|
||||
window.addEventListener("phx:set-input-value", (e) => {
|
||||
document.getElementById(e.detail.id).value = e.detail.value
|
||||
})
|
||||
```
|
||||
**Rationale:** LiveView DOM patching has race conditions with rapid state changes in autocomplete components. Direct DOM manipulation via `push_event` is the idiomatic LiveView solution for this edge case.
|
||||
|
||||
**3. Fuzzy Search Implementation**
|
||||
Combined PostgreSQL Full-Text Search + Trigram for optimal results:
|
||||
```sql
|
||||
-- FTS for exact word matching
|
||||
search_vector @@ websearch_to_tsquery('simple', 'greta')
|
||||
-- Trigram for typo tolerance
|
||||
word_similarity('gre', first_name) > 0.2
|
||||
-- Substring for email/IDs
|
||||
email ILIKE '%greta%'
|
||||
```
|
||||
|
||||
### Key Learnings
|
||||
|
||||
#### 1. Ash `manage_relationship` Internals
|
||||
**Critical Discovery:** During validation, relationship data lives in `changeset.relationships`, NOT `changeset.attributes`:
|
||||
|
||||
```elixir
|
||||
# During validation (manage_relationship processing):
|
||||
changeset.relationships.member = [{[%{id: "uuid"}], opts}]
|
||||
changeset.attributes.member_id = nil # Still nil!
|
||||
|
||||
# After action completes:
|
||||
changeset.attributes.member_id = "uuid" # Now set
|
||||
```
|
||||
|
||||
**Solution:** Extract member_id from both sources:
|
||||
```elixir
|
||||
defp get_member_id_from_changeset(changeset) do
|
||||
case Map.get(changeset.relationships, :member) do
|
||||
[{[%{id: id}], _opts}] -> id # New link
|
||||
_ -> Ash.Changeset.get_attribute(changeset, :member_id) # Existing
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Impact:** Fixed email validation false positives when linking user+member with identical emails.
|
||||
|
||||
#### 2. LiveView + JavaScript Integration Patterns
|
||||
|
||||
**When to use JavaScript:**
|
||||
- ✅ Direct DOM manipulation (autocomplete, input values)
|
||||
- ✅ Browser APIs (clipboard, geolocation)
|
||||
- ✅ Third-party libraries
|
||||
|
||||
**When NOT to use JavaScript:**
|
||||
- ❌ Form submissions
|
||||
- ❌ Simple show/hide logic
|
||||
- ❌ Server-side data fetching
|
||||
|
||||
**Pattern:**
|
||||
```elixir
|
||||
socket |> push_event("event-name", %{key: value})
|
||||
```
|
||||
```javascript
|
||||
window.addEventListener("phx:event-name", (e) => { /* handle */ })
|
||||
```
|
||||
|
||||
#### 3. PostgreSQL Trigram Search
|
||||
Requires `pg_trgm` extension with GIN indexes:
|
||||
```sql
|
||||
CREATE INDEX members_first_name_trgm_idx
|
||||
ON members USING GIN(first_name gin_trgm_ops);
|
||||
```
|
||||
Supports:
|
||||
- Typo tolerance: "Gret" finds "Greta"
|
||||
- Partial matching: "Mit" finds "Mitglied"
|
||||
- Substring: "exam" finds "example.com"
|
||||
|
||||
#### 4. Test-Driven Development for Bug Fixes
|
||||
Effective workflow:
|
||||
1. Write test that reproduces bug (should fail)
|
||||
2. Implement minimal fix
|
||||
3. Verify test passes
|
||||
4. Refactor while green
|
||||
|
||||
**Result:** 355 tests passing, 100% backend coverage for new features.
|
||||
|
||||
### Files Changed
|
||||
|
||||
**Backend:**
|
||||
- `lib/membership/member.ex` - `:available_for_linking` action with fuzzy search
|
||||
- `lib/mv/accounts/user/validations/email_not_used_by_other_member.ex` - Relationship change extraction
|
||||
- `lib/mv_web/live/user_live/form.ex` - Event handlers, state management
|
||||
|
||||
**Frontend:**
|
||||
- `assets/js/app.js` - Input value hook (6 lines)
|
||||
- `priv/gettext/**/*.po` - 10 new translation keys (DE/EN)
|
||||
|
||||
**Tests (NEW):**
|
||||
- `test/membership/member_fuzzy_search_linking_test.exs`
|
||||
- `test/accounts/user_member_linking_email_test.exs`
|
||||
- `test/mv_web/user_live/form_member_linking_ui_test.exs`
|
||||
|
||||
### Deployment Notes
|
||||
- **Assets:** Requires `cd assets && npm run build`
|
||||
- **Database:** No migrations (uses existing indexes)
|
||||
- **Config:** No changes required
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This project demonstrates a modern Phoenix application built with:
|
||||
|
|
|
|||
2502
docs/roles-and-permissions-architecture.md
Normal file
2502
docs/roles-and-permissions-architecture.md
Normal file
File diff suppressed because it is too large
Load diff
1653
docs/roles-and-permissions-implementation-plan.md
Normal file
1653
docs/roles-and-permissions-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load diff
506
docs/roles-and-permissions-overview.md
Normal file
506
docs/roles-and-permissions-overview.md
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
# Roles and Permissions - Architecture Overview
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
|
||||
**Version:** 2.0
|
||||
**Last Updated:** 2025-11-13
|
||||
**Status:** Architecture Design - MVP Approach
|
||||
|
||||
---
|
||||
|
||||
## Purpose of This Document
|
||||
|
||||
This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts.
|
||||
|
||||
**For detailed technical implementation:** See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Requirements Summary](#requirements-summary)
|
||||
3. [Evaluated Approaches](#evaluated-approaches)
|
||||
4. [Selected Architecture](#selected-architecture)
|
||||
5. [Permission System Design](#permission-system-design)
|
||||
6. [User-Member Linking Strategy](#user-member-linking-strategy)
|
||||
7. [Field-Level Permissions Strategy](#field-level-permissions-strategy)
|
||||
8. [Migration Strategy](#migration-strategy)
|
||||
9. [Related Documents](#related-documents)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Mila membership management system requires a flexible authorization system that controls:
|
||||
- **Who** can access **what** resources
|
||||
- **Which** pages users can view
|
||||
- **How** users interact with their own vs. others' data
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
1. **Simplicity First:** Start with hardcoded permissions for fast MVP delivery
|
||||
2. **Performance:** No database queries for permission checks in MVP
|
||||
3. **Clear Migration Path:** Easy upgrade to database-backed permissions when needed
|
||||
4. **Security:** Explicit action-based authorization with no ambiguity
|
||||
5. **Maintainability:** Permission logic reviewable in Git, testable as pure functions
|
||||
|
||||
### Core Concepts
|
||||
|
||||
**Permission Set:** Defines a collection of permissions (e.g., "read_only", "admin")
|
||||
|
||||
**Role:** A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only")
|
||||
|
||||
**User:** Each user has exactly one Role, inheriting that Role's Permission Set
|
||||
|
||||
**Scope:** Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything)
|
||||
|
||||
---
|
||||
|
||||
## Evaluated Approaches
|
||||
|
||||
During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility.
|
||||
|
||||
### Approach 1: JSONB in Roles Table
|
||||
|
||||
Store all permissions as a single JSONB column directly in the roles table.
|
||||
|
||||
**Advantages:**
|
||||
- Simplest database schema (single table)
|
||||
- Very flexible structure
|
||||
- No additional tables needed
|
||||
- Fast to implement
|
||||
|
||||
**Disadvantages:**
|
||||
- Poor queryability (can't efficiently filter by specific permissions)
|
||||
- No referential integrity
|
||||
- Difficult to validate structure
|
||||
- Hard to audit permission changes
|
||||
- Can't leverage database indexes effectively
|
||||
|
||||
**Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic.
|
||||
|
||||
---
|
||||
|
||||
### Approach 2: Normalized Database Tables
|
||||
|
||||
Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization.
|
||||
|
||||
**Advantages:**
|
||||
- Fully queryable with SQL
|
||||
- Runtime configurable permissions
|
||||
- Strong referential integrity
|
||||
- Easy to audit changes
|
||||
- Can index for performance
|
||||
|
||||
**Disadvantages:**
|
||||
- Complex database schema (4+ tables)
|
||||
- DB queries required for every permission check
|
||||
- Requires ETS cache for performance
|
||||
- Needs admin UI for permission management
|
||||
- Longer implementation time (4-5 weeks)
|
||||
- Overkill for fixed set of 4 permission sets
|
||||
|
||||
**Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.
|
||||
|
||||
---
|
||||
|
||||
### Approach 3: Custom Authorizer
|
||||
|
||||
Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
|
||||
|
||||
**Advantages:**
|
||||
- Complete control over authorization logic
|
||||
- Can implement any custom behavior
|
||||
- Not constrained by Ash Policy DSL
|
||||
|
||||
**Disadvantages:**
|
||||
- Significantly more code to write and maintain
|
||||
- Loses benefits of Ash's declarative policies
|
||||
- Harder to test than built-in policy system
|
||||
- Mixes declarative and imperative approaches
|
||||
- Must reimplement filter generation for queries
|
||||
- Higher bug risk
|
||||
|
||||
**Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.
|
||||
|
||||
---
|
||||
|
||||
### Approach 4: Simple Role Enum
|
||||
|
||||
Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy.
|
||||
|
||||
**Advantages:**
|
||||
- Very simple to implement (< 1 week)
|
||||
- No extra tables needed
|
||||
- Fast performance
|
||||
- Easy to understand
|
||||
|
||||
**Disadvantages:**
|
||||
- No separation between roles and permissions
|
||||
- Can't add new roles without code changes
|
||||
- No dynamic permission configuration
|
||||
- Not extensible to field-level permissions
|
||||
- Violates separation of concerns (role = job function, not permission set)
|
||||
- Difficult to maintain as requirements grow
|
||||
|
||||
**Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.
|
||||
|
||||
---
|
||||
|
||||
### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
|
||||
|
||||
Permission Sets hardcoded in Elixir module, only Roles table in database.
|
||||
|
||||
**Advantages:**
|
||||
- Fast implementation (2-3 weeks vs 4-5 weeks)
|
||||
- Maximum performance (zero DB queries, < 1 microsecond)
|
||||
- Simple to test (pure functions)
|
||||
- Code-reviewable permissions (visible in Git)
|
||||
- No migration needed for existing data
|
||||
- Clearly defined 4 permission sets as required
|
||||
- Clear migration path to database-backed solution (Phase 3)
|
||||
- Maintains separation of roles and permission sets
|
||||
|
||||
**Disadvantages:**
|
||||
- Permissions not editable at runtime (only role assignment possible)
|
||||
- New permissions require code deployment
|
||||
- Not suitable if permissions change frequently (> 1x/week)
|
||||
- Limited to the 4 predefined permission sets
|
||||
|
||||
**Why Selected:**
|
||||
- MVP requirement is for 4 fixed permission sets (not custom ones)
|
||||
- No stated requirement for runtime permission editing
|
||||
- Performance is critical for authorization checks
|
||||
- Fast time-to-market (2-3 weeks)
|
||||
- Clear upgrade path when runtime configuration becomes necessary
|
||||
|
||||
**Migration Path:**
|
||||
When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
|
||||
|
||||
---
|
||||
|
||||
## Requirements Summary
|
||||
|
||||
### Four Predefined Permission Sets
|
||||
|
||||
1. **own_data** - Access only to own user account and linked member profile
|
||||
2. **read_only** - Read access to all members and custom fields
|
||||
3. **normal_user** - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
|
||||
4. **admin** - Unrestricted access to all resources including user management
|
||||
|
||||
### Example Roles
|
||||
|
||||
- **Mitglied (Member)** - Uses "own_data" permission set, default role
|
||||
- **Vorstand (Board)** - Uses "read_only" permission set
|
||||
- **Kassenwart (Treasurer)** - Uses "normal_user" permission set
|
||||
- **Buchhaltung (Accounting)** - Uses "read_only" permission set
|
||||
- **Admin** - Uses "admin" permission set
|
||||
|
||||
### Authorization Levels
|
||||
|
||||
**Resource Level (MVP):**
|
||||
- Controls create, read, update, destroy actions on resources
|
||||
- Resources: Member, User, Property, PropertyType, Role
|
||||
|
||||
**Page Level (MVP):**
|
||||
- Controls access to LiveView pages
|
||||
- Example: "/members/new" requires Member.create permission
|
||||
|
||||
**Field Level (Phase 2 - Future):**
|
||||
- Controls read/write access to specific fields
|
||||
- Example: Only Treasurer can see payment_history field
|
||||
|
||||
### Special Cases
|
||||
|
||||
1. **Own Credentials:** Users can always edit their own email and password
|
||||
2. **Linked Member Email:** Only admins can edit email of members linked to users
|
||||
3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation)
|
||||
|
||||
---
|
||||
|
||||
## Selected Architecture
|
||||
|
||||
### Conceptual Model
|
||||
|
||||
```
|
||||
Elixir Module: PermissionSets
|
||||
↓ (defines)
|
||||
Permission Set (:own_data, :read_only, :normal_user, :admin)
|
||||
↓ (referenced by)
|
||||
Role (stored in DB: "Vorstand" → "read_only")
|
||||
↓ (assigned to)
|
||||
User (each user has one role_id)
|
||||
```
|
||||
|
||||
### Database Schema (MVP)
|
||||
|
||||
**Single Table: roles**
|
||||
|
||||
Contains:
|
||||
- id (UUID)
|
||||
- name (e.g., "Vorstand")
|
||||
- description
|
||||
- permission_set_name (String: "own_data", "read_only", "normal_user", "admin")
|
||||
- is_system_role (boolean, protects critical roles)
|
||||
|
||||
**No Permission Tables:** Permission Sets are hardcoded in Elixir module.
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
**Fast Implementation:** 2-3 weeks instead of 4-5 weeks
|
||||
|
||||
**Maximum Performance:**
|
||||
- Zero database queries for permission checks
|
||||
- Pure function calls (< 1 microsecond)
|
||||
- No caching needed
|
||||
|
||||
**Code Review:**
|
||||
- Permissions visible in Git diffs
|
||||
- Easy to review changes
|
||||
- No accidental runtime modifications
|
||||
|
||||
**Clear Upgrade Path:**
|
||||
- Phase 1 (MVP): Hardcoded
|
||||
- Phase 2: Add field-level permissions
|
||||
- Phase 3: Migrate to database-backed with admin UI
|
||||
|
||||
**Meets Requirements:**
|
||||
- Four predefined permission sets ✓
|
||||
- Dynamic role creation ✓ (Roles in DB)
|
||||
- Role-to-user assignment ✓
|
||||
- No requirement for runtime permission changes stated
|
||||
|
||||
---
|
||||
|
||||
## Permission System Design
|
||||
|
||||
### Permission Structure
|
||||
|
||||
Each Permission Set contains:
|
||||
|
||||
**Resources:** List of resource permissions
|
||||
- resource: "Member", "User", "Property", etc.
|
||||
- action: :read, :create, :update, :destroy
|
||||
- scope: :own, :linked, :all
|
||||
- granted: true/false
|
||||
|
||||
**Pages:** List of accessible page paths
|
||||
- Examples: "/", "/members", "/members/:id/edit"
|
||||
- "*" for admin (all pages)
|
||||
|
||||
### Scope Definitions
|
||||
|
||||
**:own** - Only records where id == actor.id
|
||||
- Example: User can read their own User record
|
||||
|
||||
**:linked** - Only records where user_id == actor.id
|
||||
- Example: User can read Member linked to their account
|
||||
|
||||
**:all** - All records without restriction
|
||||
- Example: Admin can read all Members
|
||||
|
||||
### How Authorization Works
|
||||
|
||||
1. User attempts action on resource (e.g., read Member)
|
||||
2. System loads user's role from database
|
||||
3. Role contains permission_set_name string
|
||||
4. PermissionSets module returns permissions for that set
|
||||
5. Custom Policy Check evaluates permissions against action
|
||||
6. Access granted or denied based on scope
|
||||
|
||||
### Custom Policy Check
|
||||
|
||||
A reusable Ash Policy Check that:
|
||||
- Reads user's permission_set_name from their role
|
||||
- Calls PermissionSets.get_permissions/1
|
||||
- Matches resource + action against permissions list
|
||||
- Applies scope filters (own/linked/all)
|
||||
- Returns authorized, forbidden, or filtered query
|
||||
|
||||
---
|
||||
|
||||
## User-Member Linking Strategy
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Users need to create member profiles for themselves (self-service), but only admins should be able to:
|
||||
- Link existing members to users
|
||||
- Unlink members from users
|
||||
- Create members pre-linked to arbitrary users
|
||||
|
||||
### Selected Approach: Separate Ash Actions
|
||||
|
||||
Instead of complex field-level validation, we use action-based authorization.
|
||||
|
||||
### Actions on Member Resource
|
||||
|
||||
**1. create_member_for_self** (All authenticated users)
|
||||
- Automatically sets user_id = actor.id
|
||||
- User cannot specify different user_id
|
||||
- UI: "Create My Profile" button
|
||||
|
||||
**2. create_member** (Admin only)
|
||||
- Can set user_id to any user or leave unlinked
|
||||
- Full flexibility for admin
|
||||
- UI: Admin member management form
|
||||
|
||||
**3. link_member_to_user** (Admin only)
|
||||
- Updates existing member to set user_id
|
||||
- Connects unlinked member to user account
|
||||
|
||||
**4. unlink_member_from_user** (Admin only)
|
||||
- Sets user_id to nil
|
||||
- Disconnects member from user account
|
||||
|
||||
**5. update** (Permission-based)
|
||||
- Normal updates (name, address, etc.)
|
||||
- user_id NOT in accept list (prevents manipulation)
|
||||
- Available to users with Member.update permission
|
||||
|
||||
### Why Separate Actions?
|
||||
|
||||
**Explicit Semantics:** Each action has clear, single purpose
|
||||
|
||||
**Server-Side Security:** user_id set by server, not client input
|
||||
|
||||
**Better UX:** Different UI flows for different use cases
|
||||
|
||||
**Simple Policies:** Authorization at action level, not field level
|
||||
|
||||
**Easy Testing:** Each action independently testable
|
||||
|
||||
---
|
||||
|
||||
## Field-Level Permissions Strategy
|
||||
|
||||
### Status: Phase 2 (Future Implementation)
|
||||
|
||||
Field-level permissions are NOT implemented in MVP but have a clear strategy defined.
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Some scenarios require field-level control:
|
||||
- **Read restrictions:** Hide payment_history from certain roles
|
||||
- **Write restrictions:** Only treasurer can edit payment fields
|
||||
- **Complexity:** Ash Policies work at resource level, not field level
|
||||
|
||||
### Selected Strategy
|
||||
|
||||
**For Read Restrictions:**
|
||||
Use Ash Calculations or Custom Preparations
|
||||
- Calculations: Dynamically compute field based on permissions
|
||||
- Preparations: Filter select to only allowed fields
|
||||
- Field returns nil or "[Hidden]" if unauthorized
|
||||
|
||||
**For Write Restrictions:**
|
||||
Use Custom Validations
|
||||
- Validate changeset against field permissions
|
||||
- Similar to existing linked-member email validation
|
||||
- Return error if field modification not allowed
|
||||
|
||||
### Why This Strategy?
|
||||
|
||||
**Leverages Ash Features:** Uses built-in mechanisms, not custom authorizer
|
||||
|
||||
**Performance:** Calculations are lazy, Preparations run once per query
|
||||
|
||||
**Maintainable:** Clear validation logic, standard Ash patterns
|
||||
|
||||
**Extensible:** Easy to add new field restrictions
|
||||
|
||||
### Implementation Timeline
|
||||
|
||||
**Phase 1 (MVP):** No field-level permissions
|
||||
|
||||
**Phase 2:** Extend PermissionSets to include field permissions, implement Calculations/Validations
|
||||
|
||||
**Phase 3:** If migrating to database, add permission_set_fields table
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: MVP with Hardcoded Permissions (2-3 weeks)
|
||||
|
||||
**What's Included:**
|
||||
- Roles table in database
|
||||
- PermissionSets Elixir module with 4 predefined sets
|
||||
- Custom Policy Check reading from module
|
||||
- UI Authorization Helpers for LiveView
|
||||
- Admin UI for role management (create, assign, delete roles)
|
||||
|
||||
**Limitations:**
|
||||
- Permissions not editable at runtime
|
||||
- New permissions require code deployment
|
||||
- Only 4 permission sets available
|
||||
|
||||
**Benefits:**
|
||||
- Fast implementation
|
||||
- Maximum performance
|
||||
- Simple testing and review
|
||||
|
||||
### Phase 2: Field-Level Permissions (Future, 2-3 weeks)
|
||||
|
||||
**When Needed:** Business requires field-level restrictions
|
||||
|
||||
**Implementation:**
|
||||
- Extend PermissionSets module with :fields key
|
||||
- Add Ash Calculations for read restrictions
|
||||
- Add custom validations for write restrictions
|
||||
- Update UI Helpers
|
||||
|
||||
**Migration:** No database changes, pure code additions
|
||||
|
||||
### Phase 3: Database-Backed Permissions (Future, 3-4 weeks)
|
||||
|
||||
**When Needed:** Runtime permission configuration required
|
||||
|
||||
**Implementation:**
|
||||
- Create permission tables in database
|
||||
- Seed script to migrate hardcoded permissions
|
||||
- Update PermissionSets module to query database
|
||||
- Add ETS cache for performance
|
||||
- Build admin UI for permission management
|
||||
|
||||
**Migration:** Seamless, no changes to existing Policies or UI code
|
||||
|
||||
### Decision Matrix: When to Migrate?
|
||||
|
||||
| Scenario | Recommended Phase |
|
||||
|----------|-------------------|
|
||||
| MVP with 4 fixed permission sets | Phase 1 |
|
||||
| Need field-level restrictions | Phase 2 |
|
||||
| Permission changes < 1x/month | Stay Phase 1 |
|
||||
| Need runtime permission config | Phase 3 |
|
||||
| Custom permission sets needed | Phase 3 |
|
||||
| Permission changes > 1x/week | Phase 3 |
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
**This Document (Overview):** High-level concepts, no code examples
|
||||
|
||||
**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
|
||||
|
||||
**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach
|
||||
|
||||
**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing:
|
||||
- **Speed:** 2-3 weeks implementation vs 4-5 weeks
|
||||
- **Performance:** Zero database queries for authorization
|
||||
- **Clarity:** Permissions in Git, reviewable and testable
|
||||
- **Flexibility:** Clear migration path to database-backed system
|
||||
|
||||
**User-Member linking** uses **separate Ash Actions** for clarity and security.
|
||||
|
||||
**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation.
|
||||
|
||||
The approach balances pragmatism for MVP delivery with extensibility for future requirements.
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ defmodule Mv.Accounts.User do
|
|||
# Default actions for framework/tooling integration:
|
||||
# - :read -> Standard read used across the app and by admin tooling.
|
||||
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
|
||||
#
|
||||
#
|
||||
# NOTE: :create is INTENTIONALLY excluded from defaults!
|
||||
# Using a default :create would bypass email-synchronization logic.
|
||||
# Always use one of these explicit create actions instead:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule Mv.Membership.CustomField do
|
|||
|
||||
## Attributes
|
||||
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
|
||||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||
- `description` - Optional human-readable description
|
||||
- `immutable` - If true, custom field values cannot be changed after creation
|
||||
|
|
@ -54,8 +55,14 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:read, :update, :destroy]
|
||||
default_accept [:name, :value_type, :description, :immutable, :required]
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :immutable, :required]
|
||||
change Mv.Membership.CustomField.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
|
@ -69,6 +76,15 @@ defmodule Mv.Membership.CustomField do
|
|||
trim?: true
|
||||
]
|
||||
|
||||
attribute :slug, :string,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
writable?: false,
|
||||
constraints: [
|
||||
max_length: 100,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :value_type, :atom,
|
||||
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
|
||||
allow_nil?: false,
|
||||
|
|
@ -97,5 +113,6 @@ defmodule Mv.Membership.CustomField do
|
|||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
identity :unique_slug, [:slug]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
118
lib/membership/custom_field/changes/generate_slug.ex
Normal file
118
lib/membership/custom_field/changes/generate_slug.ex
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||
@moduledoc """
|
||||
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **On Create**: Generates a slug from the name attribute using slugify
|
||||
- **On Update**: Slug remains unchanged (immutable after creation)
|
||||
- **Slug Generation**: Uses the `slugify` library to convert name to slug
|
||||
- Converts to lowercase
|
||||
- Replaces spaces with hyphens
|
||||
- Removes special characters
|
||||
- Handles UTF-8 characters (e.g., ä → a, ß → ss)
|
||||
- Trims leading/trailing hyphens
|
||||
- Truncates to max 100 characters
|
||||
|
||||
## Examples
|
||||
|
||||
# Create with automatic slug generation
|
||||
CustomField.create!(%{name: "Mobile Phone"})
|
||||
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
|
||||
|
||||
# German umlauts are converted
|
||||
CustomField.create!(%{name: "Café Müller"})
|
||||
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
|
||||
|
||||
# Slug is immutable on update
|
||||
custom_field = CustomField.create!(%{name: "Original"})
|
||||
CustomField.update!(custom_field, %{name: "New Name"})
|
||||
# => %CustomField{name: "New Name", slug: "original"} # slug unchanged!
|
||||
|
||||
## Implementation Note
|
||||
|
||||
This change only runs on `:create` actions. The slug is immutable by design,
|
||||
as changing slugs would break external references (e.g., CSV imports/exports).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@doc """
|
||||
Generates a slug from the changeset's `name` attribute.
|
||||
|
||||
Only runs on create actions. Returns the changeset unchanged if:
|
||||
- The action is not :create
|
||||
- The name is not being changed
|
||||
- The name is nil or empty
|
||||
|
||||
## Parameters
|
||||
|
||||
- `changeset` - The Ash changeset
|
||||
|
||||
## Returns
|
||||
|
||||
The changeset with the `:slug` attribute set to the generated slug.
|
||||
"""
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only generate slug on create, not on update (immutability)
|
||||
if changeset.action_type == :create do
|
||||
case Ash.Changeset.get_attribute(changeset, :name) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
name when is_binary(name) ->
|
||||
slug = generate_slug(name)
|
||||
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
||||
end
|
||||
else
|
||||
# On update, don't touch the slug (immutable)
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a URL-friendly slug from a given string.
|
||||
|
||||
Uses the `slugify` library to create a clean, lowercase slug with:
|
||||
- Spaces replaced by hyphens
|
||||
- Special characters removed
|
||||
- UTF-8 characters transliterated (ä → a, ß → ss, etc.)
|
||||
- Multiple consecutive hyphens reduced to single hyphen
|
||||
- Leading/trailing hyphens removed
|
||||
- Maximum length of 100 characters
|
||||
|
||||
## Examples
|
||||
|
||||
iex> generate_slug("Mobile Phone")
|
||||
"mobile-phone"
|
||||
|
||||
iex> generate_slug("Café Müller")
|
||||
"cafe-muller"
|
||||
|
||||
iex> generate_slug("TEST NAME")
|
||||
"test-name"
|
||||
|
||||
iex> generate_slug("E-Mail & Address!")
|
||||
"e-mail-address"
|
||||
|
||||
iex> generate_slug("Multiple Spaces")
|
||||
"multiple-spaces"
|
||||
|
||||
iex> generate_slug("-Test-")
|
||||
"test"
|
||||
|
||||
iex> generate_slug("Straße")
|
||||
"strasse"
|
||||
|
||||
"""
|
||||
def generate_slug(name) when is_binary(name) do
|
||||
slug = Slug.slugify(name)
|
||||
|
||||
case slug do
|
||||
nil -> ""
|
||||
"" -> ""
|
||||
slug when is_binary(slug) -> String.slice(slug, 0, 100)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_slug(_), do: ""
|
||||
end
|
||||
|
|
@ -152,7 +152,8 @@ defmodule Mv.Membership.Member do
|
|||
prepare fn query, _ctx ->
|
||||
q = Ash.Query.get_argument(query, :query) || ""
|
||||
|
||||
# 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results
|
||||
# 0.2 as similarity threshold (recommended)
|
||||
# Lower value can lead to more results but also to more unspecific results
|
||||
threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
|
||||
|
||||
if is_binary(q) and String.trim(q) != "" do
|
||||
|
|
@ -187,8 +188,82 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Action to find members available for linking to a user account
|
||||
# Returns only unlinked members (user_id == nil), limited to 10 results
|
||||
#
|
||||
# Special behavior for email matching:
|
||||
# - When user_email AND search_query are both provided: filter by email (email takes precedence)
|
||||
# - When only user_email provided: return all unlinked members (caller should use filter_by_email_match helper)
|
||||
# - When only search_query provided: filter by search terms
|
||||
read :available_for_linking do
|
||||
argument :user_email, :string, allow_nil?: true
|
||||
argument :search_query, :string, allow_nil?: true
|
||||
|
||||
prepare fn query, _ctx ->
|
||||
user_email = Ash.Query.get_argument(query, :user_email)
|
||||
search_query = Ash.Query.get_argument(query, :search_query)
|
||||
|
||||
# Start with base filter: only unlinked members
|
||||
base_query = Ash.Query.filter(query, is_nil(user))
|
||||
|
||||
# Determine filtering strategy
|
||||
# Priority: search_query (if present) > no filters
|
||||
# user_email is used for POST-filtering via filter_by_email_match helper
|
||||
if not is_nil(search_query) and String.trim(search_query) != "" do
|
||||
# Search query present: Use fuzzy search (regardless of user_email)
|
||||
trimmed = String.trim(search_query)
|
||||
|
||||
# Use same fuzzy search as :search action (PostgreSQL Trigram + FTS)
|
||||
base_query
|
||||
|> Ash.Query.filter(
|
||||
expr(
|
||||
# Full-text search
|
||||
# Trigram similarity for names
|
||||
# Exact substring match for email
|
||||
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed) or
|
||||
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed) or
|
||||
fragment("? % first_name", ^trimmed) or
|
||||
fragment("? % last_name", ^trimmed) or
|
||||
fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or
|
||||
fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or
|
||||
fragment("similarity(first_name, ?) > 0.2", ^trimmed) or
|
||||
fragment("similarity(last_name, ?) > 0.2", ^trimmed) or
|
||||
contains(email, ^trimmed)
|
||||
)
|
||||
)
|
||||
|> Ash.Query.limit(10)
|
||||
else
|
||||
# No search query: return all unlinked members
|
||||
# Caller should use filter_by_email_match helper for email match logic
|
||||
base_query
|
||||
|> Ash.Query.limit(10)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Public helper function to apply email match logic after query execution
|
||||
# This should be called after using :available_for_linking with user_email argument
|
||||
#
|
||||
# If a member with matching email exists, returns only that member
|
||||
# Otherwise returns all members (no filtering)
|
||||
def filter_by_email_match(members, user_email)
|
||||
when is_list(members) and is_binary(user_email) do
|
||||
# Check if any member matches the email
|
||||
email_match = Enum.find(members, &(&1.email == user_email))
|
||||
|
||||
if email_match do
|
||||
# Return only the email-matched member
|
||||
[email_match]
|
||||
else
|
||||
# No email match, return all members
|
||||
members
|
||||
end
|
||||
end
|
||||
|
||||
def filter_by_email_match(members, _user_email), do: members
|
||||
|
||||
validations do
|
||||
# Required fields are covered by allow_nil? false
|
||||
|
||||
|
|
|
|||
|
|
@ -41,18 +41,37 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
|||
if should_validate? do
|
||||
case Ash.Changeset.fetch_change(changeset, :email) do
|
||||
{:ok, new_email} ->
|
||||
check_email_uniqueness(new_email, member_id)
|
||||
# Extract member_id from relationship changes for new links
|
||||
member_id_to_exclude = get_member_id_from_changeset(changeset)
|
||||
check_email_uniqueness(new_email, member_id_to_exclude)
|
||||
|
||||
:error ->
|
||||
# No email change, get current email
|
||||
current_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
check_email_uniqueness(current_email, member_id)
|
||||
# Extract member_id from relationship changes for new links
|
||||
member_id_to_exclude = get_member_id_from_changeset(changeset)
|
||||
check_email_uniqueness(current_email, member_id_to_exclude)
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Extract member_id from changeset, checking relationship changes first
|
||||
# This is crucial for new links where member_id is in manage_relationship changes
|
||||
defp get_member_id_from_changeset(changeset) do
|
||||
# Try to get from relationships (for new links via manage_relationship)
|
||||
case Map.get(changeset.relationships, :member) do
|
||||
[{[%{id: id}], _opts}] when not is_nil(id) ->
|
||||
# Found in relationships - this is a new link
|
||||
id
|
||||
|
||||
_ ->
|
||||
# Fall back to attribute (for existing links)
|
||||
Ash.Changeset.get_attribute(changeset, :member_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_uniqueness(email, exclude_member_id) do
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ defmodule MvWeb.CustomFieldLive.Form do
|
|||
|
||||
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
|
|
|
|||
|
|
@ -43,8 +43,6 @@ defmodule MvWeb.CustomFieldLive.Index do
|
|||
rows={@streams.custom_fields}
|
||||
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label="Id">{custom_field.id}</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ defmodule MvWeb.CustomFieldLive.Show do
|
|||
- Return to custom field list
|
||||
|
||||
## Displayed Information
|
||||
- ID: Internal UUID identifier
|
||||
- Slug: URL-friendly identifier (auto-generated, immutable)
|
||||
- Name: Unique identifier
|
||||
- Value type: Data type constraint
|
||||
- Description: Optional explanation
|
||||
|
|
@ -29,7 +31,7 @@ defmodule MvWeb.CustomFieldLive.Show do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Custom field {@custom_field.id}
|
||||
Custom field {@custom_field.slug}
|
||||
<:subtitle>This is a custom_field record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
|
|
@ -48,6 +50,13 @@ defmodule MvWeb.CustomFieldLive.Show do
|
|||
<.list>
|
||||
<:item title="Id">{@custom_field.id}</:item>
|
||||
|
||||
<:item title="Slug">
|
||||
{@custom_field.slug}
|
||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
{gettext("Auto-generated identifier (immutable)")}
|
||||
</p>
|
||||
</:item>
|
||||
|
||||
<:item title="Name">{@custom_field.name}</:item>
|
||||
|
||||
<:item title="Description">{@custom_field.description}</:item>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage custom_field_value records in your database.")}
|
||||
{gettext("Use this form to manage Custom Field Value records in your database.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,116 @@ defmodule MvWeb.UserLive.Form do
|
|||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Member Linking Section -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
|
||||
|
||||
<%= if @user && @user.member && !@unlink_member do %>
|
||||
<!-- Show linked member with unlink button -->
|
||||
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-green-900">
|
||||
{@user.member.first_name} {@user.member.last_name}
|
||||
</p>
|
||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="unlink_member"
|
||||
class="btn btn-sm btn-error"
|
||||
>
|
||||
{gettext("Unlink Member")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if @unlink_member do %>
|
||||
<!-- Show unlink pending message -->
|
||||
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- Show member search/selection for unlinked users -->
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="member-search-input"
|
||||
role="combobox"
|
||||
phx-focus="show_member_dropdown"
|
||||
phx-change="search_members"
|
||||
phx-debounce="300"
|
||||
value={@member_search_query}
|
||||
placeholder={gettext("Search for a member to link...")}
|
||||
class="w-full input"
|
||||
name="member_search"
|
||||
disabled={@unlink_member}
|
||||
aria-label={gettext("Search for member to link")}
|
||||
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="member-dropdown"
|
||||
aria-expanded={to_string(@show_member_dropdown)}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<%= if length(@available_members) > 0 do %>
|
||||
<div
|
||||
id="member-dropdown"
|
||||
role="listbox"
|
||||
aria-label={gettext("Available members")}
|
||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||
phx-click-away="hide_member_dropdown"
|
||||
>
|
||||
<%= for member <- @available_members do %>
|
||||
<div
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected="false"
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
data-member-id={member.id}
|
||||
class="px-4 py-3 hover:bg-base-200 cursor-pointer border-b border-base-300 last:border-b-0"
|
||||
>
|
||||
<p class="font-medium">{member.first_name} {member.last_name}</p>
|
||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_member_id && @selected_member_name do %>
|
||||
<div
|
||||
id="member-selected"
|
||||
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||
</p>
|
||||
<p class="text-xs text-blue-600 mt-1">
|
||||
{gettext("Save to confirm linking.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save User")}
|
||||
|
|
@ -135,7 +245,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
user =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
|
||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
|
||||
end
|
||||
|
||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||
|
|
@ -147,6 +257,13 @@ defmodule MvWeb.UserLive.Form do
|
|||
|> assign(user: user)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:show_password_fields, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:selected_member_id, nil)
|
||||
|> assign(:selected_member_name, nil)
|
||||
|> assign(:unlink_member, false)
|
||||
|> load_initial_members()
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -170,22 +287,102 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
# First save the user without member changes
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
|
||||
{:ok, user} ->
|
||||
notify_parent({:saved, user})
|
||||
# Then handle member linking/unlinking as a separate step
|
||||
result =
|
||||
cond do
|
||||
# Selected member ID takes precedence (new link)
|
||||
socket.assigns.selected_member_id ->
|
||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, user))
|
||||
# Unlink flag is set
|
||||
socket.assigns[:unlink_member] ->
|
||||
Mv.Accounts.update_user(user, %{member: nil})
|
||||
|
||||
{:noreply, socket}
|
||||
# No changes to member relationship
|
||||
true ->
|
||||
{:ok, user}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, updated_user} ->
|
||||
notify_parent({:saved, updated_user})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, error} ->
|
||||
# Show error from member linking/unlinking
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "Failed to update member relationship: #{inspect(error)}")}
|
||||
end
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("show_member_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, show_member_dropdown: true)}
|
||||
end
|
||||
|
||||
def handle_event("hide_member_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, show_member_dropdown: false)}
|
||||
end
|
||||
|
||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:member_search_query, query)
|
||||
|> load_available_members(query)
|
||||
|> assign(:show_member_dropdown, true)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("select_member", %{"id" => member_id}, socket) do
|
||||
# Find the selected member to get their name
|
||||
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
|
||||
|
||||
member_name =
|
||||
if selected_member,
|
||||
do: "#{selected_member.first_name} #{selected_member.last_name}",
|
||||
else: ""
|
||||
|
||||
# Store the selected member ID and name in socket state and clear unlink flag
|
||||
socket =
|
||||
socket
|
||||
|> assign(:selected_member_id, member_id)
|
||||
|> assign(:selected_member_name, member_name)
|
||||
|> assign(:unlink_member, false)
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:member_search_query, member_name)
|
||||
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("unlink_member", _params, socket) do
|
||||
# Set flag to unlink member on save
|
||||
# Clear all member selection state and keep dropdown hidden
|
||||
socket =
|
||||
socket
|
||||
|> assign(:unlink_member, true)
|
||||
|> assign(:selected_member_id, nil)
|
||||
|> assign(:selected_member_name, nil)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> load_initial_members()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||
|
|
@ -209,4 +406,53 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
defp return_path("index", _user), do: ~p"/users"
|
||||
defp return_path("show", user), do: ~p"/users/#{user.id}"
|
||||
|
||||
# Load initial members when the form is loaded or member is unlinked
|
||||
defp load_initial_members(socket) do
|
||||
user = socket.assigns.user
|
||||
user_email = if user, do: user.email, else: nil
|
||||
|
||||
members = load_members_for_linking(user_email, "")
|
||||
|
||||
# Dropdown should ALWAYS be hidden initially
|
||||
# It will only show when user focuses the input field (show_member_dropdown event)
|
||||
socket
|
||||
|> assign(available_members: members)
|
||||
|> assign(show_member_dropdown: false)
|
||||
end
|
||||
|
||||
# Load members based on search query
|
||||
defp load_available_members(socket, query) do
|
||||
user = socket.assigns.user
|
||||
user_email = if user, do: user.email, else: nil
|
||||
|
||||
members = load_members_for_linking(user_email, query)
|
||||
assign(socket, available_members: members)
|
||||
end
|
||||
|
||||
# Query available members using the Ash action
|
||||
defp load_members_for_linking(user_email, search_query) do
|
||||
user_email_str = if user_email, do: to_string(user_email), else: nil
|
||||
search_query_str = if search_query && search_query != "", do: search_query, else: nil
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{
|
||||
user_email: user_email_str,
|
||||
search_query: search_query_str
|
||||
})
|
||||
|
||||
case Ash.read(query, domain: Mv.Membership) do
|
||||
{:ok, members} ->
|
||||
# Apply email match filter if user_email is provided
|
||||
if user_email_str do
|
||||
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||
else
|
||||
members
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ defmodule MvWeb.UserLive.Index do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
|
||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
|
||||
sorted = Enum.sort_by(users, & &1.email)
|
||||
|
||||
{:ok,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@
|
|||
{user.email}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
|
||||
<:col :let={user} label={gettext("Linked Member")}>
|
||||
<%= if user.member do %>
|
||||
{user.member.first_name} {user.member.last_name}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">{gettext("No member linked")}</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:action :let={user}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -75,7 +75,8 @@ defmodule Mv.MixProject do
|
|||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:ecto_commons, "~> 0.3"}
|
||||
{:ecto_commons, "~> 0.3"},
|
||||
{:slugify, "~> 1.3"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
4
mix.lock
4
mix.lock
|
|
@ -16,7 +16,7 @@
|
|||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
|
||||
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ msgid "Actions"
|
|||
msgstr "Aktionen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||
#: 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:202
|
||||
#: 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:194
|
||||
#: 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:251
|
||||
#: 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:191
|
||||
#: 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"
|
||||
|
|
@ -158,10 +158,10 @@ msgstr "Postleitzahl"
|
|||
msgid "Save Member"
|
||||
msgstr "Mitglied speichern"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: 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:124
|
||||
#: lib/mv_web/live/user_live/form.ex:234
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
msgstr "Speichern..."
|
||||
|
|
@ -203,14 +203,14 @@ msgstr "Dies ist ein Mitglied aus deiner Datenbank."
|
|||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:107
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: 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:108
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||
#: 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,10 +252,10 @@ 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:66
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||
#: 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:127
|
||||
#: lib/mv_web/live/user_live/form.ex:237
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
|
@ -265,7 +265,7 @@ msgstr "Abbrechen"
|
|||
msgid "Choose a member"
|
||||
msgstr "Mitglied auswählen"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:59
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr "Beschreibung"
|
||||
|
|
@ -285,7 +285,7 @@ msgstr "Aktiviert"
|
|||
msgid "ID"
|
||||
msgstr "ID"
|
||||
|
||||
#: 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 "Immutable"
|
||||
msgstr "Unveränderlich"
|
||||
|
|
@ -335,6 +335,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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Note"
|
||||
msgstr "Hinweis"
|
||||
|
|
@ -355,7 +356,7 @@ msgstr "Passwort-Authentifizierung"
|
|||
msgid "Profil"
|
||||
msgstr "Profil"
|
||||
|
||||
#: 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 "Required"
|
||||
msgstr "Erforderlich"
|
||||
|
|
@ -375,7 +376,7 @@ msgstr "Mitglied auswählen"
|
|||
msgid "Settings"
|
||||
msgstr "Einstellungen"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:125
|
||||
#: lib/mv_web/live/user_live/form.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save User"
|
||||
msgstr "Benutzer*in speichern"
|
||||
|
|
@ -400,7 +401,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:142
|
||||
#: lib/mv_web/live/user_live/form.ex:252
|
||||
#: lib/mv_web/live/user_live/show.ex:34
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -411,7 +412,7 @@ msgstr "Benutzer*in"
|
|||
msgid "Value"
|
||||
msgstr "Wert"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:54
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type"
|
||||
msgstr "Wertetyp"
|
||||
|
|
@ -428,7 +429,7 @@ msgstr "aufsteigend"
|
|||
msgid "descending"
|
||||
msgstr "absteigend"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:141
|
||||
#: lib/mv_web/live/user_live/form.ex:251
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New"
|
||||
msgstr "Neue*r"
|
||||
|
|
@ -503,6 +504,8 @@ msgstr "Passwort setzen"
|
|||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
||||
msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:126
|
||||
#: lib/mv_web/live/user_live/index.html.heex:53
|
||||
#: lib/mv_web/live/user_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linked Member"
|
||||
|
|
@ -513,6 +516,7 @@ msgstr "Verknüpftes Mitglied"
|
|||
msgid "Linked User"
|
||||
msgstr "Verknüpfte*r Benutzer*in"
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex:57
|
||||
#: lib/mv_web/live/user_live/show.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No member linked"
|
||||
|
|
@ -616,7 +620,7 @@ msgstr "Benutzerdefinierte Feldwerte"
|
|||
msgid "Custom field"
|
||||
msgstr "Benutzerdefiniertes Feld"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:114
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field %{action} successfully"
|
||||
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
|
||||
|
|
@ -631,7 +635,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:64
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Custom field"
|
||||
msgstr "Benutzerdefiniertes Feld speichern"
|
||||
|
|
@ -646,12 +650,67 @@ 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/live/custom_field_value_live/form.ex:42
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage custom_field_value records in your database."
|
||||
msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Fields"
|
||||
msgstr "Benutzerdefinierte Felder"
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:42
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage Custom Field Value records in your database."
|
||||
msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Auto-generated identifier (immutable)"
|
||||
msgstr "Automatisch generierte Kennung (unveränderlich)"
|
||||
|
||||
#: 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/custom_field_live/form.ex:58
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Slug"
|
||||
#~ msgstr "Slug"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ msgid "Actions"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||
#: 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:202
|
||||
#: 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:194
|
||||
#: 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:251
|
||||
#: 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:191
|
||||
#: 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 ""
|
||||
|
|
@ -159,10 +159,10 @@ msgstr ""
|
|||
msgid "Save Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: 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:124
|
||||
#: lib/mv_web/live/user_live/form.ex:234
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
msgstr ""
|
||||
|
|
@ -204,14 +204,14 @@ msgstr ""
|
|||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:107
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: 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:108
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||
#: 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,10 +253,10 @@ msgstr ""
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:66
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||
#: 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:127
|
||||
#: lib/mv_web/live/user_live/form.ex:237
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
|
@ -266,7 +266,7 @@ msgstr ""
|
|||
msgid "Choose a member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:59
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
|
@ -286,7 +286,7 @@ msgstr ""
|
|||
msgid "ID"
|
||||
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 "Immutable"
|
||||
msgstr ""
|
||||
|
|
@ -336,6 +336,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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Note"
|
||||
msgstr ""
|
||||
|
|
@ -356,7 +357,7 @@ msgstr ""
|
|||
msgid "Profil"
|
||||
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 "Required"
|
||||
msgstr ""
|
||||
|
|
@ -376,7 +377,7 @@ msgstr ""
|
|||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:125
|
||||
#: lib/mv_web/live/user_live/form.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save User"
|
||||
msgstr ""
|
||||
|
|
@ -401,7 +402,7 @@ msgstr ""
|
|||
msgid "Use this form to manage user records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:142
|
||||
#: lib/mv_web/live/user_live/form.ex:252
|
||||
#: lib/mv_web/live/user_live/show.ex:34
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -412,7 +413,7 @@ msgstr ""
|
|||
msgid "Value"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:54
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type"
|
||||
msgstr ""
|
||||
|
|
@ -429,7 +430,7 @@ msgstr ""
|
|||
msgid "descending"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:141
|
||||
#: lib/mv_web/live/user_live/form.ex:251
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New"
|
||||
msgstr ""
|
||||
|
|
@ -504,6 +505,8 @@ msgstr ""
|
|||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:126
|
||||
#: lib/mv_web/live/user_live/index.html.heex:53
|
||||
#: lib/mv_web/live/user_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linked Member"
|
||||
|
|
@ -514,6 +517,7 @@ msgstr ""
|
|||
msgid "Linked User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex:57
|
||||
#: lib/mv_web/live/user_live/show.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No member linked"
|
||||
|
|
@ -617,7 +621,7 @@ msgstr ""
|
|||
msgid "Custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:114
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field %{action} successfully"
|
||||
msgstr ""
|
||||
|
|
@ -632,7 +636,7 @@ msgstr ""
|
|||
msgid "Please select a custom field first"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Custom field"
|
||||
msgstr ""
|
||||
|
|
@ -647,12 +651,62 @@ msgstr ""
|
|||
msgid "Use this form to manage custom_field records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:42
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage custom_field_value records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:42
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage Custom Field Value records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Auto-generated identifier (immutable)"
|
||||
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
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ msgid "Actions"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||
#: 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:202
|
||||
#: 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:194
|
||||
#: 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:251
|
||||
#: 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:191
|
||||
#: 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 ""
|
||||
|
|
@ -159,10 +159,10 @@ msgstr ""
|
|||
msgid "Save Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: 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:124
|
||||
#: lib/mv_web/live/user_live/form.ex:234
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
msgstr ""
|
||||
|
|
@ -204,14 +204,14 @@ msgstr ""
|
|||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:107
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: 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:108
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||
#: 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,10 +253,10 @@ msgstr ""
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:66
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||
#: 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:127
|
||||
#: lib/mv_web/live/user_live/form.ex:237
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
|
@ -266,7 +266,7 @@ msgstr ""
|
|||
msgid "Choose a member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:59
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
|
@ -286,7 +286,7 @@ msgstr ""
|
|||
msgid "ID"
|
||||
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 "Immutable"
|
||||
msgstr ""
|
||||
|
|
@ -336,6 +336,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
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Note"
|
||||
msgstr ""
|
||||
|
|
@ -356,7 +357,7 @@ msgstr ""
|
|||
msgid "Profil"
|
||||
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 "Required"
|
||||
msgstr ""
|
||||
|
|
@ -376,7 +377,7 @@ msgstr ""
|
|||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:125
|
||||
#: lib/mv_web/live/user_live/form.ex:235
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Save User"
|
||||
msgstr ""
|
||||
|
|
@ -401,7 +402,7 @@ msgstr ""
|
|||
msgid "Use this form to manage user records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:142
|
||||
#: lib/mv_web/live/user_live/form.ex:252
|
||||
#: lib/mv_web/live/user_live/show.ex:34
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -412,7 +413,7 @@ msgstr ""
|
|||
msgid "Value"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:54
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type"
|
||||
msgstr ""
|
||||
|
|
@ -429,7 +430,7 @@ msgstr ""
|
|||
msgid "descending"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:141
|
||||
#: lib/mv_web/live/user_live/form.ex:251
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New"
|
||||
msgstr ""
|
||||
|
|
@ -504,6 +505,8 @@ msgstr "Set Password"
|
|||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
||||
msgstr "User will be created without a password. Check 'Set Password' to add one."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex:126
|
||||
#: lib/mv_web/live/user_live/index.html.heex:53
|
||||
#: lib/mv_web/live/user_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Linked Member"
|
||||
|
|
@ -514,6 +517,7 @@ msgstr ""
|
|||
msgid "Linked User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex:57
|
||||
#: lib/mv_web/live/user_live/show.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No member linked"
|
||||
|
|
@ -617,7 +621,7 @@ msgstr ""
|
|||
msgid "Custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:114
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field %{action} successfully"
|
||||
msgstr ""
|
||||
|
|
@ -632,7 +636,7 @@ msgstr ""
|
|||
msgid "Please select a custom field first"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Custom field"
|
||||
msgstr ""
|
||||
|
|
@ -647,12 +651,67 @@ msgstr ""
|
|||
msgid "Use this form to manage custom_field records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:42
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage custom_field_value records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:20
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom Fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:42
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage Custom Field Value records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Auto-generated identifier (immutable)"
|
||||
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/custom_field_live/form.ex:58
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Slug"
|
||||
#~ msgstr ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
defmodule Mv.Repo.Migrations.AddSlugToCustomFields 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
|
||||
# Step 1: Add slug column as nullable first
|
||||
alter table(:custom_fields) do
|
||||
add :slug, :text, null: true
|
||||
end
|
||||
|
||||
# Step 2: Generate slugs for existing custom fields
|
||||
execute("""
|
||||
UPDATE custom_fields
|
||||
SET slug = lower(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(name, '[^a-zA-Z0-9\\s-]', '', 'g'),
|
||||
'\\s+', '-', 'g'
|
||||
),
|
||||
'-+', '-', 'g'
|
||||
)
|
||||
)
|
||||
WHERE slug IS NULL
|
||||
""")
|
||||
|
||||
# Step 3: Make slug NOT NULL
|
||||
alter table(:custom_fields) do
|
||||
modify :slug, :text, null: false
|
||||
end
|
||||
|
||||
# Step 4: Create unique index
|
||||
create unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index")
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index")
|
||||
|
||||
alter table(:custom_fields) do
|
||||
remove :slug
|
||||
end
|
||||
end
|
||||
end
|
||||
132
priv/resource_snapshots/repo/custom_fields/20251113180429.json
Normal file
132
priv/resource_snapshots/repo/custom_fields/20251113180429.json
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "DB1D3D9F2F76F518CAEEA2CC855996CCD87FC4C8FDD3A37345CEF2980674D8F3",
|
||||
"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"
|
||||
}
|
||||
33
test/accounts/debug_changeset_test.exs
Normal file
33
test/accounts/debug_changeset_test.exs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
defmodule Mv.Accounts.DebugChangesetTest do
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Membership
|
||||
|
||||
test "debug: what's in the changeset when linking with same email" do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Emma",
|
||||
last_name: "Davis",
|
||||
email: "emma@example.com"
|
||||
})
|
||||
|
||||
IO.puts("\n=== MEMBER CREATED ===")
|
||||
IO.puts("Member ID: #{member.id}")
|
||||
IO.puts("Member Email: #{member.email}")
|
||||
|
||||
# Try to create user with same email and link
|
||||
IO.puts("\n=== ATTEMPTING TO CREATE USER WITH LINK ===")
|
||||
|
||||
# Let's intercept the validation to see what's in the changeset
|
||||
result =
|
||||
Accounts.create_user(%{
|
||||
email: "emma@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
IO.puts("\n=== RESULT ===")
|
||||
IO.inspect(result, label: "Result")
|
||||
end
|
||||
end
|
||||
169
test/accounts/user_member_linking_email_test.exs
Normal file
169
test/accounts/user_member_linking_email_test.exs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
defmodule Mv.Accounts.UserMemberLinkingEmailTest do
|
||||
@moduledoc """
|
||||
Tests email validation during user-member linking.
|
||||
Implements rules from docs/email-sync.md.
|
||||
Tests for Issue #168, specifically Problem #4: Email validation bug.
|
||||
"""
|
||||
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Membership
|
||||
|
||||
describe "link with same email" do
|
||||
test "succeeds when user.email == member.email" do
|
||||
# Create member with specific email
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Alice",
|
||||
last_name: "Johnson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|
||||
# Create user with same email and link to member
|
||||
result =
|
||||
Accounts.create_user(%{
|
||||
email: "alice@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
# Should succeed without errors
|
||||
assert {:ok, user} = result
|
||||
assert to_string(user.email) == "alice@example.com"
|
||||
|
||||
# Reload to verify link
|
||||
user = Ash.load!(user, [:member], domain: Mv.Accounts)
|
||||
assert user.member.id == member.id
|
||||
assert user.member.email == "alice@example.com"
|
||||
end
|
||||
|
||||
test "no validation error triggered when updating linked pair with same email" do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Bob",
|
||||
last_name: "Smith",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|
||||
# Create user and link
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
email: "bob@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
# Update user (should not trigger email validation error)
|
||||
result = Accounts.update_user(user, %{email: "bob@example.com"})
|
||||
|
||||
assert {:ok, updated_user} = result
|
||||
assert to_string(updated_user.email) == "bob@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "link with different emails" do
|
||||
test "fails if member.email is used by a DIFFERENT linked user" do
|
||||
# Create first user and link to a different member
|
||||
{:ok, other_member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Other",
|
||||
last_name: "Member",
|
||||
email: "other@example.com"
|
||||
})
|
||||
|
||||
{:ok, _user1} =
|
||||
Accounts.create_user(%{
|
||||
email: "user1@example.com",
|
||||
member: %{id: other_member.id}
|
||||
})
|
||||
|
||||
# Reload to ensure email sync happened
|
||||
_other_member = Ash.reload!(other_member)
|
||||
|
||||
# Create a NEW member with different email
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Charlie",
|
||||
last_name: "Brown",
|
||||
email: "charlie@example.com"
|
||||
})
|
||||
|
||||
# Try to create user2 with email that matches the linked other_member
|
||||
result =
|
||||
Accounts.create_user(%{
|
||||
email: "user1@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
# Should fail because user1@example.com is already used by other_member (which is linked to user1)
|
||||
assert {:error, _error} = result
|
||||
end
|
||||
|
||||
test "succeeds for unique emails" do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "David",
|
||||
last_name: "Wilson",
|
||||
email: "david@example.com"
|
||||
})
|
||||
|
||||
# Create user with different but unique email
|
||||
result =
|
||||
Accounts.create_user(%{
|
||||
email: "user@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
# Should succeed
|
||||
assert {:ok, user} = result
|
||||
|
||||
# Email sync should update member's email to match user's
|
||||
user = Ash.load!(user, [:member], domain: Mv.Accounts)
|
||||
assert user.member.email == "user@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "unlinking and relinking with same email works (Problem #4)" do
|
||||
# This is the exact scenario from Problem #4:
|
||||
# 1. Link user and member (both have same email)
|
||||
# 2. Unlink them (member keeps the email)
|
||||
# 3. Try to relink (validation should NOT fail)
|
||||
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Emma",
|
||||
last_name: "Davis",
|
||||
email: "emma@example.com"
|
||||
})
|
||||
|
||||
# Create user and link
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
email: "emma@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
# Verify they are linked
|
||||
user = Ash.load!(user, [:member], domain: Mv.Accounts)
|
||||
assert user.member.id == member.id
|
||||
assert user.member.email == "emma@example.com"
|
||||
|
||||
# Unlink
|
||||
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
|
||||
assert is_nil(unlinked_user.member_id)
|
||||
|
||||
# Member still has the email after unlink
|
||||
member = Ash.reload!(member)
|
||||
assert member.email == "emma@example.com"
|
||||
|
||||
# Relink (should work - this is Problem #4)
|
||||
result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}})
|
||||
|
||||
assert {:ok, relinked_user} = result
|
||||
assert relinked_user.member_id == member.id
|
||||
end
|
||||
end
|
||||
end
|
||||
130
test/accounts/user_member_linking_test.exs
Normal file
130
test/accounts/user_member_linking_test.exs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
defmodule Mv.Accounts.UserMemberLinkingTest do
|
||||
@moduledoc """
|
||||
Integration tests for User-Member linking functionality.
|
||||
|
||||
Tests the complete workflow of linking and unlinking members to users,
|
||||
including email synchronization and validation rules.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
alias Mv.Accounts
|
||||
alias Mv.Membership
|
||||
|
||||
describe "User-Member Linking with Email Sync" do
|
||||
test "link user to member with different email syncs member email" do
|
||||
# Create user with one email
|
||||
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
|
||||
|
||||
# Create member with different email
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "member@example.com"
|
||||
})
|
||||
|
||||
# Link user to member
|
||||
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}})
|
||||
|
||||
# Verify link exists
|
||||
user_with_member = Ash.get!(Mv.Accounts.User, updated_user.id, load: [:member])
|
||||
assert user_with_member.member.id == member.id
|
||||
|
||||
# Verify member email was synced to match user email
|
||||
synced_member = Ash.get!(Mv.Membership.Member, member.id)
|
||||
assert synced_member.email == "user@example.com"
|
||||
end
|
||||
|
||||
test "unlink member from user sets member to nil" do
|
||||
# Create and link user and member
|
||||
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane@example.com"
|
||||
})
|
||||
|
||||
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
|
||||
|
||||
# Verify link exists
|
||||
user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, load: [:member])
|
||||
assert user_with_member.member.id == member.id
|
||||
|
||||
# Unlink by setting member to nil
|
||||
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil})
|
||||
|
||||
# Verify link is removed
|
||||
user_without_member = Ash.get!(Mv.Accounts.User, unlinked_user.id, load: [:member])
|
||||
assert is_nil(user_without_member.member)
|
||||
|
||||
# Verify member still exists independently
|
||||
member_still_exists = Ash.get!(Mv.Membership.Member, member.id)
|
||||
assert member_still_exists.id == member.id
|
||||
end
|
||||
|
||||
test "cannot link member already linked to another user" do
|
||||
# Create first user and link to member
|
||||
{:ok, user1} = Accounts.create_user(%{email: "user1@example.com"})
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Bob",
|
||||
last_name: "Wilson",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|
||||
{:ok, _linked_user1} = Accounts.update_user(user1, %{member: %{id: member.id}})
|
||||
|
||||
# Create second user and try to link to same member
|
||||
{:ok, user2} = Accounts.create_user(%{email: "user2@example.com"})
|
||||
|
||||
# Should fail because member is already linked
|
||||
assert {:error, %Ash.Error.Invalid{}} =
|
||||
Accounts.update_user(user2, %{member: %{id: member.id}})
|
||||
end
|
||||
|
||||
test "cannot change member link directly, must unlink first" do
|
||||
# Create user and link to first member
|
||||
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
|
||||
|
||||
{:ok, member1} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Alice",
|
||||
last_name: "Johnson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|
||||
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}})
|
||||
|
||||
# Create second member
|
||||
{:ok, member2} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Charlie",
|
||||
last_name: "Brown",
|
||||
email: "charlie@example.com"
|
||||
})
|
||||
|
||||
# Try to directly change member link (should fail)
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Accounts.update_user(linked_user, %{member: %{id: member2.id}})
|
||||
|
||||
# Verify error message mentions "Remove existing member first"
|
||||
error_messages = Enum.map(errors, & &1.message)
|
||||
assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first"))
|
||||
|
||||
# Two-step process: first unlink, then link new member
|
||||
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil})
|
||||
|
||||
# After unlinking, member1 still has the user's email
|
||||
# Change member1's email to avoid conflict when relinking to member2
|
||||
{:ok, _} = Membership.update_member(member1, %{email: "alice_changed@example.com"})
|
||||
|
||||
{:ok, relinked_user} = Accounts.update_user(unlinked_user, %{member: %{id: member2.id}})
|
||||
|
||||
# Verify new link is established
|
||||
user_with_new_member = Ash.get!(Mv.Accounts.User, relinked_user.id, load: [:member])
|
||||
assert user_with_new_member.member.id == member2.id
|
||||
end
|
||||
end
|
||||
end
|
||||
397
test/membership/custom_field_slug_test.exs
Normal file
397
test/membership/custom_field_slug_test.exs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
defmodule Mv.Membership.CustomFieldSlugTest do
|
||||
@moduledoc """
|
||||
Tests for automatic slug generation on CustomField resource.
|
||||
|
||||
This test suite verifies:
|
||||
1. Slugs are automatically generated from the name attribute
|
||||
2. Slugs are unique (cannot have duplicates)
|
||||
3. Slugs are immutable (don't change when name changes)
|
||||
4. Slugs handle various edge cases (unicode, special chars, etc.)
|
||||
5. Slugs can be used for lookups
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
describe "automatic slug generation on create" do
|
||||
test "generates slug from name with simple ASCII text" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Mobile Phone",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "mobile-phone"
|
||||
end
|
||||
|
||||
test "generates slug from name with German umlauts" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Café Müller",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "cafe-muller"
|
||||
end
|
||||
|
||||
test "generates slug with lowercase conversion" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "TEST NAME",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "generates slug by removing special characters" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "E-Mail & Address!",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "e-mail-address"
|
||||
end
|
||||
|
||||
test "generates slug by replacing multiple spaces with single hyphen" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Multiple Spaces",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "multiple-spaces"
|
||||
end
|
||||
|
||||
test "trims leading and trailing hyphens" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "-Test-",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "test"
|
||||
end
|
||||
|
||||
test "handles unicode characters properly (ß becomes ss)" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Straße",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "strasse"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug uniqueness" do
|
||||
test "prevents creating custom field with duplicate slug" do
|
||||
# Create first custom field
|
||||
{:ok, _custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Attempt to create second custom field with same slug (different case in name)
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test",
|
||||
value_type: :integer
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert Exception.message(error) =~ "has already been taken"
|
||||
end
|
||||
|
||||
test "allows custom fields with different slugs" do
|
||||
{:ok, custom_field1} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test One",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, custom_field2} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Two",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field1.slug == "test-one"
|
||||
assert custom_field2.slug == "test-two"
|
||||
assert custom_field1.slug != custom_field2.slug
|
||||
end
|
||||
|
||||
test "prevents duplicate slugs when names differ only in special characters" do
|
||||
{:ok, custom_field1} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test!!!",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field1.slug == "test"
|
||||
|
||||
# Second custom field with name that generates the same slug should fail
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test???",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Should fail with uniqueness constraint error
|
||||
assert Exception.message(error) =~ "has already been taken"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug immutability" do
|
||||
test "slug cannot be manually set on create" do
|
||||
# Attempting to set slug manually should fail because slug is not writable
|
||||
result =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string,
|
||||
slug: "custom-slug"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Should fail because slug is not an accepted input
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
assert Exception.message(elem(result, 1)) =~ "No such input"
|
||||
end
|
||||
|
||||
test "slug does not change when name is updated" do
|
||||
# Create custom field
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Original Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
original_slug = custom_field.slug
|
||||
assert original_slug == "original-name"
|
||||
|
||||
# Update the name
|
||||
{:ok, updated_custom_field} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{
|
||||
name: "New Different Name"
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Slug should remain unchanged
|
||||
assert updated_custom_field.slug == original_slug
|
||||
assert updated_custom_field.slug == "original-name"
|
||||
assert updated_custom_field.name == "New Different Name"
|
||||
end
|
||||
|
||||
test "slug cannot be manually updated" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
original_slug = custom_field.slug
|
||||
assert original_slug == "test"
|
||||
|
||||
# Attempt to manually update slug should fail because slug is not writable
|
||||
result =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{
|
||||
slug: "new-slug"
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Should fail because slug is not an accepted input
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
assert Exception.message(elem(result, 1)) =~ "No such input"
|
||||
|
||||
# Reload to verify slug hasn't changed
|
||||
reloaded = Ash.get!(CustomField, custom_field.id)
|
||||
assert reloaded.slug == "test"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug edge cases" do
|
||||
test "handles very long names by truncating slug" do
|
||||
# Create a name at the maximum length (100 chars)
|
||||
long_name = String.duplicate("abcdefghij", 10)
|
||||
# 100 characters exactly
|
||||
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: long_name,
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Slug should be truncated to maximum 100 characters
|
||||
assert String.length(custom_field.slug) <= 100
|
||||
# Should be the full slugified version since name is exactly 100 chars
|
||||
assert custom_field.slug == long_name
|
||||
end
|
||||
|
||||
test "rejects name with only special characters" do
|
||||
# When name contains only special characters, slug would be empty
|
||||
# This should fail validation
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "!!!",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Should fail because slug would be empty
|
||||
error_message = Exception.message(error)
|
||||
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
|
||||
end
|
||||
|
||||
test "handles mixed special characters and text" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test@#$%Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# slugify keeps the hyphen between words
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "handles numbers in name" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Field 123 Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "field-123-test"
|
||||
end
|
||||
|
||||
test "handles consecutive hyphens in name" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test---Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Should reduce multiple hyphens to single hyphen
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "handles name with dots and underscores" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test.field_name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Dots and underscores should be handled (either kept or converted)
|
||||
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug in queries and responses" do
|
||||
test "slug is included in struct after create" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Slug should be present in the struct
|
||||
assert Map.has_key?(custom_field, :slug)
|
||||
assert custom_field.slug != nil
|
||||
end
|
||||
|
||||
test "can load custom field and slug is present" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Load it back
|
||||
loaded_custom_field = Ash.get!(CustomField, custom_field.id)
|
||||
|
||||
assert loaded_custom_field.slug == "test"
|
||||
end
|
||||
|
||||
test "slug is returned in list queries" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
custom_fields = Ash.read!(CustomField)
|
||||
|
||||
found = Enum.find(custom_fields, &(&1.id == custom_field.id))
|
||||
assert found.slug == "test"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug-based lookup (future feature)" do
|
||||
@tag :skip
|
||||
test "can find custom field by slug" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Field",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# This test is for future implementation
|
||||
# We might add a custom action like :by_slug
|
||||
found = Ash.get!(CustomField, custom_field.slug, load: [:slug])
|
||||
assert found.id == custom_field.id
|
||||
end
|
||||
end
|
||||
end
|
||||
222
test/membership/member_available_for_linking_test.exs
Normal file
222
test/membership/member_available_for_linking_test.exs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||
@moduledoc """
|
||||
Tests for the Member.available_for_linking action.
|
||||
|
||||
This action returns members that can be linked to a user account:
|
||||
- Only members without existing user links (user_id == nil)
|
||||
- Limited to 10 results
|
||||
- Special email-match logic: if user_email matches member email, only return that member
|
||||
- Optional search query filtering by name and email
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
alias Mv.Membership
|
||||
|
||||
describe "available_for_linking/2" do
|
||||
setup do
|
||||
# Create 5 unlinked members with distinct names
|
||||
{:ok, member1} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|
||||
{:ok, member2} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Bob",
|
||||
last_name: "Williams",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|
||||
{:ok, member3} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Charlie",
|
||||
last_name: "Davis",
|
||||
email: "charlie@example.com"
|
||||
})
|
||||
|
||||
{:ok, member4} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Diana",
|
||||
last_name: "Martinez",
|
||||
email: "diana@example.com"
|
||||
})
|
||||
|
||||
{:ok, member5} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Emma",
|
||||
last_name: "Taylor",
|
||||
email: "emma@example.com"
|
||||
})
|
||||
|
||||
unlinked_members = [member1, member2, member3, member4, member5]
|
||||
|
||||
# Create 2 linked members (with users)
|
||||
{:ok, user1} = Mv.Accounts.create_user(%{email: "user1@example.com"})
|
||||
|
||||
{:ok, linked_member1} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Linked",
|
||||
last_name: "Member1",
|
||||
email: "linked1@example.com",
|
||||
user: %{id: user1.id}
|
||||
})
|
||||
|
||||
{:ok, user2} = Mv.Accounts.create_user(%{email: "user2@example.com"})
|
||||
|
||||
{:ok, linked_member2} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Linked",
|
||||
last_name: "Member2",
|
||||
email: "linked2@example.com",
|
||||
user: %{id: user2.id}
|
||||
})
|
||||
|
||||
%{
|
||||
unlinked_members: unlinked_members,
|
||||
linked_members: [linked_member1, linked_member2]
|
||||
}
|
||||
end
|
||||
|
||||
test "returns only unlinked members and limits to 10", %{
|
||||
unlinked_members: unlinked_members,
|
||||
linked_members: _linked_members
|
||||
} do
|
||||
# Call the action without any arguments
|
||||
members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{})
|
||||
|> Ash.read!()
|
||||
|
||||
# Should return only the 5 unlinked members, not the 2 linked ones
|
||||
assert length(members) == 5
|
||||
|
||||
returned_ids = Enum.map(members, & &1.id) |> MapSet.new()
|
||||
expected_ids = Enum.map(unlinked_members, & &1.id) |> MapSet.new()
|
||||
|
||||
assert MapSet.equal?(returned_ids, expected_ids)
|
||||
|
||||
# Verify none of the returned members have a user_id
|
||||
Enum.each(members, fn member ->
|
||||
member_with_user = Ash.get!(Mv.Membership.Member, member.id, load: [:user])
|
||||
assert is_nil(member_with_user.user)
|
||||
end)
|
||||
end
|
||||
|
||||
test "limits results to 10 members even when more exist" do
|
||||
# Create 15 additional unlinked members (total 20 unlinked)
|
||||
for i <- 6..20 do
|
||||
Membership.create_member(%{
|
||||
first_name: "Extra#{i}",
|
||||
last_name: "Member#{i}",
|
||||
email: "extra#{i}@example.com"
|
||||
})
|
||||
end
|
||||
|
||||
members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{})
|
||||
|> Ash.read!()
|
||||
|
||||
# Should be limited to 10
|
||||
assert length(members) == 10
|
||||
end
|
||||
|
||||
test "email match: returns only member with matching email when exists", %{
|
||||
unlinked_members: unlinked_members
|
||||
} do
|
||||
# Get one of the unlinked members' email
|
||||
target_member = List.first(unlinked_members)
|
||||
user_email = target_member.email
|
||||
|
||||
raw_members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{user_email: user_email})
|
||||
|> Ash.read!()
|
||||
|
||||
# Apply email match filtering (sorted results come from query)
|
||||
# When user_email matches, only that member should be returned
|
||||
members = Mv.Membership.Member.filter_by_email_match(raw_members, user_email)
|
||||
|
||||
# Should return only the member with matching email
|
||||
assert length(members) == 1
|
||||
assert List.first(members).id == target_member.id
|
||||
assert List.first(members).email == user_email
|
||||
end
|
||||
|
||||
test "email match: returns all unlinked members when no email match" do
|
||||
# Use an email that doesn't match any member
|
||||
non_matching_email = "nonexistent@example.com"
|
||||
|
||||
raw_members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email})
|
||||
|> Ash.read!()
|
||||
|
||||
# Apply email match filtering
|
||||
members = Mv.Membership.Member.filter_by_email_match(raw_members, non_matching_email)
|
||||
|
||||
# Should return all 5 unlinked members since no match
|
||||
assert length(members) == 5
|
||||
end
|
||||
|
||||
test "search query: filters by first_name, last_name, and email", %{
|
||||
unlinked_members: _unlinked_members
|
||||
} do
|
||||
# Search by first name
|
||||
members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"})
|
||||
|> Ash.read!()
|
||||
|
||||
assert length(members) == 1
|
||||
assert List.first(members).first_name == "Alice"
|
||||
|
||||
# Search by last name
|
||||
members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"})
|
||||
|> Ash.read!()
|
||||
|
||||
assert length(members) == 1
|
||||
assert List.first(members).last_name == "Williams"
|
||||
|
||||
# Search by email
|
||||
members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"})
|
||||
|> Ash.read!()
|
||||
|
||||
assert length(members) == 1
|
||||
assert List.first(members).email == "charlie@example.com"
|
||||
|
||||
# Search returns empty when no matches
|
||||
members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"})
|
||||
|> Ash.read!()
|
||||
|
||||
assert Enum.empty?(members)
|
||||
end
|
||||
|
||||
test "search query takes precedence over email match", %{unlinked_members: unlinked_members} do
|
||||
target_member = List.first(unlinked_members)
|
||||
|
||||
# Pass both email match and search query that would match different members
|
||||
raw_members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{
|
||||
user_email: target_member.email,
|
||||
search_query: "Bob"
|
||||
})
|
||||
|> Ash.read!()
|
||||
|
||||
# Search query takes precedence, should match "Bob" in the first name
|
||||
# user_email is used for POST-filtering only, not in the query
|
||||
assert length(raw_members) == 1
|
||||
# Should find the member with "Bob" first name, not target_member (Alice)
|
||||
assert List.first(raw_members).first_name == "Bob"
|
||||
refute List.first(raw_members).id == target_member.id
|
||||
end
|
||||
end
|
||||
end
|
||||
158
test/membership/member_fuzzy_search_linking_test.exs
Normal file
158
test/membership/member_fuzzy_search_linking_test.exs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
|
||||
@moduledoc """
|
||||
Tests fuzzy search in Member.available_for_linking action.
|
||||
Verifies PostgreSQL trigram matching for member search.
|
||||
"""
|
||||
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Membership
|
||||
|
||||
describe "available_for_linking with fuzzy search" do
|
||||
test "finds member despite typo" do
|
||||
# Create member with specific name
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Jonathan",
|
||||
last_name: "Smith",
|
||||
email: "jonathan@example.com"
|
||||
})
|
||||
|
||||
# Search with typo
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{
|
||||
user_email: nil,
|
||||
search_query: "Jonatan"
|
||||
})
|
||||
|
||||
{:ok, members} = Ash.read(query, domain: Mv.Membership)
|
||||
|
||||
# Should find Jonathan despite typo
|
||||
assert length(members) == 1
|
||||
assert hd(members).id == member.id
|
||||
end
|
||||
|
||||
test "finds member with partial match" do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Alexander",
|
||||
last_name: "Williams",
|
||||
email: "alex@example.com"
|
||||
})
|
||||
|
||||
# Search with partial
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{
|
||||
user_email: nil,
|
||||
search_query: "Alex"
|
||||
})
|
||||
|
||||
{:ok, members} = Ash.read(query, domain: Mv.Membership)
|
||||
|
||||
# Should find Alexander
|
||||
assert length(members) == 1
|
||||
assert hd(members).id == member.id
|
||||
end
|
||||
|
||||
test "email match overrides fuzzy search" do
|
||||
# Create two members
|
||||
{:ok, member1} =
|
||||
Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com"
|
||||
})
|
||||
|
||||
{:ok, _member2} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane@example.com"
|
||||
})
|
||||
|
||||
# Search with user_email that matches member1, but search_query that would match member2
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{
|
||||
user_email: "john@example.com",
|
||||
search_query: "Jane"
|
||||
})
|
||||
|
||||
{:ok, members} = Ash.read(query, domain: Mv.Membership)
|
||||
|
||||
# Apply email filter
|
||||
filtered_members = Mv.Membership.Member.filter_by_email_match(members, "john@example.com")
|
||||
|
||||
# Should only return member1 (email match takes precedence)
|
||||
assert length(filtered_members) == 1
|
||||
assert hd(filtered_members).id == member1.id
|
||||
end
|
||||
|
||||
test "limits to 10 results" do
|
||||
# Create 15 members with similar names
|
||||
for i <- 1..15 do
|
||||
Membership.create_member(%{
|
||||
first_name: "Test#{i}",
|
||||
last_name: "Member",
|
||||
email: "test#{i}@example.com"
|
||||
})
|
||||
end
|
||||
|
||||
# Search for "Test"
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{
|
||||
user_email: nil,
|
||||
search_query: "Test"
|
||||
})
|
||||
|
||||
{:ok, members} = Ash.read(query, domain: Mv.Membership)
|
||||
|
||||
# Should return max 10 members
|
||||
assert length(members) == 10
|
||||
end
|
||||
|
||||
test "excludes linked members" do
|
||||
# Create member and link to user
|
||||
{:ok, member1} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Linked",
|
||||
last_name: "Member",
|
||||
email: "linked@example.com"
|
||||
})
|
||||
|
||||
{:ok, _user} =
|
||||
Accounts.create_user(%{
|
||||
email: "user@example.com",
|
||||
member: %{id: member1.id}
|
||||
})
|
||||
|
||||
# Create unlinked member
|
||||
{:ok, member2} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Unlinked",
|
||||
last_name: "Member",
|
||||
email: "unlinked@example.com"
|
||||
})
|
||||
|
||||
# Search for "Member"
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{
|
||||
user_email: nil,
|
||||
search_query: "Member"
|
||||
})
|
||||
|
||||
{:ok, members} = Ash.read(query, domain: Mv.Membership)
|
||||
|
||||
# Should only return unlinked member
|
||||
member_ids = Enum.map(members, & &1.id)
|
||||
refute member1.id in member_ids
|
||||
assert member2.id in member_ids
|
||||
end
|
||||
end
|
||||
end
|
||||
48
test/mv_web/user_live/form_debug2_test.exs
Normal file
48
test/mv_web/user_live/form_debug2_test.exs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
defmodule MvWeb.UserLive.FormDebug2Test do
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
describe "direct ash query test" do
|
||||
test "check if available_for_linking works in LiveView context" do
|
||||
# Create an unlinked member
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane@example.com"
|
||||
})
|
||||
|
||||
IO.puts("\n=== Created member: #{inspect(member.id)} ===")
|
||||
|
||||
# Try the same query as in the LiveView
|
||||
user_email_str = "user@example.com"
|
||||
search_query_str = nil
|
||||
|
||||
IO.puts("\n=== Calling Ash.read with domain: Mv.Membership ===")
|
||||
|
||||
result =
|
||||
Ash.read(Mv.Membership.Member,
|
||||
domain: Mv.Membership,
|
||||
action: :available_for_linking,
|
||||
arguments: %{user_email: user_email_str, search_query: search_query_str}
|
||||
)
|
||||
|
||||
IO.puts("Result: #{inspect(result)}")
|
||||
|
||||
case result do
|
||||
{:ok, members} ->
|
||||
IO.puts("\n✓ Query succeeded, found #{length(members)} members")
|
||||
|
||||
Enum.each(members, fn m ->
|
||||
IO.puts(" - #{m.first_name} #{m.last_name} (#{m.email})")
|
||||
end)
|
||||
|
||||
# Apply filter
|
||||
filtered = Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||
IO.puts("\n✓ After filter_by_email_match: #{length(filtered)} members")
|
||||
|
||||
{:error, error} ->
|
||||
IO.puts("\n✗ Query failed: #{inspect(error)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
52
test/mv_web/user_live/form_debug_test.exs
Normal file
52
test/mv_web/user_live/form_debug_test.exs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
defmodule MvWeb.UserLive.FormDebugTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
# Helper to setup authenticated connection and live view
|
||||
defp setup_live_view(conn, path) do
|
||||
conn = conn_with_oidc_user(conn, %{email: "admin@example.com"})
|
||||
live(conn, path)
|
||||
end
|
||||
|
||||
describe "debug member loading" do
|
||||
test "check if members are loaded on mount", %{conn: conn} do
|
||||
# Create an unlinked member
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane@example.com"
|
||||
})
|
||||
|
||||
# Create user without member
|
||||
user = create_test_user(%{email: "user@example.com"})
|
||||
|
||||
# Mount the form
|
||||
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
# Debug: Check what's in the HTML
|
||||
IO.puts("\n=== HTML OUTPUT ===")
|
||||
IO.puts(html)
|
||||
IO.puts("\n=== END HTML ===")
|
||||
|
||||
# Check socket assigns
|
||||
IO.puts("\n=== SOCKET ASSIGNS ===")
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
IO.puts("available_members: #{inspect(assigns[:available_members])}")
|
||||
IO.puts("show_member_dropdown: #{inspect(assigns[:show_member_dropdown])}")
|
||||
IO.puts("member_search_query: #{inspect(assigns[:member_search_query])}")
|
||||
IO.puts("user.member: #{inspect(assigns[:user].member)}")
|
||||
IO.puts("\n=== END ASSIGNS ===")
|
||||
|
||||
# Try to find the dropdown
|
||||
assert has_element?(view, "input[name='member_search']")
|
||||
|
||||
# Check if member is in the dropdown
|
||||
if has_element?(view, "div[data-member-id='#{member.id}']") do
|
||||
IO.puts("\n✓ Member found in dropdown")
|
||||
else
|
||||
IO.puts("\n✗ Member NOT found in dropdown")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
433
test/mv_web/user_live/form_member_linking_ui_test.exs
Normal file
433
test/mv_web/user_live/form_member_linking_ui_test.exs
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||
@moduledoc """
|
||||
UI tests for member linking in UserLive.Form.
|
||||
Tests dropdown behavior, fuzzy search, selection, and unlink workflow.
|
||||
Related to Issue #168.
|
||||
"""
|
||||
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Membership
|
||||
|
||||
# Helper to setup authenticated connection for admin
|
||||
defp setup_admin_conn(conn) do
|
||||
conn_with_oidc_user(conn, %{email: "admin@example.com"})
|
||||
end
|
||||
|
||||
describe "dropdown visibility" do
|
||||
test "dropdown hidden on mount", %{conn: conn} do
|
||||
conn = setup_admin_conn(conn)
|
||||
html = conn |> live(~p"/users/new") |> render()
|
||||
|
||||
# Dropdown should not be visible initially
|
||||
refute html =~ ~r/role="listbox"/
|
||||
end
|
||||
|
||||
test "dropdown shows after focus event", %{conn: conn} do
|
||||
conn = setup_admin_conn(conn)
|
||||
# Create unlinked members
|
||||
create_unlinked_members(3)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Focus the member search input
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_focus()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Dropdown should now be visible
|
||||
assert html =~ ~r/role="listbox"/
|
||||
end
|
||||
|
||||
test "dropdown shows top 10 unlinked members on focus", %{conn: conn} do
|
||||
# Create 15 unlinked members
|
||||
members = create_unlinked_members(15)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Focus the member search input
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_focus()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Should show only 10 members
|
||||
shown_members = Enum.take(members, 10)
|
||||
hidden_members = Enum.drop(members, 10)
|
||||
|
||||
for member <- shown_members do
|
||||
assert html =~ member.first_name
|
||||
end
|
||||
|
||||
for member <- hidden_members do
|
||||
refute html =~ member.first_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "fuzzy search" do
|
||||
test "finds member with exact name", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Jonathan",
|
||||
last_name: "Smith",
|
||||
email: "jonathan.smith@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Type exact name
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_change(%{"member_search_query" => "Jonathan"})
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "Jonathan"
|
||||
assert html =~ "Smith"
|
||||
end
|
||||
|
||||
test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Jonathan",
|
||||
last_name: "Smith",
|
||||
email: "jonathan.smith@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Type with typo
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_change(%{"member_search_query" => "Jon"})
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Fuzzy search should find Jonathan
|
||||
assert html =~ "Jonathan"
|
||||
assert html =~ "Smith"
|
||||
end
|
||||
|
||||
test "finds member with partial substring", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Alexander",
|
||||
last_name: "Williams",
|
||||
email: "alex@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Type partial
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_change(%{"member_search_query" => "lex"})
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "Alexander"
|
||||
end
|
||||
|
||||
test "returns empty for no matches", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Type something that doesn't match
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_change(%{"member_search_query" => "zzzzzzz"})
|
||||
|
||||
html = render(view)
|
||||
|
||||
refute html =~ "John"
|
||||
end
|
||||
end
|
||||
|
||||
describe "member selection" do
|
||||
test "input field shows selected member name", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Alice",
|
||||
last_name: "Johnson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Focus and search
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_focus()
|
||||
|
||||
# Select member
|
||||
view
|
||||
|> element("[data-member-id='#{member.id}']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Input field should show member name
|
||||
assert html =~ "Alice Johnson"
|
||||
end
|
||||
|
||||
test "confirmation box appears", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Bob",
|
||||
last_name: "Williams",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Focus input
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_focus()
|
||||
|
||||
# Select member
|
||||
view
|
||||
|> element("[data-member-id='#{member.id}']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Confirmation box should appear
|
||||
assert html =~ "Selected"
|
||||
assert html =~ "Bob Williams"
|
||||
assert html =~ "Save to confirm linking"
|
||||
end
|
||||
|
||||
test "hidden input stores member ID", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Charlie",
|
||||
last_name: "Brown",
|
||||
email: "charlie@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Focus input
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_focus()
|
||||
|
||||
# Select member
|
||||
view
|
||||
|> element("[data-member-id='#{member.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Check socket assigns (member ID should be stored)
|
||||
assert view |> element("#user-form") |> has_element?()
|
||||
end
|
||||
end
|
||||
|
||||
describe "email handling" do
|
||||
test "links user and member with identical email successfully", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "David",
|
||||
last_name: "Miller",
|
||||
email: "david@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Fill user form with same email
|
||||
view
|
||||
|> form("#user-form", user: %{email: "david@example.com"})
|
||||
|> render_change()
|
||||
|
||||
# Focus input
|
||||
view
|
||||
|> element("#member-search-input")
|
||||
|> render_focus()
|
||||
|
||||
# Select member
|
||||
view
|
||||
|> element("[data-member-id='#{member.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Submit form
|
||||
view
|
||||
|> form("#user-form", user: %{email: "david@example.com"})
|
||||
|> render_submit()
|
||||
|
||||
# Should succeed without errors
|
||||
assert_redirected(view, ~p"/users")
|
||||
end
|
||||
|
||||
test "shows info when member has same email", %{conn: conn} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Emma",
|
||||
last_name: "Davis",
|
||||
email: "emma@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||
|
||||
# Fill user form with same email
|
||||
view
|
||||
|> form("#user-form", user: %{email: "emma@example.com"})
|
||||
|> render_change()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Should show info message about email conflict
|
||||
assert html =~ "A member with this email already exists"
|
||||
end
|
||||
end
|
||||
|
||||
describe "unlink workflow" do
|
||||
test "unlink hides dropdown", %{conn: conn} do
|
||||
# Create user with linked member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Frank",
|
||||
last_name: "Wilson",
|
||||
email: "frank@example.com"
|
||||
})
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
email: "frank@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||
|
||||
# Click unlink button
|
||||
view
|
||||
|> element("button[phx-click='unlink_member']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Dropdown should not be visible
|
||||
refute html =~ ~r/role="listbox"/
|
||||
end
|
||||
|
||||
test "unlink shows warning", %{conn: conn} do
|
||||
# Create user with linked member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Grace",
|
||||
last_name: "Taylor",
|
||||
email: "grace@example.com"
|
||||
})
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
email: "grace@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||
|
||||
# Click unlink button
|
||||
view
|
||||
|> element("button[phx-click='unlink_member']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Should show warning
|
||||
assert html =~ "Unlinking scheduled"
|
||||
assert html =~ "Cannot select new member until saved"
|
||||
end
|
||||
|
||||
test "unlink disables input", %{conn: conn} do
|
||||
# Create user with linked member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Henry",
|
||||
last_name: "Anderson",
|
||||
email: "henry@example.com"
|
||||
})
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
email: "henry@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||
|
||||
# Click unlink button
|
||||
view
|
||||
|> element("button[phx-click='unlink_member']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Input should be disabled
|
||||
assert html =~ ~r/disabled/
|
||||
end
|
||||
|
||||
test "save re-enables member selection", %{conn: conn} do
|
||||
# Create user with linked member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Isabel",
|
||||
last_name: "Martinez",
|
||||
email: "isabel@example.com"
|
||||
})
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.create_user(%{
|
||||
email: "isabel@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||
|
||||
# Click unlink button
|
||||
view
|
||||
|> element("button[phx-click='unlink_member']")
|
||||
|> render_click()
|
||||
|
||||
# Submit form
|
||||
view
|
||||
|> form("#user-form")
|
||||
|> render_submit()
|
||||
|
||||
# Navigate back to edit
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Should now show member selection input (not disabled)
|
||||
assert html =~ "member-search-input"
|
||||
refute html =~ "Unlinking scheduled"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
defp create_unlinked_members(count) do
|
||||
for i <- 1..count do
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "FirstName#{i}",
|
||||
last_name: "LastName#{i}",
|
||||
email: "member#{i}@example.com"
|
||||
})
|
||||
|
||||
member
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -281,4 +281,101 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
assert edit_html =~ "Change Password"
|
||||
end
|
||||
end
|
||||
|
||||
describe "member linking - display" do
|
||||
test "shows linked member with unlink button when user has member", %{conn: conn} do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com"
|
||||
})
|
||||
|
||||
# Create user linked to member
|
||||
user = create_test_user(%{email: "user@example.com"})
|
||||
{:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}})
|
||||
|
||||
# Load form
|
||||
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
# Should show linked member section
|
||||
assert html =~ "Linked Member"
|
||||
assert html =~ "John Doe"
|
||||
assert html =~ "user@example.com"
|
||||
assert has_element?(view, "button[phx-click='unlink_member']")
|
||||
assert html =~ "Unlink Member"
|
||||
end
|
||||
|
||||
test "shows member search field when user has no member", %{conn: conn} do
|
||||
user = create_test_user(%{email: "user@example.com"})
|
||||
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
# Should show member search section
|
||||
assert html =~ "Linked Member"
|
||||
assert has_element?(view, "input[phx-change='search_members']")
|
||||
# Should not show unlink button
|
||||
refute has_element?(view, "button[phx-click='unlink_member']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "member linking - workflow" do
|
||||
test "selecting member and saving links member to user", %{conn: conn} do
|
||||
# Create unlinked member
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane@example.com"
|
||||
})
|
||||
|
||||
# Create user without member
|
||||
user = create_test_user(%{email: "user@example.com"})
|
||||
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
# Select member
|
||||
view |> element("div[data-member-id='#{member.id}']") |> render_click()
|
||||
|
||||
# Submit form
|
||||
view
|
||||
|> form("#user-form", user: %{email: "user@example.com"})
|
||||
|> render_submit()
|
||||
|
||||
assert_redirected(view, "/users")
|
||||
|
||||
# Verify member is linked
|
||||
updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member])
|
||||
assert updated_user.member.id == member.id
|
||||
end
|
||||
|
||||
test "unlinking member and saving removes member from user", %{conn: conn} do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Bob",
|
||||
last_name: "Wilson",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|
||||
# Create user linked to member
|
||||
user = create_test_user(%{email: "user@example.com"})
|
||||
{:ok, _} = Mv.Accounts.update_user(user, %{member: %{id: member.id}})
|
||||
|
||||
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
# Click unlink button
|
||||
view |> element("button[phx-click='unlink_member']") |> render_click()
|
||||
|
||||
# Submit form
|
||||
view
|
||||
|> form("#user-form", user: %{email: "user@example.com"})
|
||||
|> render_submit()
|
||||
|
||||
assert_redirected(view, "/users")
|
||||
|
||||
# Verify member is unlinked
|
||||
updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member])
|
||||
assert is_nil(updated_user.member)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -410,4 +410,35 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
assert html =~ long_email
|
||||
end
|
||||
end
|
||||
|
||||
describe "member linking display" do
|
||||
test "displays linked member name in user list", %{conn: conn} do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Alice",
|
||||
last_name: "Johnson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|
||||
# Create user linked to member
|
||||
user = create_test_user(%{email: "user@example.com"})
|
||||
{:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}})
|
||||
|
||||
# Create another user without member
|
||||
_unlinked_user = create_test_user(%{email: "unlinked@example.com"})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
||||
# Should show linked member name
|
||||
assert html =~ "Alice Johnson"
|
||||
# Should show user email
|
||||
assert html =~ "user@example.com"
|
||||
# Should show unlinked user
|
||||
assert html =~ "unlinked@example.com"
|
||||
# Should show "No member linked" or similar for unlinked user
|
||||
assert html =~ "No member linked"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue