feat(member): make overview columns sortable through accessible sort headers

This commit is contained in:
Simon 2026-07-06 10:48:28 +02:00
parent dfc616257d
commit 0c375234c8
9 changed files with 350 additions and 51 deletions

View file

@ -0,0 +1,55 @@
defmodule MvWeb.MemberLive.IndexSortA11yTest do
@moduledoc """
§1.11 When a column is sorted, exactly one `th` carries `aria-sort` with the
correct direction, a non-color sort glyph is present, and the sort is reflected
in the URL.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
@moduletag :ui
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Sortable", last_name: "Member", email: "sortable@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp aria_sort_count(html) do
~r/aria-sort="/ |> Regex.scan(html) |> length()
end
test "exactly one th carries aria-sort ascending with a non-color glyph", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=asc")
assert aria_sort_count(html) == 1
assert has_element?(view, "th[aria-sort='ascending']")
# Non-color shape glyph for the active ascending sort.
assert html =~ "hero-chevron-up"
end
test "aria-sort reflects descending and stays unique", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=desc")
assert aria_sort_count(html) == 1
assert has_element?(view, "th[aria-sort='descending']")
assert html =~ "hero-chevron-down"
end
test "clicking a header reflects the sort in the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[data-testid='join_date']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=join_date"
assert path =~ "sort_order=asc"
end
end

View file

@ -0,0 +1,106 @@
defmodule MvWeb.MemberLive.IndexSortNameAddressTest do
@moduledoc """
§1.21 the composite Member (Name) and Address columns are sortable DB-side:
Name by last name first name (email in the cell is display-only), Address by
city postal code street. Ascending/descending, reflected in the URL and in
`aria-sort` (§1.11).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
alias MvWeb.MemberLive.Index.OverviewQuery
setup %{conn: conn} do
actor = SystemActor.get_system_actor()
# Same last name, different first name — proves the first-name tie-breaker.
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Bruno",
last_name: "Meyer",
email: "z-bruno@example.com",
city: "Aachen",
postal_code: "52062",
street: "Zebra"
},
actor: actor
)
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Anna",
last_name: "Meyer",
email: "a-anna@example.com",
city: "Zwickau",
postal_code: "08056",
street: "Alpha"
},
actor: actor
)
%{conn: conn_with_oidc_user(conn), actor: actor}
end
defp read_names(query, actor) do
query
|> Ash.read!(actor: actor)
|> Enum.map(&{&1.last_name, &1.first_name})
end
test "name sort orders by last name then first name, ignoring email", %{actor: actor} do
asc = OverviewQuery.build(%{sort_field: :name, sort_order: :asc}) |> read_names(actor)
# Both share last name "Meyer"; the first-name tie-breaker orders Anna before
# Bruno, even though Bruno's email sorts first alphabetically.
assert asc == [{"Meyer", "Anna"}, {"Meyer", "Bruno"}]
desc = OverviewQuery.build(%{sort_field: :name, sort_order: :desc}) |> read_names(actor)
assert desc == [{"Meyer", "Bruno"}, {"Meyer", "Anna"}]
end
test "address sort orders by city, then postal code, then street", %{actor: actor} do
asc =
OverviewQuery.build(%{sort_field: :address, sort_order: :asc})
|> Ash.read!(actor: actor)
|> Enum.map(& &1.city)
assert asc == ["Aachen", "Zwickau"]
desc =
OverviewQuery.build(%{sort_field: :address, sort_order: :desc})
|> Ash.read!(actor: actor)
|> Enum.map(& &1.city)
assert desc == ["Zwickau", "Aachen"]
end
test "clicking the Name header sorts and reflects state in URL and aria-sort", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[phx-click='sort'][data-testid='name']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=name"
# Exactly one column carries aria-sort (§1.11).
assert has_element?(view, "th[aria-sort='ascending']")
assert render(view) |> aria_sort_count() == 1
end
test "clicking the Address header sorts and reflects state in URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[phx-click='sort'][data-testid='address']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=address"
assert has_element?(view, "th[aria-sort='ascending']")
end
defp aria_sort_count(html) do
html |> String.split("aria-sort=") |> length() |> Kernel.-(1)
end
end