From b79d7ac9eac468fd2ee12fd1c5f09c2c3d89b8a4 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:31:44 +0200 Subject: [PATCH] feat(member): stream the overview with keyset infinite scroll instead of loading every member --- assets/js/app.js | 63 ++ .../components/bulk_actions_dropdown.ex | 32 +- lib/mv_web/components/core_components.ex | 38 +- lib/mv_web/live/member_live/index.ex | 649 ++++++++---------- lib/mv_web/live/member_live/index.html.heex | 55 +- priv/gettext/de/LC_MESSAGES/default.po | 104 ++- priv/gettext/default.pot | 89 ++- priv/gettext/en/LC_MESSAGES/default.po | 104 ++- .../member_live/index_bulk_scope_test.exs | 71 ++ .../member_live/index_filter_panel_test.exs | 79 +++ .../member_live/index_live_region_test.exs | 68 ++ .../member_live/index_pagination_test.exs | 96 +++ test/mv_web/member_live/index_test.exs | 70 +- 13 files changed, 1067 insertions(+), 451 deletions(-) create mode 100644 test/mv_web/member_live/index_bulk_scope_test.exs create mode 100644 test/mv_web/member_live/index_filter_panel_test.exs create mode 100644 test/mv_web/member_live/index_live_region_test.exs create mode 100644 test/mv_web/member_live/index_pagination_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index a003e279..a6a7d694 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -126,6 +126,29 @@ Hooks.RowSelectionGuard = { } } +// StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width +// (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar +// lives in a full-width table row that is wider than the viewport when the table scrolls +// horizontally; matching the sticky element to the container's visible width keeps its +// centered content pinned at the bottom-center of the visible area instead of drifting off +// with the scrolled table. The data-scroll-container attribute names the container element id. +Hooks.StickyViewportWidth = { + mounted() { + this.container = document.getElementById(this.el.dataset.scrollContainer) + this.sync = () => { + if (this.container) this.el.style.width = this.container.clientWidth + "px" + } + this.sync() + window.addEventListener("resize", this.sync) + }, + updated() { + this.sync() + }, + destroyed() { + window.removeEventListener("resize", this.sync) + } +} + // FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) Hooks.FocusRestore = { mounted() { @@ -360,6 +383,37 @@ Hooks.SidebarState = { } } +// LoadMorePrefetch: fires an infinite-scroll load event as the sentinel +// approaches the viewport, rather than only once it is fully scrolled into view. +// An IntersectionObserver rooted at the scroll container with a positive bottom +// rootMargin treats the sentinel as visible while it is still that many pixels +// below the fold, so the next page loads ahead of time. It complements the +// built-in phx-viewport-bottom binding (which stays as a reliable backstop); +// duplicate loads are harmless because the server guards on "more?" and the +// stream de-duplicates rows by id. +Hooks.LoadMorePrefetch = { + mounted() { + const container = document.getElementById(this.el.dataset.scrollContainer) + const rootMargin = `0px 0px ${this.el.dataset.margin || "600px"} 0px` + const event = this.el.dataset.event || "load_more" + let lastPush = 0 + + this.observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + const now = Date.now() + if (now - lastPush < 400) return + lastPush = now + this.pushEvent(event) + }, {root: container || null, rootMargin, threshold: 0}) + + this.observer.observe(this.el) + }, + + destroyed() { + if (this.observer) this.observer.disconnect() + } +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { @@ -378,6 +432,15 @@ window.addEventListener("phx:set-input-value", (e) => { } }) +// Return the members list to the top after a sort or filter change. The server +// resets the keyset stream to page 1 on those changes and pushes this event; a +// user scrolled down would otherwise be stranded past the shorter content with +// infinite scroll not re-arming. No-op if the scroll container is absent. +window.addEventListener("phx:members:scroll-top", () => { + const el = document.getElementById("members-table-guard") + if (el) el.scrollTop = 0 +}) + // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) diff --git a/lib/mv_web/components/bulk_actions_dropdown.ex b/lib/mv_web/components/bulk_actions_dropdown.ex index d0b6172a..c6db9145 100644 --- a/lib/mv_web/components/bulk_actions_dropdown.ex +++ b/lib/mv_web/components/bulk_actions_dropdown.ex @@ -36,11 +36,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do ## Event routing - `dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the - component owns its own `:open` state. The copy item carries an *un-targeted* - `phx-click="copy_emails"`, which therefore reaches the parent LiveView's - `handle_event/3` (which keeps access to `@members`), plus the - `CopyToClipboard` hook. + `dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` *un-targeted*, so the + parent LiveView owns the `:open` state (passed back in via the `open` assign). + This lets the parent fetch the mailto recipients lazily when the dropdown opens + rather than on every selection/filter change. The copy item likewise carries an + un-targeted `phx-click="copy_emails"`, which reaches the parent's + `handle_event/3` (which keeps access to `@members`), plus the `CopyToClipboard` + hook. """ use MvWeb, :live_component use Gettext, backend: MvWeb.Gettext @@ -72,13 +74,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do |> assign(:recipient_count, assigns[:recipient_count] || 0) |> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false) - # The parent never sets :open (the component owns it via toggle/close). - # Honouring an explicit :open assign keeps the component renderable in - # isolation (render_component/2) for structural tests. + # The parent owns :open and passes it in. Defaulting to false keeps the + # component renderable in isolation (render_component/2) for structural tests + # that omit it. socket = case Map.fetch(assigns, :open) do {:ok, open} -> assign(socket, :open, open) - :error -> socket + :error -> assign_new(socket, :open, fn -> false end) end {:ok, socket} @@ -98,7 +100,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do button_label={gettext("Actions")} icon="hero-bolt" open={@open} - phx_target={@myself} + phx_target={nil} menu_width="w-70" menu_align="left" button_class="btn-secondary gap-2" @@ -232,12 +234,6 @@ defmodule MvWeb.Components.BulkActionsDropdown do end end - @impl true - def handle_event("toggle_dropdown", _params, socket) do - {:noreply, assign(socket, :open, !socket.assigns.open)} - end - - def handle_event("close_dropdown", _params, socket) do - {:noreply, assign(socket, :open, false)} - end + # Open/close are handled by the parent LiveView (see "Event routing"); the + # component no longer owns dropdown state. end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 2ed22fd1..d01b3d63 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -953,6 +953,11 @@ defmodule MvWeb.CoreComponents do doc: "when true, first header/body column gets sticky left positioning to keep selection controls visible" + attr :viewport_bottom, :any, + default: nil, + doc: + "optional phx-viewport-bottom event (string) rendered on the streamed tbody so the built-in InfiniteScroll hook appends the next page without shifting the scroll position; nil omits the binding" + slot :col, required: true do attr :label, :string attr :class, :string @@ -964,6 +969,10 @@ defmodule MvWeb.CoreComponents do slot :action, doc: "the slot for showing user actions in the last table column" + slot :footer, + doc: + "optional after-rows content rendered as a full-width row in a separate, non-streamed tbody. Combined with viewport_bottom it acts as the infinite-scroll sentinel (e.g. a loading indicator) that fires the load event and disappears once no more pages remain." + def table(assigns) do assigns = with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do @@ -986,6 +995,15 @@ defmodule MvWeb.CoreComponents do assigns = assign(assigns, :first_row_click_col_idx, first_row_click_col_idx) + # Total column span for full-width footer rows (e.g. the infinite-scroll + # loading indicator): fixed columns + dynamic custom-field columns + the + # optional action column. + col_count = + length(assigns.col) + length(assigns[:dynamic_cols] || []) + + if assigns.action != [], do: 1, else: 0 + + assigns = assign(assigns, :col_count, col_count) + ~H"""
- + + <%!-- After-rows footer in its own, non-streamed tbody so it stays put + outside the phx-update="stream" container. When viewport_bottom is set it + doubles as the infinite-scroll sentinel: rendered only while more pages + exist, so once the last page loads it disappears and stops firing. --%> + + + + {render_slot(@footer)} + + +
""" diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c991f7fe..f246f5ee 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -30,8 +30,6 @@ defmodule MvWeb.MemberLive.Index do import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Membership - alias Mv.Membership.CustomFieldSort - alias Mv.Membership.Member, as: MemberResource alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter @@ -42,6 +40,7 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.MemberLive.Index.OverviewQuery require Ash.Query require Logger @@ -173,11 +172,29 @@ defmodule MvWeb.MemberLive.Index do ) |> assign(:show_current_cycle, false) |> assign(:membership_fee_status_filter, nil) + |> assign(:page, nil) + |> assign(:after_cursor, nil) + |> assign(:more?, false) + |> assign(:total_count, 0) + |> assign(:loading?, false) + # The bulk-actions dropdown's open state lives here so the mailto recipient + # list can be fetched lazily on open (never on a mere selection change). + |> assign(:bulk_actions_open, false) + |> assign(:mailto_bcc, "") + |> assign(:recipient_count, 0) + |> assign(:mailto_disabled?, false) + |> stream_configure(:members, dom_id: &"row-#{&1.id}") + |> stream(:members, []) |> assign_export_payload() {:ok, socket} end + # Number of members fetched per keyset page (matches the :overview action's + # default_limit). Mount loads one page; further pages arrive via infinite + # scroll (phx-viewport-bottom) so the socket never holds the full table. + @page_limit 50 + # ----------------------------------------------------------------- # Handle Events # ----------------------------------------------------------------- @@ -208,12 +225,15 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:selected_members, selected) + |> restream_member(id) |> update_selection_assigns()} end @impl true def handle_event("select_all", _params, socket) do - all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new() + # Select-all spans the whole filtered set, not just the loaded page (§1.17): + # re-query every matching id. Toggling off when all are already selected. + all_ids = all_filtered_member_ids(socket) selected = if MapSet.equal?(socket.assigns.selected_members, all_ids) do @@ -225,6 +245,33 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:selected_members, selected) + |> restream_all_members() + |> update_selection_assigns()} + end + + # The bulk-actions dropdown routes its open/close here (un-targeted) so the + # mailto recipient list is fetched only when the menu actually opens, not on + # every selection or filter change. + @impl true + def handle_event("toggle_dropdown", _params, socket) do + open? = !socket.assigns.bulk_actions_open + + socket = assign(socket, :bulk_actions_open, open?) + socket = if open?, do: assign_mailto_recipients(socket), else: socket + + {:noreply, socket} + end + + @impl true + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :bulk_actions_open, false)} + end + + @impl true + def handle_event("load_more", _params, socket) do + {:noreply, + socket + |> load_more() |> update_selection_assigns()} end @@ -236,6 +283,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:show_current_cycle, new_show_current) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -247,21 +295,16 @@ defmodule MvWeb.MemberLive.Index do new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true def handle_event("copy_emails", _params, socket) do - members = socket.assigns.members - selected_ids = socket.assigns.selected_members - any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id)) - - # Recipients follow the current scope: the selection when present, otherwise - # every member in the (filtered) list. Members without an email are excluded - # in both cases (unchanged missing-email handling). With no selection we no - # longer hard-stop with "No members selected" — we act on the scope; the - # empty-recipient feedback below is preserved. - formatted_emails = scope_member_emails(members, selected_ids, any_selected?) + # Recipients follow the current scope, re-queried from the DB so a no-selection + # (all/filtered) copy or a select-all copy spans the whole filtered set rather + # than only the loaded page (§1.17). Members without an email are excluded; + # the empty-recipient feedback below is preserved. + formatted_emails = scope_emails(socket) email_count = length(formatted_emails) if email_count == 0 do @@ -310,6 +353,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:sort_order, new_order) |> update_sort_components(old_field, new_field, new_order) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() # URL sync - push_patch happens synchronously in the event handler @@ -325,7 +369,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns[:fields_in_url?] || false ) - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + {:noreply, push_reload(socket, ~p"/members?#{query_params}")} end # ----------------------------------------------------------------- @@ -347,6 +391,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:query, q) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -358,7 +403,7 @@ defmodule MvWeb.MemberLive.Index do new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -367,6 +412,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:cycle_status_filter, filter) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -377,7 +423,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -393,6 +439,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:boolean_custom_field_filters, updated_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -403,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -421,6 +468,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:group_filters, group_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -431,7 +479,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -449,6 +497,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:fee_type_filters, fee_type_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -459,7 +508,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -468,6 +517,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:date_filters, new_date_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -478,7 +528,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end # Backward compatibility: tuple form delegates to map form @@ -537,6 +587,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -547,7 +598,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -691,6 +742,9 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() end + # The (re)load is complete: clear the busy flag set by `push_reload/2` (§1.10). + socket = assign(socket, :loading?, false) + # Update sort components after rendering socket = if socket.assigns[:sort_needs_update] do @@ -806,7 +860,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) new_path = ~p"/members?#{query_params}" - push_patch(socket, to: new_path, replace: true) + push_reload(socket, new_path) end defp update_session_field_selection(socket, selection) do @@ -975,81 +1029,145 @@ defmodule MvWeb.MemberLive.Index do # Loading members # ------------------------------------------------------------- + # Loads the first keyset page of the overview and resets the stream. All + # filtering and sorting run in PostgreSQL via the `:overview` read action + # (see OverviewQuery); the socket holds only the loaded window, never the + # whole table. defp load_members(socket) do - search_query = socket.assigns.query + # `count: true` runs one COUNT for the active filter set so the live region + # can announce the exact total matching count (§1.9), not just the loaded + # page size. + page = read_overview_page(socket, limit: @page_limit, count: true) - query = - Mv.Membership.Member - |> Ash.Query.new() - |> Ash.Query.select(@overview_fields) + socket + |> assign(:members, index_by_id(page.results)) + |> assign(:after_cursor, next_cursor(page)) + |> assign(:more?, page.more?) + |> assign(:total_count, page.count || length(page.results)) + |> AshPhoenix.LiveView.assign_page_and_stream_result(page, + results_key: :members, + page_key: :page, + stream_opts: [reset: true] + ) + end - query = load_custom_field_values(query, compute_ids_to_load(socket)) + # Returns the members scroll container to the top after a sort/filter reset. + # A reset re-streams from page 1, so a user who was scrolled down would be left + # past the (now shorter) content with infinite scroll not re-arming; scrolling + # back to the top shows the new ordering from #1 and re-arms the loader. Only + # used on genuine resets, never on load_more (which must preserve position). + # The client handler (app.js) is a no-op if the container is not found. + defp scroll_list_to_top(socket), do: push_event(socket, "members:scroll-top", %{}) - query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) + # Marks the table region busy (aria-busy, §1.10) for the reload triggered by a + # filter/sort/search patch, then hands off to the patch. `handle_params` clears + # the flag once the page has been (re)loaded. + defp push_reload(socket, path) do + socket + |> assign(:loading?, true) + |> push_patch(to: path, replace: true) + end - # Load groups for each member (id, name, slug only) - query = - Ash.Query.load(query, groups: [:id, :name, :slug]) + # Fetches the next keyset page (if any) and appends it to the stream and the + # loaded window. Triggered by phx-viewport-bottom; a no-op once the last page + # has been reached so the bottom sentinel stops fetching. + defp load_more(%{assigns: %{more?: false}} = socket), do: socket - # Load membership_fee_type when the column is visible or when sorting by it - query = - if :membership_fee_type in socket.assigns.member_fields_visible or - socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do - Ash.Query.load(query, membership_fee_type: [:id, :name]) - else - query - end + defp load_more(socket) do + page = read_overview_page(socket, limit: @page_limit, after: socket.assigns.after_cursor) - query = apply_search_filter(query, search_query) + socket = + Enum.reduce(page.results, socket, fn member, acc -> + stream_insert(acc, :members, member) + end) - query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) + socket + |> assign(:members, Map.merge(socket.assigns.members, index_by_id(page.results))) + |> assign(:after_cursor, next_cursor(page)) + |> assign(:more?, page.more?) + |> assign(:page, %{page | results: nil}) + end - query = - apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) + # Loaded members are held as a `%{id => member}` map (not a list): appends on + # load_more stay O(page) via Map.merge instead of copying an ever-growing list + # with `++`, and a per-row selection re-render is an O(1) lookup by id. + defp index_by_id(members), do: Map.new(members, &{&1.id, &1}) - # Built-in date filters (join_date, exit_date) are pushed to the DB so - # excluded rows never reach the BEAM. The active_only default is part of - # this — fresh load returns only members without an exit_date or with an - # exit_date strictly in the future. - query = - DateFilter.apply_ash_filter(query, socket.assigns.date_filters) + # Re-inserts a single loaded member into the stream so its row re-renders with + # the current selection state (checkbox + selected highlight). Stream rows are + # not re-rendered on assign changes, so a selection toggle must re-stream the + # affected row. + defp restream_member(socket, id) do + case Map.get(socket.assigns[:members] || %{}, id) do + nil -> socket + member -> stream_insert(socket, :members, member) + end + end - # Use ALL custom fields for sorting (not just show_in_overview subset) - custom_fields_for_sort = socket.assigns.all_custom_fields + # Re-inserts every loaded member so all visible rows reflect a bulk selection + # change (select-all / deselect-all). + defp restream_all_members(socket) do + Enum.reduce(Map.values(socket.assigns[:members] || %{}), socket, fn member, acc -> + stream_insert(acc, :members, member) + end) + end - {query, sort_after_load} = - maybe_sort( - query, - socket.assigns.sort_field, - socket.assigns.sort_order, - custom_fields_for_sort - ) + # Reads one keyset page of the overview with display loads applied. Reads run + # as the real user actor so Ash policies are enforced. + defp read_overview_page(socket, page_opts) do + socket + |> overview_query() + |> Ash.read!(actor: current_actor(socket), page: page_opts) + end - # Errors in handle_params are handled by Phoenix LiveView - actor = current_actor(socket) - members = Ash.read!(query, actor: actor) + # The cursor of the last loaded row, used as the `after` bound for the next + # page; nil once there are no further pages. + defp next_cursor(%{more?: true, results: results}) when results != [], + do: List.last(results).__metadata__.keyset - # Custom field values are already filtered at the database level in load_custom_field_values/2 - # No need for in-memory filtering anymore + defp next_cursor(_page), do: nil - members = apply_in_memory_filters(members, socket) + # Builds the `:overview` query (filter/sort via OverviewQuery) and layers on + # the display-only loads (custom field values, cycles, groups, fee type). + defp overview_query(socket) do + socket + |> overview_query_opts() + |> OverviewQuery.build() + |> Ash.Query.select(@overview_fields) + |> load_custom_field_values(compute_ids_to_load(socket)) + |> MembershipFeeStatus.load_cycles_for_members(socket.assigns.show_current_cycle) + |> Ash.Query.load(groups: [:id, :name, :slug]) + |> maybe_load_fee_type(socket) + end - # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) - # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status - members = - if sort_after_load and - socket.assigns.sort_field != :membership_fee_status do - sort_members_in_memory( - members, - socket.assigns.sort_field, - socket.assigns.sort_order, - custom_fields_for_sort - ) - else - members - end + defp maybe_load_fee_type(query, socket) do + if :membership_fee_type in socket.assigns.member_fields_visible or + socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + else + query + end + end - assign(socket, :members, members) + # Assembles the filter/sort options OverviewQuery understands from the current + # LiveView assigns. + defp overview_query_opts(socket) do + %{ + search: socket.assigns.query, + group_filters: socket.assigns[:group_filters], + groups: socket.assigns[:groups], + fee_type_filters: socket.assigns[:fee_type_filters], + fee_types: socket.assigns[:fee_types], + boolean_custom_field_filters: socket.assigns.boolean_custom_field_filters, + boolean_custom_fields: socket.assigns.boolean_custom_fields, + date_filters: socket.assigns.date_filters, + date_custom_fields: socket.assigns[:date_custom_fields], + cycle_status_filter: socket.assigns.cycle_status_filter, + show_current_cycle: socket.assigns.show_current_cycle, + sort_field: socket.assigns.sort_field, + sort_order: socket.assigns.sort_order, + custom_fields: socket.assigns.all_custom_fields + } end # Collects every custom field UUID whose values must be loaded for a given @@ -1083,24 +1201,6 @@ defmodule MvWeb.MemberLive.Index do |> Enum.uniq() end - # Post-DB filtering: cycle status, boolean custom fields, and custom date - # fields. Date custom fields are last so they see the already-narrowed list. - defp apply_in_memory_filters(members, socket) do - members - |> apply_cycle_status_filter( - socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle - ) - |> apply_boolean_custom_field_filters( - socket.assigns.boolean_custom_field_filters, - socket.assigns.all_custom_fields - ) - |> DateFilter.apply_in_memory( - socket.assigns.date_filters, - socket.assigns[:date_custom_fields] || [] - ) - end - defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, custom_field_ids) do @@ -1117,166 +1217,10 @@ defmodule MvWeb.MemberLive.Index do # Helper Functions # ------------------------------------------------------------- - defp apply_search_filter(query, search_query) do - if search_query && String.trim(search_query) != "" do - query - |> MemberResource.fuzzy_search(%{query: search_query}) - else - query - end - end - - # Multiple group filters combine with AND: member must match all selected group conditions. - defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query - - defp apply_group_filters(query, group_filters, groups) do - valid_ids = - groups - |> Enum.map(&normalize_uuid_string(to_string(&1.id))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> - member? = MapSet.member?(valid_ids, group_id_str) - - if member? do - apply_one_group_filter(q, group_id_str, value) - else - q - end - end) - end - - defp apply_one_group_filter(query, _group_id_str, nil), do: query - - defp apply_one_group_filter(query, group_id_str, :in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, group_uuid} -> - Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid))) - - _ -> - query - end - end - - defp apply_one_group_filter(query, group_id_str, :not_in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, group_uuid} -> - Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid))) - - _ -> - query - end - end - - defp apply_one_group_filter(query, _, _), do: query - - # Fee type filters: :in selections combine with OR (member has any of the selected types); - # :not_in selections combine with AND (member must not have type A and not have type B). - defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{}, - do: query - - defp apply_fee_type_filters(query, fee_type_filters, fee_types) do - valid_ids = - fee_types - |> Enum.map(&normalize_uuid_string(to_string(&1.id))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - {in_id_strs, not_in_filters} = - fee_type_filters - |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) - |> Enum.split_with(fn {_, value} -> value == :in end) - - in_uuids = - in_id_strs - |> Enum.map(fn {id_str, _} -> id_str end) - |> Enum.map(&Ecto.UUID.cast/1) - |> Enum.filter(&match?({:ok, _}, &1)) - |> Enum.map(fn {:ok, uuid} -> uuid end) - - query = - if in_uuids == [] do - query - else - Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) - end - - Enum.reduce(not_in_filters, query, fn {fee_type_id_str, _}, q -> - apply_one_fee_type_filter(q, fee_type_id_str, :not_in) - end) - end - - defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do - case Ecto.UUID.cast(fee_type_id_str) do - {:ok, fee_type_uuid} -> - Ash.Query.filter( - query, - expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id)) - ) - - _ -> - query - end - end - - defp apply_cycle_status_filter(members, nil, _show_current), do: members - - defp apply_cycle_status_filter(members, status, show_current) - when status in [:paid, :unpaid] do - MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current) - end - defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp toggle_order(nil), do: :asc - # Function to sort the column if needed. - # Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash. - # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory. - defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false} - defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} - - defp maybe_sort(query, field, order, _custom_fields) do - # :groups is in computed_member_fields() but can be sorted in-memory - # Only :membership_fee_status should be blocked from sorting - if field == :membership_fee_status or field == "membership_fee_status" do - {query, false} - else - apply_sort_to_query(query, field, order) - end - end - - defp apply_sort_to_query(query, field, order) do - cond do - # Groups sort -> after load (in memory) - field in [:groups, "groups"] -> - {query, true} - - # Membership fee type sort -> by related name at DB - field in [:membership_fee_type, "membership_fee_type"] -> - {Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false} - - # Custom field sort -> after load - custom_field_sort?(field) -> - {query, true} - - # DB field sort (atom) - is_atom(field) -> - {Ash.Query.sort(query, [{field, order}]), false} - - # DB field sort (string) -> convert only if allowed - is_binary(field) -> - case safe_member_field_atom_only(field) do - nil -> {query, false} - atom -> {Ash.Query.sort(query, [{atom, order}]), false} - end - - true -> - {query, false} - end - end - defp valid_sort_field?(field) when is_atom(field) do # :groups is in computed_member_fields() but can be sorted # Only :membership_fee_status should be blocked @@ -1331,19 +1275,6 @@ defmodule MvWeb.MemberLive.Index do defp custom_field_sort?(_), do: false - defp extract_custom_field_id(field) when is_atom(field) do - field |> Atom.to_string() |> extract_custom_field_id() - end - - defp extract_custom_field_id(field) when is_binary(field) do - case String.split(field, @custom_field_prefix) do - ["", id_str] -> id_str - _ -> nil - end - end - - defp extract_custom_field_id(_), do: nil - defp extract_custom_field_ids(visible_custom_fields) do Enum.map(visible_custom_fields, fn field_string -> case String.split(field_string, @custom_field_prefix) do @@ -1354,91 +1285,6 @@ defmodule MvWeb.MemberLive.Index do |> Enum.filter(&(&1 != nil)) end - defp sort_members_in_memory(members, field, order, custom_fields) do - if field in [:groups, "groups"] do - sort_members_by_groups(members, order) - else - custom_field_id_str = extract_custom_field_id(field) - - case custom_field_id_str do - nil -> members - id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) - end - end - end - - defp sort_members_by_groups(members, order) do - # Members with groups first, then by first group name alphabetically (min = first by sort order) - first_group_name = fn member -> - (member.groups || []) - |> Enum.map(& &1.name) - |> Enum.min(fn -> nil end) - end - - members - |> Enum.sort_by(fn member -> - name = first_group_name.(member) - # Nil (no groups) sorts last in asc, first in desc - {name == nil, name || ""} - end) - |> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end) - end - - defp sort_members_by_custom_field(members, id_str, order, custom_fields) do - custom_field = find_custom_field_by_id(custom_fields, id_str) - - case custom_field do - nil -> members - cf -> sort_members_with_custom_field(members, cf, order) - end - end - - defp find_custom_field_by_id(custom_fields, id_str) do - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - end - - defp sort_members_with_custom_field(members, custom_field, order) do - {members_with_values, members_without_values} = - split_members_by_value_presence(members, custom_field) - - sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) - sorted_with_values ++ members_without_values - end - - defp split_members_by_value_presence(members, custom_field) do - Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end) - end - - defp has_non_empty_value?(member, custom_field) do - case get_custom_field_value(member, custom_field) do - nil -> - false - - cfv -> - not empty_value?(cfv.value, custom_field.value_type) - end - end - - defp sort_members_with_values(members_with_values, custom_field, order) do - sorted = - Enum.sort_by(members_with_values, fn member -> - cfv = get_custom_field_value(member, custom_field) - CustomFieldSort.sort_key(cfv.value, custom_field.value_type) - end) - - if order == :desc, do: Enum.reverse(sorted), else: sorted - end - - defp empty_value?(%Ash.Union{value: value, type: type}, _expected_type), - do: empty_value?(value, type) - - defp empty_value?(nil, _type), do: true - - defp empty_value?(value, type) when type in [:string, :email] and is_binary(value), - do: String.trim(value) == "" - - defp empty_value?(_value, _type), do: false - defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) @@ -1800,11 +1646,11 @@ defmodule MvWeb.MemberLive.Index do def format_date(date), do: DateFormatter.format_date(date) defp update_selection_assigns(socket) do - members = socket.assigns[:members] || [] selected_members = socket.assigns.selected_members - - selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id)) - any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id)) + # The selection may span members beyond the loaded page (after select-all), + # so its size is the MapSet size, not a count over the loaded window. + selected_count = MapSet.size(selected_members) + any_selected? = selected_count > 0 # Scope drives the trigger label: the selection when present, otherwise the # whole list (filtered, when a search term or any filter is active). @@ -1815,13 +1661,31 @@ defmodule MvWeb.MemberLive.Index do true -> :all end - # Copy/Mailto recipients: the members in scope that have a usable email. - # With a selection that is the selected subset (existing behaviour); without - # a selection it is every member in scope (deliberate behaviour change). In - # both cases members without an email are excluded, exactly as today's - # format_selected_member_emails does for the selection case. - recipient_emails = scope_member_emails(members, selected_members, any_selected?) - recipient_count = length(recipient_emails) + # No DB work here: the mailto recipient list is computed lazily when the + # bulk-actions dropdown opens (assign_mailto_recipients/1), so a selection or + # filter change never reads members for a link the user may never open. + socket + |> assign(:selected_count, selected_count) + |> assign(:scope, scope) + |> assign_export_payload() + end + + # Fetches the mailto BCC recipients for the current bulk scope. Called only when + # the bulk-actions dropdown opens (the mailto item, a native anchor, is rendered + # only then), keeping selection toggles free of DB reads. + defp assign_mailto_recipients(socket) do + selected_count = MapSet.size(socket.assigns.selected_members) + + # Size of the whole bulk scope (§1.17): the selection, or the full filtered + # total — never just the loaded page. + scope_size = if selected_count > 0, do: selected_count, else: socket.assigns.total_count + cap = Mv.Constants.max_mailto_bulk_recipients() + mailto_disabled? = scope_size >= cap + + # Mailto recipients are bounded by the cap; only re-query the (small) scope + # when it is within the cap, otherwise the link is disabled anyway. + recipient_emails = if mailto_disabled?, do: [], else: scope_emails(socket, limit: cap) + recipient_count = if mailto_disabled?, do: scope_size, else: length(recipient_emails) # RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +) mailto_bcc = @@ -1830,29 +1694,60 @@ defmodule MvWeb.MemberLive.Index do |> URI.encode_www_form() |> String.replace("+", "%20") - mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients() - socket - |> assign(:selected_count, selected_count) - |> assign(:scope, scope) |> assign(:recipient_count, recipient_count) |> assign(:mailto_disabled?, mailto_disabled?) |> assign(:mailto_bcc, mailto_bcc) - |> assign_export_payload() end - # Returns the formatted "Name " recipient list for the current scope: - # the selected members when any are selected, otherwise every member in the - # (filtered) list. Members without an email are excluded in both cases. - defp scope_member_emails(members, selected_members, true = _any_selected?), - do: format_selected_member_emails(members, selected_members) - - defp scope_member_emails(members, _selected_members, false = _any_selected?) do - members + # Formatted "Name " recipients for the current bulk scope, re-queried + # from the DB so the set spans the whole selection / filtered total rather than + # only the loaded page (§1.17). Members without an email are excluded. + defp scope_emails(socket, opts \\ []) do + socket + |> scope_members(opts) |> Enum.filter(fn member -> member.email && member.email != "" end) |> Enum.map(&format_member_email/1) end + # MapSet of every member id matching the active filters (no pagination), used + # by select-all so the selection spans the full filtered set (§1.17). + defp all_filtered_member_ids(socket) do + socket + |> overview_query_opts() + |> Map.put(:sort_field, nil) + |> OverviewQuery.build() + |> Ash.Query.select([:id]) + |> Ash.read!(actor: current_actor(socket)) + |> MapSet.new(& &1.id) + end + + # Members matching the current bulk scope: the selected members when any are + # selected, otherwise every member matching the active filters. Re-queried as + # the real user actor so policies are enforced. + defp scope_members(socket, opts) do + selected = socket.assigns.selected_members + + query = + if MapSet.size(selected) > 0 do + ids = MapSet.to_list(selected) + + Mv.Membership.Member + |> Ash.Query.for_read(:overview) + |> Ash.Query.filter(expr(id in ^ids)) + else + socket + |> overview_query_opts() + |> Map.put(:sort_field, nil) + |> OverviewQuery.build() + end + |> Ash.Query.select([:id, :first_name, :last_name, :email]) + + query = if opts[:limit], do: Ash.Query.limit(query, opts[:limit]), else: query + + Ash.read!(query, actor: current_actor(socket)) + end + @doc """ Returns true when the member list is restricted by a non-empty search term or any active filter (cycle status, group, fee type, boolean custom field, or a diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index eb7085dc..d9049146 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -5,6 +5,7 @@ <.live_component module={MvWeb.Components.BulkActionsDropdown} id="bulk-actions-dropdown" + open={@bulk_actions_open} export_payload_json={@export_payload_json} selected_count={@selected_count} scope={@scope} @@ -39,7 +40,7 @@ boolean_filters={@boolean_custom_field_filters} date_custom_fields={@date_custom_fields} date_filters={@date_filters} - member_count={length(@members)} + member_count={@total_count} /> <.tooltip content={ @@ -80,6 +81,19 @@ /> + <%!-- Polite live region: present on first render (before it is filled) so + screen readers announce the exact total matching count on every filter change + (WCAG 4.1.3). --%> +
+ {ngettext("%{count} member", "%{count} members", @total_count, count: @total_count)} +
+ <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
<.table id="members" - rows={@members} + rows={@streams.members} + row_item={fn {_dom_id, member} -> member end} wrapper_overflow_class="overflow-visible" sticky_header={true} sticky_first_col={true} - row_id={fn member -> "row-#{member.id}" end} - row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} + viewport_bottom={@more? && "load_more"} + row_click={ + fn {_dom_id, member} -> + JS.push("select_row_and_navigate", value: %{id: member.id}) + end + } row_tooltip={gettext("Click for member details")} row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} dynamic_cols={@dynamic_cols} @@ -114,7 +134,7 @@ type="checkbox" name="select_all" phx-click="select_all" - checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())} + checked={@total_count > 0 and @selected_count == @total_count} aria-label={gettext("Select all members")} role="checkbox" /> @@ -405,6 +425,31 @@
+ <:footer> + <%!-- Invisible sentinel: starts the next-page load as it approaches the + viewport (see the LoadMorePrefetch hook), so rows load ahead of reaching + the very bottom. Only rendered while more pages exist (with the footer). --%> + +
+ + {gettext("Loading more members …")} +
+ diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 38ad3e54..db07d05f 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -126,7 +126,9 @@ msgstr "Feld hinzufügen" msgid "Add members" msgstr "Mitglieder hinzufügen" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "Adresse" @@ -192,8 +194,7 @@ msgid "An account with this email already exists. Please verify your password to msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen." #: lib/mv_web/helpers/ash_error_helpers.ex -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format msgid "An error occurred" @@ -2288,14 +2289,12 @@ msgstr "Beitragseinstellungen" msgid "Membership fee start" msgstr "Beitragsbeginn" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "Membership fee type deleted" msgstr "Mitgliedsbeitragsart gelöscht" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "Membership fee type not found" msgstr "Mitgliedsbeitragsart nicht gefunden" @@ -2350,12 +2349,14 @@ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -2406,6 +2407,7 @@ msgstr "Neue*r Benutzer*in" msgid "New amount" msgstr "Neuer Betrag" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2693,10 +2695,10 @@ msgstr "Optionen" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "Bezahlt" @@ -3293,11 +3295,6 @@ msgstr "Buchungen/Belege aus Vereinfacht anzeigen" msgid "Show in overview" msgstr "In der Übersicht anzeigen" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "Spalten ein-/ausblenden" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3391,10 +3388,10 @@ msgid "Summary" msgstr "Zusammenfassung" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "Pausiert" @@ -3700,10 +3697,10 @@ msgstr "Aufhebung der Verknüpfung geplant" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" msgstr "Unbezahlt" @@ -3904,6 +3901,7 @@ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen" msgid "Yearly Interval - Joining Cycle Included" msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -3956,8 +3954,7 @@ msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linkte msgid "You do not have permission to %{action} members." msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}." -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "You do not have permission to access this membership fee type" msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen." @@ -3973,8 +3970,7 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen." msgid "You do not have permission to delete this member" msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen." -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen." @@ -4145,3 +4141,75 @@ msgstr "Öffnen" msgctxt "status" msgid "Open" msgstr "Offen" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "%{count} Mitglied" +msgstr[1] "%{count} Mitglieder" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "Suche zurücksetzen" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "Kompaktes Adressfeld" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "Kompakter Modus" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "Ansichtseinstellungen" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "inklusive E-Mail" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "Auf Standard zurücksetzen" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "Spalten" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "Weitere Mitglieder werden geladen …" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "Ansicht" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact Member field" +msgstr "Kompaktes Mitglied-Feld" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "Klicke, um nach Ort zu sortieren" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "Klicke, um nach Nachname zu sortieren" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "Keine Adresse" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 4c9a10a0..c3b21727 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -127,7 +127,9 @@ msgstr "" msgid "Add members" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "" @@ -2348,12 +2350,14 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -2404,6 +2408,7 @@ msgstr "" msgid "New amount" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2691,10 +2696,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "" @@ -3291,11 +3296,6 @@ msgstr "" msgid "Show in overview" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3389,10 +3389,10 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "" @@ -3698,10 +3698,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" msgstr "" @@ -3901,6 +3901,7 @@ msgstr "" msgid "Yearly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -4140,3 +4141,75 @@ msgstr "" msgctxt "status" msgid "Open" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact Member field" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index cda87b5a..fe871774 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -127,7 +127,9 @@ msgstr "" msgid "Add members" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "" @@ -193,8 +195,7 @@ msgid "An account with this email already exists. Please verify your password to msgstr "" #: lib/mv_web/helpers/ash_error_helpers.ex -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format msgid "An error occurred" @@ -2289,14 +2290,12 @@ msgstr "" msgid "Membership fee start" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type deleted" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type not found" msgstr "" @@ -2351,12 +2350,14 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -2407,6 +2408,7 @@ msgstr "" msgid "New amount" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2694,10 +2696,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "" @@ -3294,11 +3296,6 @@ msgstr "" msgid "Show in overview" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3392,10 +3389,10 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "" @@ -3701,10 +3698,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" msgstr "" @@ -3904,6 +3901,7 @@ msgstr "" msgid "Yearly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -3956,8 +3954,7 @@ msgstr "" msgid "You do not have permission to %{action} members." msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to access this membership fee type" msgstr "" @@ -3973,8 +3970,7 @@ msgstr "" msgid "You do not have permission to delete this member" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this membership fee type" msgstr "" @@ -4145,3 +4141,75 @@ msgstr "Open" msgctxt "status" msgid "Open" msgstr "Open" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Compact Member field" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "" diff --git a/test/mv_web/member_live/index_bulk_scope_test.exs b/test/mv_web/member_live/index_bulk_scope_test.exs new file mode 100644 index 00000000..66626539 --- /dev/null +++ b/test/mv_web/member_live/index_bulk_scope_test.exs @@ -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 diff --git a/test/mv_web/member_live/index_filter_panel_test.exs b/test/mv_web/member_live/index_filter_panel_test.exs new file mode 100644 index 00000000..770c0944 --- /dev/null +++ b/test/mv_web/member_live/index_filter_panel_test.exs @@ -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 diff --git a/test/mv_web/member_live/index_live_region_test.exs b/test/mv_web/member_live/index_live_region_test.exs new file mode 100644 index 00000000..152e5eed --- /dev/null +++ b/test/mv_web/member_live/index_live_region_test.exs @@ -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 diff --git a/test/mv_web/member_live/index_pagination_test.exs b/test/mv_web/member_live/index_pagination_test.exs new file mode 100644 index 00000000..e21a5531 --- /dev/null +++ b/test/mv_web/member_live/index_pagination_test.exs @@ -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/]*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 diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 19855349..8f31beb1 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -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)