feat: improve groups fillter
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-02-13 17:45:51 +01:00
parent 3322efcdf6
commit 5fd7c0e7f6
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
13 changed files with 583 additions and 258 deletions

View file

@ -62,18 +62,21 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
end
@tag :ui
test "filter dropdown has aria-label", %{
test "filter dropdown has group presence section with legend", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
{:ok, view, _html} = live(conn, "/members")
# Verify filter dropdown has aria-label
assert html =~ ~r/select.*name=["']group_filter["'].*aria-label=/ or
html =~ ~r/aria-label=.*[Gg]roup/
# Open filter dropdown
view
|> element("button[aria-label='Filter members']")
|> render_click()
# Verify dropdown is present
assert has_element?(view, "select[name='group_filter']")
html = render(view)
# Groups section: legend "Member has groups" and radios (Any / Yes / No)
assert html =~ ~r/[Gg]roups/
assert has_element?(view, "[data-testid='member-filter-form']")
end
@tag :ui
@ -92,26 +95,22 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
@tag :ui
test "keyboard navigation works for filter dropdown", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify dropdown is keyboard accessible
# Tab should focus the dropdown
# Arrow keys should navigate options
# Enter should select option
assert has_element?(view, "select[name='group_filter']")
# Test that dropdown can be focused and changed via keyboard
# (This is a basic accessibility check - actual keyboard testing would require browser automation)
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Verify change was applied
html = render(view)
assert html
assert html =~ member1.first_name
end
@tag :ui
@ -121,18 +120,14 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify sort header is keyboard accessible
# Tab should focus the sort header
# Enter/Space should activate sorting
assert has_element?(view, "[data-testid='groups']")
# Test that sort header can be activated via click (simulating keyboard)
view
|> element("[data-testid='groups']")
|> render_click()
# Verify sort was applied
assert_patch(view, "/members?query=&sort_field=groups&sort_order=asc")
# Verify sort was applied (URL may include other params)
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
@tag :ui
@ -144,19 +139,16 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply filter
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Verify filter change is announced (via aria-live region or similar)
html = render(view)
# Should show filtered results
assert html =~ member1.first_name
# Verify member count or filter status is announced
# (Implementation might use aria-live="polite" for announcements)
assert html
end
@tag :ui

View file

@ -64,7 +64,11 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
%{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2}
end
test "displays group badges for members in groups", %{conn: conn, group1: group1, group2: group2} do
test "displays group badges for members in groups", %{
conn: conn,
group1: group1,
group2: group2
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")

View file

@ -1,6 +1,9 @@
defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
@moduledoc """
Tests for filtering members by group in the member overview.
Uses the filter dropdown (MemberFilterComponent) with one row per group:
All / Yes / No (per group).
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
@ -53,7 +56,28 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
%{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2}
end
test "filter 'All groups' shows all members", %{conn: conn, member1: m1, member2: m2, member3: m3} do
defp open_filter_and_set_group(view, group_id, value) do
view
|> element("button[aria-label='Filter members']")
|> render_click()
key = "group_#{group_id}"
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{key => value, "payment_filter" => "all"})
# Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
_ = render(view)
assert_patch(view)
end
test "filter All (default) shows all members", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ m1.first_name
@ -61,7 +85,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
assert html =~ m3.first_name
end
test "filter by specific group shows only members in that group", %{
test "filter group1 Yes shows only members in group1", %{
conn: conn,
member1: m1,
member2: m2,
@ -71,9 +95,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
open_filter_and_set_group(view, group1.id, "in")
html = render(view)
assert html =~ m1.first_name
@ -81,21 +103,45 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
refute html =~ m3.first_name
end
test "filter persists in URL parameters", %{conn: conn, group1: group1, member1: m1} do
test "filter group1 No shows only members not in group1", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
open_filter_and_set_group(view, group1.id, "not_in")
html = render(view)
refute html =~ m1.first_name
assert html =~ m2.first_name
assert html =~ m3.first_name
end
test "filter persists in URL parameters", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
open_filter_and_set_group(view, group1.id, "in")
# Verify filter is applied
html = render(view)
assert html =~ m1.first_name
refute html =~ m2.first_name
refute html =~ m3.first_name
# Verify visiting with group_filter in URL shows same filtered list
{:ok, _view2, html2} = live(conn, "/members?group_filter=#{group1.id}")
{:ok, _view2, html2} = live(conn, "/members?group_#{group1.id}=in")
assert html2 =~ m1.first_name
refute html2 =~ m2.first_name
refute html2 =~ m3.first_name
end
test "filter is restored from URL on load", %{
@ -106,7 +152,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?group_filter=#{group1.id}")
{:ok, _view, html} = live(conn, "/members?group_#{group1.id}=in")
assert html =~ m1.first_name
refute html =~ m2.first_name
refute html =~ m3.first_name

View file

@ -102,8 +102,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
html = render(view)
assert html =~ member1.first_name
@ -160,13 +164,13 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
# Visit with both group filter and cycle status filter in URL (cycle filter is toggled via button, not a select).
# Cycle filter may depend on "current" cycle; we only verify the page loads with both params.
{:ok, _view, html} =
live(conn, "/members?group_filter=#{group1.id}&cycle_status_filter=paid")
live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid")
assert html =~ "Members"
assert html =~ group1.name
# member1 has a group and a paid cycle; page should load with both filters
assert html =~ member1.first_name
end
test "groups work with existing search (not testing search integration)", %{
@ -180,8 +184,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
# Apply group filter
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Apply search (this tests that filter and search work together;
# search form is in SearchBarComponent with phx-submit="search")
@ -208,8 +216,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
# Apply group filter
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Apply sorting
view

View file

@ -97,30 +97,28 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply filter
# Open filter and apply "Yes" for group1 (even-indexed members are in group1)
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
|> element("button[aria-label='Filter members']")
|> render_click()
# Verify only filtered members are shown
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Force LiveView to process {:group_filter_changed, ...}
html = render(view)
# Members with even indices (0, 2, 4, 6, 8) are in group1
even_members = Enum.filter(0..9, &(rem(&1, 2) == 0))
odd_members = Enum.filter(0..9, &(rem(&1, 2) == 1))
Enum.each(even_members, fn i ->
# Only even-indexed members (0,2,4,6,8) are in group1
Enum.each([0, 2, 4, 6, 8], fn i ->
member = Enum.at(members, i)
assert html =~ member.first_name
end)
Enum.each(odd_members, fn i ->
Enum.each([1, 3, 5, 7, 9], fn i ->
member = Enum.at(members, i)
refute html =~ member.first_name
end)
# If filtering was done in-memory, we'd load all members first
# Database-level filtering is more efficient
end
@tag :slow

View file

@ -3,7 +3,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
Tests for URL parameter persistence for groups in the member overview.
Tests cover:
- Group filter is written to URL (group_filter=<group_id>)
- Group presence filter is written to URL (group_presence=has_groups|no_groups)
- Group sorting is written to URL (sort_field=groups&sort_order=asc)
- URL parameters are restored on load
- URL parameters work with other parameters (query, sort_field, etc.)
@ -53,19 +53,22 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
test "group filter is written to URL", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Verify filter was applied (URL is patched with group_filter and other default params)
html = render(view)
assert html =~ group1.name
assert html =~ member1.first_name
end
test "group sorting is written to URL", %{
@ -92,13 +95,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
conn = conn_with_oidc_user(conn)
{:ok, view, html} =
live(conn, "/members?group_filter=#{group1.id}&sort_field=groups&sort_order=asc")
live(conn, "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc")
# Verify filter is applied
assert html =~ member1.first_name
refute html =~ member2.first_name
# Verify sort is applied
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
@ -108,23 +108,22 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?query=Alice&group_filter=#{group1.id}")
{:ok, _view, html} = live(conn, "/members?query=Alice&group_#{group1.id}=in")
# Verify both query and filter are applied (URL may include other default params)
assert html =~ member1.first_name
end
test "URL parameters work with other sort fields", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} =
live(conn, "/members?sort_field=first_name&sort_order=desc&group_filter=#{group1.id}")
live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in")
# Verify all parameters are preserved (filter applied, sort reflected in UI)
assert html =~ group1.name
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")
end
@ -134,37 +133,28 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
group1: group1
} do
conn = conn_with_oidc_user(conn)
# Simulate bookmarking a URL with filter and sort
bookmark_url = "/members?group_filter=#{group1.id}&sort_field=groups&sort_order=asc"
bookmark_url = "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc"
{:ok, view, html} = live(conn, bookmark_url)
# Verify filter and sort are both applied when loading bookmarked URL
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
test "handles multiple group_filter parameters (uses last one)", %{
test "handles multiple group filter parameters (uses last one)", %{
conn: conn,
member1: member1,
member2: member2,
group1: group1
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
# URL with duplicate parameters (should use last one)
{:ok, view, _html} =
live(conn, "/members?group_filter=#{group1.id}&group_filter=#{group2.id}")
# Duplicate param for same group: last wins. group_id=in then not_in -> not_in
{:ok, _view, html} =
live(conn, "/members?group_#{group1.id}=in&group_#{group1.id}=not_in")
# Verify the last filter value is used
# Implementation should handle this gracefully
html = render(view)
# Should show members from group2 (last filter)
assert html
# not_in group1: member2 and member3 (member1 is in group1)
refute html =~ member1.first_name
assert html =~ member2.first_name
end
test "handles invalid URL parameters gracefully", %{
@ -173,11 +163,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member2: member2
} do
conn = conn_with_oidc_user(conn)
# URL with invalid group_filter (non-existent UUID)
invalid_id = Ecto.UUID.generate()
{:ok, view, html} = live(conn, "/members?group_filter=#{invalid_id}")
{:ok, _view, html} = live(conn, "/members?group_#{invalid_id}=in")
# Verify all members are shown (invalid filter ignored)
# Unknown group id ignored, all members shown
assert html =~ member1.first_name
assert html =~ member2.first_name
end
@ -188,10 +177,8 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member2: member2
} do
conn = conn_with_oidc_user(conn)
# URL with malformed group_filter (not a UUID)
{:ok, view, html} = live(conn, "/members?group_filter=not-a-uuid")
{:ok, _view, html} = live(conn, "/members?group_not-a-uuid=in")
# Verify all members are shown (malformed filter ignored)
assert html =~ member1.first_name
assert html =~ member2.first_name
end