Compare commits

..

28 commits

Author SHA1 Message Date
dfdf4c980b chore: updated env example
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-01 10:43:19 +01:00
cf354bcf25 test updated 2025-12-01 10:43:19 +01:00
fdae610da0 adds translation 2025-12-01 10:43:19 +01:00
37553d8d6c feat: adds settings live view and updated seeds 2025-12-01 10:42:10 +01:00
193618eace chore: adds settings ressource and migration 2025-12-01 10:42:10 +01:00
418b42d35a adds tests 2025-12-01 10:42:10 +01:00
a132383d81 Merge pull request 'Show custom fields per default in member overview closes #197 and #153' (#208) from feature/197_custom_fields_overview into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #208
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
2025-12-01 10:05:29 +01:00
b584581114 performance: improvedd ash querying
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-01 09:48:29 +01:00
2284cd93c4 translate: add translation
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-01 08:53:18 +01:00
82bd573276 formatting 2025-12-01 08:50:06 +01:00
e7c4a4f62f feat: add dynamic cols to member overview and checkbox to form 2025-12-01 08:50:06 +01:00
100ed96493 feat: adds dynamic cols to table core component 2025-12-01 08:50:06 +01:00
11179e51f0 chore: show in overview attribute to custom field 2025-12-01 08:50:06 +01:00
4313703538 test: added tests 2025-12-01 08:50:06 +01:00
b509dc4ea3 chore: add migration for show in overview flag 2025-12-01 08:50:06 +01:00
9fbca13342 Merge pull request 'Allow user-member association in edit/create views closes #168' (#207) from feature/user-linking into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #207
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-11-27 16:11:02 +01:00
3da0ebcb3f
feat: Add keyboard navigation to member linking dropdown
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-27 16:05:56 +01:00
4b4ec63613 feat: improve user-member linking UI and error messages
All checks were successful
continuous-integration/drone/push Build is passing
Reload members on email change, extract user-friendly errors from Ash, add translations
2025-11-20 21:45:05 +01:00
df05eafc99 refactor: simplify Member.available_for_linking action to 9 lines
Extract filter logic into apply_linking_filters/3 helper, add Credo disable for fuzzy search complexity
2025-11-20 21:44:29 +01:00
90ced26a0e fix: correct test parameter name from member_search_query to member_search 2025-11-20 18:57:38 +01:00
adc6608e54
test: fix test auth and improve reliability
All checks were successful
continuous-integration/drone/push Build is passing
- Add admin authentication to all tests
- Fix 12 tests that were failing due to missing authentication
- 3 tests still have business logic issues (will fix separately)
2025-11-20 16:51:45 +01:00
9a03485604
refactor: add typespecs and module constants
- Add @spec for public functions in Member and UserLive.Form
- Replace magic numbers with module constants:
  - @member_search_limit = 10
  - @default_similarity_threshold = 0.2
- Add comprehensive @doc for filter_by_email_match and fuzzy_search
2025-11-20 16:51:45 +01:00
078809981d
docs: add translations and update development log (#168) 2025-11-20 16:51:44 +01:00
48b0823091
test: add LiveView tests for member linking UI (#168) 2025-11-20 16:51:44 +01:00
af193840e2
feat: add user-member linking UI with autocomplete (#168) 2025-11-20 16:51:44 +01:00
52a62bd679
fix: extract member_id from relationship changes during validation (#168) 2025-11-20 16:51:43 +01:00
39b285a714
feat: add member fuzzy search for linking (#168) 2025-11-20 16:51:43 +01:00
173f522da5
test: add tests for user-member linking and fuzzy search (#168) 2025-11-20 16:51:43 +01:00
40 changed files with 4290 additions and 156 deletions

19
CHANGELOG.md Normal file
View file

@ -0,0 +1,19 @@
# 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

View file

@ -23,9 +23,42 @@ import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
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, {
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

View file

@ -329,6 +329,11 @@ end
---
**PR #208:** *Show custom fields per default in member overview* 🔧
- added show_in_overview as attribute to custom fields
- show custom fields in member overview per default
- can be set to false in the settings for the specific custom field
## Implementation Decisions
### Architecture Patterns
@ -390,6 +395,7 @@ defmodule Mv.Membership.CustomField do
attribute :value_type, :atom # :string, :integer, :boolean, :date, :email
attribute :immutable, :boolean # Can't change after creation
attribute :required, :boolean # All members must have this
attribute :show_in_overview, :boolean # "If true, this custom field will be displayed in the member overview table"
end
# CustomFieldValue stores values
@ -1321,6 +1327,210 @@ 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
This project demonstrates a modern Phoenix application built with:
@ -1343,14 +1553,14 @@ This project demonstrates a modern Phoenix application built with:
**Next Steps:**
- Implement roles & permissions
- Add payment tracking
- Improve accessibility (WCAG 2.1 AA)
- ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented
- Member self-service portal
- Email communication features
---
**Document Version:** 1.1
**Last Updated:** 2025-11-13
**Document Version:** 1.2
**Last Updated:** 2025-11-27
**Maintainer:** Development Team
**Status:** Living Document (update as project evolves)

View file

@ -94,15 +94,18 @@
- ✅ CustomFieldValue type management
- ✅ Dynamic custom field value assignment to members
- ✅ Union type storage (JSONB)
- ✅ Default field visibility configuration
**Closed Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
**Open Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks]
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
**Missing Features:**
- ❌ Default field visibility configuration
- ❌ Field groups/categories
- ❌ Conditional fields (show field X if field Y = value)
- ❌ Field validation rules (min/max, regex patterns)

View file

@ -14,6 +14,7 @@ defmodule Mv.Membership.CustomField do
- `description` - Optional human-readable description
- `immutable` - If true, custom field values cannot be changed after creation
- `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
## Supported Value Types
- `:string` - Text data (max 10,000 characters)
@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do
actions do
defaults [:read, :update]
default_accept [:name, :value_type, :description, :immutable, :required]
default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
create :create do
accept [:name, :value_type, :description, :immutable, :required]
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
change Mv.Membership.CustomField.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
@ -119,6 +120,12 @@ defmodule Mv.Membership.CustomField do
attribute :required, :boolean,
default: false,
allow_nil?: false
attribute :show_in_overview, :boolean,
default: true,
allow_nil?: false,
public?: true,
description: "If true, this custom field will be displayed in the member overview table"
end
relationships do

View file

@ -38,6 +38,10 @@ defmodule Mv.Membership.Member do
require Ash.Query
import Ash.Expr
# Module constants
@member_search_limit 10
@default_similarity_threshold 0.2
postgres do
table "members"
repo Mv.Repo
@ -152,8 +156,10 @@ defmodule Mv.Membership.Member do
prepare fn query, _ctx ->
q = Ash.Query.get_argument(query, :query) || ""
# 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results
threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
# Use default similarity threshold if not provided
# Lower value leads to more results but also more unspecific results
threshold =
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
@ -187,8 +193,75 @@ defmodule Mv.Membership.Member do
end
end
end
# Action to find members available for linking to a user account
# Returns only unlinked members (user_id == nil), limited to 10 results
#
# 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
@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
# Required fields are covered by allow_nil? false
@ -361,7 +434,32 @@ defmodule Mv.Membership.Member do
identity :unique_email, [:email]
end
# Fuzzy Search function that can be called by live view and calls search action
@doc """
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
q = (opts[:query] || opts["query"] || "") |> to_string()
@ -377,4 +475,60 @@ defmodule Mv.Membership.Member do
Ash.Query.for_read(query, :search, args)
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

View file

@ -41,18 +41,37 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
if should_validate? do
case Ash.Changeset.fetch_change(changeset, :email) do
{:ok, new_email} ->
check_email_uniqueness(new_email, member_id)
# Extract member_id from relationship changes for new links
member_id_to_exclude = get_member_id_from_changeset(changeset)
check_email_uniqueness(new_email, member_id_to_exclude)
:error ->
# No email change, get current email
current_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(current_email, member_id)
# Extract member_id from relationship changes for new links
member_id_to_exclude = get_member_id_from_changeset(changeset)
check_email_uniqueness(current_email, member_id_to_exclude)
end
else
:ok
end
end
# Extract member_id from changeset, checking relationship changes first
# This is crucial for new links where member_id is in manage_relationship changes
defp get_member_id_from_changeset(changeset) do
# Try to get from relationships (for new links via manage_relationship)
case Map.get(changeset.relationships, :member) do
[{[%{id: id}], _opts}] when not is_nil(id) ->
# Found in relationships - this is a new link
id
_ ->
# Fall back to attribute (for existing links)
Ash.Changeset.get_attribute(changeset, :member_id)
end
end
defp check_email_uniqueness(email, exclude_member_id) do
query =
Mv.Membership.Member

View file

@ -318,6 +318,13 @@ defmodule MvWeb.CoreComponents do
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
attr :dynamic_cols, :list,
default: [],
doc: "list of dynamic column definitions with :custom_field and :render functions"
attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order"
slot :col, required: true do
attr :label, :string
end
@ -335,6 +342,16 @@ defmodule MvWeb.CoreComponents do
<thead>
<tr>
<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 != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
@ -349,6 +366,23 @@ defmodule MvWeb.CoreComponents do
>
{render_slot(col, @row_item.(row))}
</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">
<div class="flex gap-4">
<%= for action <- @action do %>

View file

@ -18,6 +18,7 @@ defmodule MvWeb.CustomFieldLive.Form do
- description - Human-readable explanation
- immutable - If true, values cannot be changed after creation (default: false)
- required - If true, all members must have this custom field (default: false)
- show_in_overview - If true, this custom field will be displayed in the member overview table (default: true)
## Value Type Selection
- `:string` - Text data (unlimited length)
@ -60,6 +61,7 @@ defmodule MvWeb.CustomFieldLive.Form do
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} />
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")}

View file

@ -26,6 +26,14 @@ defmodule MvWeb.MemberLive.Index do
"""
use MvWeb, :live_view
require Ash.Query
import Ash.Expr
alias MvWeb.MemberLive.Index.Formatter
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_"
@doc """
Initializes the LiveView state.
@ -34,6 +42,16 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
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
|> assign(:page_title, gettext("Members"))
@ -41,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, [])
|> assign(:custom_fields_visible, custom_fields_visible)
# We call handle params to use the query from the URL
{:ok, socket}
@ -60,6 +79,8 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_event("delete", %{"id" => id}, socket) do
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
# This ensures users see error messages if deletion fails (e.g., permission denied)
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
@ -108,7 +129,14 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_info({:sort, field_str}, socket) do
field = String.to_existing_atom(field_str)
# Handle both atom and string field names (for custom fields)
field =
try do
String.to_existing_atom(field_str)
rescue
ArgumentError -> field_str
end
{new_field, new_order} = determine_new_sort(field, socket)
socket
@ -158,10 +186,38 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> load_members(params["query"])
|> prepare_dynamic_cols()
{:noreply, socket}
end
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
#
# Creates a list of column definitions, each containing:
# - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member
#
# Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
dynamic_cols =
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
case get_custom_field_value(member, custom_field) do
nil ->
""
cfv ->
Formatter.format_custom_field_value(cfv.value, custom_field)
end
end
}
end)
assign(socket, :dynamic_cols, dynamic_cols)
end
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
@ -177,8 +233,8 @@ defmodule MvWeb.MemberLive.Index do
# Updates both the active and old SortHeader components
defp update_sort_components(socket, old_field, new_field, new_order) do
active_id = :"sort_#{new_field}"
old_id = :"sort_#{old_field}"
active_id = to_sort_id(new_field)
old_id = to_sort_id(old_field)
# Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
@ -197,11 +253,32 @@ defmodule MvWeb.MemberLive.Index do
socket
end
# Converts a field (atom or string) to a sort component ID atom
# Handles both existing atoms and strings that need to be converted
defp to_sort_id(field) when is_binary(field) do
try do
String.to_existing_atom("sort_#{field}")
rescue
ArgumentError -> :"sort_#{field}"
end
end
defp to_sort_id(field) when is_atom(field) do
:"sort_#{field}"
end
# Builds sort URL and pushes navigation patch
defp push_sort_url(socket, field, order) do
field_str =
if is_atom(field) do
Atom.to_string(field)
else
field
end
query_params = %{
"query" => socket.assigns.query,
"sort_field" => Atom.to_string(field),
"sort_field" => field_str,
"sort_order" => Atom.to_string(order)
}
@ -214,7 +291,24 @@ defmodule MvWeb.MemberLive.Index do
)}
end
# Load members eg based on a query for sorting
# Loads members from the database with custom field values and applies search/sort filters.
#
# Process:
# 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (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
query =
Mv.Membership.Member
@ -232,16 +326,71 @@ defmodule MvWeb.MemberLive.Index do
: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
query = apply_search_filter(query, search_query)
# Apply sorting based on current socket state
query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order)
# For custom fields, we sort after loading
{query, sort_after_load} =
maybe_sort(
query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.custom_fields_visible
)
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView
# This is appropriate for data loading in LiveViews
members = Ash.read!(query)
# 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)
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
# -------------------------------------------------------------
@ -264,15 +413,24 @@ defmodule MvWeb.MemberLive.Index do
defp toggle_order(nil), do: :asc
# Function to sort the column if needed
defp maybe_sort(query, nil, _), do: query
# Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory
defp maybe_sort(query, nil, _, _), do: {query, false}
defp maybe_sort(query, field, :asc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :asc}])
defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do
if custom_field_sort?(field) do
# Custom fields need to be sorted in memory after loading
{query, true}
else
# Only sort by atom fields (regular member fields) in database
if is_atom(field) do
{Ash.Query.sort(query, [{field, order}]), false}
else
{query, false}
end
end
end
defp maybe_sort(query, field, :desc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :desc}])
defp maybe_sort(query, _, _), do: query
defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable
defp valid_sort_field?(field) when is_atom(field) do
@ -288,12 +446,188 @@ defmodule MvWeb.MemberLive.Index do
:join_date
]
field in valid_fields
field in valid_fields or custom_field_sort?(field)
end
defp valid_sort_field?(field) when is_binary(field) do
custom_field_sort?(field)
end
defp valid_sort_field?(_), do: false
# Function to maybe update the sort
# Check if field is a custom field sort field (format: custom_field_<id>)
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
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
@ -305,33 +639,50 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_sort(socket, _), do: socket
defp determine_field(default, sf) do
case sf do
"" ->
default
# Determine sort field from URL parameter, validating against allowed fields
defp determine_field(default, ""), do: default
defp determine_field(default, nil), do: default
nil ->
default
sf when is_binary(sf) ->
sf
|> String.to_existing_atom()
|> handle_atom_conversion(default)
sf when is_atom(sf) ->
handle_atom_conversion(sf, default)
_ ->
default
# Determines the valid sort field from a URL parameter.
#
# Validates the field against allowed sort fields (regular member fields or custom fields).
# Falls back to default if the field is invalid.
#
# Parameters:
# - `default` - Default field to use if validation fails
# - `sf` - Sort field from URL (can be atom, string, nil, or empty string)
#
# Returns a valid sort field (atom or string for custom fields).
defp determine_field(default, sf) when is_binary(sf) do
# Check if it's a custom field sort (starts with "custom_field_")
if custom_field_sort?(sf) do
if valid_sort_field?(sf), do: sf, else: default
else
# Try to convert to atom for regular fields
try do
atom = String.to_existing_atom(sf)
if valid_sort_field?(atom), do: atom, else: default
rescue
ArgumentError -> default
end
end
end
defp handle_atom_conversion(val, default) when is_atom(val) do
if valid_sort_field?(val), do: val, else: default
defp determine_field(default, sf) when is_atom(sf) do
if valid_sort_field?(sf), do: sf, else: default
end
defp handle_atom_conversion(_, default), do: default
defp determine_field(default, _), do: default
# Determines the valid sort order from a URL parameter.
#
# Validates that the order is either "asc" or "desc", falling back to default if invalid.
#
# Parameters:
# - `default` - Default order to use if validation fails
# - `so` - Sort order from URL (string, atom, nil, or empty string)
#
# Returns `:asc` or `:desc`.
defp determine_order(default, so) do
case so do
"" -> default
@ -350,4 +701,36 @@ defmodule MvWeb.MemberLive.Index do
# Keep the previous search query if no new one is provided
socket
end
# -------------------------------------------------------------
# Helper Functions for Custom Field Values
# -------------------------------------------------------------
# Retrieves the custom field value for a specific member and custom field.
#
# Searches through the member's `custom_field_values` relationship to find
# the value matching the given custom field.
#
# Returns:
# - `%CustomFieldValue{}` if found
# - `nil` if not found or if member has no custom field values
#
# Examples:
# get_custom_field_value(member, custom_field) -> %CustomFieldValue{...}
# get_custom_field_value(member, non_existent_field) -> nil
def get_custom_field_value(member, custom_field) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(cfv.custom_field && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
end
end

View file

@ -19,6 +19,9 @@
id="members"
rows={@members}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
@ -185,7 +188,6 @@
>
{member.join_date}
</:col>
<:action :let={member}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>

View file

@ -0,0 +1,74 @@
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

View file

@ -120,6 +120,130 @@ defmodule MvWeb.UserLive.Form do
<% end %>
<% end %>
</div>
<!-- Member Linking Section -->
<div class="mt-6">
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
<%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button -->
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
{@user.member.first_name} {@user.member.last_name}
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
<button
type="button"
phx-click="unlink_member"
class="btn btn-sm btn-error"
>
{gettext("Unlink Member")}
</button>
</div>
</div>
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-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">
{gettext("Save User")}
@ -135,7 +259,7 @@ defmodule MvWeb.UserLive.Form do
user =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@ -147,9 +271,18 @@ defmodule MvWeb.UserLive.Form do
|> assign(user: user)
|> assign(:page_title, page_title)
|> assign(:show_password_fields, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:selected_member_id, nil)
|> assign(:selected_member_name, nil)
|> assign(:unlink_member, false)
|> assign(:focused_member_index, nil)
|> load_initial_members()
|> assign_form()}
end
@spec return_to(String.t() | nil) :: String.t()
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@ -166,28 +299,201 @@ defmodule MvWeb.UserLive.Form do
end
def handle_event("validate", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))}
validated_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
def handle_event("save", %{"user" => user_params}, socket) do
# First save the user without member changes
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
{:ok, user} ->
notify_parent({:saved, user})
# Then handle member linking/unlinking as a separate step
result =
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
socket =
socket
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, user))
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil})
{:noreply, socket}
# No changes to member relationship
true ->
{:ok, user}
end
case result do
{:ok, updated_user} ->
notify_parent({:saved, updated_user})
socket =
socket
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}
{:error, error} ->
# Show 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} ->
{:noreply, assign(socket, form: form)}
end
end
def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)}
end
def handle_event("hide_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: false, 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})
# 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
form =
if user do
@ -207,6 +513,71 @@ defmodule MvWeb.UserLive.Form do
assign(socket, form: to_form(form))
end
@spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t()
defp return_path("index", _user), do: ~p"/users"
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

View file

@ -25,7 +25,7 @@ defmodule MvWeb.UserLive.Index do
@impl true
def mount(_params, _session, socket) do
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
sorted = Enum.sort_by(users, & &1.email)
{:ok,

View file

@ -50,6 +50,13 @@
{user.email}
</:col>
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
{user.member.first_name} {user.member.last_name}
<% else %>
<span class="text-base-content/50">{gettext("No member linked")}</span>
<% end %>
</:col>
<:action :let={user}>
<div class="sr-only">

View file

@ -16,7 +16,7 @@
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
@ -80,7 +80,7 @@
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},

58
notes.md Normal file
View file

@ -0,0 +1,58 @@
# 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.

View file

@ -10,12 +10,12 @@ msgid ""
msgstr ""
"Language: en\n"
#: lib/mv_web/components/core_components.ex:339
#: lib/mv_web/components/core_components.ex:356
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/index.html.heex:148
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/member_live/index.html.heex:204
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/member_live/index.html.heex:196
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
@ -54,7 +54,7 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:80
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -70,7 +70,7 @@ msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/index.html.heex:182
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -87,7 +87,7 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
@ -121,7 +121,7 @@ msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -140,14 +140,14 @@ msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/index.html.heex:165
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/index.html.heex:131
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -158,17 +158,17 @@ msgstr "Postleitzahl"
msgid "Save Member"
msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#: lib/mv_web/live/user_live/form.ex:234
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/index.html.heex:97
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -184,6 +184,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha
msgid "Id"
msgstr "ID"
#: lib/mv_web/live/member_live/index/formatter.ex:65
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
@ -199,19 +200,20 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/index/formatter.ex:64
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr "Ja"
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr "erstellt"
#: lib/mv_web/live/custom_field_live/form.ex:109
#: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
@ -253,11 +255,11 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt"
msgid "Your password has successfully been reset"
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/form.ex:67
#: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#: lib/mv_web/live/user_live/form.ex:237
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr "Abbrechen"
@ -267,7 +269,7 @@ msgstr "Abbrechen"
msgid "Choose a member"
msgstr "Mitglied auswählen"
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Description"
msgstr "Beschreibung"
@ -287,7 +289,7 @@ msgstr "Aktiviert"
msgid "ID"
msgstr "ID"
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr "Unveränderlich"
@ -315,7 +317,7 @@ msgstr "Mitglied"
msgid "Members"
msgstr "Mitglieder"
#: lib/mv_web/live/custom_field_live/form.ex:50
#: lib/mv_web/live/custom_field_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
@ -337,6 +339,7 @@ msgstr "Nicht gesetzt"
#: lib/mv_web/live/user_live/form.ex:107
#: lib/mv_web/live/user_live/form.ex:115
#: lib/mv_web/live/user_live/form.ex:210
#, elixir-autogen, elixir-format
msgid "Note"
msgstr "Hinweis"
@ -357,17 +360,17 @@ msgstr "Passwort-Authentifizierung"
msgid "Profil"
msgstr "Profil"
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:63
#, elixir-autogen, elixir-format
msgid "Required"
msgstr "Erforderlich"
#: lib/mv_web/live/member_live/index.html.heex:34
#: lib/mv_web/live/member_live/index.html.heex:37
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr "Alle Mitglieder auswählen"
#: lib/mv_web/live/member_live/index.html.heex:48
#: lib/mv_web/live/member_live/index.html.heex:51
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr "Mitglied auswählen"
@ -377,7 +380,7 @@ msgstr "Mitglied auswählen"
msgid "Settings"
msgstr "Einstellungen"
#: lib/mv_web/live/user_live/form.ex:125
#: lib/mv_web/live/user_live/form.ex:235
#, elixir-autogen, elixir-format
msgid "Save User"
msgstr "Benutzer*in speichern"
@ -402,7 +405,7 @@ msgstr "Nicht unterstützter Wertetyp: %{type}"
msgid "Use this form to manage user records in your database."
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/form.ex:252
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
@ -413,7 +416,7 @@ msgstr "Benutzer*in"
msgid "Value"
msgstr "Wert"
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:56
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr "Wertetyp"
@ -430,7 +433,7 @@ msgstr "aufsteigend"
msgid "descending"
msgstr "absteigend"
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format
msgid "New"
msgstr "Neue*r"
@ -505,6 +508,8 @@ msgstr "Passwort setzen"
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen."
#: lib/mv_web/live/user_live/form.ex:126
#: lib/mv_web/live/user_live/index.html.heex:53
#: lib/mv_web/live/user_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Linked Member"
@ -515,6 +520,7 @@ msgstr "Verknüpftes Mitglied"
msgid "Linked User"
msgstr "Verknüpfte*r Benutzer*in"
#: lib/mv_web/live/user_live/index.html.heex:57
#: lib/mv_web/live/user_live/show.ex:65
#, elixir-autogen, elixir-format
msgid "No member linked"
@ -566,7 +572,7 @@ msgstr "Benutzer*innen"
msgid "Click to sort"
msgstr "Klicke um zu sortieren"
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "First name"
msgstr "Vorname"
@ -618,7 +624,7 @@ msgstr "Benutzerdefinierte Feldwerte"
msgid "Custom field"
msgstr "Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:117
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
@ -633,7 +639,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form.ex:65
#: lib/mv_web/live/custom_field_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr "Benutzerdefiniertes Feld speichern"
@ -643,7 +649,7 @@ msgstr "Benutzerdefiniertes Feld speichern"
msgid "Save Custom field value"
msgstr "Benutzerdefinierten Feldwert speichern"
#: lib/mv_web/live/custom_field_live/form.ex:45
#: lib/mv_web/live/custom_field_live/form.ex:46
#, elixir-autogen, elixir-format
msgid "Use this form to manage custom_field records in your database."
msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."
@ -695,6 +701,59 @@ msgstr "Obigen Text zur Bestätigung eingeben"
msgid "To confirm deletion, please enter this text:"
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
#: lib/mv_web/live/user_live/form.ex:210
#, elixir-autogen, elixir-format
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen."
#: lib/mv_web/live/user_live/form.ex:185
#, elixir-autogen, elixir-format
msgid "Available members"
msgstr "Verfügbare Mitglieder"
#: lib/mv_web/live/user_live/form.ex:152
#, elixir-autogen, elixir-format
msgid "Member will be unlinked when you save. Cannot select new member until saved."
msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden."
#: lib/mv_web/live/user_live/form.ex:226
#, elixir-autogen, elixir-format
msgid "Save to confirm linking."
msgstr "Speichern, um die Verknüpfung zu bestätigen."
#: lib/mv_web/live/user_live/form.ex:169
#, elixir-autogen, elixir-format
msgid "Search for a member to link..."
msgstr "Nach einem Mitglied zum Verknüpfen suchen..."
#: lib/mv_web/live/user_live/form.ex:173
#, elixir-autogen, elixir-format
msgid "Search for member to link"
msgstr "Nach Mitglied zum Verknüpfen suchen"
#: lib/mv_web/live/user_live/form.ex:223
#, elixir-autogen, elixir-format
msgid "Selected"
msgstr "Ausgewählt"
#: lib/mv_web/live/user_live/form.ex:143
#, elixir-autogen, elixir-format
msgid "Unlink Member"
msgstr "Mitglied entverknüpfen"
#: lib/mv_web/live/user_live/form.ex:152
#, elixir-autogen, elixir-format
msgid "Unlinking scheduled"
msgstr "Entverknüpfung geplant"
#: lib/mv_web/live/user_live/form.ex:342
#, elixir-autogen, elixir-format
msgid "Failed to link member: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Show in overview"
msgstr "In der Mitglieder-Übersicht anzeigen"
#: lib/mv_web/live/global_settings_live.ex:51
#, elixir-autogen, elixir-format
msgid "Association Name"

View file

@ -155,3 +155,7 @@ msgstr "muss mindestens 8 Zeichen lang sein"
msgid "is required"
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}"

View file

@ -11,12 +11,12 @@
msgid ""
msgstr ""
#: lib/mv_web/components/core_components.ex:339
#: lib/mv_web/components/core_components.ex:356
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/index.html.heex:148
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/member_live/index.html.heex:204
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/member_live/index.html.heex:196
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:80
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -71,7 +71,7 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/index.html.heex:182
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -141,14 +141,14 @@ msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/index.html.heex:165
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/index.html.heex:131
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -159,17 +159,17 @@ msgstr ""
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#: lib/mv_web/live/user_live/form.ex:234
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/index.html.heex:97
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -185,6 +185,7 @@ msgstr ""
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/index/formatter.ex:65
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
@ -200,19 +201,20 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/index/formatter.ex:64
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:109
#: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
@ -254,11 +256,11 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:67
#: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#: lib/mv_web/live/user_live/form.ex:237
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@ -268,7 +270,7 @@ msgstr ""
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@ -288,7 +290,7 @@ msgstr ""
msgid "ID"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
@ -316,7 +318,7 @@ msgstr ""
msgid "Members"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:50
#: lib/mv_web/live/custom_field_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -338,6 +340,7 @@ msgstr ""
#: lib/mv_web/live/user_live/form.ex:107
#: lib/mv_web/live/user_live/form.ex:115
#: lib/mv_web/live/user_live/form.ex:210
#, elixir-autogen, elixir-format
msgid "Note"
msgstr ""
@ -358,17 +361,17 @@ msgstr ""
msgid "Profil"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:63
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:34
#: lib/mv_web/live/member_live/index.html.heex:37
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:48
#: lib/mv_web/live/member_live/index.html.heex:51
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
@ -378,7 +381,7 @@ msgstr ""
msgid "Settings"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:125
#: lib/mv_web/live/user_live/form.ex:235
#, elixir-autogen, elixir-format
msgid "Save User"
msgstr ""
@ -403,7 +406,7 @@ msgstr ""
msgid "Use this form to manage user records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/form.ex:252
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
@ -414,7 +417,7 @@ msgstr ""
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:56
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -431,7 +434,7 @@ msgstr ""
msgid "descending"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format
msgid "New"
msgstr ""
@ -506,6 +509,8 @@ msgstr ""
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:126
#: lib/mv_web/live/user_live/index.html.heex:53
#: lib/mv_web/live/user_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Linked Member"
@ -516,6 +521,7 @@ msgstr ""
msgid "Linked User"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:57
#: lib/mv_web/live/user_live/show.ex:65
#, elixir-autogen, elixir-format
msgid "No member linked"
@ -567,7 +573,7 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "First name"
msgstr ""
@ -619,7 +625,7 @@ msgstr ""
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:117
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
@ -634,7 +640,7 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:65
#: lib/mv_web/live/custom_field_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
@ -644,7 +650,7 @@ msgstr ""
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:45
#: lib/mv_web/live/custom_field_live/form.ex:46
#, elixir-autogen, elixir-format
msgid "Use this form to manage custom_field records in your database."
msgstr ""
@ -696,6 +702,9 @@ msgstr ""
msgid "To confirm deletion, please enter this text:"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Show in overview"
#: lib/mv_web/live/global_settings_live.ex:51
#, elixir-autogen, elixir-format
msgid "Association Name"

View file

@ -11,12 +11,12 @@ msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex:339
#: lib/mv_web/components/core_components.ex:356
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/index.html.heex:148
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/member_live/index.html.heex:204
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/member_live/index.html.heex:196
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:80
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -71,7 +71,7 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/index.html.heex:182
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -141,14 +141,14 @@ msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/index.html.heex:165
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/index.html.heex:131
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -159,17 +159,17 @@ msgstr ""
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#: lib/mv_web/live/user_live/form.ex:234
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/index.html.heex:97
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -185,6 +185,7 @@ msgstr ""
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/index/formatter.ex:65
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
@ -200,19 +201,20 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/index/formatter.ex:64
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:109
#: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
@ -254,11 +256,11 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:67
#: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#: lib/mv_web/live/user_live/form.ex:237
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@ -268,7 +270,7 @@ msgstr ""
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@ -288,7 +290,7 @@ msgstr ""
msgid "ID"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
@ -316,7 +318,7 @@ msgstr ""
msgid "Members"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:50
#: lib/mv_web/live/custom_field_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -338,6 +340,7 @@ msgstr ""
#: lib/mv_web/live/user_live/form.ex:107
#: lib/mv_web/live/user_live/form.ex:115
#: lib/mv_web/live/user_live/form.ex:210
#, elixir-autogen, elixir-format, fuzzy
msgid "Note"
msgstr ""
@ -358,17 +361,17 @@ msgstr ""
msgid "Profil"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:63
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:34
#: lib/mv_web/live/member_live/index.html.heex:37
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:48
#: lib/mv_web/live/member_live/index.html.heex:51
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
@ -378,7 +381,7 @@ msgstr ""
msgid "Settings"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:125
#: lib/mv_web/live/user_live/form.ex:235
#, elixir-autogen, elixir-format, fuzzy
msgid "Save User"
msgstr ""
@ -403,7 +406,7 @@ msgstr ""
msgid "Use this form to manage user records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/form.ex:252
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
@ -414,7 +417,7 @@ msgstr ""
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:56
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -431,7 +434,7 @@ msgstr ""
msgid "descending"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format
msgid "New"
msgstr ""
@ -506,6 +509,8 @@ msgstr "Set Password"
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "User will be created without a password. Check 'Set Password' to add one."
#: lib/mv_web/live/user_live/form.ex:126
#: lib/mv_web/live/user_live/index.html.heex:53
#: lib/mv_web/live/user_live/show.ex:55
#, elixir-autogen, elixir-format, fuzzy
msgid "Linked Member"
@ -516,6 +521,7 @@ msgstr ""
msgid "Linked User"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:57
#: lib/mv_web/live/user_live/show.ex:65
#, elixir-autogen, elixir-format
msgid "No member linked"
@ -567,7 +573,7 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
msgstr ""
@ -619,7 +625,7 @@ msgstr ""
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:117
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
@ -634,7 +640,7 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:65
#: lib/mv_web/live/custom_field_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
@ -644,7 +650,7 @@ msgstr ""
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:45
#: lib/mv_web/live/custom_field_live/form.ex:46
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage custom_field records in your database."
msgstr ""
@ -696,6 +702,58 @@ msgstr ""
msgid "To confirm deletion, please enter this text:"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:210
#, elixir-autogen, elixir-format
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:185
#, elixir-autogen, elixir-format
msgid "Available members"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:152
#, elixir-autogen, elixir-format
msgid "Member will be unlinked when you save. Cannot select new member until saved."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:226
#, elixir-autogen, elixir-format
msgid "Save to confirm linking."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:169
#, elixir-autogen, elixir-format
msgid "Search for a member to link..."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:173
#, elixir-autogen, elixir-format
msgid "Search for member to link"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:223
#, elixir-autogen, elixir-format, fuzzy
msgid "Selected"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:143
#, elixir-autogen, elixir-format
msgid "Unlink Member"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:152
#, elixir-autogen, elixir-format
msgid "Unlinking scheduled"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:342
#, elixir-autogen, elixir-format
msgid "Failed to link member: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Show in overview"
#: lib/mv_web/live/global_settings_live.ex:51
#, elixir-autogen, elixir-format
msgid "Association Name"

View file

@ -155,3 +155,7 @@ msgstr ""
msgid "is required"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
msgid "Failed to link member: %{error}"
msgstr ""

View file

@ -152,3 +152,7 @@ msgstr ""
msgid "is required"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
msgid "Failed to link member: %{error}"
msgstr ""

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:custom_fields) do
add :show_in_overview, :boolean, null: false, default: true
end
end
def down do
alter table(:custom_fields) do
remove :show_in_overview
end
end
end

View file

@ -0,0 +1,118 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "immutable",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "required",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "show_in_overview",
"type": "boolean"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "9FBFC42DA896058F88DEDAE774614919222BF2EF2F8CB27386D02C2CE67F03DE",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_fields"
}

View file

@ -0,0 +1,169 @@
defmodule Mv.Accounts.UserMemberLinkingEmailTest do
@moduledoc """
Tests email validation during user-member linking.
Implements rules from docs/email-sync.md.
Tests for Issue #168, specifically Problem #4: Email validation bug.
"""
use Mv.DataCase, async: 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

View file

@ -0,0 +1,130 @@
defmodule Mv.Accounts.UserMemberLinkingTest do
@moduledoc """
Integration tests for User-Member linking functionality.
Tests the complete workflow of linking and unlinking members to users,
including email synchronization and validation rules.
"""
use Mv.DataCase, async: 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

View file

@ -0,0 +1,77 @@
defmodule Mv.Membership.CustomFieldShowInOverviewTest do
@moduledoc """
Tests for CustomField show_in_overview attribute.
Tests cover:
- Creating custom fields with show_in_overview: true
- Creating custom fields with show_in_overview: false (default)
- Updating show_in_overview to true
- Updating show_in_overview to false
"""
use Mv.DataCase, async: true
alias Mv.Membership.CustomField
describe "show_in_overview attribute" do
test "creates custom field with show_in_overview: true" do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_show",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
assert custom_field.show_in_overview == true
end
test "creates custom field with show_in_overview: true (default)" do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_hide",
value_type: :string
})
|> Ash.create()
assert custom_field.show_in_overview == true
end
test "updates show_in_overview to true" do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_update",
value_type: :string,
show_in_overview: false
})
|> Ash.create()
assert {:ok, updated_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{show_in_overview: true})
|> Ash.update()
assert updated_field.show_in_overview == true
end
test "updates show_in_overview to false" do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_update2",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
assert {:ok, updated_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{show_in_overview: false})
|> Ash.update()
assert updated_field.show_in_overview == false
end
end
end

View file

@ -0,0 +1,222 @@
defmodule Mv.Membership.MemberAvailableForLinkingTest do
@moduledoc """
Tests for the Member.available_for_linking action.
This action returns members that can be linked to a user account:
- Only members without existing user links (user_id == nil)
- Limited to 10 results
- Special email-match logic: if user_email matches member email, only return that member
- Optional search query filtering by name and email
"""
use Mv.DataCase, async: 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

View file

@ -0,0 +1,158 @@
defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
@moduledoc """
Tests fuzzy search in Member.available_for_linking action.
Verifies PostgreSQL trigram matching for member search.
"""
use Mv.DataCase, async: 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

View file

@ -0,0 +1,113 @@
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

View file

@ -0,0 +1,262 @@
defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
@moduledoc """
Tests for displaying custom fields in the member overview.
Tests cover:
- Custom fields with show_in_overview: true are displayed
- Custom fields with show_in_overview: false are not displayed
- Multiple custom fields with show_in_overview: true are all displayed
- Custom field values are correctly formatted for different types
- Members without custom field values show empty cell or "-"
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
# Create custom fields
{:ok, field_show_string} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "phone_mobile",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
{:ok, field_hide} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "internal_note",
value_type: :string,
show_in_overview: false
})
|> Ash.create()
{:ok, field_show_integer} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :integer,
show_in_overview: true
})
|> Ash.create()
{:ok, field_show_boolean} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "newsletter",
value_type: :boolean,
show_in_overview: true
})
|> Ash.create()
{:ok, field_show_date} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "birthday",
value_type: :date,
show_in_overview: true
})
|> Ash.create()
{:ok, field_show_email} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "secondary_email",
value_type: :email,
show_in_overview: true
})
|> Ash.create()
# Create custom field values for member1
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_show_string.id,
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_show_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 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

View file

@ -0,0 +1,173 @@
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

View file

@ -0,0 +1,459 @@
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

View file

@ -0,0 +1,149 @@
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

View file

@ -0,0 +1,112 @@
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

View file

@ -0,0 +1,233 @@
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

View file

@ -281,4 +281,101 @@ defmodule MvWeb.UserLive.FormTest do
assert edit_html =~ "Change Password"
end
end
describe "member linking - display" do
test "shows linked member with unlink button when user has member", %{conn: conn} do
# Create member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
})
# Create user linked to member
user = create_test_user(%{email: "user@example.com"})
{:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}})
# Load form
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
# Should show linked member section
assert html =~ "Linked Member"
assert html =~ "John Doe"
assert html =~ "user@example.com"
assert has_element?(view, "button[phx-click='unlink_member']")
assert html =~ "Unlink Member"
end
test "shows member search field when user has no member", %{conn: conn} do
user = create_test_user(%{email: "user@example.com"})
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
# Should show member search section
assert html =~ "Linked Member"
assert has_element?(view, "input[phx-change='search_members']")
# Should not show unlink button
refute has_element?(view, "button[phx-click='unlink_member']")
end
end
describe "member linking - workflow" do
test "selecting member and saving links member to user", %{conn: conn} do
# Create unlinked member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
# Create user without member
user = create_test_user(%{email: "user@example.com"})
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
# Select member
view |> element("div[data-member-id='#{member.id}']") |> render_click()
# Submit form
view
|> form("#user-form", user: %{email: "user@example.com"})
|> render_submit()
assert_redirected(view, "/users")
# Verify member is linked
updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member])
assert updated_user.member.id == member.id
end
test "unlinking member and saving removes member from user", %{conn: conn} do
# Create member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Bob",
last_name: "Wilson",
email: "bob@example.com"
})
# Create user linked to member
user = create_test_user(%{email: "user@example.com"})
{:ok, _} = Mv.Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
# Click unlink button
view |> element("button[phx-click='unlink_member']") |> render_click()
# Submit form
view
|> form("#user-form", user: %{email: "user@example.com"})
|> render_submit()
assert_redirected(view, "/users")
# Verify member is unlinked
updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member])
assert is_nil(updated_user.member)
end
end
end

View file

@ -410,4 +410,35 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ long_email
end
end
describe "member linking display" do
test "displays linked member name in user list", %{conn: conn} do
# Create member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
})
# Create user linked to member
user = create_test_user(%{email: "user@example.com"})
{:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}})
# Create another user without member
_unlinked_user = create_test_user(%{email: "unlinked@example.com"})
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
# Should show linked member name
assert html =~ "Alice Johnson"
# Should show user email
assert html =~ "user@example.com"
# Should show unlinked user
assert html =~ "unlinked@example.com"
# Should show "No member linked" or similar for unlinked user
assert html =~ "No member linked"
end
end
end

96
test/support/fixtures.ex Normal file
View file

@ -0,0 +1,96 @@
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