From e5d4e84bd254b7bdd93d8b3afa1f3cf16b5d4f1c Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 27 Nov 2025 16:01:42 +0100 Subject: [PATCH 1/2] feat: Add keyboard navigation to member linking dropdown --- assets/js/app.js | 25 +++++++- docs/development-progress-log.md | 89 ++++++++++++++++++++++++++--- lib/mv_web/live/user_live/form.ex | 95 +++++++++++++++++++++++++++++-- notes.md | 58 +++++++++++++++++++ 4 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 notes.md diff --git a/assets/js/app.js b/assets/js/app.js index 9b95296..e55a06d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,9 +24,32 @@ import topbar from "../vendor/topbar" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +// Hooks for LiveView components +let Hooks = {} + +// ComboBox hook: Prevents form submission when Enter is pressed in dropdown +Hooks.ComboBox = { + mounted() { + this.handleKeyDown = (e) => { + const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true" + + if (e.key === "Enter" && isDropdownOpen) { + e.preventDefault() + } + } + + this.el.addEventListener("keydown", this.handleKeyDown) + }, + + destroyed() { + this.el.removeEventListener("keydown", this.handleKeyDown) + } +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken} + params: {_csrf_token: csrfToken}, + hooks: Hooks }) // Listen for custom events from LiveView diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 1b86106..51d0749 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -1328,9 +1328,10 @@ Implemented user-member linking functionality in User Edit/Create views with fuz **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) +- WCAG 2.1 AA compliant (ARIA labels, keyboard accessibility) - Bilingual UI (English/German) ### Technical Decisions @@ -1350,7 +1351,45 @@ window.addEventListener("phx:set-input-value", (e) => { ``` **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. Fuzzy Search Implementation** +**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 @@ -1393,11 +1432,13 @@ end - ✅ 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 @@ -1407,6 +1448,12 @@ socket |> push_event("event-name", %{key: value}) 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 @@ -1418,7 +1465,34 @@ Supports: - Partial matching: "Mit" finds "Mitglied" - Substring: "exam" finds "example.com" -#### 4. Test-Driven Development for Bug Fixes +#### 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 @@ -1435,7 +1509,8 @@ Effective workflow: - `lib/mv_web/live/user_live/form.ex` - Event handlers, state management **Frontend:** -- `assets/js/app.js` - Input value hook (6 lines) +- `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):** @@ -1472,14 +1547,14 @@ This project demonstrates a modern Phoenix application built with: **Next Steps:** - Implement roles & permissions - Add payment tracking -- Improve accessibility (WCAG 2.1 AA) +- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented - Member self-service portal - Email communication features --- -**Document Version:** 1.1 -**Last Updated:** 2025-11-13 +**Document Version:** 1.2 +**Last Updated:** 2025-11-27 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index b8a0294..6934cf5 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -162,9 +162,11 @@ defmodule MvWeb.UserLive.Form do type="text" id="member-search-input" role="combobox" + phx-hook="ComboBox" phx-focus="show_member_dropdown" phx-change="search_members" phx-debounce="300" + phx-window-keydown="member_dropdown_keydown" value={@member_search_query} placeholder={gettext("Search for a member to link...")} class="w-full input" @@ -175,6 +177,11 @@ defmodule MvWeb.UserLive.Form do aria-autocomplete="list" aria-controls="member-dropdown" aria-expanded={to_string(@show_member_dropdown)} + aria-activedescendant={ + if @focused_member_index, + do: "member-option-#{@focused_member_index}", + else: nil + } autocomplete="off" /> @@ -186,15 +193,22 @@ defmodule MvWeb.UserLive.Form do class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"} phx-click-away="hide_member_dropdown" > - <%= for member <- @available_members do %> + <%= for {member, index} <- Enum.with_index(@available_members) do %>

{member.first_name} {member.last_name}

{member.email}

@@ -263,6 +277,7 @@ defmodule MvWeb.UserLive.Form do |> 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 @@ -353,7 +368,67 @@ defmodule MvWeb.UserLive.Form do end def handle_event("hide_member_dropdown", _params, socket) do - {:noreply, assign(socket, show_member_dropdown: false)} + {: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 -> + case socket.assigns.focused_member_index do + nil -> + {:noreply, socket} + + index -> + member = Enum.at(socket.assigns.available_members, index) + + if member do + handle_event("select_member", %{"id" => member.id}, socket) + else + {:noreply, socket} + end + end + 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 @@ -362,6 +437,7 @@ defmodule MvWeb.UserLive.Form do |> assign(:member_search_query, query) |> load_available_members(query) |> assign(:show_member_dropdown, true) + |> assign(:focused_member_index, nil) {:noreply, socket} end @@ -406,6 +482,17 @@ defmodule MvWeb.UserLive.Form do @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 + @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 = diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..a5aa44f --- /dev/null +++ b/notes.md @@ -0,0 +1,58 @@ +# User-Member Association - Test Status + +## Test Files Created/Modified + +### 1. test/membership/member_available_for_linking_test.exs (NEU) +**Status**: Alle Tests sollten FEHLSCHLAGEN ❌ +**Grund**: Die `:available_for_linking` Action existiert noch nicht + +Tests: +- ✗ returns only unlinked members and limits to 10 +- ✗ limits results to 10 members even when more exist +- ✗ email match: returns only member with matching email when exists +- ✗ email match: returns all unlinked members when no email match +- ✗ search query: filters by first_name, last_name, and email +- ✗ email match takes precedence over search query + +### 2. test/accounts/user_member_linking_test.exs (NEU) +**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌ + +Tests: +- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert) +- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert) +- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert) +- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert) + +### 3. test/mv_web/user_live/form_test.exs (ERWEITERT) +**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌ +**Grund**: Member-Linking UI ist noch nicht implementiert + +Neue Tests: +- ✗ shows linked member with unlink button when user has member +- ✗ shows member search field when user has no member +- ✗ selecting member and saving links member to user +- ✗ unlinking member and saving removes member from user + +### 4. test/mv_web/user_live/index_test.exs (ERWEITERT) +**Status**: Neuer Test sollte FEHLSCHLAGEN ❌ +**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt + +Neuer Test: +- ✗ displays linked member name in user list + +## Zusammenfassung + +**Tests gesamt**: 13 +**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden) +**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert) + +## Nächste Schritte + +1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex` +2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex` +3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex` +4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu +5. Füge Gettext-Übersetzungen hinzu + +Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden. + From 3da0ebcb3f6a86bfe3f667bdbfa56a966b9c7f5b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 27 Nov 2025 16:01:42 +0100 Subject: [PATCH 2/2] feat: Add keyboard navigation to member linking dropdown --- assets/js/app.js | 25 +++++++- docs/development-progress-log.md | 89 ++++++++++++++++++++++++++--- lib/mv_web/live/user_live/form.ex | 95 +++++++++++++++++++++++++++++-- notes.md | 58 +++++++++++++++++++ 4 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 notes.md diff --git a/assets/js/app.js b/assets/js/app.js index 9b95296..e55a06d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,9 +24,32 @@ import topbar from "../vendor/topbar" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +// Hooks for LiveView components +let Hooks = {} + +// ComboBox hook: Prevents form submission when Enter is pressed in dropdown +Hooks.ComboBox = { + mounted() { + this.handleKeyDown = (e) => { + const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true" + + if (e.key === "Enter" && isDropdownOpen) { + e.preventDefault() + } + } + + this.el.addEventListener("keydown", this.handleKeyDown) + }, + + destroyed() { + this.el.removeEventListener("keydown", this.handleKeyDown) + } +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken} + params: {_csrf_token: csrfToken}, + hooks: Hooks }) // Listen for custom events from LiveView diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 1b86106..51d0749 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -1328,9 +1328,10 @@ Implemented user-member linking functionality in User Edit/Create views with fuz **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) +- WCAG 2.1 AA compliant (ARIA labels, keyboard accessibility) - Bilingual UI (English/German) ### Technical Decisions @@ -1350,7 +1351,45 @@ window.addEventListener("phx:set-input-value", (e) => { ``` **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. Fuzzy Search Implementation** +**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 @@ -1393,11 +1432,13 @@ end - ✅ 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 @@ -1407,6 +1448,12 @@ socket |> push_event("event-name", %{key: value}) 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 @@ -1418,7 +1465,34 @@ Supports: - Partial matching: "Mit" finds "Mitglied" - Substring: "exam" finds "example.com" -#### 4. Test-Driven Development for Bug Fixes +#### 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 @@ -1435,7 +1509,8 @@ Effective workflow: - `lib/mv_web/live/user_live/form.ex` - Event handlers, state management **Frontend:** -- `assets/js/app.js` - Input value hook (6 lines) +- `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):** @@ -1472,14 +1547,14 @@ This project demonstrates a modern Phoenix application built with: **Next Steps:** - Implement roles & permissions - Add payment tracking -- Improve accessibility (WCAG 2.1 AA) +- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented - Member self-service portal - Email communication features --- -**Document Version:** 1.1 -**Last Updated:** 2025-11-13 +**Document Version:** 1.2 +**Last Updated:** 2025-11-27 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index b8a0294..9619a15 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -162,9 +162,11 @@ defmodule MvWeb.UserLive.Form do type="text" id="member-search-input" role="combobox" + phx-hook="ComboBox" phx-focus="show_member_dropdown" phx-change="search_members" phx-debounce="300" + phx-window-keydown="member_dropdown_keydown" value={@member_search_query} placeholder={gettext("Search for a member to link...")} class="w-full input" @@ -175,6 +177,11 @@ defmodule MvWeb.UserLive.Form do aria-autocomplete="list" aria-controls="member-dropdown" aria-expanded={to_string(@show_member_dropdown)} + aria-activedescendant={ + if @focused_member_index, + do: "member-option-#{@focused_member_index}", + else: nil + } autocomplete="off" /> @@ -186,15 +193,22 @@ defmodule MvWeb.UserLive.Form do class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"} phx-click-away="hide_member_dropdown" > - <%= for member <- @available_members do %> + <%= for {member, index} <- Enum.with_index(@available_members) do %>

{member.first_name} {member.last_name}

{member.email}

@@ -263,6 +277,7 @@ defmodule MvWeb.UserLive.Form do |> 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 @@ -353,7 +368,55 @@ defmodule MvWeb.UserLive.Form do end def handle_event("hide_member_dropdown", _params, socket) do - {:noreply, assign(socket, show_member_dropdown: false)} + {: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 @@ -362,6 +425,7 @@ defmodule MvWeb.UserLive.Form do |> assign(:member_search_query, query) |> load_available_members(query) |> assign(:show_member_dropdown, true) + |> assign(:focused_member_index, nil) {:noreply, socket} end @@ -406,6 +470,29 @@ defmodule MvWeb.UserLive.Form do @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 = diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..a5aa44f --- /dev/null +++ b/notes.md @@ -0,0 +1,58 @@ +# User-Member Association - Test Status + +## Test Files Created/Modified + +### 1. test/membership/member_available_for_linking_test.exs (NEU) +**Status**: Alle Tests sollten FEHLSCHLAGEN ❌ +**Grund**: Die `:available_for_linking` Action existiert noch nicht + +Tests: +- ✗ returns only unlinked members and limits to 10 +- ✗ limits results to 10 members even when more exist +- ✗ email match: returns only member with matching email when exists +- ✗ email match: returns all unlinked members when no email match +- ✗ search query: filters by first_name, last_name, and email +- ✗ email match takes precedence over search query + +### 2. test/accounts/user_member_linking_test.exs (NEU) +**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌ + +Tests: +- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert) +- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert) +- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert) +- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert) + +### 3. test/mv_web/user_live/form_test.exs (ERWEITERT) +**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌ +**Grund**: Member-Linking UI ist noch nicht implementiert + +Neue Tests: +- ✗ shows linked member with unlink button when user has member +- ✗ shows member search field when user has no member +- ✗ selecting member and saving links member to user +- ✗ unlinking member and saving removes member from user + +### 4. test/mv_web/user_live/index_test.exs (ERWEITERT) +**Status**: Neuer Test sollte FEHLSCHLAGEN ❌ +**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt + +Neuer Test: +- ✗ displays linked member name in user list + +## Zusammenfassung + +**Tests gesamt**: 13 +**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden) +**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert) + +## Nächste Schritte + +1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex` +2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex` +3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex` +4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu +5. Füge Gettext-Übersetzungen hinzu + +Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden. +