feat(member): stream the overview with keyset infinite scroll instead of loading every member

This commit is contained in:
Simon 2026-07-03 11:31:44 +02:00
parent b09cdf7f3a
commit b79d7ac9ea
13 changed files with 1067 additions and 451 deletions

View file

@ -0,0 +1,71 @@
defmodule MvWeb.MemberLive.IndexBulkScopeTest do
@moduledoc """
§1.17 Bulk "all" scope spans the whole filtered set (re-queried from the DB),
not only the loaded/visible page, and the count reflects the full filtered
total.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
# More than one keyset page so "loaded" and "all matching" differ.
@total 60
defp seed(n) do
actor = SystemActor.get_system_actor()
Enum.each(1..n, fn i ->
idx = String.pad_leading(Integer.to_string(i), 3, "0")
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Bulk#{idx}", last_name: "Scope", email: "bulk#{idx}@example.com"},
actor: actor
)
end)
end
defp scope_badge_text(view) do
view
|> render()
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
|> LazyHTML.text()
|> String.trim()
end
test "select all selects the whole filtered set, not just the loaded page", %{conn: conn} do
seed(@total)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[phx-click='select_all']") |> render_click()
# The selection count badge reflects all matching members, not the 50 loaded.
assert scope_badge_text(view) == "#{@total}"
end
test "copy-emails with no selection copies every matching member's email", %{conn: conn} do
seed(@total)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
result = render_hook(view, "copy_emails", %{})
# All 60 matching members are copied, not only the 50 loaded rows.
assert result =~ "#{@total}"
end
test "select-all then copy copies the whole filtered set", %{conn: conn} do
seed(@total)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[phx-click='select_all']") |> render_click()
result = render_hook(view, "copy_emails", %{})
assert result =~ "#{@total}"
end
end

View file

@ -0,0 +1,79 @@
defmodule MvWeb.MemberLive.IndexFilterPanelTest do
@moduledoc """
§1.18 The existing "Apply filters" panel is unchanged: same fieldset/legend
form structure and payment-status controls, no chip/add-filter-builder
paradigm (that is #548). Its filters now resolve DB-side via the `:overview`
read action.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Panel", last_name: "Member", email: "panel@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp open_filter(view) do
view
|> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
|> render_click()
end
test "the filter panel keeps its fieldset form structure and payment controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_filter(view)
# Semantic form structure (fieldset/legend, radio controls) is preserved.
assert has_element?(view, ~s(form[data-testid="member-filter-form"]))
assert has_element?(view, "#payment-filter-all")
assert has_element?(view, "#payment-filter-paid")
assert has_element?(view, "#payment-filter-unpaid")
end
test "no chip / add-filter-builder paradigm is present (#548 is out of scope)", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members")
open_filter(view)
html = html <> render(view)
refute html =~ ~s(data-testid="add-filter")
refute html =~ ~s(data-testid="filter-chip")
refute html =~ "filter-builder"
end
test "the panel's payment filter resolves DB-side via :overview", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
fee_type = Mv.Fixtures.create_fee_type(%{interval: :yearly}, system_actor)
last_year_start = Date.new!(Date.utc_today().year - 1, 1, 1)
{:ok, paid} =
Mv.Membership.create_member(
%{
first_name: "PaidPanel",
last_name: "X",
email: "paidpanel@example.com",
membership_fee_type_id: fee_type.id
},
actor: system_actor
)
Mv.Fixtures.create_cycle(
paid,
fee_type,
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
system_actor
)
{:ok, _view, html} = live(conn, ~p"/members?cycle_status_filter=paid")
assert html =~ "PaidPanel"
refute html =~ "Panel Member"
end
end

View file

@ -0,0 +1,68 @@
defmodule MvWeb.MemberLive.IndexLiveRegionTest do
@moduledoc """
§1.9 The polite live region announces the exact total matching count,
computed via a count query for the active filter set.
§1.10 The live region element exists in the DOM before it is filled, and the
table region carries `aria-busy`.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
defp seed(n) do
actor = SystemActor.get_system_actor()
Enum.each(1..n, fn i ->
idx = String.pad_leading(Integer.to_string(i), 3, "0")
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Lr#{idx}", last_name: "Region", email: "lr#{idx}@example.com"},
actor: actor
)
end)
end
test "polite live region exists on first render and announces the exact total", %{conn: conn} do
seed(60)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
# Present-before-fill: the live region element is in the initial DOM.
assert has_element?(view, "#members-result-count[aria-live='polite']")
# Exact total, not the loaded page size (60 matched, only 50 loaded).
region = view |> element("#members-result-count") |> render()
assert region =~ "60 members"
end
test "table region binds aria-busy to the loading flag (settled after load)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
# aria-busy is bound to @loading? (`to_string(@loading?)`), not hardcoded: once
# the page has loaded the region is not busy. It is set true across a
# filter/sort/search reload patch (push_reload/2, §1.10).
assert has_element?(view, "[data-testid='members-table-scroll'][aria-busy='false']")
end
test "count reflects the active filter set, not the whole table", %{conn: conn} do
actor = SystemActor.get_system_actor()
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Findme", last_name: "Unique", email: "findme@example.com"},
actor: actor
)
seed(5)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members?query=Findme")
# Only the single matching member is counted (1 of 6 total).
region = view |> element("#members-result-count") |> render()
assert region =~ "1 member"
refute region =~ "6 member"
end
end

View file

@ -0,0 +1,96 @@
defmodule MvWeb.MemberLive.IndexPaginationTest do
@moduledoc """
§1.7 Mount loads only one keyset page, not the whole table.
§1.8 phx-viewport-bottom fetches and appends the next page; once the last
page is reached no further fetch is issued.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Helpers.SystemActor
# Matches the :overview default_limit / @page_limit in the LiveView.
@page_limit 50
defp seed_members(n) do
actor = SystemActor.get_system_actor()
Enum.each(1..n, fn i ->
# Zero-padded names keep first_name-ascending order deterministic.
idx = String.pad_leading(Integer.to_string(i), 4, "0")
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Page#{idx}", last_name: "Member", email: "page#{idx}@example.com"},
actor: actor
)
end)
end
defp row_count(html) do
~r/<tr[^>]*id="row-/ |> Regex.scan(html) |> length()
end
test "mount loads exactly one page when more members exist", %{conn: conn} do
seed_members(@page_limit + 10)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
assert row_count(html) == @page_limit
# More rows remain, so the infinite-scroll sentinel is armed.
assert html =~ ~s(phx-viewport-bottom="load_more")
end
test "viewport-bottom appends the next page and stops at the last page", %{conn: conn} do
seed_members(@page_limit + 10)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, ~p"/members")
assert row_count(html) == @page_limit
# Fetch the next page: the remaining rows are appended to the stream.
html_after = render_hook(view, "load_more", %{})
assert row_count(html_after) == @page_limit + 10
# Last page reached: the sentinel is disarmed and a further fetch is a no-op.
refute html_after =~ ~s(phx-viewport-bottom="load_more")
html_again = render_hook(view, "load_more", %{})
assert row_count(html_again) == @page_limit + 10
end
test "selecting a row from a later page re-renders it as checked", %{conn: conn} do
seed_members(@page_limit + 10)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
# Load the second page so its rows are in the loaded window.
render_hook(view, "load_more", %{})
# The highest-index member sorts last and lives on the second page.
actor = SystemActor.get_system_actor()
last_member =
Mv.Membership.Member
|> Ash.Query.sort(first_name: :asc)
|> Ash.read!(actor: actor)
|> List.last()
html = render_click(view, "select_member", %{"id" => last_member.id})
# The re-streamed row for a later-page member reflects the selection.
assert html =~ ~s(id="row-#{last_member.id}")
assert has_element?(view, ~s(tr#row-#{last_member.id} input[type="checkbox"][checked]))
end
test "single page does not arm the infinite-scroll sentinel", %{conn: conn} do
seed_members(3)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
assert row_count(html) == 3
refute html =~ ~s(phx-viewport-bottom="load_more")
end
end

View file

@ -306,7 +306,8 @@ defmodule MvWeb.MemberLive.IndexTest do
# asserted on internal state to preserve the original coverage of the callback.
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.query == "Friedrich"
assert is_list(assigns.members)
# Loaded members are held as a `%{id => member}` map (the restream lookup window).
assert is_map(assigns.members)
end
@tag :ui
@ -1069,6 +1070,63 @@ defmodule MvWeb.MemberLive.IndexTest do
assert bcc =~ "scope1%40example.com"
refute bcc =~ "scope2%40example.com"
end
test "selecting a member does not read the DB for mailto recipients (deferred to open)",
%{conn: conn, member1: member1} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# A checkbox toggle is a non-search interaction; it must not issue a member
# read just to precompute a mailto link the user may never open. The
# recipient list is fetched lazily when the bulk-actions dropdown opens.
member_reads =
capture_member_select_queries(fn ->
render_click(view, "select_member", %{"id" => member1.id})
end)
assert member_reads == [],
"select_member must not read members for the mailto recipients; got: #{inspect(member_reads)}"
# Opening the dropdown still surfaces the correct recipients for the selection.
bcc = mailto_bcc(view)
assert bcc =~ "scope1%40example.com"
refute bcc =~ "scope2%40example.com"
end
# Captures every SELECT against the members table emitted while `fun` runs.
defp capture_member_select_queries(fun) do
test_pid = self()
handler_id = "test-member-select-#{System.unique_integer([:positive])}"
:telemetry.attach(
handler_id,
[:mv, :repo, :query],
fn _event, _measurements, metadata, _config ->
sql = metadata[:query] || ""
if String.contains?(sql, "SELECT") and String.contains?(sql, "\"members\"") do
send(test_pid, {:member_query, sql})
end
end,
nil
)
try do
fun.()
after
:telemetry.detach(handler_id)
end
collect_member_queries([])
end
defp collect_member_queries(acc) do
receive do
{:member_query, sql} -> collect_member_queries([sql | acc])
after
0 -> Enum.reverse(acc)
end
end
end
describe "cycle status filter" do
@ -2372,12 +2430,12 @@ defmodule MvWeb.MemberLive.IndexTest do
# Should complete in less than 1 second (1000ms)
assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
# Verify filtering worked correctly - should show all true members
Enum.each(1..75, fn i ->
assert html =~ "TrueMember#{i}"
end)
# The overview now keyset-paginates: mount loads only the first page, not
# the whole filtered set (§1.7). The filter still resolves DB-side, so the
# loaded page contains only matching (true) members and never a non-matching
# (false) one.
assert html =~ "TrueMember"
# Should not show false members
Enum.each(1..75, fn i ->
refute html =~ "FalseMember#{i}"
end)