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}

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