Compare commits
6 commits
dfdf4c980b
...
8e33eea9de
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e33eea9de | |||
| 2027c993b4 | |||
| 24b0faf95a | |||
| f8c2c7bbe3 | |||
| 936ed0ace1 | |||
| 8503c085cb |
40 changed files with 156 additions and 4290 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,19 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- User-Member linking with fuzzy search autocomplete (#168)
|
|
||||||
- PostgreSQL trigram-based member search with typo tolerance
|
|
||||||
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
|
|
||||||
- Bilingual UI (German/English) for member linking workflow
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
|
||||||
- Relationship data extraction from Ash manage_relationship during validation
|
|
||||||
|
|
||||||
|
|
@ -23,42 +23,9 @@ import {LiveSocket} from "phoenix_live_view"
|
||||||
import topbar from "../vendor/topbar"
|
import topbar from "../vendor/topbar"
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
|
|
||||||
// Hooks for LiveView components
|
|
||||||
let Hooks = {}
|
|
||||||
|
|
||||||
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
|
|
||||||
Hooks.ComboBox = {
|
|
||||||
mounted() {
|
|
||||||
this.handleKeyDown = (e) => {
|
|
||||||
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
|
|
||||||
|
|
||||||
if (e.key === "Enter" && isDropdownOpen) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.el.addEventListener("keydown", this.handleKeyDown)
|
|
||||||
},
|
|
||||||
|
|
||||||
destroyed() {
|
|
||||||
this.el.removeEventListener("keydown", this.handleKeyDown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {_csrf_token: csrfToken},
|
params: {_csrf_token: csrfToken}
|
||||||
hooks: Hooks
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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
|
// Show progress bar on live navigation and form submits
|
||||||
|
|
|
||||||
|
|
@ -329,11 +329,6 @@ end
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**PR #208:** *Show custom fields per default in member overview* 🔧
|
|
||||||
- added show_in_overview as attribute to custom fields
|
|
||||||
- show custom fields in member overview per default
|
|
||||||
- can be set to false in the settings for the specific custom field
|
|
||||||
|
|
||||||
## Implementation Decisions
|
## Implementation Decisions
|
||||||
|
|
||||||
### Architecture Patterns
|
### Architecture Patterns
|
||||||
|
|
@ -395,7 +390,6 @@ defmodule Mv.Membership.CustomField do
|
||||||
attribute :value_type, :atom # :string, :integer, :boolean, :date, :email
|
attribute :value_type, :atom # :string, :integer, :boolean, :date, :email
|
||||||
attribute :immutable, :boolean # Can't change after creation
|
attribute :immutable, :boolean # Can't change after creation
|
||||||
attribute :required, :boolean # All members must have this
|
attribute :required, :boolean # All members must have this
|
||||||
attribute :show_in_overview, :boolean # "If true, this custom field will be displayed in the member overview table"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# CustomFieldValue stores values
|
# CustomFieldValue stores values
|
||||||
|
|
@ -1327,210 +1321,6 @@ 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
|
|
||||||
- Keyboard navigation (Arrow keys, Enter, Escape)
|
|
||||||
- Link/unlink members to user accounts
|
|
||||||
- Email synchronization between linked entities
|
|
||||||
- WCAG 2.1 AA compliant (ARIA labels, keyboard accessibility)
|
|
||||||
- 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. Keyboard Navigation: Hybrid Approach**
|
|
||||||
Implemented keyboard accessibility with **mostly Server-Side + minimal Client-Side**:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
# Server-Side: Navigation and Selection (~45 lines)
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
|
||||||
# Focus management on server
|
|
||||||
new_index = min(current + 1, max_index)
|
|
||||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Client-Side: Only preventDefault for Enter in forms (~13 lines)
|
|
||||||
Hooks.ComboBox = {
|
|
||||||
mounted() {
|
|
||||||
this.el.addEventListener("keydown", (e) => {
|
|
||||||
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
|
|
||||||
if (e.key === "Enter" && isDropdownOpen) {
|
|
||||||
e.preventDefault() // Prevent form submission
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rationale:**
|
|
||||||
- Server-Side handles all navigation logic → simpler, testable, follows LiveView best practices
|
|
||||||
- Client-Side only prevents browser default behavior (form submit on Enter)
|
|
||||||
- Latency (~20-50ms) is imperceptible for keyboard events without DB queries
|
|
||||||
- Follows CODE_GUIDELINES "Minimal JavaScript Philosophy"
|
|
||||||
|
|
||||||
**Alternative Considered:** Full Client-Side with JavaScript Hook (~80 lines)
|
|
||||||
- ❌ More complex code
|
|
||||||
- ❌ State synchronization between client/server
|
|
||||||
- ✅ Zero latency (but not noticeable in practice)
|
|
||||||
- **Decision:** Server-Side approach is simpler and sufficient
|
|
||||||
|
|
||||||
**4. 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
|
|
||||||
- ✅ Preventing browser default behaviors (form submit, scroll)
|
|
||||||
|
|
||||||
**When NOT to use JavaScript:**
|
|
||||||
- ❌ Form submissions
|
|
||||||
- ❌ Simple show/hide logic
|
|
||||||
- ❌ Server-side data fetching
|
|
||||||
- ❌ Keyboard navigation logic (can be done server-side efficiently)
|
|
||||||
|
|
||||||
**Pattern:**
|
|
||||||
```elixir
|
|
||||||
socket |> push_event("event-name", %{key: value})
|
|
||||||
```
|
|
||||||
```javascript
|
|
||||||
window.addEventListener("phx:event-name", (e) => { /* handle */ })
|
|
||||||
```
|
|
||||||
|
|
||||||
**Keyboard Events Pattern:**
|
|
||||||
For keyboard navigation in forms, use hybrid approach:
|
|
||||||
- Server handles navigation logic via `phx-window-keydown`
|
|
||||||
- Minimal hook only for `preventDefault()` to avoid form submit conflicts
|
|
||||||
- Result: ~13 lines JS vs ~80 lines for full client-side solution
|
|
||||||
|
|
||||||
#### 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. Server-Side Keyboard Navigation Performance
|
|
||||||
**Challenge:** Concern that server-side keyboard events would feel laggy.
|
|
||||||
|
|
||||||
**Reality Check:**
|
|
||||||
- LiveView roundtrip: ~20-50ms on decent connection
|
|
||||||
- Human perception threshold: ~100ms
|
|
||||||
- Result: **Feels instant** in practice
|
|
||||||
|
|
||||||
**Why it works:**
|
|
||||||
```elixir
|
|
||||||
# Event handler only updates index (no DB queries)
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
|
||||||
new_index = min(socket.assigns.focused_member_index + 1, max_index)
|
|
||||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
|
||||||
end
|
|
||||||
```
|
|
||||||
- No database queries
|
|
||||||
- No complex computations
|
|
||||||
- Just state updates → extremely fast
|
|
||||||
|
|
||||||
**When to use Client-Side instead:**
|
|
||||||
- Complex animations (Canvas, WebGL)
|
|
||||||
- Real-time gaming
|
|
||||||
- Continuous interactions (drag & drop, drawing)
|
|
||||||
|
|
||||||
**Lesson:** Don't prematurely optimize for latency. Server-side is simpler and often sufficient.
|
|
||||||
|
|
||||||
#### 5. 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) + ComboBox hook (13 lines)
|
|
||||||
- `lib/mv_web/live/user_live/form.ex` - Keyboard event handlers, focus management
|
|
||||||
- `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
|
## Conclusion
|
||||||
|
|
||||||
This project demonstrates a modern Phoenix application built with:
|
This project demonstrates a modern Phoenix application built with:
|
||||||
|
|
@ -1553,14 +1343,14 @@ This project demonstrates a modern Phoenix application built with:
|
||||||
**Next Steps:**
|
**Next Steps:**
|
||||||
- Implement roles & permissions
|
- Implement roles & permissions
|
||||||
- Add payment tracking
|
- Add payment tracking
|
||||||
- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented
|
- Improve accessibility (WCAG 2.1 AA)
|
||||||
- Member self-service portal
|
- Member self-service portal
|
||||||
- Email communication features
|
- Email communication features
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Document Version:** 1.2
|
**Document Version:** 1.1
|
||||||
**Last Updated:** 2025-11-27
|
**Last Updated:** 2025-11-13
|
||||||
**Maintainer:** Development Team
|
**Maintainer:** Development Team
|
||||||
**Status:** Living Document (update as project evolves)
|
**Status:** Living Document (update as project evolves)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,18 +94,15 @@
|
||||||
- ✅ CustomFieldValue type management
|
- ✅ CustomFieldValue type management
|
||||||
- ✅ Dynamic custom field value assignment to members
|
- ✅ Dynamic custom field value assignment to members
|
||||||
- ✅ Union type storage (JSONB)
|
- ✅ Union type storage (JSONB)
|
||||||
- ✅ Default field visibility configuration
|
|
||||||
|
|
||||||
**Closed Issues:**
|
|
||||||
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
|
|
||||||
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
|
|
||||||
|
|
||||||
**Open Issues:**
|
**Open Issues:**
|
||||||
|
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks]
|
||||||
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
|
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
|
||||||
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
|
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
|
||||||
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
|
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
|
||||||
|
|
||||||
**Missing Features:**
|
**Missing Features:**
|
||||||
|
- ❌ Default field visibility configuration
|
||||||
- ❌ Field groups/categories
|
- ❌ Field groups/categories
|
||||||
- ❌ Conditional fields (show field X if field Y = value)
|
- ❌ Conditional fields (show field X if field Y = value)
|
||||||
- ❌ Field validation rules (min/max, regex patterns)
|
- ❌ Field validation rules (min/max, regex patterns)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ defmodule Mv.Membership.CustomField do
|
||||||
- `description` - Optional human-readable description
|
- `description` - Optional human-readable description
|
||||||
- `immutable` - If true, custom field values cannot be changed after creation
|
- `immutable` - If true, custom field values cannot be changed after creation
|
||||||
- `required` - If true, all members must have this custom field (future feature)
|
- `required` - If true, all members must have this custom field (future feature)
|
||||||
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
|
||||||
|
|
||||||
## Supported Value Types
|
## Supported Value Types
|
||||||
- `:string` - Text data (max 10,000 characters)
|
- `:string` - Text data (max 10,000 characters)
|
||||||
|
|
@ -60,10 +59,10 @@ defmodule Mv.Membership.CustomField do
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
defaults [:read, :update]
|
defaults [:read, :update]
|
||||||
default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
default_accept [:name, :value_type, :description, :immutable, :required]
|
||||||
|
|
||||||
create :create do
|
create :create do
|
||||||
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
accept [:name, :value_type, :description, :immutable, :required]
|
||||||
change Mv.Membership.CustomField.Changes.GenerateSlug
|
change Mv.Membership.CustomField.Changes.GenerateSlug
|
||||||
validate string_length(:slug, min: 1)
|
validate string_length(:slug, min: 1)
|
||||||
end
|
end
|
||||||
|
|
@ -120,12 +119,6 @@ defmodule Mv.Membership.CustomField do
|
||||||
attribute :required, :boolean,
|
attribute :required, :boolean,
|
||||||
default: false,
|
default: false,
|
||||||
allow_nil?: false
|
allow_nil?: false
|
||||||
|
|
||||||
attribute :show_in_overview, :boolean,
|
|
||||||
default: true,
|
|
||||||
allow_nil?: false,
|
|
||||||
public?: true,
|
|
||||||
description: "If true, this custom field will be displayed in the member overview table"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,6 @@ defmodule Mv.Membership.Member do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
# Module constants
|
|
||||||
@member_search_limit 10
|
|
||||||
@default_similarity_threshold 0.2
|
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "members"
|
table "members"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
@ -156,10 +152,8 @@ defmodule Mv.Membership.Member do
|
||||||
prepare fn query, _ctx ->
|
prepare fn query, _ctx ->
|
||||||
q = Ash.Query.get_argument(query, :query) || ""
|
q = Ash.Query.get_argument(query, :query) || ""
|
||||||
|
|
||||||
# Use default similarity threshold if not provided
|
# 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results
|
||||||
# Lower value leads to more results but also more unspecific results
|
threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
|
||||||
threshold =
|
|
||||||
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
|
||||||
|
|
||||||
if is_binary(q) and String.trim(q) != "" do
|
if is_binary(q) and String.trim(q) != "" do
|
||||||
q2 = String.trim(q)
|
q2 = String.trim(q)
|
||||||
|
|
@ -193,75 +187,8 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
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
|
|
||||||
#
|
|
||||||
# Filtering behavior:
|
|
||||||
# - If search_query provided: fuzzy search on names and email
|
|
||||||
# - If no search_query: return all unlinked members (up to limit)
|
|
||||||
# - user_email should be handled by caller with filter_by_email_match/2
|
|
||||||
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)
|
|
||||||
|
|
||||||
query
|
|
||||||
|> Ash.Query.filter(is_nil(user))
|
|
||||||
|> apply_linking_filters(user_email, search_query)
|
|
||||||
|> Ash.Query.limit(@member_search_limit)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Filters members list based on email match priority.
|
|
||||||
|
|
||||||
Priority logic:
|
|
||||||
1. If email matches a member: return ONLY that member (highest priority)
|
|
||||||
2. If email doesn't match: return all members (for display in dropdown)
|
|
||||||
|
|
||||||
This is used with :available_for_linking action to implement email-priority behavior:
|
|
||||||
- user_email matches → Only this member
|
|
||||||
- user_email does NOT match + NO search_query → All unlinked members
|
|
||||||
- user_email does NOT match + search_query provided → search_query filtered members
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
- `members` - List of Member structs (from :available_for_linking action)
|
|
||||||
- `user_email` - Email string to match against member emails
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
- List of Member structs (either single email match or all members)
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
|
|
||||||
iex> filter_by_email_match(members, "test@example.com")
|
|
||||||
[%Member{email: "test@example.com"}]
|
|
||||||
|
|
||||||
iex> filter_by_email_match(members, "nomatch@example.com")
|
|
||||||
[%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
|
|
||||||
"""
|
|
||||||
@spec filter_by_email_match([t()], String.t()) :: [t()]
|
|
||||||
def filter_by_email_match(members, user_email)
|
|
||||||
when is_list(members) and is_binary(user_email) do
|
|
||||||
email_match = Enum.find(members, &(&1.email == user_email))
|
|
||||||
|
|
||||||
if email_match do
|
|
||||||
# Email match found - return only this member (highest priority)
|
|
||||||
[email_match]
|
|
||||||
else
|
|
||||||
# No email match - return all members unchanged
|
|
||||||
members
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec filter_by_email_match(any(), any()) :: any()
|
|
||||||
def filter_by_email_match(members, _user_email), do: members
|
|
||||||
|
|
||||||
validations do
|
validations do
|
||||||
# Required fields are covered by allow_nil? false
|
# Required fields are covered by allow_nil? false
|
||||||
|
|
||||||
|
|
@ -434,32 +361,7 @@ defmodule Mv.Membership.Member do
|
||||||
identity :unique_email, [:email]
|
identity :unique_email, [:email]
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
# Fuzzy Search function that can be called by live view and calls search action
|
||||||
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
|
||||||
|
|
||||||
Wraps the `:search` action with convenient opts-based argument passing.
|
|
||||||
Searches across first_name, last_name, email, and other text fields using
|
|
||||||
full-text search combined with trigram similarity.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
- `query` - Ash.Query.t() to apply search to
|
|
||||||
- `opts` - Keyword list or map with search options:
|
|
||||||
- `:query` or `"query"` - Search string
|
|
||||||
- `:fields` or `"fields"` - Optional field restrictions
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
- Modified Ash.Query.t() with search filters applied
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!()
|
|
||||||
[%Member{first_name: "Greta", ...}]
|
|
||||||
|
|
||||||
iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant
|
|
||||||
[%Member{first_name: "Greta", ...}]
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t()
|
|
||||||
def fuzzy_search(query, opts) do
|
def fuzzy_search(query, opts) do
|
||||||
q = (opts[:query] || opts["query"] || "") |> to_string()
|
q = (opts[:query] || opts["query"] || "") |> to_string()
|
||||||
|
|
||||||
|
|
@ -475,60 +377,4 @@ defmodule Mv.Membership.Member do
|
||||||
Ash.Query.for_read(query, :search, args)
|
Ash.Query.for_read(query, :search, args)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Private helper to apply filters for :available_for_linking action
|
|
||||||
# user_email: may be nil/empty when creating new user, or populated when editing
|
|
||||||
# search_query: optional search term for fuzzy matching
|
|
||||||
#
|
|
||||||
# Logic: (email == user_email) OR (fuzzy_search on search_query)
|
|
||||||
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches
|
|
||||||
# - This allows a single filter expression instead of duplicating fuzzy search logic
|
|
||||||
#
|
|
||||||
# Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires
|
|
||||||
# multiple OR conditions for good search quality (FTS + trigram similarity + substring)
|
|
||||||
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
|
|
||||||
defp apply_linking_filters(query, user_email, search_query) do
|
|
||||||
has_search = search_query && String.trim(search_query) != ""
|
|
||||||
# Use empty string instead of nil to simplify filter logic
|
|
||||||
trimmed_email = if user_email, do: String.trim(user_email), else: ""
|
|
||||||
|
|
||||||
if has_search do
|
|
||||||
# Search query provided: return email-match OR fuzzy-search candidates
|
|
||||||
trimmed_search = String.trim(search_query)
|
|
||||||
|
|
||||||
query
|
|
||||||
|> Ash.Query.filter(
|
|
||||||
expr(
|
|
||||||
# Email match candidate (for filter_by_email_match priority)
|
|
||||||
# If email is "", this is always false and fuzzy search takes over
|
|
||||||
# Fuzzy search candidates
|
|
||||||
email == ^trimmed_email or
|
|
||||||
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or
|
|
||||||
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or
|
|
||||||
fragment("? % first_name", ^trimmed_search) or
|
|
||||||
fragment("? % last_name", ^trimmed_search) or
|
|
||||||
fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or
|
|
||||||
fragment(
|
|
||||||
"word_similarity(?, last_name) > ?",
|
|
||||||
^trimmed_search,
|
|
||||||
^@default_similarity_threshold
|
|
||||||
) or
|
|
||||||
fragment(
|
|
||||||
"similarity(first_name, ?) > ?",
|
|
||||||
^trimmed_search,
|
|
||||||
^@default_similarity_threshold
|
|
||||||
) or
|
|
||||||
fragment(
|
|
||||||
"similarity(last_name, ?) > ?",
|
|
||||||
^trimmed_search,
|
|
||||||
^@default_similarity_threshold
|
|
||||||
) or
|
|
||||||
contains(email, ^trimmed_search)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
# No search query: return all unlinked (filter_by_email_match will prioritize email if provided)
|
|
||||||
query
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -41,37 +41,18 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
||||||
if should_validate? do
|
if should_validate? do
|
||||||
case Ash.Changeset.fetch_change(changeset, :email) do
|
case Ash.Changeset.fetch_change(changeset, :email) do
|
||||||
{:ok, new_email} ->
|
{:ok, new_email} ->
|
||||||
# Extract member_id from relationship changes for new links
|
check_email_uniqueness(new_email, member_id)
|
||||||
member_id_to_exclude = get_member_id_from_changeset(changeset)
|
|
||||||
check_email_uniqueness(new_email, member_id_to_exclude)
|
|
||||||
|
|
||||||
:error ->
|
:error ->
|
||||||
# No email change, get current email
|
# No email change, get current email
|
||||||
current_email = Ash.Changeset.get_attribute(changeset, :email)
|
current_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||||
# Extract member_id from relationship changes for new links
|
check_email_uniqueness(current_email, member_id)
|
||||||
member_id_to_exclude = get_member_id_from_changeset(changeset)
|
|
||||||
check_email_uniqueness(current_email, member_id_to_exclude)
|
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
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
|
defp check_email_uniqueness(email, exclude_member_id) do
|
||||||
query =
|
query =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|
|
|
||||||
|
|
@ -318,13 +318,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
default: &Function.identity/1,
|
default: &Function.identity/1,
|
||||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||||
|
|
||||||
attr :dynamic_cols, :list,
|
|
||||||
default: [],
|
|
||||||
doc: "list of dynamic column definitions with :custom_field and :render functions"
|
|
||||||
|
|
||||||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
|
||||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
|
||||||
|
|
||||||
slot :col, required: true do
|
slot :col, required: true do
|
||||||
attr :label, :string
|
attr :label, :string
|
||||||
end
|
end
|
||||||
|
|
@ -342,16 +335,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th :for={col <- @col}>{col[:label]}</th>
|
<th :for={col <- @col}>{col[:label]}</th>
|
||||||
<th :for={dyn_col <- @dynamic_cols}>
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
|
||||||
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
|
||||||
label={dyn_col[:custom_field].name}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th :if={@action != []}>
|
<th :if={@action != []}>
|
||||||
<span class="sr-only">{gettext("Actions")}</span>
|
<span class="sr-only">{gettext("Actions")}</span>
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -366,23 +349,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
>
|
>
|
||||||
{render_slot(col, @row_item.(row))}
|
{render_slot(col, @row_item.(row))}
|
||||||
</td>
|
</td>
|
||||||
<td
|
|
||||||
:for={dyn_col <- @dynamic_cols}
|
|
||||||
phx-click={@row_click && @row_click.(row)}
|
|
||||||
class={@row_click && "hover:cursor-pointer"}
|
|
||||||
>
|
|
||||||
{if dyn_col[:render] do
|
|
||||||
rendered = dyn_col[:render].(@row_item.(row))
|
|
||||||
|
|
||||||
if rendered == "" do
|
|
||||||
""
|
|
||||||
else
|
|
||||||
rendered
|
|
||||||
end
|
|
||||||
else
|
|
||||||
""
|
|
||||||
end}
|
|
||||||
</td>
|
|
||||||
<td :if={@action != []} class="w-0 font-semibold">
|
<td :if={@action != []} class="w-0 font-semibold">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<%= for action <- @action do %>
|
<%= for action <- @action do %>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ defmodule MvWeb.CustomFieldLive.Form do
|
||||||
- description - Human-readable explanation
|
- description - Human-readable explanation
|
||||||
- immutable - If true, values cannot be changed after creation (default: false)
|
- immutable - If true, values cannot be changed after creation (default: false)
|
||||||
- required - If true, all members must have this custom field (default: false)
|
- required - If true, all members must have this custom field (default: false)
|
||||||
- show_in_overview - If true, this custom field will be displayed in the member overview table (default: true)
|
|
||||||
|
|
||||||
## Value Type Selection
|
## Value Type Selection
|
||||||
- `:string` - Text data (unlimited length)
|
- `:string` - Text data (unlimited length)
|
||||||
|
|
@ -61,7 +60,6 @@ defmodule MvWeb.CustomFieldLive.Form do
|
||||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||||
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
||||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||||
<.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} />
|
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save Custom field")}
|
{gettext("Save Custom field")}
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
|
||||||
|
|
||||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
|
||||||
@custom_field_prefix "custom_field_"
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Initializes the LiveView state.
|
Initializes the LiveView state.
|
||||||
|
|
||||||
|
|
@ -42,16 +34,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
# Load custom fields that should be shown in overview
|
|
||||||
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
|
|
||||||
# and result in a 500 error page. This is appropriate for LiveViews where errors
|
|
||||||
# should be visible to the user rather than silently failing.
|
|
||||||
custom_fields_visible =
|
|
||||||
Mv.Membership.CustomField
|
|
||||||
|> Ash.Query.filter(expr(show_in_overview == true))
|
|
||||||
|> Ash.Query.sort(name: :asc)
|
|
||||||
|> Ash.read!()
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Members"))
|
|> assign(:page_title, gettext("Members"))
|
||||||
|
|
@ -59,7 +41,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign_new(:sort_field, fn -> :first_name end)
|
|> assign_new(:sort_field, fn -> :first_name end)
|
||||||
|> assign_new(:sort_order, fn -> :asc end)
|
|> assign_new(:sort_order, fn -> :asc end)
|
||||||
|> assign(:selected_members, [])
|
|> assign(:selected_members, [])
|
||||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
|
||||||
|
|
||||||
# We call handle params to use the query from the URL
|
# We call handle params to use the query from the URL
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -79,8 +60,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
|
|
||||||
# This ensures users see error messages if deletion fails (e.g., permission denied)
|
|
||||||
member = Ash.get!(Mv.Membership.Member, id)
|
member = Ash.get!(Mv.Membership.Member, id)
|
||||||
Ash.destroy!(member)
|
Ash.destroy!(member)
|
||||||
|
|
||||||
|
|
@ -129,14 +108,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:sort, field_str}, socket) do
|
def handle_info({:sort, field_str}, socket) do
|
||||||
# Handle both atom and string field names (for custom fields)
|
field = String.to_existing_atom(field_str)
|
||||||
field =
|
|
||||||
try do
|
|
||||||
String.to_existing_atom(field_str)
|
|
||||||
rescue
|
|
||||||
ArgumentError -> field_str
|
|
||||||
end
|
|
||||||
|
|
||||||
{new_field, new_order} = determine_new_sort(field, socket)
|
{new_field, new_order} = determine_new_sort(field, socket)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|
|
@ -186,38 +158,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> maybe_update_search(params)
|
|> maybe_update_search(params)
|
||||||
|> maybe_update_sort(params)
|
|> maybe_update_sort(params)
|
||||||
|> load_members(params["query"])
|
|> load_members(params["query"])
|
||||||
|> prepare_dynamic_cols()
|
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
|
|
||||||
#
|
|
||||||
# Creates a list of column definitions, each containing:
|
|
||||||
# - `:custom_field` - The CustomField resource
|
|
||||||
# - `:render` - A function that formats the custom field value for a given member
|
|
||||||
#
|
|
||||||
# Returns the socket with `:dynamic_cols` assigned.
|
|
||||||
defp prepare_dynamic_cols(socket) do
|
|
||||||
dynamic_cols =
|
|
||||||
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
|
|
||||||
%{
|
|
||||||
custom_field: custom_field,
|
|
||||||
render: fn member ->
|
|
||||||
case get_custom_field_value(member, custom_field) do
|
|
||||||
nil ->
|
|
||||||
""
|
|
||||||
|
|
||||||
cfv ->
|
|
||||||
Formatter.format_custom_field_value(cfv.value, custom_field)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
assign(socket, :dynamic_cols, dynamic_cols)
|
|
||||||
end
|
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
# FUNCTIONS
|
# FUNCTIONS
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
|
|
@ -233,8 +177,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
# Updates both the active and old SortHeader components
|
# Updates both the active and old SortHeader components
|
||||||
defp update_sort_components(socket, old_field, new_field, new_order) do
|
defp update_sort_components(socket, old_field, new_field, new_order) do
|
||||||
active_id = to_sort_id(new_field)
|
active_id = :"sort_#{new_field}"
|
||||||
old_id = to_sort_id(old_field)
|
old_id = :"sort_#{old_field}"
|
||||||
|
|
||||||
# Update the new SortHeader
|
# Update the new SortHeader
|
||||||
send_update(MvWeb.Components.SortHeaderComponent,
|
send_update(MvWeb.Components.SortHeaderComponent,
|
||||||
|
|
@ -253,32 +197,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
# Converts a field (atom or string) to a sort component ID atom
|
|
||||||
# Handles both existing atoms and strings that need to be converted
|
|
||||||
defp to_sort_id(field) when is_binary(field) do
|
|
||||||
try do
|
|
||||||
String.to_existing_atom("sort_#{field}")
|
|
||||||
rescue
|
|
||||||
ArgumentError -> :"sort_#{field}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp to_sort_id(field) when is_atom(field) do
|
|
||||||
:"sort_#{field}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Builds sort URL and pushes navigation patch
|
# Builds sort URL and pushes navigation patch
|
||||||
defp push_sort_url(socket, field, order) do
|
defp push_sort_url(socket, field, order) do
|
||||||
field_str =
|
|
||||||
if is_atom(field) do
|
|
||||||
Atom.to_string(field)
|
|
||||||
else
|
|
||||||
field
|
|
||||||
end
|
|
||||||
|
|
||||||
query_params = %{
|
query_params = %{
|
||||||
"query" => socket.assigns.query,
|
"query" => socket.assigns.query,
|
||||||
"sort_field" => field_str,
|
"sort_field" => Atom.to_string(field),
|
||||||
"sort_order" => Atom.to_string(order)
|
"sort_order" => Atom.to_string(order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,24 +214,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Loads members from the database with custom field values and applies search/sort filters.
|
# Load members eg based on a query for sorting
|
||||||
#
|
|
||||||
# Process:
|
|
||||||
# 1. Builds base query with selected fields
|
|
||||||
# 2. Loads custom field values for visible custom fields (filtered at database level)
|
|
||||||
# 3. Applies search filter if provided
|
|
||||||
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields)
|
|
||||||
#
|
|
||||||
# Performance Considerations:
|
|
||||||
# - Database-level filtering: Custom field values are filtered directly in the database
|
|
||||||
# using Ash relationship filters, reducing memory usage and improving performance.
|
|
||||||
# - In-memory sorting: Custom field sorting is done in memory after loading.
|
|
||||||
# This is suitable for small to medium datasets (<1000 members).
|
|
||||||
# For larger datasets, consider implementing database-level sorting or pagination.
|
|
||||||
# - No pagination: All matching members are loaded at once. For large result sets,
|
|
||||||
# consider implementing pagination (see Issue #165).
|
|
||||||
#
|
|
||||||
# Returns the socket with `:members` assigned.
|
|
||||||
defp load_members(socket, search_query) do
|
defp load_members(socket, search_query) do
|
||||||
query =
|
query =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|
|
@ -326,71 +232,16 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
:join_date
|
:join_date
|
||||||
])
|
])
|
||||||
|
|
||||||
# Load custom field values for visible custom fields
|
|
||||||
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
|
|
||||||
query = load_custom_field_values(query, custom_field_ids_list)
|
|
||||||
|
|
||||||
# Apply the search filter first
|
# Apply the search filter first
|
||||||
query = apply_search_filter(query, search_query)
|
query = apply_search_filter(query, search_query)
|
||||||
|
|
||||||
# Apply sorting based on current socket state
|
# Apply sorting based on current socket state
|
||||||
# For custom fields, we sort after loading
|
query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order)
|
||||||
{query, sort_after_load} =
|
|
||||||
maybe_sort(
|
|
||||||
query,
|
|
||||||
socket.assigns.sort_field,
|
|
||||||
socket.assigns.sort_order,
|
|
||||||
socket.assigns.custom_fields_visible
|
|
||||||
)
|
|
||||||
|
|
||||||
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView
|
|
||||||
# This is appropriate for data loading in LiveViews
|
|
||||||
members = Ash.read!(query)
|
members = Ash.read!(query)
|
||||||
|
|
||||||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
|
||||||
# No need for in-memory filtering anymore
|
|
||||||
|
|
||||||
# Sort in memory if needed (for custom fields)
|
|
||||||
members =
|
|
||||||
if sort_after_load do
|
|
||||||
sort_members_in_memory(
|
|
||||||
members,
|
|
||||||
socket.assigns.sort_field,
|
|
||||||
socket.assigns.sort_order,
|
|
||||||
socket.assigns.custom_fields_visible
|
|
||||||
)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
|
|
||||||
assign(socket, :members, members)
|
assign(socket, :members, members)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Load custom field values for the given custom field IDs
|
|
||||||
#
|
|
||||||
# Filters custom field values directly in the database using Ash relationship filters.
|
|
||||||
# This is more efficient than loading all values and filtering in memory.
|
|
||||||
#
|
|
||||||
# Performance: Database-level filtering reduces:
|
|
||||||
# - Memory usage (only visible custom field values are loaded)
|
|
||||||
# - Network transfer (less data from database to application)
|
|
||||||
# - Processing time (no need to iterate through all members and filter)
|
|
||||||
defp load_custom_field_values(query, []) do
|
|
||||||
query
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do
|
|
||||||
# Filter custom field values at the database level using Ash relationship query
|
|
||||||
# This ensures only visible custom field values are loaded
|
|
||||||
custom_field_values_query =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|
|
||||||
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
|
|
||||||
|
|
||||||
query
|
|
||||||
|> Ash.Query.load(custom_field_values: custom_field_values_query)
|
|
||||||
end
|
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
|
|
@ -413,24 +264,15 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp toggle_order(nil), do: :asc
|
defp toggle_order(nil), do: :asc
|
||||||
|
|
||||||
# Function to sort the column if needed
|
# Function to sort the column if needed
|
||||||
# Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory
|
defp maybe_sort(query, nil, _), do: query
|
||||||
defp maybe_sort(query, nil, _, _), do: {query, false}
|
|
||||||
|
|
||||||
defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do
|
defp maybe_sort(query, field, :asc) when not is_nil(field),
|
||||||
if custom_field_sort?(field) do
|
do: Ash.Query.sort(query, [{field, :asc}])
|
||||||
# Custom fields need to be sorted in memory after loading
|
|
||||||
{query, true}
|
|
||||||
else
|
|
||||||
# Only sort by atom fields (regular member fields) in database
|
|
||||||
if is_atom(field) do
|
|
||||||
{Ash.Query.sort(query, [{field, order}]), false}
|
|
||||||
else
|
|
||||||
{query, false}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_sort(query, _, _, _), do: {query, false}
|
defp maybe_sort(query, field, :desc) when not is_nil(field),
|
||||||
|
do: Ash.Query.sort(query, [{field, :desc}])
|
||||||
|
|
||||||
|
defp maybe_sort(query, _, _), do: query
|
||||||
|
|
||||||
# Validate that a field is sortable
|
# Validate that a field is sortable
|
||||||
defp valid_sort_field?(field) when is_atom(field) do
|
defp valid_sort_field?(field) when is_atom(field) do
|
||||||
|
|
@ -446,188 +288,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
:join_date
|
:join_date
|
||||||
]
|
]
|
||||||
|
|
||||||
field in valid_fields or custom_field_sort?(field)
|
field in valid_fields
|
||||||
end
|
|
||||||
|
|
||||||
defp valid_sort_field?(field) when is_binary(field) do
|
|
||||||
custom_field_sort?(field)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp valid_sort_field?(_), do: false
|
defp valid_sort_field?(_), do: false
|
||||||
|
|
||||||
# Check if field is a custom field sort field (format: custom_field_<id>)
|
# Function to maybe update the sort
|
||||||
defp custom_field_sort?(field) when is_atom(field) do
|
|
||||||
field_str = Atom.to_string(field)
|
|
||||||
String.starts_with?(field_str, @custom_field_prefix)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp custom_field_sort?(field) when is_binary(field) do
|
|
||||||
String.starts_with?(field, @custom_field_prefix)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp custom_field_sort?(_), do: false
|
|
||||||
|
|
||||||
# Extracts the custom field ID from a sort field name.
|
|
||||||
#
|
|
||||||
# Sort fields for custom fields use the format: "custom_field_<id>"
|
|
||||||
# This function extracts the ID part.
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# extract_custom_field_id("custom_field_123") -> "123"
|
|
||||||
# extract_custom_field_id(:custom_field_123) -> "123"
|
|
||||||
# extract_custom_field_id("first_name") -> nil
|
|
||||||
defp extract_custom_field_id(field) when is_atom(field) do
|
|
||||||
field_str = Atom.to_string(field)
|
|
||||||
extract_custom_field_id(field_str)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_custom_field_id(field) when is_binary(field) do
|
|
||||||
case String.split(field, @custom_field_prefix) do
|
|
||||||
["", id_str] -> id_str
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_custom_field_id(_), do: nil
|
|
||||||
|
|
||||||
# Sorts members in memory by a custom field value.
|
|
||||||
#
|
|
||||||
# Process:
|
|
||||||
# 1. Extracts custom field ID from sort field name
|
|
||||||
# 2. Finds the corresponding CustomField resource
|
|
||||||
# 3. Splits members into those with values and those without
|
|
||||||
# 4. Sorts members with values by the extracted value
|
|
||||||
# 5. Combines: sorted values first, then NULL/empty values at the end
|
|
||||||
#
|
|
||||||
# Performance Note:
|
|
||||||
# This function sorts in memory, which is suitable for small to medium datasets (<1000 members).
|
|
||||||
# For larger datasets, consider implementing database-level sorting or pagination.
|
|
||||||
#
|
|
||||||
# Parameters:
|
|
||||||
# - `members` - List of Member resources to sort
|
|
||||||
# - `field` - Sort field name (format: "custom_field_<id>" or atom)
|
|
||||||
# - `order` - Sort order (`:asc` or `:desc`)
|
|
||||||
# - `custom_fields` - List of visible CustomField resources
|
|
||||||
#
|
|
||||||
# Returns the sorted list of members.
|
|
||||||
defp sort_members_in_memory(members, field, order, custom_fields) do
|
|
||||||
custom_field_id_str = extract_custom_field_id(field)
|
|
||||||
|
|
||||||
case custom_field_id_str do
|
|
||||||
nil ->
|
|
||||||
members
|
|
||||||
|
|
||||||
id_str ->
|
|
||||||
sort_members_by_custom_field(members, id_str, order, custom_fields)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Sorts members by a specific custom field ID
|
|
||||||
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
|
|
||||||
custom_field = find_custom_field_by_id(custom_fields, id_str)
|
|
||||||
|
|
||||||
case custom_field do
|
|
||||||
nil ->
|
|
||||||
members
|
|
||||||
|
|
||||||
cf ->
|
|
||||||
sort_members_with_custom_field(members, cf, order)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Finds a custom field by matching its ID string
|
|
||||||
defp find_custom_field_by_id(custom_fields, id_str) do
|
|
||||||
Enum.find(custom_fields, fn cf ->
|
|
||||||
to_string(cf.id) == id_str
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Sorts members that have a specific custom field
|
|
||||||
defp sort_members_with_custom_field(members, custom_field, order) do
|
|
||||||
# Split members into those with values and those without (NULL/empty)
|
|
||||||
{members_with_values, members_without_values} =
|
|
||||||
split_members_by_value_presence(members, custom_field)
|
|
||||||
|
|
||||||
# Sort members with values
|
|
||||||
sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
|
|
||||||
|
|
||||||
# Combine: sorted values first, then NULL/empty values at the end
|
|
||||||
sorted_with_values ++ members_without_values
|
|
||||||
end
|
|
||||||
|
|
||||||
# Splits members into those with values and those without
|
|
||||||
defp split_members_by_value_presence(members, custom_field) do
|
|
||||||
Enum.split_with(members, fn member ->
|
|
||||||
has_non_empty_value?(member, custom_field)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Checks if a member has a non-empty value for the custom field
|
|
||||||
defp has_non_empty_value?(member, custom_field) do
|
|
||||||
case get_custom_field_value(member, custom_field) do
|
|
||||||
nil ->
|
|
||||||
false
|
|
||||||
|
|
||||||
cfv ->
|
|
||||||
extracted = extract_sort_value(cfv.value, custom_field.value_type)
|
|
||||||
not empty_value?(extracted, custom_field.value_type)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Sorts members that have values for the custom field
|
|
||||||
defp sort_members_with_values(members_with_values, custom_field, order) do
|
|
||||||
sorted =
|
|
||||||
Enum.sort_by(members_with_values, fn member ->
|
|
||||||
cfv = get_custom_field_value(member, custom_field)
|
|
||||||
extracted = extract_sort_value(cfv.value, custom_field.value_type)
|
|
||||||
normalize_sort_value(extracted, order)
|
|
||||||
end)
|
|
||||||
|
|
||||||
# For DESC, reverse only the members with values
|
|
||||||
if order == :desc do
|
|
||||||
Enum.reverse(sorted)
|
|
||||||
else
|
|
||||||
sorted
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extracts a sortable value from a custom field value based on its type.
|
|
||||||
#
|
|
||||||
# Handles different value formats:
|
|
||||||
# - `%Ash.Union{}` - Extracts value and type from union
|
|
||||||
# - Direct values - Returns as-is for primitive types
|
|
||||||
#
|
|
||||||
# Returns the extracted value suitable for sorting.
|
|
||||||
defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do
|
|
||||||
extract_sort_value(value, type)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_sort_value(value, :string) when is_binary(value), do: value
|
|
||||||
defp extract_sort_value(value, :integer) when is_integer(value), do: value
|
|
||||||
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
|
|
||||||
defp extract_sort_value(%Date{} = date, :date), do: date
|
|
||||||
defp extract_sort_value(value, :email) when is_binary(value), do: value
|
|
||||||
defp extract_sort_value(value, _type), do: to_string(value)
|
|
||||||
|
|
||||||
# Check if a value is considered empty (NULL or empty string)
|
|
||||||
defp empty_value?(value, :string) when is_binary(value) do
|
|
||||||
String.trim(value) == ""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp empty_value?(value, :email) when is_binary(value) do
|
|
||||||
String.trim(value) == ""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp empty_value?(_value, _type), do: false
|
|
||||||
|
|
||||||
# Normalize sort value for DESC order
|
|
||||||
# For DESC, we sort ascending first, then reverse the list
|
|
||||||
# This function is kept for consistency but doesn't need to invert values
|
|
||||||
defp normalize_sort_value(value, _order), do: value
|
|
||||||
|
|
||||||
# Updates sort field and order from URL parameters if present.
|
|
||||||
#
|
|
||||||
# Validates the sort field and order, falling back to defaults if invalid.
|
|
||||||
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
|
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
|
||||||
field = determine_field(socket.assigns.sort_field, sf)
|
field = determine_field(socket.assigns.sort_field, sf)
|
||||||
order = determine_order(socket.assigns.sort_order, so)
|
order = determine_order(socket.assigns.sort_order, so)
|
||||||
|
|
@ -639,50 +305,33 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
defp maybe_update_sort(socket, _), do: socket
|
defp maybe_update_sort(socket, _), do: socket
|
||||||
|
|
||||||
# Determine sort field from URL parameter, validating against allowed fields
|
defp determine_field(default, sf) do
|
||||||
defp determine_field(default, ""), do: default
|
case sf do
|
||||||
defp determine_field(default, nil), do: default
|
"" ->
|
||||||
|
default
|
||||||
|
|
||||||
# Determines the valid sort field from a URL parameter.
|
nil ->
|
||||||
#
|
default
|
||||||
# Validates the field against allowed sort fields (regular member fields or custom fields).
|
|
||||||
# Falls back to default if the field is invalid.
|
sf when is_binary(sf) ->
|
||||||
#
|
sf
|
||||||
# Parameters:
|
|> String.to_existing_atom()
|
||||||
# - `default` - Default field to use if validation fails
|
|> handle_atom_conversion(default)
|
||||||
# - `sf` - Sort field from URL (can be atom, string, nil, or empty string)
|
|
||||||
#
|
sf when is_atom(sf) ->
|
||||||
# Returns a valid sort field (atom or string for custom fields).
|
handle_atom_conversion(sf, default)
|
||||||
defp determine_field(default, sf) when is_binary(sf) do
|
|
||||||
# Check if it's a custom field sort (starts with "custom_field_")
|
_ ->
|
||||||
if custom_field_sort?(sf) do
|
default
|
||||||
if valid_sort_field?(sf), do: sf, else: default
|
|
||||||
else
|
|
||||||
# Try to convert to atom for regular fields
|
|
||||||
try do
|
|
||||||
atom = String.to_existing_atom(sf)
|
|
||||||
if valid_sort_field?(atom), do: atom, else: default
|
|
||||||
rescue
|
|
||||||
ArgumentError -> default
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp determine_field(default, sf) when is_atom(sf) do
|
defp handle_atom_conversion(val, default) when is_atom(val) do
|
||||||
if valid_sort_field?(sf), do: sf, else: default
|
if valid_sort_field?(val), do: val, else: default
|
||||||
end
|
end
|
||||||
|
|
||||||
defp determine_field(default, _), do: default
|
defp handle_atom_conversion(_, default), do: default
|
||||||
|
|
||||||
# Determines the valid sort order from a URL parameter.
|
|
||||||
#
|
|
||||||
# Validates that the order is either "asc" or "desc", falling back to default if invalid.
|
|
||||||
#
|
|
||||||
# Parameters:
|
|
||||||
# - `default` - Default order to use if validation fails
|
|
||||||
# - `so` - Sort order from URL (string, atom, nil, or empty string)
|
|
||||||
#
|
|
||||||
# Returns `:asc` or `:desc`.
|
|
||||||
defp determine_order(default, so) do
|
defp determine_order(default, so) do
|
||||||
case so do
|
case so do
|
||||||
"" -> default
|
"" -> default
|
||||||
|
|
@ -701,36 +350,4 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
# Keep the previous search query if no new one is provided
|
# Keep the previous search query if no new one is provided
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
|
||||||
# Helper Functions for Custom Field Values
|
|
||||||
# -------------------------------------------------------------
|
|
||||||
|
|
||||||
# Retrieves the custom field value for a specific member and custom field.
|
|
||||||
#
|
|
||||||
# Searches through the member's `custom_field_values` relationship to find
|
|
||||||
# the value matching the given custom field.
|
|
||||||
#
|
|
||||||
# Returns:
|
|
||||||
# - `%CustomFieldValue{}` if found
|
|
||||||
# - `nil` if not found or if member has no custom field values
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# get_custom_field_value(member, custom_field) -> %CustomFieldValue{...}
|
|
||||||
# get_custom_field_value(member, non_existent_field) -> nil
|
|
||||||
def get_custom_field_value(member, custom_field) do
|
|
||||||
case member.custom_field_values do
|
|
||||||
nil ->
|
|
||||||
nil
|
|
||||||
|
|
||||||
values when is_list(values) ->
|
|
||||||
Enum.find(values, fn cfv ->
|
|
||||||
cfv.custom_field_id == custom_field.id or
|
|
||||||
(cfv.custom_field && cfv.custom_field.id == custom_field.id)
|
|
||||||
end)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,6 @@
|
||||||
id="members"
|
id="members"
|
||||||
rows={@members}
|
rows={@members}
|
||||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||||
dynamic_cols={@dynamic_cols}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||||
|
|
@ -188,6 +185,7 @@
|
||||||
>
|
>
|
||||||
{member.join_date}
|
{member.join_date}
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<div class="sr-only">
|
<div class="sr-only">
|
||||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.Formatter do
|
|
||||||
@moduledoc """
|
|
||||||
Formats custom field values for display in the member overview table.
|
|
||||||
|
|
||||||
Handles different value types (string, integer, boolean, date, email) and
|
|
||||||
formats them appropriately for display in the UI.
|
|
||||||
"""
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Formats a custom field value for display.
|
|
||||||
|
|
||||||
Handles different input formats:
|
|
||||||
- `nil` - Returns empty string
|
|
||||||
- `%Ash.Union{}` - Extracts value and type from union type
|
|
||||||
- Map (JSONB format) - Extracts type and value from map keys
|
|
||||||
- Direct value - Uses custom_field.value_type to determine format
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> format_custom_field_value(nil, %CustomField{value_type: :string})
|
|
||||||
""
|
|
||||||
|
|
||||||
iex> format_custom_field_value("test", %CustomField{value_type: :string})
|
|
||||||
"test"
|
|
||||||
|
|
||||||
iex> format_custom_field_value(true, %CustomField{value_type: :boolean})
|
|
||||||
"Yes"
|
|
||||||
"""
|
|
||||||
def format_custom_field_value(nil, _custom_field), do: ""
|
|
||||||
|
|
||||||
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
|
|
||||||
format_value_by_type(value, type, custom_field)
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_custom_field_value(value, custom_field) when is_map(value) do
|
|
||||||
# Handle map format from JSONB
|
|
||||||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
|
||||||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
|
||||||
format_value_by_type(val, type, custom_field)
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_custom_field_value(value, custom_field) do
|
|
||||||
format_value_by_type(value, custom_field.value_type, custom_field)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Format value based on type
|
|
||||||
|
|
||||||
defp format_value_by_type(value, :string, _), do: to_string(value)
|
|
||||||
|
|
||||||
defp format_value_by_type(value, :integer, _), do: to_string(value)
|
|
||||||
|
|
||||||
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
|
|
||||||
# Return empty string if value is empty
|
|
||||||
if String.trim(value) == "", do: "", else: value
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_value_by_type(value, :email, _), do: to_string(value)
|
|
||||||
|
|
||||||
defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes")
|
|
||||||
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
|
|
||||||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
|
||||||
|
|
||||||
defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date)
|
|
||||||
|
|
||||||
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
|
||||||
case Date.from_iso8601(value) do
|
|
||||||
{:ok, date} -> Date.to_string(date)
|
|
||||||
_ -> value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_value_by_type(value, _type, _), do: to_string(value)
|
|
||||||
end
|
|
||||||
|
|
@ -120,130 +120,6 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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-hook="ComboBox"
|
|
||||||
phx-focus="show_member_dropdown"
|
|
||||||
phx-change="search_members"
|
|
||||||
phx-debounce="300"
|
|
||||||
phx-window-keydown="member_dropdown_keydown"
|
|
||||||
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)}
|
|
||||||
aria-activedescendant={
|
|
||||||
if @focused_member_index,
|
|
||||||
do: "member-option-#{@focused_member_index}",
|
|
||||||
else: nil
|
|
||||||
}
|
|
||||||
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, index} <- Enum.with_index(@available_members) do %>
|
|
||||||
<div
|
|
||||||
id={"member-option-#{index}"}
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected={to_string(@focused_member_index == index)}
|
|
||||||
phx-click="select_member"
|
|
||||||
phx-value-id={member.id}
|
|
||||||
data-member-id={member.id}
|
|
||||||
class={[
|
|
||||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
|
||||||
if(@focused_member_index == index,
|
|
||||||
do: "bg-base-300",
|
|
||||||
else: "hover:bg-base-200"
|
|
||||||
)
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<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">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save User")}
|
{gettext("Save User")}
|
||||||
|
|
@ -259,7 +135,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
user =
|
user =
|
||||||
case params["id"] do
|
case params["id"] do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
|
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
|
||||||
end
|
end
|
||||||
|
|
||||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||||
|
|
@ -271,18 +147,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> assign(user: user)
|
|> assign(user: user)
|
||||||
|> assign(:page_title, page_title)
|
|> assign(:page_title, page_title)
|
||||||
|> assign(:show_password_fields, false)
|
|> 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)
|
|
||||||
|> assign(:focused_member_index, nil)
|
|
||||||
|> load_initial_members()
|
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec return_to(String.t() | nil) :: String.t()
|
|
||||||
defp return_to("show"), do: "show"
|
defp return_to("show"), do: "show"
|
||||||
defp return_to(_), do: "index"
|
defp return_to(_), do: "index"
|
||||||
|
|
||||||
|
|
@ -299,201 +166,28 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))}
|
||||||
|
|
||||||
# Reload members if email changed (for email-match priority)
|
|
||||||
socket =
|
|
||||||
if Map.has_key?(user_params, "email") do
|
|
||||||
user_email = user_params["email"]
|
|
||||||
members = load_members_for_linking(user_email, socket.assigns.member_search_query)
|
|
||||||
|
|
||||||
assign(socket, form: validated_form, available_members: members)
|
|
||||||
else
|
|
||||||
assign(socket, form: validated_form)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save", %{"user" => user_params}, socket) do
|
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
|
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
|
||||||
{:ok, user} ->
|
{:ok, user} ->
|
||||||
# Then handle member linking/unlinking as a separate step
|
notify_parent({:saved, user})
|
||||||
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}})
|
|
||||||
|
|
||||||
# Unlink flag is set
|
socket =
|
||||||
socket.assigns[:unlink_member] ->
|
socket
|
||||||
Mv.Accounts.update_user(user, %{member: nil})
|
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|
||||||
|
|> push_navigate(to: return_path(socket.assigns.return_to, user))
|
||||||
|
|
||||||
# No changes to member relationship
|
{:noreply, socket}
|
||||||
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 user-friendly error from member linking/unlinking
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("Failed to link member: %{error}", error: error_message)
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, form} ->
|
{:error, form} ->
|
||||||
{:noreply, assign(socket, form: form)}
|
{:noreply, assign(socket, form: form)}
|
||||||
end
|
end
|
||||||
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, focused_member_index: nil)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
max_index = length(socket.assigns.available_members) - 1
|
|
||||||
current = socket.assigns.focused_member_index
|
|
||||||
|
|
||||||
new_index =
|
|
||||||
case current do
|
|
||||||
nil -> 0
|
|
||||||
index when index < max_index -> index + 1
|
|
||||||
_ -> current
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
current = socket.assigns.focused_member_index
|
|
||||||
|
|
||||||
new_index =
|
|
||||||
case current do
|
|
||||||
nil -> 0
|
|
||||||
0 -> 0
|
|
||||||
index -> index - 1
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
select_focused_member(socket)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
|
||||||
# Ignore other keys
|
|
||||||
{:noreply, socket}
|
|
||||||
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)
|
|
||||||
|> assign(:focused_member_index, nil)
|
|
||||||
|
|
||||||
{: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
|
|
||||||
|
|
||||||
@spec notify_parent(any()) :: any()
|
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||||
|
|
||||||
# Helper to ignore keyboard events when dropdown is closed
|
|
||||||
@spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) ::
|
|
||||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
|
||||||
defp return_if_dropdown_closed(socket, func) do
|
|
||||||
if socket.assigns.show_member_dropdown do
|
|
||||||
func.()
|
|
||||||
else
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Select the currently focused member from the dropdown
|
|
||||||
@spec select_focused_member(Phoenix.LiveView.Socket.t()) ::
|
|
||||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
|
||||||
defp select_focused_member(socket) do
|
|
||||||
with index when not is_nil(index) <- socket.assigns.focused_member_index,
|
|
||||||
member when not is_nil(member) <- Enum.at(socket.assigns.available_members, index) do
|
|
||||||
handle_event("select_member", %{"id" => member.id}, socket)
|
|
||||||
else
|
|
||||||
_ -> {:noreply, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
|
||||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||||
form =
|
form =
|
||||||
if user do
|
if user do
|
||||||
|
|
@ -513,71 +207,6 @@ defmodule MvWeb.UserLive.Form do
|
||||||
assign(socket, form: to_form(form))
|
assign(socket, form: to_form(form))
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t()
|
|
||||||
defp return_path("index", _user), do: ~p"/users"
|
defp return_path("index", _user), do: ~p"/users"
|
||||||
defp return_path("show", user), do: ~p"/users/#{user.id}"
|
defp return_path("show", user), do: ~p"/users/#{user.id}"
|
||||||
|
|
||||||
@spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
|
||||||
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
|
|
||||||
|
|
||||||
@spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) ::
|
|
||||||
Phoenix.LiveView.Socket.t()
|
|
||||||
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
|
|
||||||
|
|
||||||
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
|
|
||||||
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
|
|
||||||
|
|
||||||
# Extract user-friendly error message from Ash.Error
|
|
||||||
@spec extract_error_message(any()) :: String.t()
|
|
||||||
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
|
||||||
# Take first error and extract message
|
|
||||||
case List.first(errors) do
|
|
||||||
%{message: message} when is_binary(message) -> message
|
|
||||||
%{field: field, message: message} -> "#{field}: #{message}"
|
|
||||||
_ -> "Unknown error"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_error_message(error) when is_binary(error), do: error
|
|
||||||
defp extract_error_message(_), do: "Unknown error"
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ defmodule MvWeb.UserLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
|
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
|
||||||
sorted = Enum.sort_by(users, & &1.email)
|
sorted = Enum.sort_by(users, & &1.email)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,6 @@
|
||||||
{user.email}
|
{user.email}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</: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}>
|
<:action :let={user}>
|
||||||
<div class="sr-only">
|
<div class="sr-only">
|
||||||
|
|
|
||||||
4
mix.lock
4
mix.lock
|
|
@ -16,7 +16,7 @@
|
||||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
"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"},
|
"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"},
|
"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", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
"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"},
|
"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_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"},
|
"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"},
|
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{: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"},
|
"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"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
|
|
|
||||||
58
notes.md
58
notes.md
|
|
@ -1,58 +0,0 @@
|
||||||
# User-Member Association - Test Status
|
|
||||||
|
|
||||||
## Test Files Created/Modified
|
|
||||||
|
|
||||||
### 1. test/membership/member_available_for_linking_test.exs (NEU)
|
|
||||||
**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
|
|
||||||
**Grund**: Die `:available_for_linking` Action existiert noch nicht
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
- ✗ returns only unlinked members and limits to 10
|
|
||||||
- ✗ limits results to 10 members even when more exist
|
|
||||||
- ✗ email match: returns only member with matching email when exists
|
|
||||||
- ✗ email match: returns all unlinked members when no email match
|
|
||||||
- ✗ search query: filters by first_name, last_name, and email
|
|
||||||
- ✗ email match takes precedence over search query
|
|
||||||
|
|
||||||
### 2. test/accounts/user_member_linking_test.exs (NEU)
|
|
||||||
**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
|
|
||||||
- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
|
|
||||||
- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
|
|
||||||
- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
|
|
||||||
|
|
||||||
### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
|
|
||||||
**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
|
|
||||||
**Grund**: Member-Linking UI ist noch nicht implementiert
|
|
||||||
|
|
||||||
Neue Tests:
|
|
||||||
- ✗ shows linked member with unlink button when user has member
|
|
||||||
- ✗ shows member search field when user has no member
|
|
||||||
- ✗ selecting member and saving links member to user
|
|
||||||
- ✗ unlinking member and saving removes member from user
|
|
||||||
|
|
||||||
### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
|
|
||||||
**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
|
|
||||||
**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
|
|
||||||
|
|
||||||
Neuer Test:
|
|
||||||
- ✗ displays linked member name in user list
|
|
||||||
|
|
||||||
## Zusammenfassung
|
|
||||||
|
|
||||||
**Tests gesamt**: 13
|
|
||||||
**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
|
|
||||||
**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
|
|
||||||
|
|
||||||
## Nächste Schritte
|
|
||||||
|
|
||||||
1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
|
|
||||||
2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
|
|
||||||
3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
|
|
||||||
4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
|
|
||||||
5. Füge Gettext-Übersetzungen hinzu
|
|
||||||
|
|
||||||
Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.
|
|
||||||
|
|
||||||
|
|
@ -10,12 +10,12 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Language: en\n"
|
"Language: en\n"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex:356
|
#: lib/mv_web/components/core_components.ex:339
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Aktionen"
|
msgstr "Aktionen"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:65
|
#: lib/mv_web/live/user_live/index.html.heex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Are you sure?"
|
msgid "Are you sure?"
|
||||||
|
|
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
|
||||||
msgstr "Verbindung wird wiederhergestellt"
|
msgstr "Verbindung wird wiederhergestellt"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:54
|
#: lib/mv_web/live/member_live/form.ex:54
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
#: lib/mv_web/live/member_live/index.html.heex:145
|
||||||
#: lib/mv_web/live/member_live/show.ex:59
|
#: lib/mv_web/live/member_live/show.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr "Stadt"
|
msgstr "Stadt"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:204
|
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:67
|
#: lib/mv_web/live/user_live/index.html.heex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Löschen"
|
msgstr "Löschen"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:196
|
#: lib/mv_web/live/member_live/index.html.heex:194
|
||||||
#: lib/mv_web/live/user_live/form.ex:141
|
#: 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/index.html.heex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -54,7 +54,7 @@ msgid "Edit Member"
|
||||||
msgstr "Mitglied bearbeiten"
|
msgstr "Mitglied bearbeiten"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:47
|
#: lib/mv_web/live/member_live/form.ex:47
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:80
|
#: lib/mv_web/live/member_live/index.html.heex:77
|
||||||
#: lib/mv_web/live/member_live/show.ex:50
|
#: lib/mv_web/live/member_live/show.ex:50
|
||||||
#: lib/mv_web/live/user_live/form.ex:46
|
#: lib/mv_web/live/user_live/form.ex:46
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||||
|
|
@ -70,7 +70,7 @@ msgid "First Name"
|
||||||
msgstr "Vorname"
|
msgstr "Vorname"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:51
|
#: lib/mv_web/live/member_live/form.ex:51
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||||
#: lib/mv_web/live/member_live/show.ex:56
|
#: lib/mv_web/live/member_live/show.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join Date"
|
msgid "Join Date"
|
||||||
|
|
@ -87,7 +87,7 @@ msgstr "Nachname"
|
||||||
msgid "New Member"
|
msgid "New Member"
|
||||||
msgstr "Neues Mitglied"
|
msgstr "Neues Mitglied"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
#: lib/mv_web/live/member_live/index.html.heex:191
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:56
|
#: lib/mv_web/live/user_live/index.html.heex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show"
|
msgid "Show"
|
||||||
|
|
@ -121,7 +121,7 @@ msgid "Exit Date"
|
||||||
msgstr "Austrittsdatum"
|
msgstr "Austrittsdatum"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:56
|
#: lib/mv_web/live/member_live/form.ex:56
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:114
|
#: lib/mv_web/live/member_live/index.html.heex:111
|
||||||
#: lib/mv_web/live/member_live/show.ex:61
|
#: lib/mv_web/live/member_live/show.ex:61
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "House Number"
|
msgid "House Number"
|
||||||
|
|
@ -140,14 +140,14 @@ msgid "Paid"
|
||||||
msgstr "Bezahlt"
|
msgstr "Bezahlt"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:50
|
#: lib/mv_web/live/member_live/form.ex:50
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
#: lib/mv_web/live/member_live/index.html.heex:162
|
||||||
#: lib/mv_web/live/member_live/show.ex:55
|
#: lib/mv_web/live/member_live/show.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Phone Number"
|
msgid "Phone Number"
|
||||||
msgstr "Telefonnummer"
|
msgstr "Telefonnummer"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:57
|
#: lib/mv_web/live/member_live/form.ex:57
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
#: lib/mv_web/live/member_live/index.html.heex:128
|
||||||
#: lib/mv_web/live/member_live/show.ex:62
|
#: lib/mv_web/live/member_live/show.ex:62
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Postal Code"
|
msgid "Postal Code"
|
||||||
|
|
@ -158,17 +158,17 @@ msgstr "Postleitzahl"
|
||||||
msgid "Save Member"
|
msgid "Save Member"
|
||||||
msgstr "Mitglied speichern"
|
msgstr "Mitglied speichern"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:66
|
#: 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/custom_field_value_live/form.ex:74
|
||||||
#: lib/mv_web/live/global_settings_live.ex:55
|
#: lib/mv_web/live/global_settings_live.ex:55
|
||||||
#: lib/mv_web/live/member_live/form.ex:79
|
#: lib/mv_web/live/member_live/form.ex:79
|
||||||
#: lib/mv_web/live/user_live/form.ex:234
|
#: lib/mv_web/live/user_live/form.ex:124
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Saving..."
|
msgid "Saving..."
|
||||||
msgstr "Speichern..."
|
msgstr "Speichern..."
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:55
|
#: lib/mv_web/live/member_live/form.ex:55
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
#: lib/mv_web/live/member_live/index.html.heex:94
|
||||||
#: lib/mv_web/live/member_live/show.ex:60
|
#: lib/mv_web/live/member_live/show.ex:60
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
|
|
@ -184,7 +184,6 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha
|
||||||
msgid "Id"
|
msgid "Id"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex:65
|
|
||||||
#: lib/mv_web/live/member_live/show.ex:53
|
#: lib/mv_web/live/member_live/show.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No"
|
msgid "No"
|
||||||
|
|
@ -200,20 +199,19 @@ msgstr "Mitglied anzeigen"
|
||||||
msgid "This is a member record from your database."
|
msgid "This is a member record from your database."
|
||||||
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
|
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex:64
|
|
||||||
#: lib/mv_web/live/member_live/show.ex:53
|
#: lib/mv_web/live/member_live/show.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yes"
|
msgid "Yes"
|
||||||
msgstr "Ja"
|
msgstr "Ja"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:110
|
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
||||||
#: lib/mv_web/live/member_live/form.ex:138
|
#: lib/mv_web/live/member_live/form.ex:138
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "create"
|
msgid "create"
|
||||||
msgstr "erstellt"
|
msgstr "erstellt"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:111
|
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
||||||
#: lib/mv_web/live/member_live/form.ex:139
|
#: lib/mv_web/live/member_live/form.ex:139
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -255,11 +253,11 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt"
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:69
|
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex:120
|
#: lib/mv_web/live/custom_field_live/index.ex:120
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
||||||
#: lib/mv_web/live/member_live/form.ex:82
|
#: lib/mv_web/live/member_live/form.ex:82
|
||||||
#: lib/mv_web/live/user_live/form.ex:237
|
#: lib/mv_web/live/user_live/form.ex:127
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Abbrechen"
|
msgstr "Abbrechen"
|
||||||
|
|
@ -269,7 +267,7 @@ msgstr "Abbrechen"
|
||||||
msgid "Choose a member"
|
msgid "Choose a member"
|
||||||
msgstr "Mitglied auswählen"
|
msgstr "Mitglied auswählen"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Description"
|
msgid "Description"
|
||||||
msgstr "Beschreibung"
|
msgstr "Beschreibung"
|
||||||
|
|
@ -289,7 +287,7 @@ msgstr "Aktiviert"
|
||||||
msgid "ID"
|
msgid "ID"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:62
|
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Immutable"
|
msgid "Immutable"
|
||||||
msgstr "Unveränderlich"
|
msgstr "Unveränderlich"
|
||||||
|
|
@ -317,7 +315,7 @@ msgstr "Mitglied"
|
||||||
msgid "Members"
|
msgid "Members"
|
||||||
msgstr "Mitglieder"
|
msgstr "Mitglieder"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:51
|
#: lib/mv_web/live/custom_field_live/form.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
@ -339,7 +337,6 @@ msgstr "Nicht gesetzt"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:107
|
#: 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:115
|
||||||
#: lib/mv_web/live/user_live/form.ex:210
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Note"
|
msgid "Note"
|
||||||
msgstr "Hinweis"
|
msgstr "Hinweis"
|
||||||
|
|
@ -360,17 +357,17 @@ msgstr "Passwort-Authentifizierung"
|
||||||
msgid "Profil"
|
msgid "Profil"
|
||||||
msgstr "Profil"
|
msgstr "Profil"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
#: lib/mv_web/live/custom_field_live/form.ex:62
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required"
|
msgid "Required"
|
||||||
msgstr "Erforderlich"
|
msgstr "Erforderlich"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:37
|
#: lib/mv_web/live/member_live/index.html.heex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select all members"
|
msgid "Select all members"
|
||||||
msgstr "Alle Mitglieder auswählen"
|
msgstr "Alle Mitglieder auswählen"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:51
|
#: lib/mv_web/live/member_live/index.html.heex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select member"
|
msgid "Select member"
|
||||||
msgstr "Mitglied auswählen"
|
msgstr "Mitglied auswählen"
|
||||||
|
|
@ -380,7 +377,7 @@ msgstr "Mitglied auswählen"
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Einstellungen"
|
msgstr "Einstellungen"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:235
|
#: lib/mv_web/live/user_live/form.ex:125
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save User"
|
msgid "Save User"
|
||||||
msgstr "Benutzer*in speichern"
|
msgstr "Benutzer*in speichern"
|
||||||
|
|
@ -405,7 +402,7 @@ msgstr "Nicht unterstützter Wertetyp: %{type}"
|
||||||
msgid "Use this form to manage user records in your database."
|
msgid "Use this form to manage user records in your database."
|
||||||
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
|
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:252
|
#: lib/mv_web/live/user_live/form.ex:142
|
||||||
#: lib/mv_web/live/user_live/show.ex:34
|
#: lib/mv_web/live/user_live/show.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User"
|
msgid "User"
|
||||||
|
|
@ -416,7 +413,7 @@ msgstr "Benutzer*in"
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Wert"
|
msgstr "Wert"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:56
|
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Value type"
|
msgid "Value type"
|
||||||
msgstr "Wertetyp"
|
msgstr "Wertetyp"
|
||||||
|
|
@ -433,7 +430,7 @@ msgstr "aufsteigend"
|
||||||
msgid "descending"
|
msgid "descending"
|
||||||
msgstr "absteigend"
|
msgstr "absteigend"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:251
|
#: lib/mv_web/live/user_live/form.ex:141
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "Neue*r"
|
msgstr "Neue*r"
|
||||||
|
|
@ -508,8 +505,6 @@ msgstr "Passwort setzen"
|
||||||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
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."
|
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
|
#: lib/mv_web/live/user_live/show.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linked Member"
|
msgid "Linked Member"
|
||||||
|
|
@ -520,7 +515,6 @@ msgstr "Verknüpftes Mitglied"
|
||||||
msgid "Linked User"
|
msgid "Linked User"
|
||||||
msgstr "Verknüpfte*r Benutzer*in"
|
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
|
#: lib/mv_web/live/user_live/show.ex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No member linked"
|
msgid "No member linked"
|
||||||
|
|
@ -572,7 +566,7 @@ msgstr "Benutzer*innen"
|
||||||
msgid "Click to sort"
|
msgid "Click to sort"
|
||||||
msgstr "Klicke um zu sortieren"
|
msgstr "Klicke um zu sortieren"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:63
|
#: lib/mv_web/live/member_live/index.html.heex:60
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "First name"
|
msgid "First name"
|
||||||
msgstr "Vorname"
|
msgstr "Vorname"
|
||||||
|
|
@ -624,7 +618,7 @@ msgstr "Benutzerdefinierte Feldwerte"
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
msgstr "Benutzerdefiniertes Feld"
|
msgstr "Benutzerdefiniertes Feld"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:117
|
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom field %{action} successfully"
|
msgid "Custom field %{action} successfully"
|
||||||
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
|
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
|
||||||
|
|
@ -639,7 +633,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
|
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Custom field"
|
msgid "Save Custom field"
|
||||||
msgstr "Benutzerdefiniertes Feld speichern"
|
msgstr "Benutzerdefiniertes Feld speichern"
|
||||||
|
|
@ -649,7 +643,7 @@ msgstr "Benutzerdefiniertes Feld speichern"
|
||||||
msgid "Save Custom field value"
|
msgid "Save Custom field value"
|
||||||
msgstr "Benutzerdefinierten Feldwert speichern"
|
msgstr "Benutzerdefinierten Feldwert speichern"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:46
|
#: lib/mv_web/live/custom_field_live/form.ex:45
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Use this form to manage custom_field records in your database."
|
msgid "Use this form to manage custom_field records in your database."
|
||||||
msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."
|
msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."
|
||||||
|
|
@ -701,59 +695,6 @@ msgstr "Obigen Text zur Bestätigung eingeben"
|
||||||
msgid "To confirm deletion, please enter this text:"
|
msgid "To confirm deletion, please enter this text:"
|
||||||
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
|
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:210
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
|
||||||
msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:185
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Available members"
|
|
||||||
msgstr "Verfügbare Mitglieder"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:152
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Member will be unlinked when you save. Cannot select new member until saved."
|
|
||||||
msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:226
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Save to confirm linking."
|
|
||||||
msgstr "Speichern, um die Verknüpfung zu bestätigen."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:169
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Search for a member to link..."
|
|
||||||
msgstr "Nach einem Mitglied zum Verknüpfen suchen..."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:173
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Search for member to link"
|
|
||||||
msgstr "Nach Mitglied zum Verknüpfen suchen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:223
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Selected"
|
|
||||||
msgstr "Ausgewählt"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:143
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Unlink Member"
|
|
||||||
msgstr "Mitglied entverknüpfen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:152
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Unlinking scheduled"
|
|
||||||
msgstr "Entverknüpfung geplant"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:342
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Failed to link member: %{error}"
|
|
||||||
msgstr ""
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Show in overview"
|
|
||||||
msgstr "In der Mitglieder-Übersicht anzeigen"
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex:51
|
#: lib/mv_web/live/global_settings_live.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Association Name"
|
msgid "Association Name"
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,3 @@ msgstr "muss mindestens 8 Zeichen lang sein"
|
||||||
|
|
||||||
msgid "is required"
|
msgid "is required"
|
||||||
msgstr "ist erforderlich"
|
msgstr "ist erforderlich"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
|
||||||
msgid "Failed to link member: %{error}"
|
|
||||||
msgstr "Fehler beim Verknüpfen des Mitglieds: %{error}"
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,12 @@
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex:356
|
#: lib/mv_web/components/core_components.ex:339
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:65
|
#: lib/mv_web/live/user_live/index.html.heex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Are you sure?"
|
msgid "Are you sure?"
|
||||||
|
|
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:54
|
#: lib/mv_web/live/member_live/form.ex:54
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
#: lib/mv_web/live/member_live/index.html.heex:145
|
||||||
#: lib/mv_web/live/member_live/show.ex:59
|
#: lib/mv_web/live/member_live/show.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:204
|
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:67
|
#: lib/mv_web/live/user_live/index.html.heex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:196
|
#: lib/mv_web/live/member_live/index.html.heex:194
|
||||||
#: lib/mv_web/live/user_live/form.ex:141
|
#: 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/index.html.heex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -55,7 +55,7 @@ msgid "Edit Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:47
|
#: lib/mv_web/live/member_live/form.ex:47
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:80
|
#: lib/mv_web/live/member_live/index.html.heex:77
|
||||||
#: lib/mv_web/live/member_live/show.ex:50
|
#: lib/mv_web/live/member_live/show.ex:50
|
||||||
#: lib/mv_web/live/user_live/form.ex:46
|
#: lib/mv_web/live/user_live/form.ex:46
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||||
|
|
@ -71,7 +71,7 @@ msgid "First Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:51
|
#: lib/mv_web/live/member_live/form.ex:51
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||||
#: lib/mv_web/live/member_live/show.ex:56
|
#: lib/mv_web/live/member_live/show.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join Date"
|
msgid "Join Date"
|
||||||
|
|
@ -88,7 +88,7 @@ msgstr ""
|
||||||
msgid "New Member"
|
msgid "New Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
#: lib/mv_web/live/member_live/index.html.heex:191
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:56
|
#: lib/mv_web/live/user_live/index.html.heex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show"
|
msgid "Show"
|
||||||
|
|
@ -122,7 +122,7 @@ msgid "Exit Date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:56
|
#: lib/mv_web/live/member_live/form.ex:56
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:114
|
#: lib/mv_web/live/member_live/index.html.heex:111
|
||||||
#: lib/mv_web/live/member_live/show.ex:61
|
#: lib/mv_web/live/member_live/show.ex:61
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "House Number"
|
msgid "House Number"
|
||||||
|
|
@ -141,14 +141,14 @@ msgid "Paid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:50
|
#: lib/mv_web/live/member_live/form.ex:50
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
#: lib/mv_web/live/member_live/index.html.heex:162
|
||||||
#: lib/mv_web/live/member_live/show.ex:55
|
#: lib/mv_web/live/member_live/show.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Phone Number"
|
msgid "Phone Number"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:57
|
#: lib/mv_web/live/member_live/form.ex:57
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
#: lib/mv_web/live/member_live/index.html.heex:128
|
||||||
#: lib/mv_web/live/member_live/show.ex:62
|
#: lib/mv_web/live/member_live/show.ex:62
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Postal Code"
|
msgid "Postal Code"
|
||||||
|
|
@ -159,17 +159,17 @@ msgstr ""
|
||||||
msgid "Save Member"
|
msgid "Save Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:66
|
#: 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/custom_field_value_live/form.ex:74
|
||||||
#: lib/mv_web/live/global_settings_live.ex:55
|
#: lib/mv_web/live/global_settings_live.ex:55
|
||||||
#: lib/mv_web/live/member_live/form.ex:79
|
#: lib/mv_web/live/member_live/form.ex:79
|
||||||
#: lib/mv_web/live/user_live/form.ex:234
|
#: lib/mv_web/live/user_live/form.ex:124
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Saving..."
|
msgid "Saving..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:55
|
#: lib/mv_web/live/member_live/form.ex:55
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
#: lib/mv_web/live/member_live/index.html.heex:94
|
||||||
#: lib/mv_web/live/member_live/show.ex:60
|
#: lib/mv_web/live/member_live/show.ex:60
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
|
|
@ -185,7 +185,6 @@ msgstr ""
|
||||||
msgid "Id"
|
msgid "Id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex:65
|
|
||||||
#: lib/mv_web/live/member_live/show.ex:53
|
#: lib/mv_web/live/member_live/show.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No"
|
msgid "No"
|
||||||
|
|
@ -201,20 +200,19 @@ msgstr ""
|
||||||
msgid "This is a member record from your database."
|
msgid "This is a member record from your database."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex:64
|
|
||||||
#: lib/mv_web/live/member_live/show.ex:53
|
#: lib/mv_web/live/member_live/show.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yes"
|
msgid "Yes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:110
|
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
||||||
#: lib/mv_web/live/member_live/form.ex:138
|
#: lib/mv_web/live/member_live/form.ex:138
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "create"
|
msgid "create"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:111
|
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
||||||
#: lib/mv_web/live/member_live/form.ex:139
|
#: lib/mv_web/live/member_live/form.ex:139
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -256,11 +254,11 @@ msgstr ""
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:69
|
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex:120
|
#: lib/mv_web/live/custom_field_live/index.ex:120
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
||||||
#: lib/mv_web/live/member_live/form.ex:82
|
#: lib/mv_web/live/member_live/form.ex:82
|
||||||
#: lib/mv_web/live/user_live/form.ex:237
|
#: lib/mv_web/live/user_live/form.ex:127
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -270,7 +268,7 @@ msgstr ""
|
||||||
msgid "Choose a member"
|
msgid "Choose a member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Description"
|
msgid "Description"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -290,7 +288,7 @@ msgstr ""
|
||||||
msgid "ID"
|
msgid "ID"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:62
|
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Immutable"
|
msgid "Immutable"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -318,7 +316,7 @@ msgstr ""
|
||||||
msgid "Members"
|
msgid "Members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:51
|
#: lib/mv_web/live/custom_field_live/form.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -340,7 +338,6 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:107
|
#: 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:115
|
||||||
#: lib/mv_web/live/user_live/form.ex:210
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Note"
|
msgid "Note"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -361,17 +358,17 @@ msgstr ""
|
||||||
msgid "Profil"
|
msgid "Profil"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
#: lib/mv_web/live/custom_field_live/form.ex:62
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required"
|
msgid "Required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:37
|
#: lib/mv_web/live/member_live/index.html.heex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select all members"
|
msgid "Select all members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:51
|
#: lib/mv_web/live/member_live/index.html.heex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select member"
|
msgid "Select member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -381,7 +378,7 @@ msgstr ""
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:235
|
#: lib/mv_web/live/user_live/form.ex:125
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save User"
|
msgid "Save User"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -406,7 +403,7 @@ msgstr ""
|
||||||
msgid "Use this form to manage user records in your database."
|
msgid "Use this form to manage user records in your database."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:252
|
#: lib/mv_web/live/user_live/form.ex:142
|
||||||
#: lib/mv_web/live/user_live/show.ex:34
|
#: lib/mv_web/live/user_live/show.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User"
|
msgid "User"
|
||||||
|
|
@ -417,7 +414,7 @@ msgstr ""
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:56
|
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Value type"
|
msgid "Value type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -434,7 +431,7 @@ msgstr ""
|
||||||
msgid "descending"
|
msgid "descending"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:251
|
#: lib/mv_web/live/user_live/form.ex:141
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -509,8 +506,6 @@ msgstr ""
|
||||||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
msgid "User will be created without a password. Check 'Set Password' to add one."
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/user_live/show.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linked Member"
|
msgid "Linked Member"
|
||||||
|
|
@ -521,7 +516,6 @@ msgstr ""
|
||||||
msgid "Linked User"
|
msgid "Linked User"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:57
|
|
||||||
#: lib/mv_web/live/user_live/show.ex:65
|
#: lib/mv_web/live/user_live/show.ex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No member linked"
|
msgid "No member linked"
|
||||||
|
|
@ -573,7 +567,7 @@ msgstr ""
|
||||||
msgid "Click to sort"
|
msgid "Click to sort"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:63
|
#: lib/mv_web/live/member_live/index.html.heex:60
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "First name"
|
msgid "First name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -625,7 +619,7 @@ msgstr ""
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:117
|
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom field %{action} successfully"
|
msgid "Custom field %{action} successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -640,7 +634,7 @@ msgstr ""
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Custom field"
|
msgid "Save Custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -650,7 +644,7 @@ msgstr ""
|
||||||
msgid "Save Custom field value"
|
msgid "Save Custom field value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:46
|
#: lib/mv_web/live/custom_field_live/form.ex:45
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Use this form to manage custom_field records in your database."
|
msgid "Use this form to manage custom_field records in your database."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -702,9 +696,6 @@ msgstr ""
|
||||||
msgid "To confirm deletion, please enter this text:"
|
msgid "To confirm deletion, please enter this text:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Show in overview"
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex:51
|
#: lib/mv_web/live/global_settings_live.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Association Name"
|
msgid "Association Name"
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,12 @@ msgstr ""
|
||||||
"Language: en\n"
|
"Language: en\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex:356
|
#: lib/mv_web/components/core_components.ex:339
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:65
|
#: lib/mv_web/live/user_live/index.html.heex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Are you sure?"
|
msgid "Are you sure?"
|
||||||
|
|
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:54
|
#: lib/mv_web/live/member_live/form.ex:54
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
#: lib/mv_web/live/member_live/index.html.heex:145
|
||||||
#: lib/mv_web/live/member_live/show.ex:59
|
#: lib/mv_web/live/member_live/show.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:204
|
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:67
|
#: lib/mv_web/live/user_live/index.html.heex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:196
|
#: lib/mv_web/live/member_live/index.html.heex:194
|
||||||
#: lib/mv_web/live/user_live/form.ex:141
|
#: 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/index.html.heex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -55,7 +55,7 @@ msgid "Edit Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:47
|
#: lib/mv_web/live/member_live/form.ex:47
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:80
|
#: lib/mv_web/live/member_live/index.html.heex:77
|
||||||
#: lib/mv_web/live/member_live/show.ex:50
|
#: lib/mv_web/live/member_live/show.ex:50
|
||||||
#: lib/mv_web/live/user_live/form.ex:46
|
#: lib/mv_web/live/user_live/form.ex:46
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||||
|
|
@ -71,7 +71,7 @@ msgid "First Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:51
|
#: lib/mv_web/live/member_live/form.ex:51
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||||
#: lib/mv_web/live/member_live/show.ex:56
|
#: lib/mv_web/live/member_live/show.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join Date"
|
msgid "Join Date"
|
||||||
|
|
@ -88,7 +88,7 @@ msgstr ""
|
||||||
msgid "New Member"
|
msgid "New Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
#: lib/mv_web/live/member_live/index.html.heex:191
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:56
|
#: lib/mv_web/live/user_live/index.html.heex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show"
|
msgid "Show"
|
||||||
|
|
@ -122,7 +122,7 @@ msgid "Exit Date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:56
|
#: lib/mv_web/live/member_live/form.ex:56
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:114
|
#: lib/mv_web/live/member_live/index.html.heex:111
|
||||||
#: lib/mv_web/live/member_live/show.ex:61
|
#: lib/mv_web/live/member_live/show.ex:61
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "House Number"
|
msgid "House Number"
|
||||||
|
|
@ -141,14 +141,14 @@ msgid "Paid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:50
|
#: lib/mv_web/live/member_live/form.ex:50
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
#: lib/mv_web/live/member_live/index.html.heex:162
|
||||||
#: lib/mv_web/live/member_live/show.ex:55
|
#: lib/mv_web/live/member_live/show.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Phone Number"
|
msgid "Phone Number"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:57
|
#: lib/mv_web/live/member_live/form.ex:57
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
#: lib/mv_web/live/member_live/index.html.heex:128
|
||||||
#: lib/mv_web/live/member_live/show.ex:62
|
#: lib/mv_web/live/member_live/show.ex:62
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Postal Code"
|
msgid "Postal Code"
|
||||||
|
|
@ -159,17 +159,17 @@ msgstr ""
|
||||||
msgid "Save Member"
|
msgid "Save Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:66
|
#: 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/custom_field_value_live/form.ex:74
|
||||||
#: lib/mv_web/live/global_settings_live.ex:55
|
#: lib/mv_web/live/global_settings_live.ex:55
|
||||||
#: lib/mv_web/live/member_live/form.ex:79
|
#: lib/mv_web/live/member_live/form.ex:79
|
||||||
#: lib/mv_web/live/user_live/form.ex:234
|
#: lib/mv_web/live/user_live/form.ex:124
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Saving..."
|
msgid "Saving..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex:55
|
#: lib/mv_web/live/member_live/form.ex:55
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
#: lib/mv_web/live/member_live/index.html.heex:94
|
||||||
#: lib/mv_web/live/member_live/show.ex:60
|
#: lib/mv_web/live/member_live/show.ex:60
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
|
|
@ -185,7 +185,6 @@ msgstr ""
|
||||||
msgid "Id"
|
msgid "Id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex:65
|
|
||||||
#: lib/mv_web/live/member_live/show.ex:53
|
#: lib/mv_web/live/member_live/show.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No"
|
msgid "No"
|
||||||
|
|
@ -201,20 +200,19 @@ msgstr ""
|
||||||
msgid "This is a member record from your database."
|
msgid "This is a member record from your database."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex:64
|
|
||||||
#: lib/mv_web/live/member_live/show.ex:53
|
#: lib/mv_web/live/member_live/show.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yes"
|
msgid "Yes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:110
|
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
||||||
#: lib/mv_web/live/member_live/form.ex:138
|
#: lib/mv_web/live/member_live/form.ex:138
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "create"
|
msgid "create"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:111
|
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
||||||
#: lib/mv_web/live/member_live/form.ex:139
|
#: lib/mv_web/live/member_live/form.ex:139
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -256,11 +254,11 @@ msgstr ""
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:69
|
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex:120
|
#: lib/mv_web/live/custom_field_live/index.ex:120
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
||||||
#: lib/mv_web/live/member_live/form.ex:82
|
#: lib/mv_web/live/member_live/form.ex:82
|
||||||
#: lib/mv_web/live/user_live/form.ex:237
|
#: lib/mv_web/live/user_live/form.ex:127
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -270,7 +268,7 @@ msgstr ""
|
||||||
msgid "Choose a member"
|
msgid "Choose a member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Description"
|
msgid "Description"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -290,7 +288,7 @@ msgstr ""
|
||||||
msgid "ID"
|
msgid "ID"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:62
|
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Immutable"
|
msgid "Immutable"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -318,7 +316,7 @@ msgstr ""
|
||||||
msgid "Members"
|
msgid "Members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:51
|
#: lib/mv_web/live/custom_field_live/form.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -340,7 +338,6 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:107
|
#: 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:115
|
||||||
#: lib/mv_web/live/user_live/form.ex:210
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Note"
|
msgid "Note"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -361,17 +358,17 @@ msgstr ""
|
||||||
msgid "Profil"
|
msgid "Profil"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
#: lib/mv_web/live/custom_field_live/form.ex:62
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required"
|
msgid "Required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:37
|
#: lib/mv_web/live/member_live/index.html.heex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select all members"
|
msgid "Select all members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:51
|
#: lib/mv_web/live/member_live/index.html.heex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select member"
|
msgid "Select member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -381,7 +378,7 @@ msgstr ""
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:235
|
#: lib/mv_web/live/user_live/form.ex:125
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Save User"
|
msgid "Save User"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -406,7 +403,7 @@ msgstr ""
|
||||||
msgid "Use this form to manage user records in your database."
|
msgid "Use this form to manage user records in your database."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:252
|
#: lib/mv_web/live/user_live/form.ex:142
|
||||||
#: lib/mv_web/live/user_live/show.ex:34
|
#: lib/mv_web/live/user_live/show.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User"
|
msgid "User"
|
||||||
|
|
@ -417,7 +414,7 @@ msgstr ""
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:56
|
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Value type"
|
msgid "Value type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -434,7 +431,7 @@ msgstr ""
|
||||||
msgid "descending"
|
msgid "descending"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:251
|
#: lib/mv_web/live/user_live/form.ex:141
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -509,8 +506,6 @@ msgstr "Set Password"
|
||||||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
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."
|
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
|
#: lib/mv_web/live/user_live/show.ex:55
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Linked Member"
|
msgid "Linked Member"
|
||||||
|
|
@ -521,7 +516,6 @@ msgstr ""
|
||||||
msgid "Linked User"
|
msgid "Linked User"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex:57
|
|
||||||
#: lib/mv_web/live/user_live/show.ex:65
|
#: lib/mv_web/live/user_live/show.ex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No member linked"
|
msgid "No member linked"
|
||||||
|
|
@ -573,7 +567,7 @@ msgstr ""
|
||||||
msgid "Click to sort"
|
msgid "Click to sort"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex:63
|
#: lib/mv_web/live/member_live/index.html.heex:60
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "First name"
|
msgid "First name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -625,7 +619,7 @@ msgstr ""
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:117
|
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom field %{action} successfully"
|
msgid "Custom field %{action} successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -640,7 +634,7 @@ msgstr ""
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Custom field"
|
msgid "Save Custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -650,7 +644,7 @@ msgstr ""
|
||||||
msgid "Save Custom field value"
|
msgid "Save Custom field value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:46
|
#: lib/mv_web/live/custom_field_live/form.ex:45
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Use this form to manage custom_field records in your database."
|
msgid "Use this form to manage custom_field records in your database."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -702,58 +696,6 @@ msgstr ""
|
||||||
msgid "To confirm deletion, please enter this text:"
|
msgid "To confirm deletion, please enter this text:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:210
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:185
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Available members"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:152
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Member will be unlinked when you save. Cannot select new member until saved."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:226
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Save to confirm linking."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:169
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Search for a member to link..."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:173
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Search for member to link"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:223
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Selected"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:143
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Unlink Member"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:152
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Unlinking scheduled"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex:342
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Failed to link member: %{error}"
|
|
||||||
msgstr ""
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Show in overview"
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex:51
|
#: lib/mv_web/live/global_settings_live.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Association Name"
|
msgid "Association Name"
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,3 @@ msgstr ""
|
||||||
|
|
||||||
msgid "is required"
|
msgid "is required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
|
||||||
msgid "Failed to link member: %{error}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,3 @@ msgstr ""
|
||||||
|
|
||||||
msgid "is required"
|
msgid "is required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
|
||||||
msgid "Failed to link member: %{error}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields do
|
|
||||||
@moduledoc """
|
|
||||||
Updates resources based on their most recent snapshots.
|
|
||||||
|
|
||||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
|
||||||
"""
|
|
||||||
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def up do
|
|
||||||
alter table(:custom_fields) do
|
|
||||||
add :show_in_overview, :boolean, null: false, default: true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def down do
|
|
||||||
alter table(:custom_fields) do
|
|
||||||
remove :show_in_overview
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
{
|
|
||||||
"attributes": [
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "fragment(\"gen_random_uuid()\")",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": true,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "id",
|
|
||||||
"type": "uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "name",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "value_type",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "description",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "false",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "immutable",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "false",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "required",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "false",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "show_in_overview",
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"base_filter": null,
|
|
||||||
"check_constraints": [],
|
|
||||||
"custom_indexes": [],
|
|
||||||
"custom_statements": [],
|
|
||||||
"has_create_action": true,
|
|
||||||
"hash": "9FBFC42DA896058F88DEDAE774614919222BF2EF2F8CB27386D02C2CE67F03DE",
|
|
||||||
"identities": [
|
|
||||||
{
|
|
||||||
"all_tenants?": false,
|
|
||||||
"base_filter": null,
|
|
||||||
"index_name": "custom_fields_unique_name_index",
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"type": "atom",
|
|
||||||
"value": "name"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "unique_name",
|
|
||||||
"nils_distinct?": true,
|
|
||||||
"where": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"multitenancy": {
|
|
||||||
"attribute": null,
|
|
||||||
"global": null,
|
|
||||||
"strategy": null
|
|
||||||
},
|
|
||||||
"repo": "Elixir.Mv.Repo",
|
|
||||||
"schema": null,
|
|
||||||
"table": "custom_fields"
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
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: false
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
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: false
|
|
||||||
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
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
defmodule Mv.Membership.CustomFieldShowInOverviewTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for CustomField show_in_overview attribute.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Creating custom fields with show_in_overview: true
|
|
||||||
- Creating custom fields with show_in_overview: false (default)
|
|
||||||
- Updating show_in_overview to true
|
|
||||||
- Updating show_in_overview to false
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: true
|
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
|
||||||
|
|
||||||
describe "show_in_overview attribute" do
|
|
||||||
test "creates custom field with show_in_overview: true" do
|
|
||||||
assert {:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "test_field_show",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
assert custom_field.show_in_overview == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "creates custom field with show_in_overview: true (default)" do
|
|
||||||
assert {:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "test_field_hide",
|
|
||||||
value_type: :string
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
assert custom_field.show_in_overview == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "updates show_in_overview to true" do
|
|
||||||
{:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "test_field_update",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: false
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
assert {:ok, updated_field} =
|
|
||||||
custom_field
|
|
||||||
|> Ash.Changeset.for_update(:update, %{show_in_overview: true})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
assert updated_field.show_in_overview == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "updates show_in_overview to false" do
|
|
||||||
{:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "test_field_update2",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
assert {:ok, updated_field} =
|
|
||||||
custom_field
|
|
||||||
|> Ash.Changeset.for_update(:update, %{show_in_overview: false})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
assert updated_field.show_in_overview == false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
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: false
|
|
||||||
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 "user_email takes precedence over search_query", %{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!()
|
|
||||||
|
|
||||||
# Apply email-match filter (as LiveView does)
|
|
||||||
members = Mv.Membership.Member.filter_by_email_match(raw_members, target_member.email)
|
|
||||||
|
|
||||||
# Email takes precedence: should match target_member by email, ignoring search_query
|
|
||||||
assert length(members) == 1
|
|
||||||
assert List.first(members).id == target_member.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
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: false
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
|
|
||||||
@moduledoc """
|
|
||||||
Accessibility tests for custom field columns in the member overview.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- SortHeaderComponent for custom fields has correct ARIA labels
|
|
||||||
- Tab navigation works for custom field columns
|
|
||||||
- Screen reader announcements for sorting
|
|
||||||
"""
|
|
||||||
use MvWeb.ConnCase, async: true
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
|
||||||
|
|
||||||
setup do
|
|
||||||
# Create test member
|
|
||||||
{:ok, member} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field with show_in_overview: true
|
|
||||||
{:ok, field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "membership_number",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field value
|
|
||||||
{:ok, _cfv} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "A001"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
%{member: member, field: field}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sort header component for custom fields has correct ARIA labels", %{
|
|
||||||
conn: conn,
|
|
||||||
field: field
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that the sort button has aria-label
|
|
||||||
assert html =~ ~r/aria-label=["']Click to sort["']/i or
|
|
||||||
html =~ ~r/aria-label=["'].*sort.*["']/i
|
|
||||||
|
|
||||||
# Check that data-testid is present for testing
|
|
||||||
assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sort header component shows correct ARIA label when sorted ascending", %{
|
|
||||||
conn: conn,
|
|
||||||
field: field
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Check that aria-label indicates ascending sort
|
|
||||||
assert html =~ ~r/aria-label=["'].*ascending.*["']/i
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sort header component shows correct ARIA label when sorted descending", %{
|
|
||||||
conn: conn,
|
|
||||||
field: field
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Check that aria-label indicates descending sort
|
|
||||||
assert html =~ ~r/aria-label=["'].*descending.*["']/i
|
|
||||||
end
|
|
||||||
|
|
||||||
test "custom field column header is keyboard accessible", %{conn: conn, field: field} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that the sort button is a button element (keyboard accessible)
|
|
||||||
assert html =~ ~r/<button[^>]*data-testid=["']custom_field_#{field.id}["']/
|
|
||||||
|
|
||||||
# Button should not have tabindex="-1" (which would remove from tab order)
|
|
||||||
refute html =~ ~r/tabindex=["']-1["']/
|
|
||||||
end
|
|
||||||
|
|
||||||
test "custom field column header has proper semantic structure", %{conn: conn, field: field} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that custom field name is displayed in the header
|
|
||||||
assert html =~ field.name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for displaying custom fields in the member overview.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Custom fields with show_in_overview: true are displayed
|
|
||||||
- Custom fields with show_in_overview: false are not displayed
|
|
||||||
- Multiple custom fields with show_in_overview: true are all displayed
|
|
||||||
- Custom field values are correctly formatted for different types
|
|
||||||
- Members without custom field values show empty cell or "-"
|
|
||||||
"""
|
|
||||||
use MvWeb.ConnCase, async: true
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
|
||||||
|
|
||||||
setup do
|
|
||||||
# Create test members
|
|
||||||
{:ok, member1} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member2} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Bob",
|
|
||||||
last_name: "Brown",
|
|
||||||
email: "bob@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom fields
|
|
||||||
{:ok, field_show_string} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "phone_mobile",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, field_hide} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "internal_note",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: false
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, field_show_integer} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "membership_number",
|
|
||||||
value_type: :integer,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, field_show_boolean} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "newsletter",
|
|
||||||
value_type: :boolean,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, field_show_date} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "birthday",
|
|
||||||
value_type: :date,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, field_show_email} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "secondary_email",
|
|
||||||
value_type: :email,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field values for member1
|
|
||||||
{:ok, _cfv1} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member1.id,
|
|
||||||
custom_field_id: field_show_string.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member1.id,
|
|
||||||
custom_field_id: field_show_integer.id,
|
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 12_345}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member1.id,
|
|
||||||
custom_field_id: field_show_boolean.id,
|
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv4} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member1.id,
|
|
||||||
custom_field_id: field_show_date.id,
|
|
||||||
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv5} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member1.id,
|
|
||||||
custom_field_id: field_show_email.id,
|
|
||||||
value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create hidden custom field value (should not be displayed)
|
|
||||||
{:ok, _cfv_hidden} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member1.id,
|
|
||||||
custom_field_id: field_hide.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Internal note"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
%{
|
|
||||||
member1: member1,
|
|
||||||
member2: member2,
|
|
||||||
field_show_string: field_show_string,
|
|
||||||
field_hide: field_hide,
|
|
||||||
field_show_integer: field_show_integer,
|
|
||||||
field_show_boolean: field_show_boolean,
|
|
||||||
field_show_date: field_show_date,
|
|
||||||
field_show_email: field_show_email
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "displays custom field with show_in_overview: true", %{
|
|
||||||
conn: conn,
|
|
||||||
member1: _member1,
|
|
||||||
field_show_string: field
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that the custom field column header is displayed
|
|
||||||
assert html =~ field.name
|
|
||||||
|
|
||||||
# Check that the value is displayed
|
|
||||||
assert html =~ "+49123456789"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not display custom field with show_in_overview: false", %{
|
|
||||||
conn: conn,
|
|
||||||
member1: _member1,
|
|
||||||
field_hide: field
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that the hidden custom field column header is NOT displayed
|
|
||||||
refute html =~ field.name
|
|
||||||
|
|
||||||
# Check that the value is NOT displayed
|
|
||||||
refute html =~ "Internal note"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "displays multiple custom fields with show_in_overview: true", %{
|
|
||||||
conn: conn,
|
|
||||||
field_show_string: field_string,
|
|
||||||
field_show_integer: field_integer,
|
|
||||||
field_show_boolean: field_boolean
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that all visible custom field column headers are displayed
|
|
||||||
assert html =~ field_string.name
|
|
||||||
assert html =~ field_integer.name
|
|
||||||
assert html =~ field_boolean.name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "formats string custom field values correctly", %{conn: conn, member1: _member1} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
assert html =~ "+49123456789"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "formats integer custom field values correctly", %{conn: conn, member1: _member1} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
assert html =~ "12345"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "formats boolean custom field values correctly", %{conn: conn, member1: _member1} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Boolean should be displayed as "Yes" or "No" or similar
|
|
||||||
# Check for true representation
|
|
||||||
assert html =~ "true" or html =~ "Yes" or html =~ "Ja"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "formats date custom field values correctly", %{conn: conn, member1: _member1} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Date should be displayed in readable format
|
|
||||||
assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
assert html =~ "alice.private@example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shows empty cell or placeholder for members without custom field values", %{
|
|
||||||
conn: conn,
|
|
||||||
member2: _member2,
|
|
||||||
field_show_string: field
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# The custom field column should exist
|
|
||||||
assert html =~ field.name
|
|
||||||
|
|
||||||
# Member2 should have an empty cell for this field
|
|
||||||
# We check that member2's row exists but doesn't have the value
|
|
||||||
assert html =~ "Bob Brown"
|
|
||||||
# The value should not appear for member2 (only for member1)
|
|
||||||
# We check that the value appears somewhere (for member1) but member2 row should have "-"
|
|
||||||
assert html =~ "+49123456789"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
|
||||||
@moduledoc """
|
|
||||||
Edge case tests for custom fields in the member overview.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Custom field without values (all members have no value)
|
|
||||||
- Very long custom field values are correctly displayed
|
|
||||||
"""
|
|
||||||
use MvWeb.ConnCase, async: true
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, Member}
|
|
||||||
|
|
||||||
test "displays custom field column even when no members have values", %{conn: conn} do
|
|
||||||
# Create test members without custom field values
|
|
||||||
{:ok, _member1} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _member2} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Bob",
|
|
||||||
last_name: "Brown",
|
|
||||||
email: "bob@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field with show_in_overview: true but no values
|
|
||||||
{:ok, field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "membership_number",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that the custom field column header is still displayed
|
|
||||||
assert html =~ field.name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "displays very long custom field values correctly", %{conn: conn} do
|
|
||||||
# Create test member
|
|
||||||
{:ok, member} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field
|
|
||||||
{:ok, field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "long_note",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create very long value (but within limits)
|
|
||||||
long_value = String.duplicate("A", 500)
|
|
||||||
|
|
||||||
{:ok, _cfv} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => long_value}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that the value is displayed (may be truncated in UI, but should be present)
|
|
||||||
# We check for at least part of the value
|
|
||||||
assert html =~ "A" or html =~ long_value
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do
|
|
||||||
# Create test member
|
|
||||||
{:ok, member} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create multiple custom fields with show_in_overview: true
|
|
||||||
{:ok, field1} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "field1",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, field2} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "field2",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, field3} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "field3",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create values for all fields
|
|
||||||
{:ok, _cfv1} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: field1.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Value1"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: field2.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Value2"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: field3.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Value3"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Check that all custom field columns are displayed
|
|
||||||
assert html =~ field1.name
|
|
||||||
assert html =~ field2.name
|
|
||||||
assert html =~ field3.name
|
|
||||||
|
|
||||||
# Check that all values are displayed
|
|
||||||
assert html =~ "Value1"
|
|
||||||
assert html =~ "Value2"
|
|
||||||
assert html =~ "Value3"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,459 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for sorting by custom fields in the member overview.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Sorting by custom field (ascending)
|
|
||||||
- Sorting by custom field (descending)
|
|
||||||
- Sorting by custom field works with search
|
|
||||||
- Sorting by custom field works with URL parameters
|
|
||||||
- Sorting by custom field works with other columns
|
|
||||||
"""
|
|
||||||
use MvWeb.ConnCase, async: true
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
|
||||||
|
|
||||||
setup do
|
|
||||||
# Create test members
|
|
||||||
{:ok, member1} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member2} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Bob",
|
|
||||||
last_name: "Brown",
|
|
||||||
email: "bob@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member3} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Charlie",
|
|
||||||
last_name: "Clark",
|
|
||||||
email: "charlie@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field with show_in_overview: true
|
|
||||||
{:ok, field_string} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "membership_number",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, field_integer} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "priority",
|
|
||||||
value_type: :integer,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field values
|
|
||||||
{:ok, _cfv1} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member1.id,
|
|
||||||
custom_field_id: field_string.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "A001"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member2.id,
|
|
||||||
custom_field_id: field_string.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "C003"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member3.id,
|
|
||||||
custom_field_id: field_string.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "B002"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv4} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member1.id,
|
|
||||||
custom_field_id: field_integer.id,
|
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 10}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv5} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member2.id,
|
|
||||||
custom_field_id: field_integer.id,
|
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 30}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv6} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member3.id,
|
|
||||||
custom_field_id: field_integer.id,
|
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 20}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
%{
|
|
||||||
member1: member1,
|
|
||||||
member2: member2,
|
|
||||||
member3: member3,
|
|
||||||
field_string: field_string,
|
|
||||||
field_integer: field_integer
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sorts by custom field ascending", %{conn: conn, field_string: field} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Click on custom field column header to sort
|
|
||||||
view
|
|
||||||
|> element("[data-testid='custom_field_#{field.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Check URL was updated
|
|
||||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
|
||||||
|
|
||||||
# Verify sort state
|
|
||||||
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sorts by custom field descending", %{conn: conn, field_string: field} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members?sort_field=custom_field_#{field.id}&sort_order=asc")
|
|
||||||
|
|
||||||
# Click again to toggle to descending
|
|
||||||
view
|
|
||||||
|> element("[data-testid='custom_field_#{field.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Check URL was updated
|
|
||||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
|
||||||
|
|
||||||
# Verify sort state
|
|
||||||
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sorting by custom field works with search", %{conn: conn, field_string: field} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members?query=Alice")
|
|
||||||
|
|
||||||
# Click on custom field column header to sort
|
|
||||||
view
|
|
||||||
|> element("[data-testid='custom_field_#{field.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Check URL maintains search query
|
|
||||||
assert_patch(view, "/members?query=Alice&sort_field=custom_field_#{field.id}&sort_order=asc")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
|
||||||
|
|
||||||
# Check that the sort state is correctly applied
|
|
||||||
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "clicking different custom field column resets order to ascending", %{
|
|
||||||
conn: conn,
|
|
||||||
field_string: field_string,
|
|
||||||
field_integer: field_integer
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc")
|
|
||||||
|
|
||||||
# Click on a different custom field column
|
|
||||||
view
|
|
||||||
|> element("[data-testid='custom_field_#{field_integer.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
assert_patch(
|
|
||||||
view,
|
|
||||||
"/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "clicking regular column after custom field column works", %{
|
|
||||||
conn: conn,
|
|
||||||
field_string: field
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
|
||||||
|
|
||||||
# Click on email column
|
|
||||||
view
|
|
||||||
|> element("[data-testid='email']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "clicking custom field column after regular column works", %{
|
|
||||||
conn: conn,
|
|
||||||
field_string: field
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
|
||||||
|
|
||||||
# Click on custom field column
|
|
||||||
view
|
|
||||||
|> element("[data-testid='custom_field_#{field.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
|
|
||||||
# Create additional members with NULL and empty string values
|
|
||||||
{:ok, member_with_value} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "WithValue",
|
|
||||||
last_name: "Test",
|
|
||||||
email: "withvalue@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member_with_empty} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "WithEmpty",
|
|
||||||
last_name: "Test",
|
|
||||||
email: "withempty@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member_with_null} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "WithNull",
|
|
||||||
last_name: "Test",
|
|
||||||
email: "withnull@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member_with_another_value} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "AnotherValue",
|
|
||||||
last_name: "Test",
|
|
||||||
email: "another@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field
|
|
||||||
{:ok, field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "test_field",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
|
|
||||||
{:ok, _cfv1} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member_with_value.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member_with_empty.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => ""}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# member_with_null has no custom field value (NULL)
|
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member_with_another_value.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Apple"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Find positions of member first names in the HTML to verify sort order
|
|
||||||
apple_pos = :binary.match(html, member_with_another_value.first_name)
|
|
||||||
zebra_pos = :binary.match(html, member_with_value.first_name)
|
|
||||||
empty_pos = :binary.match(html, member_with_empty.first_name)
|
|
||||||
null_pos = :binary.match(html, member_with_null.first_name)
|
|
||||||
|
|
||||||
assert apple_pos != :nomatch, "AnotherValue (Apple) should be in HTML"
|
|
||||||
assert zebra_pos != :nomatch, "WithValue (Zebra) should be in HTML"
|
|
||||||
assert empty_pos != :nomatch, "WithEmpty should be in HTML"
|
|
||||||
assert null_pos != :nomatch, "WithNull should be in HTML"
|
|
||||||
|
|
||||||
{apple_idx, _} = apple_pos
|
|
||||||
{zebra_idx, _} = zebra_pos
|
|
||||||
{empty_idx, _} = empty_pos
|
|
||||||
{null_idx, _} = null_pos
|
|
||||||
|
|
||||||
# In ASC order: Apple should come before Zebra
|
|
||||||
assert apple_idx < zebra_idx, "Apple should come before Zebra in ASC order"
|
|
||||||
|
|
||||||
# NULL and empty should come after all values
|
|
||||||
assert apple_idx < empty_idx, "Apple should come before empty value"
|
|
||||||
assert apple_idx < null_idx, "Apple should come before NULL value"
|
|
||||||
assert zebra_idx < empty_idx, "Zebra should come before empty value"
|
|
||||||
assert zebra_idx < null_idx, "Zebra should come before NULL value"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do
|
|
||||||
# Create additional members with NULL and empty string values
|
|
||||||
{:ok, member_with_value} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "WithValue",
|
|
||||||
last_name: "Test",
|
|
||||||
email: "withvalue@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member_with_empty} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "WithEmpty",
|
|
||||||
last_name: "Test",
|
|
||||||
email: "withempty@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member_with_null} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "WithNull",
|
|
||||||
last_name: "Test",
|
|
||||||
email: "withnull@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, member_with_another_value} =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "AnotherValue",
|
|
||||||
last_name: "Test",
|
|
||||||
email: "another@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create custom field
|
|
||||||
{:ok, field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "test_field",
|
|
||||||
value_type: :string,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
|
|
||||||
{:ok, _cfv1} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member_with_value.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Apple"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member_with_empty.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => ""}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# member_with_null has no custom field value (NULL)
|
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member_with_another_value.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Find positions of member first names in the HTML to verify sort order
|
|
||||||
apple_pos = :binary.match(html, member_with_value.first_name)
|
|
||||||
zebra_pos = :binary.match(html, member_with_another_value.first_name)
|
|
||||||
empty_pos = :binary.match(html, member_with_empty.first_name)
|
|
||||||
null_pos = :binary.match(html, member_with_null.first_name)
|
|
||||||
|
|
||||||
assert apple_pos != :nomatch, "WithValue (Apple) should be in HTML"
|
|
||||||
assert zebra_pos != :nomatch, "AnotherValue (Zebra) should be in HTML"
|
|
||||||
assert empty_pos != :nomatch, "WithEmpty should be in HTML"
|
|
||||||
assert null_pos != :nomatch, "WithNull should be in HTML"
|
|
||||||
|
|
||||||
{apple_idx, _} = apple_pos
|
|
||||||
{zebra_idx, _} = zebra_pos
|
|
||||||
{empty_idx, _} = empty_pos
|
|
||||||
{null_idx, _} = null_pos
|
|
||||||
|
|
||||||
# In DESC order: Zebra should come before Apple
|
|
||||||
assert zebra_idx < apple_idx, "Zebra should come before Apple in DESC order"
|
|
||||||
|
|
||||||
# NULL and empty should come after all values
|
|
||||||
assert zebra_idx < empty_idx, "Zebra should come before empty value"
|
|
||||||
assert zebra_idx < null_idx, "Zebra should come before NULL value"
|
|
||||||
assert apple_idx < empty_idx, "Apple should come before empty value"
|
|
||||||
assert apple_idx < null_idx, "Apple should come before NULL value"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
defmodule MvWeb.UserLive.FormMemberDropdownTest do
|
|
||||||
@moduledoc """
|
|
||||||
UI tests for member linking dropdown visibility and email handling.
|
|
||||||
Tests dropdown behavior, visibility states, and email conflict scenarios.
|
|
||||||
Related to Issue #168.
|
|
||||||
"""
|
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: true
|
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
|
|
||||||
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)
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/users/new")
|
|
||||||
|
|
||||||
# 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
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Count how many member entries are shown in the dropdown
|
|
||||||
# Each member creates a div with role="option"
|
|
||||||
member_count = html |> String.split(~r/role="option"/) |> length() |> Kernel.-(1)
|
|
||||||
|
|
||||||
# Should show exactly 10 members (limit)
|
|
||||||
assert member_count == 10
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "email handling" do
|
|
||||||
test "links user and member with identical email successfully", %{conn: conn} do
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{: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 member with same email in dropdown", %{conn: conn} do
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{: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()
|
|
||||||
|
|
||||||
# Focus the member search to trigger loading
|
|
||||||
view
|
|
||||||
|> element("#member-search-input")
|
|
||||||
|> render_focus()
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Should show member with matching email in dropdown
|
|
||||||
assert html =~ "Emma Davis"
|
|
||||||
assert html =~ "emma@example.com"
|
|
||||||
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
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
defmodule MvWeb.UserLive.FormMemberSearchTest do
|
|
||||||
@moduledoc """
|
|
||||||
UI tests for fuzzy search functionality in member linking.
|
|
||||||
Tests PostgreSQL trigram-based fuzzy search behavior.
|
|
||||||
Related to Issue #168.
|
|
||||||
"""
|
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: true
|
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
|
|
||||||
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 "fuzzy search" do
|
|
||||||
test "finds member with exact name", %{conn: conn} do
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{: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" => "Jonathan"})
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
assert html =~ "Jonathan"
|
|
||||||
assert html =~ "Smith"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{: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" => "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
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{: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" => "lex"})
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
assert html =~ "Alexander"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shows partial match with similar names", %{conn: conn} do
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{:ok, _member} =
|
|
||||||
Membership.create_member(%{
|
|
||||||
first_name: "Johnny",
|
|
||||||
last_name: "Doeson",
|
|
||||||
email: "johnny@example.com"
|
|
||||||
})
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
|
||||||
|
|
||||||
# Type partial match
|
|
||||||
view
|
|
||||||
|> element("#member-search-input")
|
|
||||||
|> render_change(%{"member_search" => "John"})
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Should find member with similar name
|
|
||||||
assert html =~ "Johnny"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
|
||||||
@moduledoc """
|
|
||||||
UI tests for member selection and unlink workflow.
|
|
||||||
Tests member selection behavior and unlink process.
|
|
||||||
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 "member selection" do
|
|
||||||
test "input field shows selected member name", %{conn: conn} do
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{: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
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{: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
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
|
|
||||||
{: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 "unlink workflow" do
|
|
||||||
test "unlink hides dropdown", %{conn: conn} do
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
# 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
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
# 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
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
# 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
|
|
||||||
conn = setup_admin_conn(conn)
|
|
||||||
# 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
|
|
||||||
end
|
|
||||||
|
|
@ -281,101 +281,4 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
assert edit_html =~ "Change Password"
|
assert edit_html =~ "Change Password"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -410,35 +410,4 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
assert html =~ long_email
|
assert html =~ long_email
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
defmodule Mv.Fixtures do
|
|
||||||
@moduledoc """
|
|
||||||
Shared test fixtures for consistent test data creation.
|
|
||||||
|
|
||||||
This module provides factory functions for creating test data across
|
|
||||||
different test suites, ensuring consistency and reducing duplication.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Creates a member with default or custom attributes.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
- `attrs` - Map or keyword list of attributes to override defaults
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
- Member struct
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> member_fixture()
|
|
||||||
%Mv.Membership.Member{first_name: "Test", ...}
|
|
||||||
|
|
||||||
iex> member_fixture(%{first_name: "Alice", email: "alice@example.com"})
|
|
||||||
%Mv.Membership.Member{first_name: "Alice", email: "alice@example.com"}
|
|
||||||
|
|
||||||
"""
|
|
||||||
def member_fixture(attrs \\ %{}) do
|
|
||||||
attrs
|
|
||||||
|> Enum.into(%{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
|
||||||
})
|
|
||||||
|> Mv.Membership.create_member()
|
|
||||||
|> case do
|
|
||||||
{:ok, member} -> member
|
|
||||||
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Creates a user with default or custom attributes.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
- `attrs` - Map or keyword list of attributes to override defaults
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
- User struct
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> user_fixture()
|
|
||||||
%Mv.Accounts.User{email: "user123@example.com"}
|
|
||||||
|
|
||||||
iex> user_fixture(%{email: "custom@example.com"})
|
|
||||||
%Mv.Accounts.User{email: "custom@example.com"}
|
|
||||||
|
|
||||||
"""
|
|
||||||
def user_fixture(attrs \\ %{}) do
|
|
||||||
attrs
|
|
||||||
|> Enum.into(%{
|
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com"
|
|
||||||
})
|
|
||||||
|> Mv.Accounts.create_user()
|
|
||||||
|> case do
|
|
||||||
{:ok, user} -> user
|
|
||||||
{:error, error} -> raise "Failed to create user: #{inspect(error)}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Creates a user linked to a member.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
- `user_attrs` - Map or keyword list of user attributes
|
|
||||||
- `member_attrs` - Map or keyword list of member attributes
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
- Tuple of {user, member}
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> {user, member} = linked_user_member_fixture()
|
|
||||||
iex> user.member_id == member.id
|
|
||||||
true
|
|
||||||
|
|
||||||
"""
|
|
||||||
def linked_user_member_fixture(user_attrs \\ %{}, member_attrs \\ %{}) do
|
|
||||||
member = member_fixture(member_attrs)
|
|
||||||
|
|
||||||
user_attrs = Map.put(user_attrs, :member, %{id: member.id})
|
|
||||||
user = user_fixture(user_attrs)
|
|
||||||
|
|
||||||
{user, member}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue