feat(member): stream the overview with keyset infinite scroll instead of loading every member
This commit is contained in:
parent
b09cdf7f3a
commit
b79d7ac9ea
13 changed files with 1067 additions and 451 deletions
71
test/mv_web/member_live/index_bulk_scope_test.exs
Normal file
71
test/mv_web/member_live/index_bulk_scope_test.exs
Normal 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
|
||||
79
test/mv_web/member_live/index_filter_panel_test.exs
Normal file
79
test/mv_web/member_live/index_filter_panel_test.exs
Normal 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
|
||||
68
test/mv_web/member_live/index_live_region_test.exs
Normal file
68
test/mv_web/member_live/index_live_region_test.exs
Normal 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
|
||||
96
test/mv_web/member_live/index_pagination_test.exs
Normal file
96
test/mv_web/member_live/index_pagination_test.exs
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue