diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..74df997
--- /dev/null
+++ b/CHANGELOG.md
@@ -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
+
diff --git a/assets/js/app.js b/assets/js/app.js
index d5e278a..e55a06d 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -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
diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md
index f7447f2..51d0749 100644
--- a/docs/development-progress-log.md
+++ b/docs/development-progress-log.md
@@ -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)
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index eeb12c9..da69861 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -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
diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex
index 9cea265..af68f96 100644
--- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex
+++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex
@@ -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
diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex
index cf7b687..9619a15 100644
--- a/lib/mv_web/live/user_live/form.ex
+++ b/lib/mv_web/live/user_live/form.ex
@@ -120,6 +120,130 @@ defmodule MvWeb.UserLive.Form do
<% end %>
<% end %>
+
+
+
+
{gettext("Linked Member")}
+
+ <%= if @user && @user.member && !@unlink_member do %>
+
+
+
+
+
+ {@user.member.first_name} {@user.member.last_name}
+
+
{@user.member.email}
+
+
+ {gettext("Unlink Member")}
+
+
+
+ <% else %>
+ <%= if @unlink_member do %>
+
+
+
+ {gettext("Unlinking scheduled")}: {gettext(
+ "Member will be unlinked when you save. Cannot select new member until saved."
+ )}
+
+
+ <% end %>
+
+
+
+
+
+ <%= if length(@available_members) > 0 do %>
+
+ <%= for {member, index} <- Enum.with_index(@available_members) do %>
+
+
{member.first_name} {member.last_name}
+
{member.email}
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
+
+
+ {gettext("Note")}: {gettext(
+ "A member with this email already exists. To link with a different member, please change one of the email addresses first."
+ )}
+
+
+ <% end %>
+
+ <%= if @selected_member_id && @selected_member_name do %>
+
+
+ {gettext("Selected")}: {@selected_member_name}
+
+
+ {gettext("Save to confirm linking.")}
+
+
+ <% end %>
+
+ <% end %>
+
<.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
diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex
index 8803237..0c1d7be 100644
--- a/lib/mv_web/live/user_live/index.ex
+++ b/lib/mv_web/live/user_live/index.ex
@@ -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,
diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex
index 66e3b9e..3582046 100644
--- a/lib/mv_web/live/user_live/index.html.heex
+++ b/lib/mv_web/live/user_live/index.html.heex
@@ -50,6 +50,13 @@
{user.email}
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}
+ <:col :let={user} label={gettext("Linked Member")}>
+ <%= if user.member do %>
+ {user.member.first_name} {user.member.last_name}
+ <% else %>
+ {gettext("No member linked")}
+ <% end %>
+
<:action :let={user}>
diff --git a/mix.lock b/mix.lock
index 28683a3..77dcc09 100644
--- a/mix.lock
+++ b/mix.lock
@@ -16,7 +16,7 @@
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
- "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
+ "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
@@ -80,7 +80,7 @@
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
- "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
+ "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
diff --git a/notes.md b/notes.md
new file mode 100644
index 0000000..a5aa44f
--- /dev/null
+++ b/notes.md
@@ -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.
+
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 842ab40..7b8c86e 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -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 ""
diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po
index e0db8dd..92d3048 100644
--- a/priv/gettext/de/LC_MESSAGES/errors.po
+++ b/priv/gettext/de/LC_MESSAGES/errors.po
@@ -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}"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 5942951..a1ae484 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 32a2d76..28339fc 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po
index 62df4a7..e1f18de 100644
--- a/priv/gettext/en/LC_MESSAGES/errors.po
+++ b/priv/gettext/en/LC_MESSAGES/errors.po
@@ -155,3 +155,7 @@ msgstr ""
msgid "is required"
msgstr ""
+
+#: lib/mv_web/live/user_live/form.ex
+msgid "Failed to link member: %{error}"
+msgstr ""
diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot
index 8f522c0..5d840fe 100644
--- a/priv/gettext/errors.pot
+++ b/priv/gettext/errors.pot
@@ -152,3 +152,7 @@ msgstr ""
msgid "is required"
msgstr ""
+
+#: lib/mv_web/live/user_live/form.ex
+msgid "Failed to link member: %{error}"
+msgstr ""
diff --git a/test/accounts/user_member_linking_email_test.exs b/test/accounts/user_member_linking_email_test.exs
new file mode 100644
index 0000000..d7c2817
--- /dev/null
+++ b/test/accounts/user_member_linking_email_test.exs
@@ -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
diff --git a/test/accounts/user_member_linking_test.exs b/test/accounts/user_member_linking_test.exs
new file mode 100644
index 0000000..1111436
--- /dev/null
+++ b/test/accounts/user_member_linking_test.exs
@@ -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
diff --git a/test/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs
new file mode 100644
index 0000000..2f3e018
--- /dev/null
+++ b/test/membership/member_available_for_linking_test.exs
@@ -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
diff --git a/test/membership/member_fuzzy_search_linking_test.exs b/test/membership/member_fuzzy_search_linking_test.exs
new file mode 100644
index 0000000..4cbd8d9
--- /dev/null
+++ b/test/membership/member_fuzzy_search_linking_test.exs
@@ -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
diff --git a/test/mv_web/user_live/form_member_dropdown_test.exs b/test/mv_web/user_live/form_member_dropdown_test.exs
new file mode 100644
index 0000000..0e93d4d
--- /dev/null
+++ b/test/mv_web/user_live/form_member_dropdown_test.exs
@@ -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
diff --git a/test/mv_web/user_live/form_member_search_test.exs b/test/mv_web/user_live/form_member_search_test.exs
new file mode 100644
index 0000000..b2644f3
--- /dev/null
+++ b/test/mv_web/user_live/form_member_search_test.exs
@@ -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
diff --git a/test/mv_web/user_live/form_member_selection_test.exs b/test/mv_web/user_live/form_member_selection_test.exs
new file mode 100644
index 0000000..74810df
--- /dev/null
+++ b/test/mv_web/user_live/form_member_selection_test.exs
@@ -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
diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs
index 111ff42..b8f7313 100644
--- a/test/mv_web/user_live/form_test.exs
+++ b/test/mv_web/user_live/form_test.exs
@@ -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
diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs
index 6393e3b..c0b0275 100644
--- a/test/mv_web/user_live/index_test.exs
+++ b/test/mv_web/user_live/index_test.exs
@@ -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
diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex
new file mode 100644
index 0000000..5dd14a9
--- /dev/null
+++ b/test/support/fixtures.ex
@@ -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