diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 74df997..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [Unreleased]
-
-### Added
-- User-Member linking with fuzzy search autocomplete (#168)
-- PostgreSQL trigram-based member search with typo tolerance
-- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
-- Bilingual UI (German/English) for member linking workflow
-
-### Fixed
-- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
-- Relationship data extraction from Ash manage_relationship during validation
-
diff --git a/assets/js/app.js b/assets/js/app.js
index e55a06d..d5e278a 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -23,42 +23,9 @@ 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},
- 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
- }
+ params: {_csrf_token: csrfToken}
})
// Show progress bar on live navigation and form submits
diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md
index 51d0749..f7447f2 100644
--- a/docs/development-progress-log.md
+++ b/docs/development-progress-log.md
@@ -1321,210 +1321,6 @@ end
---
-## Session: User-Member Linking UI Enhancement (2025-01-13)
-
-### Feature Summary
-Implemented user-member linking functionality in User Edit/Create views with fuzzy search autocomplete, email conflict handling, and accessibility support.
-
-**Key Features:**
-- Autocomplete dropdown with PostgreSQL Trigram fuzzy search
-- Keyboard navigation (Arrow keys, Enter, Escape)
-- Link/unlink members to user accounts
-- Email synchronization between linked entities
-- WCAG 2.1 AA compliant (ARIA labels, keyboard accessibility)
-- Bilingual UI (English/German)
-
-### Technical Decisions
-
-**1. Search Priority Logic**
-Search query takes precedence over email filtering to provide better UX:
-- User types → fuzzy search across all unlinked members
-- Email matching only used for post-filtering when no search query present
-
-**2. JavaScript Hook for Input Value**
-Used minimal JavaScript (~6 lines) for reliable input field updates:
-```javascript
-// assets/js/app.js
-window.addEventListener("phx:set-input-value", (e) => {
- document.getElementById(e.detail.id).value = e.detail.value
-})
-```
-**Rationale:** LiveView DOM patching has race conditions with rapid state changes in autocomplete components. Direct DOM manipulation via `push_event` is the idiomatic LiveView solution for this edge case.
-
-**3. Keyboard Navigation: Hybrid Approach**
-Implemented keyboard accessibility with **mostly Server-Side + minimal Client-Side**:
-
-```elixir
-# Server-Side: Navigation and Selection (~45 lines)
-def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
- # Focus management on server
- new_index = min(current + 1, max_index)
- {:noreply, assign(socket, focused_member_index: new_index)}
-end
-```
-
-```javascript
-// Client-Side: Only preventDefault for Enter in forms (~13 lines)
-Hooks.ComboBox = {
- mounted() {
- this.el.addEventListener("keydown", (e) => {
- const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
- if (e.key === "Enter" && isDropdownOpen) {
- e.preventDefault() // Prevent form submission
- }
- })
- }
-}
-```
-
-**Rationale:**
-- Server-Side handles all navigation logic → simpler, testable, follows LiveView best practices
-- Client-Side only prevents browser default behavior (form submit on Enter)
-- Latency (~20-50ms) is imperceptible for keyboard events without DB queries
-- Follows CODE_GUIDELINES "Minimal JavaScript Philosophy"
-
-**Alternative Considered:** Full Client-Side with JavaScript Hook (~80 lines)
-- ❌ More complex code
-- ❌ State synchronization between client/server
-- ✅ Zero latency (but not noticeable in practice)
-- **Decision:** Server-Side approach is simpler and sufficient
-
-**4. Fuzzy Search Implementation**
-Combined PostgreSQL Full-Text Search + Trigram for optimal results:
-```sql
--- FTS for exact word matching
-search_vector @@ websearch_to_tsquery('simple', 'greta')
--- Trigram for typo tolerance
-word_similarity('gre', first_name) > 0.2
--- Substring for email/IDs
-email ILIKE '%greta%'
-```
-
-### Key Learnings
-
-#### 1. Ash `manage_relationship` Internals
-**Critical Discovery:** During validation, relationship data lives in `changeset.relationships`, NOT `changeset.attributes`:
-
-```elixir
-# During validation (manage_relationship processing):
-changeset.relationships.member = [{[%{id: "uuid"}], opts}]
-changeset.attributes.member_id = nil # Still nil!
-
-# After action completes:
-changeset.attributes.member_id = "uuid" # Now set
-```
-
-**Solution:** Extract member_id from both sources:
-```elixir
-defp get_member_id_from_changeset(changeset) do
- case Map.get(changeset.relationships, :member) do
- [{[%{id: id}], _opts}] -> id # New link
- _ -> Ash.Changeset.get_attribute(changeset, :member_id) # Existing
- end
-end
-```
-
-**Impact:** Fixed email validation false positives when linking user+member with identical emails.
-
-#### 2. LiveView + JavaScript Integration Patterns
-
-**When to use JavaScript:**
-- ✅ Direct DOM manipulation (autocomplete, input values)
-- ✅ Browser APIs (clipboard, geolocation)
-- ✅ Third-party libraries
-- ✅ Preventing browser default behaviors (form submit, scroll)
-
-**When NOT to use JavaScript:**
-- ❌ Form submissions
-- ❌ Simple show/hide logic
-- ❌ Server-side data fetching
-- ❌ Keyboard navigation logic (can be done server-side efficiently)
-
-**Pattern:**
-```elixir
-socket |> push_event("event-name", %{key: value})
-```
-```javascript
-window.addEventListener("phx:event-name", (e) => { /* handle */ })
-```
-
-**Keyboard Events Pattern:**
-For keyboard navigation in forms, use hybrid approach:
-- Server handles navigation logic via `phx-window-keydown`
-- Minimal hook only for `preventDefault()` to avoid form submit conflicts
-- Result: ~13 lines JS vs ~80 lines for full client-side solution
-
-#### 3. PostgreSQL Trigram Search
-Requires `pg_trgm` extension with GIN indexes:
-```sql
-CREATE INDEX members_first_name_trgm_idx
- ON members USING GIN(first_name gin_trgm_ops);
-```
-Supports:
-- Typo tolerance: "Gret" finds "Greta"
-- Partial matching: "Mit" finds "Mitglied"
-- Substring: "exam" finds "example.com"
-
-#### 4. Server-Side Keyboard Navigation Performance
-**Challenge:** Concern that server-side keyboard events would feel laggy.
-
-**Reality Check:**
-- LiveView roundtrip: ~20-50ms on decent connection
-- Human perception threshold: ~100ms
-- Result: **Feels instant** in practice
-
-**Why it works:**
-```elixir
-# Event handler only updates index (no DB queries)
-def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
- new_index = min(socket.assigns.focused_member_index + 1, max_index)
- {:noreply, assign(socket, focused_member_index: new_index)}
-end
-```
-- No database queries
-- No complex computations
-- Just state updates → extremely fast
-
-**When to use Client-Side instead:**
-- Complex animations (Canvas, WebGL)
-- Real-time gaming
-- Continuous interactions (drag & drop, drawing)
-
-**Lesson:** Don't prematurely optimize for latency. Server-side is simpler and often sufficient.
-
-#### 5. Test-Driven Development for Bug Fixes
-Effective workflow:
-1. Write test that reproduces bug (should fail)
-2. Implement minimal fix
-3. Verify test passes
-4. Refactor while green
-
-**Result:** 355 tests passing, 100% backend coverage for new features.
-
-### Files Changed
-
-**Backend:**
-- `lib/membership/member.ex` - `:available_for_linking` action with fuzzy search
-- `lib/mv/accounts/user/validations/email_not_used_by_other_member.ex` - Relationship change extraction
-- `lib/mv_web/live/user_live/form.ex` - Event handlers, state management
-
-**Frontend:**
-- `assets/js/app.js` - Input value hook (6 lines) + ComboBox hook (13 lines)
-- `lib/mv_web/live/user_live/form.ex` - Keyboard event handlers, focus management
-- `priv/gettext/**/*.po` - 10 new translation keys (DE/EN)
-
-**Tests (NEW):**
-- `test/membership/member_fuzzy_search_linking_test.exs`
-- `test/accounts/user_member_linking_email_test.exs`
-- `test/mv_web/user_live/form_member_linking_ui_test.exs`
-
-### Deployment Notes
-- **Assets:** Requires `cd assets && npm run build`
-- **Database:** No migrations (uses existing indexes)
-- **Config:** No changes required
-
----
-
## Conclusion
This project demonstrates a modern Phoenix application built with:
@@ -1547,14 +1343,14 @@ This project demonstrates a modern Phoenix application built with:
**Next Steps:**
- Implement roles & permissions
- Add payment tracking
-- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented
+- Improve accessibility (WCAG 2.1 AA)
- Member self-service portal
- Email communication features
---
-**Document Version:** 1.2
-**Last Updated:** 2025-11-27
+**Document Version:** 1.1
+**Last Updated:** 2025-11-13
**Maintainer:** Development Team
**Status:** Living Document (update as project evolves)
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index da69861..eeb12c9 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -38,10 +38,6 @@ 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
@@ -156,10 +152,8 @@ defmodule Mv.Membership.Member do
prepare fn query, _ctx ->
q = Ash.Query.get_argument(query, :query) || ""
- # 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
+ # 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results
+ threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
@@ -193,75 +187,8 @@ 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
@@ -434,32 +361,7 @@ defmodule Mv.Membership.Member do
identity :unique_email, [:email]
end
- @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()
+ # Fuzzy Search function that can be called by live view and calls search action
def fuzzy_search(query, opts) do
q = (opts[:query] || opts["query"] || "") |> to_string()
@@ -475,60 +377,4 @@ 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 af68f96..9cea265 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,37 +41,18 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
if should_validate? do
case Ash.Changeset.fetch_change(changeset, :email) do
{:ok, new_email} ->
- # 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)
+ check_email_uniqueness(new_email, member_id)
:error ->
# No email change, get current email
current_email = Ash.Changeset.get_attribute(changeset, :email)
- # 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)
+ check_email_uniqueness(current_email, member_id)
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 9619a15..cf7b687 100644
--- a/lib/mv_web/live/user_live/form.ex
+++ b/lib/mv_web/live/user_live/form.ex
@@ -120,130 +120,6 @@ 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")}
@@ -259,7 +135,7 @@ defmodule MvWeb.UserLive.Form do
user =
case params["id"] do
nil -> nil
- id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
+ id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@@ -271,18 +147,9 @@ 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"
@@ -299,201 +166,28 @@ defmodule MvWeb.UserLive.Form do
end
def handle_event("validate", %{"user" => user_params}, socket) do
- 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}
+ {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))}
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} ->
- # 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}})
+ notify_parent({:saved, user})
- # Unlink flag is set
- socket.assigns[:unlink_member] ->
- Mv.Accounts.update_user(user, %{member: nil})
+ socket =
+ socket
+ |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
+ |> push_navigate(to: return_path(socket.assigns.return_to, user))
- # No changes to member relationship
- 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
+ {:noreply, socket}
{: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
@@ -513,71 +207,6 @@ 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 0c1d7be..8803237 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, load: [:member])
+ users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
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 3582046..66e3b9e 100644
--- a/lib/mv_web/live/user_live/index.html.heex
+++ b/lib/mv_web/live/user_live/index.html.heex
@@ -50,13 +50,6 @@
{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 77dcc09..28683a3 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", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
+ "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"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", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
+ "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
"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
deleted file mode 100644
index a5aa44f..0000000
--- a/notes.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# User-Member Association - Test Status
-
-## Test Files Created/Modified
-
-### 1. test/membership/member_available_for_linking_test.exs (NEU)
-**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
-**Grund**: Die `:available_for_linking` Action existiert noch nicht
-
-Tests:
-- ✗ returns only unlinked members and limits to 10
-- ✗ limits results to 10 members even when more exist
-- ✗ email match: returns only member with matching email when exists
-- ✗ email match: returns all unlinked members when no email match
-- ✗ search query: filters by first_name, last_name, and email
-- ✗ email match takes precedence over search query
-
-### 2. test/accounts/user_member_linking_test.exs (NEU)
-**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
-
-Tests:
-- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
-- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
-- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
-- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
-
-### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
-**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
-**Grund**: Member-Linking UI ist noch nicht implementiert
-
-Neue Tests:
-- ✗ shows linked member with unlink button when user has member
-- ✗ shows member search field when user has no member
-- ✗ selecting member and saving links member to user
-- ✗ unlinking member and saving removes member from user
-
-### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
-**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
-**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
-
-Neuer Test:
-- ✗ displays linked member name in user list
-
-## Zusammenfassung
-
-**Tests gesamt**: 13
-**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
-**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
-
-## Nächste Schritte
-
-1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
-2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
-3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
-4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
-5. Füge Gettext-Übersetzungen hinzu
-
-Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.
-
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 27acc80..eed9c4a 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -161,7 +161,7 @@ msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
-#: lib/mv_web/live/user_live/form.ex:234
+#: lib/mv_web/live/user_live/form.ex:124
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr "Speichern..."
@@ -258,7 +258,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:237
+#: lib/mv_web/live/user_live/form.ex:127
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr "Abbrechen"
@@ -338,7 +338,6 @@ 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"
@@ -379,7 +378,7 @@ msgstr "Mitglied auswählen"
msgid "Settings"
msgstr "Einstellungen"
-#: lib/mv_web/live/user_live/form.ex:235
+#: lib/mv_web/live/user_live/form.ex:125
#, elixir-autogen, elixir-format
msgid "Save User"
msgstr "Benutzer*in speichern"
@@ -404,7 +403,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:252
+#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
@@ -432,7 +431,7 @@ msgstr "aufsteigend"
msgid "descending"
msgstr "absteigend"
-#: lib/mv_web/live/user_live/form.ex:251
+#: lib/mv_web/live/user_live/form.ex:141
#, elixir-autogen, elixir-format
msgid "New"
msgstr "Neue*r"
@@ -507,8 +506,6 @@ 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"
@@ -519,7 +516,6 @@ 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"
@@ -700,55 +696,6 @@ msgstr "Obigen Text zur Bestätigung eingeben"
msgid "To confirm deletion, please enter this text:"
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
-#: lib/mv_web/live/user_live/form.ex:210
-#, elixir-autogen, elixir-format
-msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
-msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen."
-
-#: lib/mv_web/live/user_live/form.ex:185
-#, elixir-autogen, elixir-format
-msgid "Available members"
-msgstr "Verfügbare Mitglieder"
-
-#: lib/mv_web/live/user_live/form.ex:152
-#, elixir-autogen, elixir-format
-msgid "Member will be unlinked when you save. Cannot select new member until saved."
-msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden."
-
-#: lib/mv_web/live/user_live/form.ex:226
-#, elixir-autogen, elixir-format
-msgid "Save to confirm linking."
-msgstr "Speichern, um die Verknüpfung zu bestätigen."
-
-#: lib/mv_web/live/user_live/form.ex:169
-#, elixir-autogen, elixir-format
-msgid "Search for a member to link..."
-msgstr "Nach einem Mitglied zum Verknüpfen suchen..."
-
-#: lib/mv_web/live/user_live/form.ex:173
-#, elixir-autogen, elixir-format
-msgid "Search for member to link"
-msgstr "Nach Mitglied zum Verknüpfen suchen"
-
-#: lib/mv_web/live/user_live/form.ex:223
-#, elixir-autogen, elixir-format
-msgid "Selected"
-msgstr "Ausgewählt"
-
-#: lib/mv_web/live/user_live/form.ex:143
-#, elixir-autogen, elixir-format
-msgid "Unlink Member"
-msgstr "Mitglied entverknüpfen"
-
-#: lib/mv_web/live/user_live/form.ex:152
-#, elixir-autogen, elixir-format
-msgid "Unlinking scheduled"
-msgstr "Entverknüpfung geplant"
-
-#: lib/mv_web/live/user_live/form.ex:342
-#, elixir-autogen, elixir-format
-msgid "Failed to link member: %{error}"
-msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Show in overview"
diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po
index 92d3048..e0db8dd 100644
--- a/priv/gettext/de/LC_MESSAGES/errors.po
+++ b/priv/gettext/de/LC_MESSAGES/errors.po
@@ -155,7 +155,3 @@ 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 7cf507b..128b9bf 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -162,7 +162,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
-#: lib/mv_web/live/user_live/form.ex:234
+#: lib/mv_web/live/user_live/form.ex:124
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
@@ -259,7 +259,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:237
+#: lib/mv_web/live/user_live/form.ex:127
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@@ -339,7 +339,6 @@ 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 ""
@@ -380,7 +379,7 @@ msgstr ""
msgid "Settings"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex:235
+#: lib/mv_web/live/user_live/form.ex:125
#, elixir-autogen, elixir-format
msgid "Save User"
msgstr ""
@@ -405,7 +404,7 @@ msgstr ""
msgid "Use this form to manage user records in your database."
msgstr ""
-#: lib/mv_web/live/user_live/form.ex:252
+#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
@@ -433,7 +432,7 @@ msgstr ""
msgid "descending"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex:251
+#: lib/mv_web/live/user_live/form.ex:141
#, elixir-autogen, elixir-format
msgid "New"
msgstr ""
@@ -508,8 +507,6 @@ 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"
@@ -520,7 +517,6 @@ 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"
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index ed38b0e..399b843 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -162,7 +162,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
-#: lib/mv_web/live/user_live/form.ex:234
+#: lib/mv_web/live/user_live/form.ex:124
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
@@ -259,7 +259,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:237
+#: lib/mv_web/live/user_live/form.ex:127
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@@ -339,7 +339,6 @@ 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 ""
@@ -380,7 +379,7 @@ msgstr ""
msgid "Settings"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex:235
+#: lib/mv_web/live/user_live/form.ex:125
#, elixir-autogen, elixir-format, fuzzy
msgid "Save User"
msgstr ""
@@ -405,7 +404,7 @@ msgstr ""
msgid "Use this form to manage user records in your database."
msgstr ""
-#: lib/mv_web/live/user_live/form.ex:252
+#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
@@ -433,7 +432,7 @@ msgstr ""
msgid "descending"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex:251
+#: lib/mv_web/live/user_live/form.ex:141
#, elixir-autogen, elixir-format
msgid "New"
msgstr ""
@@ -508,8 +507,6 @@ 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"
@@ -520,7 +517,6 @@ 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"
@@ -701,55 +697,6 @@ msgstr ""
msgid "To confirm deletion, please enter this text:"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex:210
-#, elixir-autogen, elixir-format
-msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:185
-#, elixir-autogen, elixir-format
-msgid "Available members"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:152
-#, elixir-autogen, elixir-format
-msgid "Member will be unlinked when you save. Cannot select new member until saved."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:226
-#, elixir-autogen, elixir-format
-msgid "Save to confirm linking."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:169
-#, elixir-autogen, elixir-format
-msgid "Search for a member to link..."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:173
-#, elixir-autogen, elixir-format
-msgid "Search for member to link"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:223
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Selected"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:143
-#, elixir-autogen, elixir-format
-msgid "Unlink Member"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:152
-#, elixir-autogen, elixir-format
-msgid "Unlinking scheduled"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex:342
-#, elixir-autogen, elixir-format
-msgid "Failed to link member: %{error}"
-msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Show in overview"
diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po
index e1f18de..62df4a7 100644
--- a/priv/gettext/en/LC_MESSAGES/errors.po
+++ b/priv/gettext/en/LC_MESSAGES/errors.po
@@ -155,7 +155,3 @@ 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 5d840fe..8f522c0 100644
--- a/priv/gettext/errors.pot
+++ b/priv/gettext/errors.pot
@@ -152,7 +152,3 @@ 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
deleted file mode 100644
index d7c2817..0000000
--- a/test/accounts/user_member_linking_email_test.exs
+++ /dev/null
@@ -1,169 +0,0 @@
-defmodule Mv.Accounts.UserMemberLinkingEmailTest do
- @moduledoc """
- Tests email validation during user-member linking.
- Implements rules from docs/email-sync.md.
- Tests for Issue #168, specifically Problem #4: Email validation bug.
- """
-
- use Mv.DataCase, async: false
-
- alias Mv.Accounts
- alias Mv.Membership
-
- describe "link with same email" do
- test "succeeds when user.email == member.email" do
- # Create member with specific email
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Alice",
- last_name: "Johnson",
- email: "alice@example.com"
- })
-
- # Create user with same email and link to member
- result =
- Accounts.create_user(%{
- email: "alice@example.com",
- member: %{id: member.id}
- })
-
- # Should succeed without errors
- assert {:ok, user} = result
- assert to_string(user.email) == "alice@example.com"
-
- # Reload to verify link
- user = Ash.load!(user, [:member], domain: Mv.Accounts)
- assert user.member.id == member.id
- assert user.member.email == "alice@example.com"
- end
-
- test "no validation error triggered when updating linked pair with same email" do
- # Create member
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Bob",
- last_name: "Smith",
- email: "bob@example.com"
- })
-
- # Create user and link
- {:ok, user} =
- Accounts.create_user(%{
- email: "bob@example.com",
- member: %{id: member.id}
- })
-
- # Update user (should not trigger email validation error)
- result = Accounts.update_user(user, %{email: "bob@example.com"})
-
- assert {:ok, updated_user} = result
- assert to_string(updated_user.email) == "bob@example.com"
- end
- end
-
- describe "link with different emails" do
- test "fails if member.email is used by a DIFFERENT linked user" do
- # Create first user and link to a different member
- {:ok, other_member} =
- Membership.create_member(%{
- first_name: "Other",
- last_name: "Member",
- email: "other@example.com"
- })
-
- {:ok, _user1} =
- Accounts.create_user(%{
- email: "user1@example.com",
- member: %{id: other_member.id}
- })
-
- # Reload to ensure email sync happened
- _other_member = Ash.reload!(other_member)
-
- # Create a NEW member with different email
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Charlie",
- last_name: "Brown",
- email: "charlie@example.com"
- })
-
- # Try to create user2 with email that matches the linked other_member
- result =
- Accounts.create_user(%{
- email: "user1@example.com",
- member: %{id: member.id}
- })
-
- # Should fail because user1@example.com is already used by other_member (which is linked to user1)
- assert {:error, _error} = result
- end
-
- test "succeeds for unique emails" do
- # Create member
- {:ok, member} =
- Membership.create_member(%{
- first_name: "David",
- last_name: "Wilson",
- email: "david@example.com"
- })
-
- # Create user with different but unique email
- result =
- Accounts.create_user(%{
- email: "user@example.com",
- member: %{id: member.id}
- })
-
- # Should succeed
- assert {:ok, user} = result
-
- # Email sync should update member's email to match user's
- user = Ash.load!(user, [:member], domain: Mv.Accounts)
- assert user.member.email == "user@example.com"
- end
- end
-
- describe "edge cases" do
- test "unlinking and relinking with same email works (Problem #4)" do
- # This is the exact scenario from Problem #4:
- # 1. Link user and member (both have same email)
- # 2. Unlink them (member keeps the email)
- # 3. Try to relink (validation should NOT fail)
-
- # Create member
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Emma",
- last_name: "Davis",
- email: "emma@example.com"
- })
-
- # Create user and link
- {:ok, user} =
- Accounts.create_user(%{
- email: "emma@example.com",
- member: %{id: member.id}
- })
-
- # Verify they are linked
- user = Ash.load!(user, [:member], domain: Mv.Accounts)
- assert user.member.id == member.id
- assert user.member.email == "emma@example.com"
-
- # Unlink
- {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
- assert is_nil(unlinked_user.member_id)
-
- # Member still has the email after unlink
- member = Ash.reload!(member)
- assert member.email == "emma@example.com"
-
- # Relink (should work - this is Problem #4)
- result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}})
-
- assert {:ok, relinked_user} = result
- assert relinked_user.member_id == member.id
- end
- end
-end
diff --git a/test/accounts/user_member_linking_test.exs b/test/accounts/user_member_linking_test.exs
deleted file mode 100644
index 1111436..0000000
--- a/test/accounts/user_member_linking_test.exs
+++ /dev/null
@@ -1,130 +0,0 @@
-defmodule Mv.Accounts.UserMemberLinkingTest do
- @moduledoc """
- Integration tests for User-Member linking functionality.
-
- Tests the complete workflow of linking and unlinking members to users,
- including email synchronization and validation rules.
- """
- use Mv.DataCase, async: false
- alias Mv.Accounts
- alias Mv.Membership
-
- describe "User-Member Linking with Email Sync" do
- test "link user to member with different email syncs member email" do
- # Create user with one email
- {:ok, user} = Accounts.create_user(%{email: "user@example.com"})
-
- # Create member with different email
- {:ok, member} =
- Membership.create_member(%{
- first_name: "John",
- last_name: "Doe",
- email: "member@example.com"
- })
-
- # Link user to member
- {:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}})
-
- # Verify link exists
- user_with_member = Ash.get!(Mv.Accounts.User, updated_user.id, load: [:member])
- assert user_with_member.member.id == member.id
-
- # Verify member email was synced to match user email
- synced_member = Ash.get!(Mv.Membership.Member, member.id)
- assert synced_member.email == "user@example.com"
- end
-
- test "unlink member from user sets member to nil" do
- # Create and link user and member
- {:ok, user} = Accounts.create_user(%{email: "user@example.com"})
-
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Jane",
- last_name: "Smith",
- email: "jane@example.com"
- })
-
- {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
-
- # Verify link exists
- user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, load: [:member])
- assert user_with_member.member.id == member.id
-
- # Unlink by setting member to nil
- {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil})
-
- # Verify link is removed
- user_without_member = Ash.get!(Mv.Accounts.User, unlinked_user.id, load: [:member])
- assert is_nil(user_without_member.member)
-
- # Verify member still exists independently
- member_still_exists = Ash.get!(Mv.Membership.Member, member.id)
- assert member_still_exists.id == member.id
- end
-
- test "cannot link member already linked to another user" do
- # Create first user and link to member
- {:ok, user1} = Accounts.create_user(%{email: "user1@example.com"})
-
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Bob",
- last_name: "Wilson",
- email: "bob@example.com"
- })
-
- {:ok, _linked_user1} = Accounts.update_user(user1, %{member: %{id: member.id}})
-
- # Create second user and try to link to same member
- {:ok, user2} = Accounts.create_user(%{email: "user2@example.com"})
-
- # Should fail because member is already linked
- assert {:error, %Ash.Error.Invalid{}} =
- Accounts.update_user(user2, %{member: %{id: member.id}})
- end
-
- test "cannot change member link directly, must unlink first" do
- # Create user and link to first member
- {:ok, user} = Accounts.create_user(%{email: "user@example.com"})
-
- {:ok, member1} =
- Membership.create_member(%{
- first_name: "Alice",
- last_name: "Johnson",
- email: "alice@example.com"
- })
-
- {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}})
-
- # Create second member
- {:ok, member2} =
- Membership.create_member(%{
- first_name: "Charlie",
- last_name: "Brown",
- email: "charlie@example.com"
- })
-
- # Try to directly change member link (should fail)
- assert {:error, %Ash.Error.Invalid{errors: errors}} =
- Accounts.update_user(linked_user, %{member: %{id: member2.id}})
-
- # Verify error message mentions "Remove existing member first"
- error_messages = Enum.map(errors, & &1.message)
- assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first"))
-
- # Two-step process: first unlink, then link new member
- {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil})
-
- # After unlinking, member1 still has the user's email
- # Change member1's email to avoid conflict when relinking to member2
- {:ok, _} = Membership.update_member(member1, %{email: "alice_changed@example.com"})
-
- {:ok, relinked_user} = Accounts.update_user(unlinked_user, %{member: %{id: member2.id}})
-
- # Verify new link is established
- user_with_new_member = Ash.get!(Mv.Accounts.User, relinked_user.id, load: [:member])
- assert user_with_new_member.member.id == member2.id
- end
- end
-end
diff --git a/test/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs
deleted file mode 100644
index 2f3e018..0000000
--- a/test/membership/member_available_for_linking_test.exs
+++ /dev/null
@@ -1,222 +0,0 @@
-defmodule Mv.Membership.MemberAvailableForLinkingTest do
- @moduledoc """
- Tests for the Member.available_for_linking action.
-
- This action returns members that can be linked to a user account:
- - Only members without existing user links (user_id == nil)
- - Limited to 10 results
- - Special email-match logic: if user_email matches member email, only return that member
- - Optional search query filtering by name and email
- """
- use Mv.DataCase, async: false
- alias Mv.Membership
-
- describe "available_for_linking/2" do
- setup do
- # Create 5 unlinked members with distinct names
- {:ok, member1} =
- Membership.create_member(%{
- first_name: "Alice",
- last_name: "Anderson",
- email: "alice@example.com"
- })
-
- {:ok, member2} =
- Membership.create_member(%{
- first_name: "Bob",
- last_name: "Williams",
- email: "bob@example.com"
- })
-
- {:ok, member3} =
- Membership.create_member(%{
- first_name: "Charlie",
- last_name: "Davis",
- email: "charlie@example.com"
- })
-
- {:ok, member4} =
- Membership.create_member(%{
- first_name: "Diana",
- last_name: "Martinez",
- email: "diana@example.com"
- })
-
- {:ok, member5} =
- Membership.create_member(%{
- first_name: "Emma",
- last_name: "Taylor",
- email: "emma@example.com"
- })
-
- unlinked_members = [member1, member2, member3, member4, member5]
-
- # Create 2 linked members (with users)
- {:ok, user1} = Mv.Accounts.create_user(%{email: "user1@example.com"})
-
- {:ok, linked_member1} =
- Membership.create_member(%{
- first_name: "Linked",
- last_name: "Member1",
- email: "linked1@example.com",
- user: %{id: user1.id}
- })
-
- {:ok, user2} = Mv.Accounts.create_user(%{email: "user2@example.com"})
-
- {:ok, linked_member2} =
- Membership.create_member(%{
- first_name: "Linked",
- last_name: "Member2",
- email: "linked2@example.com",
- user: %{id: user2.id}
- })
-
- %{
- unlinked_members: unlinked_members,
- linked_members: [linked_member1, linked_member2]
- }
- end
-
- test "returns only unlinked members and limits to 10", %{
- unlinked_members: unlinked_members,
- linked_members: _linked_members
- } do
- # Call the action without any arguments
- members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{})
- |> Ash.read!()
-
- # Should return only the 5 unlinked members, not the 2 linked ones
- assert length(members) == 5
-
- returned_ids = Enum.map(members, & &1.id) |> MapSet.new()
- expected_ids = Enum.map(unlinked_members, & &1.id) |> MapSet.new()
-
- assert MapSet.equal?(returned_ids, expected_ids)
-
- # Verify none of the returned members have a user_id
- Enum.each(members, fn member ->
- member_with_user = Ash.get!(Mv.Membership.Member, member.id, load: [:user])
- assert is_nil(member_with_user.user)
- end)
- end
-
- test "limits results to 10 members even when more exist" do
- # Create 15 additional unlinked members (total 20 unlinked)
- for i <- 6..20 do
- Membership.create_member(%{
- first_name: "Extra#{i}",
- last_name: "Member#{i}",
- email: "extra#{i}@example.com"
- })
- end
-
- members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{})
- |> Ash.read!()
-
- # Should be limited to 10
- assert length(members) == 10
- end
-
- test "email match: returns only member with matching email when exists", %{
- unlinked_members: unlinked_members
- } do
- # Get one of the unlinked members' email
- target_member = List.first(unlinked_members)
- user_email = target_member.email
-
- raw_members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{user_email: user_email})
- |> Ash.read!()
-
- # Apply email match filtering (sorted results come from query)
- # When user_email matches, only that member should be returned
- members = Mv.Membership.Member.filter_by_email_match(raw_members, user_email)
-
- # Should return only the member with matching email
- assert length(members) == 1
- assert List.first(members).id == target_member.id
- assert List.first(members).email == user_email
- end
-
- test "email match: returns all unlinked members when no email match" do
- # Use an email that doesn't match any member
- non_matching_email = "nonexistent@example.com"
-
- raw_members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email})
- |> Ash.read!()
-
- # Apply email match filtering
- members = Mv.Membership.Member.filter_by_email_match(raw_members, non_matching_email)
-
- # Should return all 5 unlinked members since no match
- assert length(members) == 5
- end
-
- test "search query: filters by first_name, last_name, and email", %{
- unlinked_members: _unlinked_members
- } do
- # Search by first name
- members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"})
- |> Ash.read!()
-
- assert length(members) == 1
- assert List.first(members).first_name == "Alice"
-
- # Search by last name
- members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"})
- |> Ash.read!()
-
- assert length(members) == 1
- assert List.first(members).last_name == "Williams"
-
- # Search by email
- members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"})
- |> Ash.read!()
-
- assert length(members) == 1
- assert List.first(members).email == "charlie@example.com"
-
- # Search returns empty when no matches
- members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"})
- |> Ash.read!()
-
- assert Enum.empty?(members)
- end
-
- test "user_email takes precedence over search_query", %{unlinked_members: unlinked_members} do
- target_member = List.first(unlinked_members)
-
- # Pass both email match and search query that would match different members
- raw_members =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{
- user_email: target_member.email,
- search_query: "Bob"
- })
- |> Ash.read!()
-
- # Apply email-match filter (as LiveView does)
- members = Mv.Membership.Member.filter_by_email_match(raw_members, target_member.email)
-
- # Email takes precedence: should match target_member by email, ignoring search_query
- assert length(members) == 1
- assert List.first(members).id == target_member.id
- end
- end
-end
diff --git a/test/membership/member_fuzzy_search_linking_test.exs b/test/membership/member_fuzzy_search_linking_test.exs
deleted file mode 100644
index 4cbd8d9..0000000
--- a/test/membership/member_fuzzy_search_linking_test.exs
+++ /dev/null
@@ -1,158 +0,0 @@
-defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
- @moduledoc """
- Tests fuzzy search in Member.available_for_linking action.
- Verifies PostgreSQL trigram matching for member search.
- """
-
- use Mv.DataCase, async: false
-
- alias Mv.Accounts
- alias Mv.Membership
-
- describe "available_for_linking with fuzzy search" do
- test "finds member despite typo" do
- # Create member with specific name
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Jonathan",
- last_name: "Smith",
- email: "jonathan@example.com"
- })
-
- # Search with typo
- query =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{
- user_email: nil,
- search_query: "Jonatan"
- })
-
- {:ok, members} = Ash.read(query, domain: Mv.Membership)
-
- # Should find Jonathan despite typo
- assert length(members) == 1
- assert hd(members).id == member.id
- end
-
- test "finds member with partial match" do
- # Create member
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Alexander",
- last_name: "Williams",
- email: "alex@example.com"
- })
-
- # Search with partial
- query =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{
- user_email: nil,
- search_query: "Alex"
- })
-
- {:ok, members} = Ash.read(query, domain: Mv.Membership)
-
- # Should find Alexander
- assert length(members) == 1
- assert hd(members).id == member.id
- end
-
- test "email match overrides fuzzy search" do
- # Create two members
- {:ok, member1} =
- Membership.create_member(%{
- first_name: "John",
- last_name: "Doe",
- email: "john@example.com"
- })
-
- {:ok, _member2} =
- Membership.create_member(%{
- first_name: "Jane",
- last_name: "Smith",
- email: "jane@example.com"
- })
-
- # Search with user_email that matches member1, but search_query that would match member2
- query =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{
- user_email: "john@example.com",
- search_query: "Jane"
- })
-
- {:ok, members} = Ash.read(query, domain: Mv.Membership)
-
- # Apply email filter
- filtered_members = Mv.Membership.Member.filter_by_email_match(members, "john@example.com")
-
- # Should only return member1 (email match takes precedence)
- assert length(filtered_members) == 1
- assert hd(filtered_members).id == member1.id
- end
-
- test "limits to 10 results" do
- # Create 15 members with similar names
- for i <- 1..15 do
- Membership.create_member(%{
- first_name: "Test#{i}",
- last_name: "Member",
- email: "test#{i}@example.com"
- })
- end
-
- # Search for "Test"
- query =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{
- user_email: nil,
- search_query: "Test"
- })
-
- {:ok, members} = Ash.read(query, domain: Mv.Membership)
-
- # Should return max 10 members
- assert length(members) == 10
- end
-
- test "excludes linked members" do
- # Create member and link to user
- {:ok, member1} =
- Membership.create_member(%{
- first_name: "Linked",
- last_name: "Member",
- email: "linked@example.com"
- })
-
- {:ok, _user} =
- Accounts.create_user(%{
- email: "user@example.com",
- member: %{id: member1.id}
- })
-
- # Create unlinked member
- {:ok, member2} =
- Membership.create_member(%{
- first_name: "Unlinked",
- last_name: "Member",
- email: "unlinked@example.com"
- })
-
- # Search for "Member"
- query =
- Mv.Membership.Member
- |> Ash.Query.for_read(:available_for_linking, %{
- user_email: nil,
- search_query: "Member"
- })
-
- {:ok, members} = Ash.read(query, domain: Mv.Membership)
-
- # Should only return unlinked member
- member_ids = Enum.map(members, & &1.id)
- refute member1.id in member_ids
- assert member2.id in member_ids
- end
- end
-end
diff --git a/test/mv_web/user_live/form_member_dropdown_test.exs b/test/mv_web/user_live/form_member_dropdown_test.exs
deleted file mode 100644
index 0e93d4d..0000000
--- a/test/mv_web/user_live/form_member_dropdown_test.exs
+++ /dev/null
@@ -1,149 +0,0 @@
-defmodule MvWeb.UserLive.FormMemberDropdownTest do
- @moduledoc """
- UI tests for member linking dropdown visibility and email handling.
- Tests dropdown behavior, visibility states, and email conflict scenarios.
- Related to Issue #168.
- """
-
- use MvWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
-
- alias Mv.Membership
-
- # Helper to setup authenticated connection for admin
- defp setup_admin_conn(conn) do
- conn_with_oidc_user(conn, %{email: "admin@example.com"})
- end
-
- describe "dropdown visibility" do
- test "dropdown hidden on mount", %{conn: conn} do
- conn = setup_admin_conn(conn)
- {:ok, _view, html} = live(conn, ~p"/users/new")
-
- # Dropdown should not be visible initially
- refute html =~ ~r/role="listbox"/
- end
-
- test "dropdown shows after focus event", %{conn: conn} do
- conn = setup_admin_conn(conn)
- # Create unlinked members
- create_unlinked_members(3)
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Focus the member search input
- view
- |> element("#member-search-input")
- |> render_focus()
-
- html = render(view)
-
- # Dropdown should now be visible
- assert html =~ ~r/role="listbox"/
- end
-
- test "dropdown shows top 10 unlinked members on focus", %{conn: conn} do
- conn = setup_admin_conn(conn)
- # Create 15 unlinked members
- _members = create_unlinked_members(15)
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Focus the member search input
- view
- |> element("#member-search-input")
- |> render_focus()
-
- html = render(view)
-
- # Count how many member entries are shown in the dropdown
- # Each member creates a div with role="option"
- member_count = html |> String.split(~r/role="option"/) |> length() |> Kernel.-(1)
-
- # Should show exactly 10 members (limit)
- assert member_count == 10
- end
- end
-
- describe "email handling" do
- test "links user and member with identical email successfully", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, member} =
- Membership.create_member(%{
- first_name: "David",
- last_name: "Miller",
- email: "david@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Fill user form with same email
- view
- |> form("#user-form", user: %{email: "david@example.com"})
- |> render_change()
-
- # Focus input
- view
- |> element("#member-search-input")
- |> render_focus()
-
- # Select member
- view
- |> element("[data-member-id='#{member.id}']")
- |> render_click()
-
- # Submit form
- view
- |> form("#user-form", user: %{email: "david@example.com"})
- |> render_submit()
-
- # Should succeed without errors
- assert_redirected(view, ~p"/users")
- end
-
- test "shows member with same email in dropdown", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, _member} =
- Membership.create_member(%{
- first_name: "Emma",
- last_name: "Davis",
- email: "emma@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Fill user form with same email
- view
- |> form("#user-form", user: %{email: "emma@example.com"})
- |> render_change()
-
- # Focus the member search to trigger loading
- view
- |> element("#member-search-input")
- |> render_focus()
-
- html = render(view)
-
- # Should show member with matching email in dropdown
- assert html =~ "Emma Davis"
- assert html =~ "emma@example.com"
- end
- end
-
- # Helper functions
- defp create_unlinked_members(count) do
- for i <- 1..count do
- {:ok, member} =
- Membership.create_member(%{
- first_name: "FirstName#{i}",
- last_name: "LastName#{i}",
- email: "member#{i}@example.com"
- })
-
- member
- end
- end
-end
diff --git a/test/mv_web/user_live/form_member_search_test.exs b/test/mv_web/user_live/form_member_search_test.exs
deleted file mode 100644
index b2644f3..0000000
--- a/test/mv_web/user_live/form_member_search_test.exs
+++ /dev/null
@@ -1,112 +0,0 @@
-defmodule MvWeb.UserLive.FormMemberSearchTest do
- @moduledoc """
- UI tests for fuzzy search functionality in member linking.
- Tests PostgreSQL trigram-based fuzzy search behavior.
- Related to Issue #168.
- """
-
- use MvWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
-
- alias Mv.Membership
-
- # Helper to setup authenticated connection for admin
- defp setup_admin_conn(conn) do
- conn_with_oidc_user(conn, %{email: "admin@example.com"})
- end
-
- describe "fuzzy search" do
- test "finds member with exact name", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, _member} =
- Membership.create_member(%{
- first_name: "Jonathan",
- last_name: "Smith",
- email: "jonathan.smith@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Type exact name
- view
- |> element("#member-search-input")
- |> render_change(%{"member_search" => "Jonathan"})
-
- html = render(view)
-
- assert html =~ "Jonathan"
- assert html =~ "Smith"
- end
-
- test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, _member} =
- Membership.create_member(%{
- first_name: "Jonathan",
- last_name: "Smith",
- email: "jonathan.smith@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Type with typo
- view
- |> element("#member-search-input")
- |> render_change(%{"member_search" => "Jon"})
-
- html = render(view)
-
- # Fuzzy search should find Jonathan
- assert html =~ "Jonathan"
- assert html =~ "Smith"
- end
-
- test "finds member with partial substring", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, _member} =
- Membership.create_member(%{
- first_name: "Alexander",
- last_name: "Williams",
- email: "alex@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Type partial
- view
- |> element("#member-search-input")
- |> render_change(%{"member_search" => "lex"})
-
- html = render(view)
-
- assert html =~ "Alexander"
- end
-
- test "shows partial match with similar names", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, _member} =
- Membership.create_member(%{
- first_name: "Johnny",
- last_name: "Doeson",
- email: "johnny@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Type partial match
- view
- |> element("#member-search-input")
- |> render_change(%{"member_search" => "John"})
-
- html = render(view)
-
- # Should find member with similar name
- assert html =~ "Johnny"
- end
- end
-end
diff --git a/test/mv_web/user_live/form_member_selection_test.exs b/test/mv_web/user_live/form_member_selection_test.exs
deleted file mode 100644
index 74810df..0000000
--- a/test/mv_web/user_live/form_member_selection_test.exs
+++ /dev/null
@@ -1,233 +0,0 @@
-defmodule MvWeb.UserLive.FormMemberSelectionTest do
- @moduledoc """
- UI tests for member selection and unlink workflow.
- Tests member selection behavior and unlink process.
- Related to Issue #168.
- """
-
- use MvWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
-
- alias Mv.Accounts
- alias Mv.Membership
-
- # Helper to setup authenticated connection for admin
- defp setup_admin_conn(conn) do
- conn_with_oidc_user(conn, %{email: "admin@example.com"})
- end
-
- describe "member selection" do
- test "input field shows selected member name", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Alice",
- last_name: "Johnson",
- email: "alice@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Focus and search
- view
- |> element("#member-search-input")
- |> render_focus()
-
- # Select member
- view
- |> element("[data-member-id='#{member.id}']")
- |> render_click()
-
- html = render(view)
-
- # Input field should show member name
- assert html =~ "Alice Johnson"
- end
-
- test "confirmation box appears", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Bob",
- last_name: "Williams",
- email: "bob@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Focus input
- view
- |> element("#member-search-input")
- |> render_focus()
-
- # Select member
- view
- |> element("[data-member-id='#{member.id}']")
- |> render_click()
-
- html = render(view)
-
- # Confirmation box should appear
- assert html =~ "Selected"
- assert html =~ "Bob Williams"
- assert html =~ "Save to confirm linking"
- end
-
- test "hidden input stores member ID", %{conn: conn} do
- conn = setup_admin_conn(conn)
-
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Charlie",
- last_name: "Brown",
- email: "charlie@example.com"
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/new")
-
- # Focus input
- view
- |> element("#member-search-input")
- |> render_focus()
-
- # Select member
- view
- |> element("[data-member-id='#{member.id}']")
- |> render_click()
-
- # Check socket assigns (member ID should be stored)
- assert view |> element("#user-form") |> has_element?()
- end
- end
-
- describe "unlink workflow" do
- test "unlink hides dropdown", %{conn: conn} do
- conn = setup_admin_conn(conn)
- # Create user with linked member
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Frank",
- last_name: "Wilson",
- email: "frank@example.com"
- })
-
- {:ok, user} =
- Accounts.create_user(%{
- email: "frank@example.com",
- member: %{id: member.id}
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
-
- # Click unlink button
- view
- |> element("button[phx-click='unlink_member']")
- |> render_click()
-
- html = render(view)
-
- # Dropdown should not be visible
- refute html =~ ~r/role="listbox"/
- end
-
- test "unlink shows warning", %{conn: conn} do
- conn = setup_admin_conn(conn)
- # Create user with linked member
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Grace",
- last_name: "Taylor",
- email: "grace@example.com"
- })
-
- {:ok, user} =
- Accounts.create_user(%{
- email: "grace@example.com",
- member: %{id: member.id}
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
-
- # Click unlink button
- view
- |> element("button[phx-click='unlink_member']")
- |> render_click()
-
- html = render(view)
-
- # Should show warning
- assert html =~ "Unlinking scheduled"
- assert html =~ "Cannot select new member until saved"
- end
-
- test "unlink disables input", %{conn: conn} do
- conn = setup_admin_conn(conn)
- # Create user with linked member
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Henry",
- last_name: "Anderson",
- email: "henry@example.com"
- })
-
- {:ok, user} =
- Accounts.create_user(%{
- email: "henry@example.com",
- member: %{id: member.id}
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
-
- # Click unlink button
- view
- |> element("button[phx-click='unlink_member']")
- |> render_click()
-
- html = render(view)
-
- # Input should be disabled
- assert html =~ ~r/disabled/
- end
-
- test "save re-enables member selection", %{conn: conn} do
- conn = setup_admin_conn(conn)
- # Create user with linked member
- {:ok, member} =
- Membership.create_member(%{
- first_name: "Isabel",
- last_name: "Martinez",
- email: "isabel@example.com"
- })
-
- {:ok, user} =
- Accounts.create_user(%{
- email: "isabel@example.com",
- member: %{id: member.id}
- })
-
- {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
-
- # Click unlink button
- view
- |> element("button[phx-click='unlink_member']")
- |> render_click()
-
- # Submit form
- view
- |> form("#user-form")
- |> render_submit()
-
- # Navigate back to edit
- {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
-
- html = render(view)
-
- # Should now show member selection input (not disabled)
- assert html =~ "member-search-input"
- refute html =~ "Unlinking scheduled"
- end
- end
-end
diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs
index b8f7313..111ff42 100644
--- a/test/mv_web/user_live/form_test.exs
+++ b/test/mv_web/user_live/form_test.exs
@@ -281,101 +281,4 @@ 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 c0b0275..6393e3b 100644
--- a/test/mv_web/user_live/index_test.exs
+++ b/test/mv_web/user_live/index_test.exs
@@ -410,35 +410,4 @@ 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
deleted file mode 100644
index 5dd14a9..0000000
--- a/test/support/fixtures.ex
+++ /dev/null
@@ -1,96 +0,0 @@
-defmodule Mv.Fixtures do
- @moduledoc """
- Shared test fixtures for consistent test data creation.
-
- This module provides factory functions for creating test data across
- different test suites, ensuring consistency and reducing duplication.
- """
-
- @doc """
- Creates a member with default or custom attributes.
-
- ## Parameters
- - `attrs` - Map or keyword list of attributes to override defaults
-
- ## Returns
- - Member struct
-
- ## Examples
-
- iex> member_fixture()
- %Mv.Membership.Member{first_name: "Test", ...}
-
- iex> member_fixture(%{first_name: "Alice", email: "alice@example.com"})
- %Mv.Membership.Member{first_name: "Alice", email: "alice@example.com"}
-
- """
- def member_fixture(attrs \\ %{}) do
- attrs
- |> Enum.into(%{
- first_name: "Test",
- last_name: "Member",
- email: "test#{System.unique_integer([:positive])}@example.com"
- })
- |> Mv.Membership.create_member()
- |> case do
- {:ok, member} -> member
- {:error, error} -> raise "Failed to create member: #{inspect(error)}"
- end
- end
-
- @doc """
- Creates a user with default or custom attributes.
-
- ## Parameters
- - `attrs` - Map or keyword list of attributes to override defaults
-
- ## Returns
- - User struct
-
- ## Examples
-
- iex> user_fixture()
- %Mv.Accounts.User{email: "user123@example.com"}
-
- iex> user_fixture(%{email: "custom@example.com"})
- %Mv.Accounts.User{email: "custom@example.com"}
-
- """
- def user_fixture(attrs \\ %{}) do
- attrs
- |> Enum.into(%{
- email: "user#{System.unique_integer([:positive])}@example.com"
- })
- |> Mv.Accounts.create_user()
- |> case do
- {:ok, user} -> user
- {:error, error} -> raise "Failed to create user: #{inspect(error)}"
- end
- end
-
- @doc """
- Creates a user linked to a member.
-
- ## Parameters
- - `user_attrs` - Map or keyword list of user attributes
- - `member_attrs` - Map or keyword list of member attributes
-
- ## Returns
- - Tuple of {user, member}
-
- ## Examples
-
- iex> {user, member} = linked_user_member_fixture()
- iex> user.member_id == member.id
- true
-
- """
- def linked_user_member_fixture(user_attrs \\ %{}, member_attrs \\ %{}) do
- member = member_fixture(member_attrs)
-
- user_attrs = Map.put(user_attrs, :member, %{id: member.id})
- user = user_fixture(user_attrs)
-
- {user, member}
- end
-end