feat(member): let members tailor overview density and visible columns

The View dropdown drives row density and whether the Member and Address
fields render as composite cells or split into their underlying columns;
the column manager toggles visibility and resets to the curated default.
All choices persist per browser through the URL/session/cookie/global
chain, so a saved layout survives reloads without a per-account store.
This commit is contained in:
Simon 2026-07-06 10:45:53 +02:00
parent af2cc2e0d4
commit dfc616257d
21 changed files with 1643 additions and 263 deletions

View file

@ -0,0 +1,78 @@
defmodule MvWeb.MemberLive.IndexA11yHardeningTest do
@moduledoc """
§3.5 WCAG 2.2 AA posture: 24px interactive targets in both densities,
scroll-margin on rows so a focused row is not obscured by the sticky header,
select-all `indeterminate` for partial selection, and reflow markup keeping the
table in its own focusable scroll region.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
@moduletag :ui
setup %{conn: conn} do
actor = SystemActor.get_system_actor()
members =
for i <- 1..3 do
{:ok, m} =
Mv.Membership.create_member(
%{first_name: "Wcag#{i}", last_name: "Row", email: "wcag#{i}@example.com"},
actor: actor
)
m
end
%{conn: conn_with_oidc_user(conn), members: members}
end
test "rows reserve scroll-margin under the sticky header", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/members")
assert html =~ "scroll-mt-16"
end
test "the table lives in its own focusable scroll region that reflows", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/members")
# Focusable region (1.4.10: table scrolls within its own focusable region).
assert html =~ ~r/data-testid="members-table-scroll"[^>]*tabindex="0"/
# The toolbar reflows to one column on narrow viewports.
assert html =~ "flex-wrap"
end
test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Compact (default) and comfortable both keep the >=24px checkbox target.
assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6")
# Switch to comfortable via the view-settings dropdown.
view |> element("[data-testid='view-settings-button']") |> render_click()
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6")
end
test "select-all reflects indeterminate for a partial selection", %{
conn: conn,
members: members
} do
{:ok, view, _html} = live(conn, ~p"/members")
# No selection: not indeterminate.
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
# Select one of several -> partial -> indeterminate.
[m | _] = members
render_click(view, "select_member", %{"id" => m.id})
assert has_element?(view, "#select-all-checkbox[data-indeterminate='true']")
# Select all -> fully checked, not indeterminate.
view |> element("[phx-click='select_all']") |> render_click()
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
assert has_element?(view, "#select-all-checkbox[checked]")
end
end

View file

@ -0,0 +1,76 @@
defmodule MvWeb.MemberLive.IndexColumnManagerTest do
@moduledoc """
§1.6 The column manager sits in the filter toolbar, offers a per-column
visibility toggle plus All and None controls styled as buttons (not links),
and applies changes while preserving the existing persistence chain.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Manager", last_name: "Columns", email: "manager@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Both the filter panel trigger and the column-manager trigger render in the
# same toolbar.
assert has_element?(view, ~s(button[aria-label="Filter members"]))
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
end
test "All and None are buttons, not links, and per-column toggles are checkboxes", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
# All / None render as buttons with button styling.
assert has_element?(view, "button.btn[phx-click='select_all']", "All")
assert has_element?(view, "button.btn[phx-click='select_none']", "None")
refute has_element?(view, "a[phx-click='select_all']")
refute has_element?(view, "a[phx-click='select_none']")
# Per-column visibility toggles are accessible checkbox menu items.
assert has_element?(view, "button[role='menuitemcheckbox'][phx-value-item='name']")
end
test "toggling a column applies and is reflected in the URL (persistence)", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
view |> element("button[phx-value-item='join_date']") |> render_click()
# The selection is pushed to the URL fields param so it survives reloads.
path = assert_patch(view)
assert path =~ "fields="
refute has_element?(view, "[data-testid='join_date']")
end
test "a reset button restores the curated default column set", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
# Reset is a button styled like All/None, sitting next to them, with a tooltip.
assert has_element?(view, "button.btn[phx-click='reset_fields']")
# Hide a curated-default column, then reset restores it.
view |> element("button[phx-value-item='join_date']") |> render_click()
assert_patch(view)
refute has_element?(view, "[data-testid='join_date']")
view |> element("button[phx-click='reset_fields']") |> render_click()
assert_patch(view)
assert has_element?(view, "[data-testid='join_date']")
end
end

View file

@ -1,8 +1,9 @@
defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
@moduledoc """
§1.2 With no persisted column selection, exactly the curated default columns
are visible: selection checkbox, Name (name + email), Address (composite),
fee type, fee status, groups, join date.
are visible: selection checkbox, Name (composite, no in-cell email line),
E-Mail (its own column while the compact Member field keeps the email line off),
Address (composite), fee type, fee status, groups, join date.
"""
use MvWeb.ConnCase, async: false
@ -34,6 +35,8 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
# Composite Name + Address cells.
assert has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='member-address']")
# E-Mail is its own column by default (compact Member field, email line off).
assert has_element?(view, "[data-testid='email']")
# Fee type, fee status, groups, join date headers.
assert has_element?(view, "[data-testid='membership_fee_type']")
assert has_element?(view, "[data-testid='join_date']")
@ -44,7 +47,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
for field <- ~w(first_name last_name email city street house_number postal_code country) do
# The composite Name/Address cells replace their constituents by default.
# E-Mail is excluded here: it is a default-visible column of its own.
for field <- ~w(first_name last_name city street house_number postal_code country) do
refute has_element?(view, "[data-testid='#{field}']")
end
end

View file

@ -0,0 +1,73 @@
defmodule MvWeb.MemberLive.IndexDensityTest do
@moduledoc """
§1.4 The density toggle switches the table row-spacing token and reflects the
active density in the control state.
§1.5 Density persists across reload per browser (session/cookie), with the
global default applying when nothing is stored.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Dense", last_name: "Row", email: "dense@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp open_view_settings(view) do
view |> element("[data-testid='view-settings-button']") |> render_click()
view
end
test "defaults to compact when nothing is stored", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
# The compact-mode toggle reflects the active (compact) state.
open_view_settings(view)
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='true']")
end
test "toggle switches the density token and the control state", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='false']")
# Toggling again returns to compact.
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
end
test "restores a persisted density from the session on reload", %{conn: conn} do
conn =
Plug.Test.init_test_session(conn, %{
"member_view_settings" => ~s({"density":"comfortable"})
})
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
end
test "restores a persisted density on the connected mount via connect params", %{conn: conn} do
# Faithful reload round-trip: the connected mount cannot read the cookie
# (the live socket's connect-info map has no cookies), so the client echoes
# the persisted value through connect params. This is the path that was
# broken in the R1 accept (cookie written but not restored on mount).
conn = put_connect_params(conn, %{"view_settings" => ~s({"density":"comfortable"})})
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
end
end

View file

@ -22,6 +22,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
require Ash.Query
# Disables the compact Member/Address composites (per-browser view setting) so
# the individual constituent columns (first_name/last_name/email, street/…) are
# offered by the column manager and available via ?fields=.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
end
test "displays all member fields in dropdown", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# The individual name/address constituents are only offered when the
# compact Member/Address composites are off, so opt out of them first.
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
@ -148,9 +161,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The curated Name column carries the email, so it is visible initially.
# The curated Name column carries the member names, so they are visible.
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Anderson"
# Open dropdown and hide the Name column
view
@ -161,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_click()
# The Name column (and the email it carries) is no longer visible
# The Name column (and the names it carries) is no longer visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "bob@example.com"
refute html =~ "Anderson"
refute html =~ "Brown"
end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
@ -298,8 +311,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# All fields should be visible by default
assert html =~ "alice@example.com"
# The curated Name and Address columns are visible by default.
assert html =~ "Anderson"
assert html =~ "Main St"
end
@ -309,7 +322,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Hide the curated Name column (which carries the email) via dropdown
# Hide the curated Name column (which carries the names) via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
@ -319,7 +332,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> render_click()
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "Anderson"
end
end
@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=")
# Should fall back to global settings
assert html =~ "alice@example.com"
# Should fall back to global settings (curated Name column visible)
assert html =~ "Anderson"
end
test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
# Should ignore invalid fields and use defaults
assert html =~ "alice@example.com"
# Should ignore invalid fields and use defaults (curated Name column visible)
assert html =~ "Anderson"
end
test "handles custom field that doesn't exist", %{conn: conn} do
@ -429,9 +442,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The curated Name column carries the email, so it is visible initially.
# The curated Name column carries the names, so they are visible initially.
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Anderson"
# Open dropdown
view
@ -443,9 +456,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_keydown(%{key: "Enter"})
# The Name column (and the email it carries) is no longer visible
# The Name column (and the names it carries) is no longer visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "Anderson"
end
end
end

View file

@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
require Ash.Query
# Disables the compact Member composite (per-browser view setting) so the
# individual first_name column is offered and selectable via ?fields=.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -122,7 +132,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(

View file

@ -38,6 +38,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# The composite Member cell carries first + last name; the in-cell email
# line stays off by default, but the email is surfaced in its own column
# (compact Member field, include-email off).
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
assert html =~ field
end

View file

@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do
alias Mv.Membership.CustomFieldValue
alias MvWeb.MemberLive.Index, as: MemberIndex
# Disables the compact Member/Address composites (per-browser view setting) so
# the individual constituent columns (first_name/last_name, street/…) are
# offered by the column manager and selectable via ?fields=. The composite
# E-Mail column is offered even in compact mode, so email-only tests do not
# need this.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
describe "desktop layout: scroll container and sticky table header" do
@describetag :ui
@ -155,9 +168,11 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "sorting integration" do
@describetag :ui
# The curated default columns no longer expose the individual name/address
# sort headers, so these tests make the relevant column visible via ?fields=.
# That puts a `fields` param on every push_patch, hence the relaxed
# substring assertions instead of exact patch strings.
# sort headers. The composite E-Mail column is offered even in compact mode
# (so email-only tests just add it via ?fields=), but the name/address
# constituents are only offered once their composite view setting is off —
# those tests additionally opt out via `non_compact/1`. Either way a `fields`
# param rides on every push_patch, hence the relaxed substring assertions.
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?fields=email")
@ -183,7 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
@ -199,7 +214,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(
@ -275,7 +290,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")

View file

@ -0,0 +1,102 @@
defmodule MvWeb.MemberLive.IndexViewSettingsTest do
@moduledoc """
§1.20 the view-settings dropdown toggles: the composite "Member" and address
fields, the "include email" sub-toggle and their effect on the rendered
columns. Turning a composite off surfaces the underlying separate columns.
§1.2 email is not in the Member cell by default.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Ada",
last_name: "Lovelace",
email: "ada@example.com",
street: "Baker Street",
house_number: "221",
postal_code: "10115",
city: "Berlin"
},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp open_view_settings(view) do
view |> element("[data-testid='view-settings-button']") |> render_click()
view
end
test "member cell hides the email line by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='member-name']")
refute has_element?(view, "[data-testid='member-name-email']")
end
test "the include-email sub-toggle surfaces the email line in the member cell", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-member-include-email']") |> render_click()
assert has_element?(view, "[data-testid='member-name-email']", "ada@example.com")
end
test "turning off the compact member field shows separate name columns", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-compact-member']") |> render_click()
refute has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "[data-testid='last_name']")
# With the composite off, the email is surfaced as its own column.
assert has_element?(view, "[data-testid='email']")
end
test "with the compact member field off, the include-email sub-toggle is hidden",
%{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
# While the composite Member field is on, the sub-toggle is available.
assert has_element?(view, "[data-testid='view-setting-member-include-email']")
view |> element("[data-testid='view-setting-compact-member']") |> render_click()
# Once the composite is off, the email is a separate column, so the
# in-cell "include email" sub-toggle no longer applies and is hidden.
refute has_element?(view, "[data-testid='view-setting-member-include-email']")
assert has_element?(view, "[data-testid='email']")
end
test "the compact address field renders a composite address cell by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='member-address']")
refute has_element?(view, "[data-testid='street']")
refute has_element?(view, "[data-testid='postal_code']")
refute has_element?(view, "[data-testid='city']")
end
test "turning off the compact address field shows separate address columns", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-compact-address']") |> render_click()
refute has_element?(view, "[data-testid='member-address']")
assert has_element?(view, "[data-testid='street']")
assert has_element?(view, "[data-testid='postal_code']")
assert has_element?(view, "[data-testid='city']")
end
end