Compare commits

...

11 commits

Author SHA1 Message Date
1819a1e2d1
feat: add user to member linking
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-20 15:27:41 +01:00
21ec86839a Merge pull request 'Custom Fields: Add slugs closes #195' (#205) from feature/custom-field-slug into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #205
2025-11-20 14:27:57 +01:00
efb3e1cc37
feat: add translation
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-20 14:25:06 +01:00
c246ca59db
feat: hide slug from user 2025-11-20 14:23:25 +01:00
edf8b2b79e
feat: add custom field slug 2025-11-20 14:23:25 +01:00
bc75a5853a
fix: correction of some english translation
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-20 13:48:05 +01:00
e259c29224 Merge pull request 'roles and permissions architecture and implementation plan closes #151' (#202) from feature/roles-and-permissions-concept into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #202
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-11-18 08:50:30 +01:00
93916a09f9 Merge branch 'main' into feature/roles-and-permissions-concept
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-18 08:49:02 +01:00
a273b54c75 Merge pull request 'Custom Fields: Harden implementation closes #194' (#204) from feature/harden-custom-fields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #204
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-11-17 17:01:30 +01:00
a19026e430
docs: update roles and permissions architecture and implementation plan
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-13 16:17:01 +01:00
1084f67f1f
docs: Add roles and permissions architecture and implementation plan
Complete RBAC system design with permission sets, Ash policies, and UI authorization.
Implementation broken down into 18 issues across 4 sprints with TDD approach.
Includes database schema, caching strategy, and comprehensive test coverage.
2025-11-13 13:43:58 +01:00
36 changed files with 7525 additions and 100 deletions

View file

@ -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))

View file

@ -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"
'''
}

View file

@ -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:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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.

View file

@ -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:

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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,

View file

@ -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">

View file

@ -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

View file

@ -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"},

View file

@ -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"

View file

@ -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 ""

View file

@ -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 ""

View file

@ -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

View 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"
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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