feat: Add keyboard navigation to member linking dropdown
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
4b4ec63613
commit
3da0ebcb3f
4 changed files with 255 additions and 12 deletions
|
|
@ -24,9 +24,32 @@ import topbar from "../vendor/topbar"
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
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, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {_csrf_token: csrfToken}
|
params: {_csrf_token: csrfToken},
|
||||||
|
hooks: Hooks
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen for custom events from LiveView
|
// Listen for custom events from LiveView
|
||||||
|
|
|
||||||
|
|
@ -1328,9 +1328,10 @@ Implemented user-member linking functionality in User Edit/Create views with fuz
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
- Autocomplete dropdown with PostgreSQL Trigram fuzzy search
|
- Autocomplete dropdown with PostgreSQL Trigram fuzzy search
|
||||||
|
- Keyboard navigation (Arrow keys, Enter, Escape)
|
||||||
- Link/unlink members to user accounts
|
- Link/unlink members to user accounts
|
||||||
- Email synchronization between linked entities
|
- 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)
|
- Bilingual UI (English/German)
|
||||||
|
|
||||||
### Technical Decisions
|
### 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.
|
**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:
|
Combined PostgreSQL Full-Text Search + Trigram for optimal results:
|
||||||
```sql
|
```sql
|
||||||
-- FTS for exact word matching
|
-- FTS for exact word matching
|
||||||
|
|
@ -1393,11 +1432,13 @@ end
|
||||||
- ✅ Direct DOM manipulation (autocomplete, input values)
|
- ✅ Direct DOM manipulation (autocomplete, input values)
|
||||||
- ✅ Browser APIs (clipboard, geolocation)
|
- ✅ Browser APIs (clipboard, geolocation)
|
||||||
- ✅ Third-party libraries
|
- ✅ Third-party libraries
|
||||||
|
- ✅ Preventing browser default behaviors (form submit, scroll)
|
||||||
|
|
||||||
**When NOT to use JavaScript:**
|
**When NOT to use JavaScript:**
|
||||||
- ❌ Form submissions
|
- ❌ Form submissions
|
||||||
- ❌ Simple show/hide logic
|
- ❌ Simple show/hide logic
|
||||||
- ❌ Server-side data fetching
|
- ❌ Server-side data fetching
|
||||||
|
- ❌ Keyboard navigation logic (can be done server-side efficiently)
|
||||||
|
|
||||||
**Pattern:**
|
**Pattern:**
|
||||||
```elixir
|
```elixir
|
||||||
|
|
@ -1407,6 +1448,12 @@ socket |> push_event("event-name", %{key: value})
|
||||||
window.addEventListener("phx:event-name", (e) => { /* handle */ })
|
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
|
#### 3. PostgreSQL Trigram Search
|
||||||
Requires `pg_trgm` extension with GIN indexes:
|
Requires `pg_trgm` extension with GIN indexes:
|
||||||
```sql
|
```sql
|
||||||
|
|
@ -1418,7 +1465,34 @@ Supports:
|
||||||
- Partial matching: "Mit" finds "Mitglied"
|
- Partial matching: "Mit" finds "Mitglied"
|
||||||
- Substring: "exam" finds "example.com"
|
- 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:
|
Effective workflow:
|
||||||
1. Write test that reproduces bug (should fail)
|
1. Write test that reproduces bug (should fail)
|
||||||
2. Implement minimal fix
|
2. Implement minimal fix
|
||||||
|
|
@ -1435,7 +1509,8 @@ Effective workflow:
|
||||||
- `lib/mv_web/live/user_live/form.ex` - Event handlers, state management
|
- `lib/mv_web/live/user_live/form.ex` - Event handlers, state management
|
||||||
|
|
||||||
**Frontend:**
|
**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)
|
- `priv/gettext/**/*.po` - 10 new translation keys (DE/EN)
|
||||||
|
|
||||||
**Tests (NEW):**
|
**Tests (NEW):**
|
||||||
|
|
@ -1472,14 +1547,14 @@ This project demonstrates a modern Phoenix application built with:
|
||||||
**Next Steps:**
|
**Next Steps:**
|
||||||
- Implement roles & permissions
|
- Implement roles & permissions
|
||||||
- Add payment tracking
|
- Add payment tracking
|
||||||
- Improve accessibility (WCAG 2.1 AA)
|
- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented
|
||||||
- Member self-service portal
|
- Member self-service portal
|
||||||
- Email communication features
|
- Email communication features
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Document Version:** 1.1
|
**Document Version:** 1.2
|
||||||
**Last Updated:** 2025-11-13
|
**Last Updated:** 2025-11-27
|
||||||
**Maintainer:** Development Team
|
**Maintainer:** Development Team
|
||||||
**Status:** Living Document (update as project evolves)
|
**Status:** Living Document (update as project evolves)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,9 +162,11 @@ defmodule MvWeb.UserLive.Form do
|
||||||
type="text"
|
type="text"
|
||||||
id="member-search-input"
|
id="member-search-input"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
|
phx-hook="ComboBox"
|
||||||
phx-focus="show_member_dropdown"
|
phx-focus="show_member_dropdown"
|
||||||
phx-change="search_members"
|
phx-change="search_members"
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
|
phx-window-keydown="member_dropdown_keydown"
|
||||||
value={@member_search_query}
|
value={@member_search_query}
|
||||||
placeholder={gettext("Search for a member to link...")}
|
placeholder={gettext("Search for a member to link...")}
|
||||||
class="w-full input"
|
class="w-full input"
|
||||||
|
|
@ -175,6 +177,11 @@ defmodule MvWeb.UserLive.Form do
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-controls="member-dropdown"
|
aria-controls="member-dropdown"
|
||||||
aria-expanded={to_string(@show_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"
|
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"}"}
|
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"
|
phx-click-away="hide_member_dropdown"
|
||||||
>
|
>
|
||||||
<%= for member <- @available_members do %>
|
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||||
<div
|
<div
|
||||||
|
id={"member-option-#{index}"}
|
||||||
role="option"
|
role="option"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-selected="false"
|
aria-selected={to_string(@focused_member_index == index)}
|
||||||
phx-click="select_member"
|
phx-click="select_member"
|
||||||
phx-value-id={member.id}
|
phx-value-id={member.id}
|
||||||
data-member-id={member.id}
|
data-member-id={member.id}
|
||||||
class="px-4 py-3 hover:bg-base-200 cursor-pointer border-b border-base-300 last:border-b-0"
|
class={[
|
||||||
|
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||||
|
if(@focused_member_index == index,
|
||||||
|
do: "bg-base-300",
|
||||||
|
else: "hover:bg-base-200"
|
||||||
|
)
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<p class="font-medium">{member.first_name} {member.last_name}</p>
|
<p class="font-medium">{member.first_name} {member.last_name}</p>
|
||||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||||
|
|
@ -263,6 +277,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> assign(:selected_member_id, nil)
|
|> assign(:selected_member_id, nil)
|
||||||
|> assign(:selected_member_name, nil)
|
|> assign(:selected_member_name, nil)
|
||||||
|> assign(:unlink_member, false)
|
|> assign(:unlink_member, false)
|
||||||
|
|> assign(:focused_member_index, nil)
|
||||||
|> load_initial_members()
|
|> load_initial_members()
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
@ -353,7 +368,55 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("hide_member_dropdown", _params, socket) do
|
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
|
end
|
||||||
|
|
||||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||||
|
|
@ -362,6 +425,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> assign(:member_search_query, query)
|
|> assign(:member_search_query, query)
|
||||||
|> load_available_members(query)
|
|> load_available_members(query)
|
||||||
|> assign(:show_member_dropdown, true)
|
|> assign(:show_member_dropdown, true)
|
||||||
|
|> assign(:focused_member_index, nil)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -406,6 +470,29 @@ defmodule MvWeb.UserLive.Form do
|
||||||
@spec notify_parent(any()) :: any()
|
@spec notify_parent(any()) :: any()
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
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()
|
@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
|
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||||
form =
|
form =
|
||||||
|
|
|
||||||
58
notes.md
Normal file
58
notes.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# User-Member Association - Test Status
|
||||||
|
|
||||||
|
## Test Files Created/Modified
|
||||||
|
|
||||||
|
### 1. test/membership/member_available_for_linking_test.exs (NEU)
|
||||||
|
**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
|
||||||
|
**Grund**: Die `:available_for_linking` Action existiert noch nicht
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- ✗ returns only unlinked members and limits to 10
|
||||||
|
- ✗ limits results to 10 members even when more exist
|
||||||
|
- ✗ email match: returns only member with matching email when exists
|
||||||
|
- ✗ email match: returns all unlinked members when no email match
|
||||||
|
- ✗ search query: filters by first_name, last_name, and email
|
||||||
|
- ✗ email match takes precedence over search query
|
||||||
|
|
||||||
|
### 2. test/accounts/user_member_linking_test.exs (NEU)
|
||||||
|
**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
|
||||||
|
- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
|
||||||
|
- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
|
||||||
|
- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
|
||||||
|
|
||||||
|
### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
|
||||||
|
**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
|
||||||
|
**Grund**: Member-Linking UI ist noch nicht implementiert
|
||||||
|
|
||||||
|
Neue Tests:
|
||||||
|
- ✗ shows linked member with unlink button when user has member
|
||||||
|
- ✗ shows member search field when user has no member
|
||||||
|
- ✗ selecting member and saving links member to user
|
||||||
|
- ✗ unlinking member and saving removes member from user
|
||||||
|
|
||||||
|
### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
|
||||||
|
**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
|
||||||
|
**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
|
||||||
|
|
||||||
|
Neuer Test:
|
||||||
|
- ✗ displays linked member name in user list
|
||||||
|
|
||||||
|
## Zusammenfassung
|
||||||
|
|
||||||
|
**Tests gesamt**: 13
|
||||||
|
**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
|
||||||
|
**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
|
||||||
|
2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
|
||||||
|
3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
|
||||||
|
4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
|
||||||
|
5. Füge Gettext-Übersetzungen hinzu
|
||||||
|
|
||||||
|
Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue