Allow user-member association in edit/create views closes #168 #207

Merged
moritz merged 12 commits from feature/user-linking into main 2025-11-27 16:11:03 +01:00
26 changed files with 2484 additions and 58 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

@ -1321,6 +1321,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 +1547,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

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

In the future we could maybe move fuzzy search to an helper function if we use it in multiple places?

In the future we could maybe move fuzzy search to an helper function if we use it in multiple places?

I tried it, but unfortunately the apply_linking_filters function and the :search action contain slightly different Ash Queries and I couldn't figure out how to build an Ash Query by checking different conditions.

I tried it, but unfortunately the `apply_linking_filters` function and the `:search` action contain slightly different Ash Queries and I couldn't figure out how to build an Ash Query by checking different conditions.

Should we figure that out in a different issue?

Should we figure that out in a different issue?
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

@ -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 %>
moritz marked this conversation as resolved

Nice that you considered user information :)

Nice that you considered user information :)
<!-- 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}
moritz marked this conversation as resolved

if we also use it in members, maybe we can use it as dropdown component in core components to reuse it?

if we also use it in members, maybe we can use it as dropdown component in core components to reuse it?

If we really want to reuse it, we can extract it as component, but at the moment I wouldn't use it in members.

If we really want to reuse it, we can extract it as component, but at the moment I wouldn't use it in members.
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
moritz marked this conversation as resolved

Nice work!
One thing: I cannot select a member via enter...For me it would be also fine to handle the accessibility of the dropdown in a seperate issue. Up to you :)
I am also a bit confused that we need to add JS for a simple dropdown actually....

Nice work! One thing: I cannot select a member via enter...For me it would be also fine to handle the accessibility of the dropdown in a seperate issue. Up to you :) I am also a bit confused that we need to add JS for a simple dropdown actually....
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

@ -16,7 +16,7 @@ msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/user_live/index.html.heex:65
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr "Bist du sicher?"
@ -35,14 +35,14 @@ msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:67
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#: lib/mv_web/live/user_live/form.ex:251
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr "Bearbeite"
@ -88,7 +88,7 @@ msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/user_live/index.html.heex:56
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
msgstr "Anzeigen"
@ -161,7 +161,7 @@ msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#: lib/mv_web/live/user_live/form.ex:234
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr "Speichern..."
@ -256,7 +256,7 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#: lib/mv_web/live/user_live/form.ex:237
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr "Abbrechen"
@ -336,6 +336,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"
@ -376,7 +377,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"
@ -401,7 +402,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"
@ -429,7 +430,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"
@ -504,6 +505,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"
@ -514,6 +517,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"
@ -694,7 +698,52 @@ 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/custom_field_live/index.ex:97
#~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:"
#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:"
#: lib/mv_web/live/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 ""

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

@ -17,7 +17,7 @@ msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/user_live/index.html.heex:65
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
@ -36,14 +36,14 @@ msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:67
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#: lib/mv_web/live/user_live/form.ex:251
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
@ -89,7 +89,7 @@ msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/user_live/index.html.heex:56
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
@ -162,7 +162,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#: lib/mv_web/live/user_live/form.ex:234
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
@ -257,7 +257,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#: lib/mv_web/live/user_live/form.ex:237
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@ -337,6 +337,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 ""
@ -377,7 +378,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 ""
@ -402,7 +403,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"
@ -430,7 +431,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 ""
@ -505,6 +506,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"
@ -515,6 +518,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"
@ -694,3 +698,53 @@ msgstr ""
#, elixir-autogen, elixir-format
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
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 ""

View file

@ -17,7 +17,7 @@ msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/user_live/index.html.heex:65
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
@ -36,14 +36,14 @@ msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:67
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#: lib/mv_web/live/user_live/form.ex:251
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
@ -89,7 +89,7 @@ msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/user_live/index.html.heex:56
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
@ -162,7 +162,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#: lib/mv_web/live/user_live/form.ex:234
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
@ -257,7 +257,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#: lib/mv_web/live/user_live/form.ex:237
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@ -337,6 +337,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 ""
@ -377,7 +378,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 ""
@ -402,7 +403,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"
@ -430,7 +431,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 ""
@ -505,6 +506,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"
@ -515,6 +518,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"
@ -695,7 +699,52 @@ msgstr ""
msgid "To confirm deletion, please enter this text:"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index.ex:97
#~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:"
#~ msgstr ""
#: lib/mv_web/live/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 ""

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