Rework member overview #553

Open
simon wants to merge 27 commits from issue/mitgliederverwaltung-547 into main
13 changed files with 1067 additions and 451 deletions
Showing only changes of commit b79d7ac9ea - Show all commits

View file

@ -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) // FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
Hooks.FocusRestore = { Hooks.FocusRestore = {
mounted() { 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, { let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: { 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 // Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-start", _info => topbar.show(300))

View file

@ -36,11 +36,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do
## Event routing ## Event routing
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the `dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` *un-targeted*, so the
component owns its own `:open` state. The copy item carries an *un-targeted* parent LiveView owns the `:open` state (passed back in via the `open` assign).
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's This lets the parent fetch the mailto recipients lazily when the dropdown opens
`handle_event/3` (which keeps access to `@members`), plus the rather than on every selection/filter change. The copy item likewise carries an
`CopyToClipboard` hook. 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 MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
@ -72,13 +74,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|> assign(:recipient_count, assigns[:recipient_count] || 0) |> assign(:recipient_count, assigns[:recipient_count] || 0)
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false) |> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
# The parent never sets :open (the component owns it via toggle/close). # The parent owns :open and passes it in. Defaulting to false keeps the
# Honouring an explicit :open assign keeps the component renderable in # component renderable in isolation (render_component/2) for structural tests
# isolation (render_component/2) for structural tests. # that omit it.
socket = socket =
case Map.fetch(assigns, :open) do case Map.fetch(assigns, :open) do
{:ok, open} -> assign(socket, :open, open) {:ok, open} -> assign(socket, :open, open)
:error -> socket :error -> assign_new(socket, :open, fn -> false end)
end end
{:ok, socket} {:ok, socket}
@ -98,7 +100,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
button_label={gettext("Actions")} button_label={gettext("Actions")}
icon="hero-bolt" icon="hero-bolt"
open={@open} open={@open}
phx_target={@myself} phx_target={nil}
menu_width="w-70" menu_width="w-70"
menu_align="left" menu_align="left"
button_class="btn-secondary gap-2" button_class="btn-secondary gap-2"
@ -232,12 +234,6 @@ defmodule MvWeb.Components.BulkActionsDropdown do
end end
end end
@impl true # Open/close are handled by the parent LiveView (see "Event routing"); the
def handle_event("toggle_dropdown", _params, socket) do # component no longer owns dropdown state.
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
end end

View file

@ -953,6 +953,11 @@ defmodule MvWeb.CoreComponents do
doc: doc:
"when true, first header/body column gets sticky left positioning to keep selection controls visible" "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 slot :col, required: true do
attr :label, :string attr :label, :string
attr :class, :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 :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 def table(assigns) do
assigns = assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
@ -986,6 +995,15 @@ defmodule MvWeb.CoreComponents do
assigns = assigns =
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx) 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""" ~H"""
<div <div
id={@row_click && "#{@id}-keyboard"} id={@row_click && "#{@id}-keyboard"}
@ -1021,7 +1039,10 @@ defmodule MvWeb.CoreComponents do
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}> <tbody
id={@id}
phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}
>
<tr <tr
:for={row <- @rows} :for={row <- @rows}
id={@row_id && @row_id.(row)} id={@row_id && @row_id.(row)}
@ -1126,6 +1147,21 @@ defmodule MvWeb.CoreComponents do
</td> </td>
</tr> </tr>
</tbody> </tbody>
<%!-- 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. --%>
<tbody
:if={@footer != [] && @viewport_bottom}
id={"#{@id}-footer"}
phx-viewport-bottom={@viewport_bottom}
>
<tr>
<td colspan={@col_count}>
{render_slot(@footer)}
</td>
</tr>
</tbody>
</table> </table>
</div> </div>
""" """

View file

@ -30,8 +30,6 @@ defmodule MvWeb.MemberLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.CustomFieldSort
alias Mv.Membership.Member, as: MemberResource
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
@ -42,6 +40,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.FilterParams
alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
require Ash.Query require Ash.Query
require Logger require Logger
@ -173,11 +172,29 @@ defmodule MvWeb.MemberLive.Index do
) )
|> assign(:show_current_cycle, false) |> assign(:show_current_cycle, false)
|> assign(:membership_fee_status_filter, nil) |> 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() |> assign_export_payload()
{:ok, socket} {:ok, socket}
end 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 # Handle Events
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -208,12 +225,15 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, {:noreply,
socket socket
|> assign(:selected_members, selected) |> assign(:selected_members, selected)
|> restream_member(id)
|> update_selection_assigns()} |> update_selection_assigns()}
end end
@impl true @impl true
def handle_event("select_all", _params, socket) do 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 = selected =
if MapSet.equal?(socket.assigns.selected_members, all_ids) do if MapSet.equal?(socket.assigns.selected_members, all_ids) do
@ -225,6 +245,33 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, {:noreply,
socket socket
|> assign(:selected_members, selected) |> 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()} |> update_selection_assigns()}
end end
@ -236,6 +283,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:show_current_cycle, new_show_current) |> assign(:show_current_cycle, new_show_current)
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
@ -247,21 +295,16 @@ defmodule MvWeb.MemberLive.Index do
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_reload(socket, new_path)}
end end
@impl true @impl true
def handle_event("copy_emails", _params, socket) do def handle_event("copy_emails", _params, socket) do
members = socket.assigns.members # Recipients follow the current scope, re-queried from the DB so a no-selection
selected_ids = socket.assigns.selected_members # (all/filtered) copy or a select-all copy spans the whole filtered set rather
any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id)) # than only the loaded page (§1.17). Members without an email are excluded;
# the empty-recipient feedback below is preserved.
# Recipients follow the current scope: the selection when present, otherwise formatted_emails = scope_emails(socket)
# 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?)
email_count = length(formatted_emails) email_count = length(formatted_emails)
if email_count == 0 do if email_count == 0 do
@ -310,6 +353,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:sort_order, new_order) |> assign(:sort_order, new_order)
|> update_sort_components(old_field, new_field, new_order) |> update_sort_components(old_field, new_field, new_order)
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
# URL sync - push_patch happens synchronously in the event handler # 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 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 end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -347,6 +391,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:query, q) |> assign(:query, q)
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
@ -358,7 +403,7 @@ defmodule MvWeb.MemberLive.Index do
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_reload(socket, new_path)}
end end
@impl true @impl true
@ -367,6 +412,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:cycle_status_filter, filter) |> assign(:cycle_status_filter, filter)
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
@ -377,7 +423,7 @@ defmodule MvWeb.MemberLive.Index do
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_reload(socket, new_path)}
end end
@impl true @impl true
@ -393,6 +439,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:boolean_custom_field_filters, updated_filters) |> assign(:boolean_custom_field_filters, updated_filters)
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
@ -403,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_reload(socket, new_path)}
end end
@impl true @impl true
@ -421,6 +468,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:group_filters, group_filters) |> assign(:group_filters, group_filters)
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
@ -431,7 +479,7 @@ defmodule MvWeb.MemberLive.Index do
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_reload(socket, new_path)}
end end
@impl true @impl true
@ -449,6 +497,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:fee_type_filters, fee_type_filters) |> assign(:fee_type_filters, fee_type_filters)
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
@ -459,7 +508,7 @@ defmodule MvWeb.MemberLive.Index do
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_reload(socket, new_path)}
end end
@impl true @impl true
@ -468,6 +517,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:date_filters, new_date_filters) |> assign(:date_filters, new_date_filters)
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
@ -478,7 +528,7 @@ defmodule MvWeb.MemberLive.Index do
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_reload(socket, new_path)}
end end
# Backward compatibility: tuple form delegates to map form # 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(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|> load_members() |> load_members()
|> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
@ -547,7 +598,7 @@ defmodule MvWeb.MemberLive.Index do
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_reload(socket, new_path)}
end end
@impl true @impl true
@ -691,6 +742,9 @@ defmodule MvWeb.MemberLive.Index do
|> update_selection_assigns() |> update_selection_assigns()
end 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 # Update sort components after rendering
socket = socket =
if socket.assigns[:sort_needs_update] do 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) |> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true) push_reload(socket, new_path)
end end
defp update_session_field_selection(socket, selection) do defp update_session_field_selection(socket, selection) do
@ -975,81 +1029,145 @@ defmodule MvWeb.MemberLive.Index do
# Loading members # 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 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 = socket
Mv.Membership.Member |> assign(:members, index_by_id(page.results))
|> Ash.Query.new() |> assign(:after_cursor, next_cursor(page))
|> Ash.Query.select(@overview_fields) |> 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) # Fetches the next keyset page (if any) and appends it to the stream and the
query = # loaded window. Triggered by phx-viewport-bottom; a no-op once the last page
Ash.Query.load(query, groups: [:id, :name, :slug]) # 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 defp load_more(socket) do
query = page = read_overview_page(socket, limit: @page_limit, after: socket.assigns.after_cursor)
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
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 = # Loaded members are held as a `%{id => member}` map (not a list): appends on
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) # 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 # Re-inserts a single loaded member into the stream so its row re-renders with
# excluded rows never reach the BEAM. The active_only default is part of # the current selection state (checkbox + selected highlight). Stream rows are
# this — fresh load returns only members without an exit_date or with an # not re-rendered on assign changes, so a selection toggle must re-stream the
# exit_date strictly in the future. # affected row.
query = defp restream_member(socket, id) do
DateFilter.apply_ash_filter(query, socket.assigns.date_filters) 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) # Re-inserts every loaded member so all visible rows reflect a bulk selection
custom_fields_for_sort = socket.assigns.all_custom_fields # 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} = # Reads one keyset page of the overview with display loads applied. Reads run
maybe_sort( # as the real user actor so Ash policies are enforced.
query, defp read_overview_page(socket, page_opts) do
socket.assigns.sort_field, socket
socket.assigns.sort_order, |> overview_query()
custom_fields_for_sort |> Ash.read!(actor: current_actor(socket), page: page_opts)
) end
# Errors in handle_params are handled by Phoenix LiveView # The cursor of the last loaded row, used as the `after` bound for the next
actor = current_actor(socket) # page; nil once there are no further pages.
members = Ash.read!(query, actor: actor) 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 defp next_cursor(_page), do: nil
# No need for in-memory filtering anymore
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) defp maybe_load_fee_type(query, socket) do
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status if :membership_fee_type in socket.assigns.member_fields_visible or
members = socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
if sort_after_load and Ash.Query.load(query, membership_fee_type: [:id, :name])
socket.assigns.sort_field != :membership_fee_status do else
sort_members_in_memory( query
members, end
socket.assigns.sort_field, end
socket.assigns.sort_order,
custom_fields_for_sort
)
else
members
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 end
# Collects every custom field UUID whose values must be loaded for a given # Collects every custom field UUID whose values must be loaded for a given
@ -1083,24 +1201,6 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.uniq() |> Enum.uniq()
end 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, []), do: query
defp load_custom_field_values(query, custom_field_ids) do defp load_custom_field_values(query, custom_field_ids) do
@ -1117,166 +1217,10 @@ defmodule MvWeb.MemberLive.Index do
# Helper Functions # 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(:asc), do: :desc
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
defp toggle_order(nil), 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 defp valid_sort_field?(field) when is_atom(field) do
# :groups is in computed_member_fields() but can be sorted # :groups is in computed_member_fields() but can be sorted
# Only :membership_fee_status should be blocked # Only :membership_fee_status should be blocked
@ -1331,19 +1275,6 @@ defmodule MvWeb.MemberLive.Index do
defp custom_field_sort?(_), do: false 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 defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string -> Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, @custom_field_prefix) do case String.split(field_string, @custom_field_prefix) do
@ -1354,91 +1285,6 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1 != nil)) |> Enum.filter(&(&1 != nil))
end 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 defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf) field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so) 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) def format_date(date), do: DateFormatter.format_date(date)
defp update_selection_assigns(socket) do defp update_selection_assigns(socket) do
members = socket.assigns[:members] || []
selected_members = socket.assigns.selected_members selected_members = socket.assigns.selected_members
# The selection may span members beyond the loaded page (after select-all),
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id)) # so its size is the MapSet size, not a count over the loaded window.
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id)) selected_count = MapSet.size(selected_members)
any_selected? = selected_count > 0
# Scope drives the trigger label: the selection when present, otherwise the # Scope drives the trigger label: the selection when present, otherwise the
# whole list (filtered, when a search term or any filter is active). # whole list (filtered, when a search term or any filter is active).
@ -1815,13 +1661,31 @@ defmodule MvWeb.MemberLive.Index do
true -> :all true -> :all
end end
# Copy/Mailto recipients: the members in scope that have a usable email. # No DB work here: the mailto recipient list is computed lazily when the
# With a selection that is the selected subset (existing behaviour); without # bulk-actions dropdown opens (assign_mailto_recipients/1), so a selection or
# a selection it is every member in scope (deliberate behaviour change). In # filter change never reads members for a link the user may never open.
# both cases members without an email are excluded, exactly as today's socket
# format_selected_member_emails does for the selection case. |> assign(:selected_count, selected_count)
recipient_emails = scope_member_emails(members, selected_members, any_selected?) |> assign(:scope, scope)
recipient_count = length(recipient_emails) |> 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 +) # RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
mailto_bcc = mailto_bcc =
@ -1830,29 +1694,60 @@ defmodule MvWeb.MemberLive.Index do
|> URI.encode_www_form() |> URI.encode_www_form()
|> String.replace("+", "%20") |> String.replace("+", "%20")
mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients()
socket socket
|> assign(:selected_count, selected_count)
|> assign(:scope, scope)
|> assign(:recipient_count, recipient_count) |> assign(:recipient_count, recipient_count)
|> assign(:mailto_disabled?, mailto_disabled?) |> assign(:mailto_disabled?, mailto_disabled?)
|> assign(:mailto_bcc, mailto_bcc) |> assign(:mailto_bcc, mailto_bcc)
|> assign_export_payload()
end end
# Returns the formatted "Name <email>" recipient list for the current scope: # Formatted "Name <email>" recipients for the current bulk scope, re-queried
# the selected members when any are selected, otherwise every member in the # from the DB so the set spans the whole selection / filtered total rather than
# (filtered) list. Members without an email are excluded in both cases. # only the loaded page (§1.17). Members without an email are excluded.
defp scope_member_emails(members, selected_members, true = _any_selected?), defp scope_emails(socket, opts \\ []) do
do: format_selected_member_emails(members, selected_members) socket
|> scope_members(opts)
defp scope_member_emails(members, _selected_members, false = _any_selected?) do
members
|> Enum.filter(fn member -> member.email && member.email != "" end) |> Enum.filter(fn member -> member.email && member.email != "" end)
|> Enum.map(&format_member_email/1) |> Enum.map(&format_member_email/1)
end 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 """ @doc """
Returns true when the member list is restricted by a non-empty search term or 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 any active filter (cycle status, group, fee type, boolean custom field, or a

View file

@ -5,6 +5,7 @@
<.live_component <.live_component
module={MvWeb.Components.BulkActionsDropdown} module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown" id="bulk-actions-dropdown"
open={@bulk_actions_open}
export_payload_json={@export_payload_json} export_payload_json={@export_payload_json}
selected_count={@selected_count} selected_count={@selected_count}
scope={@scope} scope={@scope}
@ -39,7 +40,7 @@
boolean_filters={@boolean_custom_field_filters} boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields} date_custom_fields={@date_custom_fields}
date_filters={@date_filters} date_filters={@date_filters}
member_count={length(@members)} member_count={@total_count}
/> />
<.tooltip <.tooltip
content={ content={
@ -80,6 +81,19 @@
/> />
</div> </div>
<%!-- 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). --%>
<div
id="members-result-count"
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{ngettext("%{count} member", "%{count} members", @total_count, count: @total_count)}
</div>
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%> <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
<div <div
id="members-table-guard" id="members-table-guard"
@ -88,15 +102,21 @@
data-testid="members-table-scroll" data-testid="members-table-scroll"
role="region" role="region"
aria-label={gettext("Members table")} aria-label={gettext("Members table")}
aria-busy={to_string(@loading?)}
> >
<.table <.table
id="members" id="members"
rows={@members} rows={@streams.members}
row_item={fn {_dom_id, member} -> member end}
wrapper_overflow_class="overflow-visible" wrapper_overflow_class="overflow-visible"
sticky_header={true} sticky_header={true}
sticky_first_col={true} sticky_first_col={true}
row_id={fn member -> "row-#{member.id}" end} viewport_bottom={@more? && "load_more"}
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} 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_tooltip={gettext("Click for member details")}
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
dynamic_cols={@dynamic_cols} dynamic_cols={@dynamic_cols}
@ -114,7 +134,7 @@
type="checkbox" type="checkbox"
name="select_all" name="select_all"
phx-click="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")} aria-label={gettext("Select all members")}
role="checkbox" role="checkbox"
/> />
@ -405,6 +425,31 @@
</.link> </.link>
</div> </div>
</:action> </:action>
<: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). --%>
<div
id="members-load-more-sentinel"
phx-hook="LoadMorePrefetch"
data-scroll-container="members-table-guard"
data-event="load_more"
data-margin="600px"
aria-hidden="true"
class="h-0"
>
</div>
<div
id="members-loading-bar"
phx-hook="StickyViewportWidth"
data-scroll-container="members-table-guard"
class="sticky left-0 flex items-center justify-center gap-2 border-t border-base-300 bg-base-200 py-4 text-base-content/70"
data-testid="members-loading-row"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
{gettext("Loading more members …")}
</div>
</:footer>
</.table> </.table>
</div> </div>
</Layouts.app> </Layouts.app>

View file

@ -126,7 +126,9 @@ msgstr "Feld hinzufügen"
msgid "Add members" msgid "Add members"
msgstr "Mitglieder hinzufügen" msgstr "Mitglieder hinzufügen"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Address" msgid "Address"
msgstr "Adresse" 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." 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/helpers/ash_error_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex #: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "An error occurred" msgid "An error occurred"
@ -2288,14 +2289,12 @@ msgstr "Beitragseinstellungen"
msgid "Membership fee start" msgid "Membership fee start"
msgstr "Beitragsbeginn" msgstr "Beitragsbeginn"
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership fee type deleted" msgid "Membership fee type deleted"
msgstr "Mitgliedsbeitragsart gelöscht" msgstr "Mitgliedsbeitragsart gelöscht"
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership fee type not found" msgid "Membership fee type not found"
msgstr "Mitgliedsbeitragsart nicht gefunden" 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/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.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_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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.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/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
@ -2406,6 +2407,7 @@ msgstr "Neue*r Benutzer*in"
msgid "New amount" msgid "New amount"
msgstr "Neuer Betrag" msgstr "Neuer Betrag"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/join_request_live/show.ex
@ -2693,10 +2695,10 @@ msgstr "Optionen"
#: lib/mv/membership/members_pdf.ex #: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "Bezahlt" msgstr "Bezahlt"
@ -3293,11 +3295,6 @@ msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
msgid "Show in overview" msgid "Show in overview"
msgstr "In der Übersicht anzeigen" 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 #: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Sign in" msgid "Sign in"
@ -3391,10 +3388,10 @@ msgid "Summary"
msgstr "Zusammenfassung" msgstr "Zusammenfassung"
#: lib/mv/membership/members_pdf.ex #: 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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Suspended" msgid "Suspended"
msgstr "Pausiert" msgstr "Pausiert"
@ -3700,10 +3697,10 @@ msgstr "Aufhebung der Verknüpfung geplant"
#: lib/mv/membership/members_pdf.ex #: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unpaid" msgid "Unpaid"
msgstr "Unbezahlt" msgstr "Unbezahlt"
@ -3904,6 +3901,7 @@ msgstr "Jährliches Intervall Beitrittszeitraum nicht einbezogen"
msgid "Yearly Interval - Joining Cycle Included" msgid "Yearly Interval - Joining Cycle Included"
msgstr "Jährliches Intervall Beitrittszeitraum einbezogen" 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/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.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." msgid "You do not have permission to %{action} members."
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}." msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type" msgid "You do not have permission to access this membership fee type"
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen." 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" msgid "You do not have permission to delete this member"
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen." msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen."
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type" msgid "You do not have permission to delete this membership fee type"
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen." msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen."
@ -4145,3 +4141,75 @@ msgstr "Öffnen"
msgctxt "status" msgctxt "status"
msgid "Open" msgid "Open"
msgstr "Offen" 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"

View file

@ -127,7 +127,9 @@ msgstr ""
msgid "Add members" msgid "Add members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Address" msgid "Address"
msgstr "" msgstr ""
@ -2348,12 +2350,14 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex #: 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/form_component.ex
#: lib/mv_web/live/member_field_live/index_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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.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/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name" msgid "Name"
msgstr "" msgstr ""
@ -2404,6 +2408,7 @@ msgstr ""
msgid "New amount" msgid "New amount"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/join_request_live/show.ex
@ -2691,10 +2696,10 @@ msgstr ""
#: lib/mv/membership/members_pdf.ex #: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
@ -3291,11 +3296,6 @@ msgstr ""
msgid "Show in overview" msgid "Show in overview"
msgstr "" 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 #: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Sign in" msgid "Sign in"
@ -3389,10 +3389,10 @@ msgid "Summary"
msgstr "" msgstr ""
#: lib/mv/membership/members_pdf.ex #: 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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Suspended" msgid "Suspended"
msgstr "" msgstr ""
@ -3698,10 +3698,10 @@ msgstr ""
#: lib/mv/membership/members_pdf.ex #: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unpaid" msgid "Unpaid"
msgstr "" msgstr ""
@ -3901,6 +3901,7 @@ msgstr ""
msgid "Yearly Interval - Joining Cycle Included" msgid "Yearly Interval - Joining Cycle Included"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/join_request_live/show.ex
@ -4140,3 +4141,75 @@ msgstr ""
msgctxt "status" msgctxt "status"
msgid "Open" msgid "Open"
msgstr "" 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 ""

View file

@ -127,7 +127,9 @@ msgstr ""
msgid "Add members" msgid "Add members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Address" msgid "Address"
msgstr "" msgstr ""
@ -193,8 +195,7 @@ msgid "An account with this email already exists. Please verify your password to
msgstr "" msgstr ""
#: lib/mv_web/helpers/ash_error_helpers.ex #: lib/mv_web/helpers/ash_error_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex #: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "An error occurred" msgid "An error occurred"
@ -2289,14 +2290,12 @@ msgstr ""
msgid "Membership fee start" msgid "Membership fee start"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type deleted" msgid "Membership fee type deleted"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type not found" msgid "Membership fee type not found"
msgstr "" msgstr ""
@ -2351,12 +2350,14 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex #: 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/form_component.ex
#: lib/mv_web/live/member_field_live/index_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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.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/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name" msgid "Name"
msgstr "" msgstr ""
@ -2407,6 +2408,7 @@ msgstr ""
msgid "New amount" msgid "New amount"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/join_request_live/show.ex
@ -2694,10 +2696,10 @@ msgstr ""
#: lib/mv/membership/members_pdf.ex #: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
@ -3294,11 +3296,6 @@ msgstr ""
msgid "Show in overview" msgid "Show in overview"
msgstr "" 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 #: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Sign in" msgid "Sign in"
@ -3392,10 +3389,10 @@ msgid "Summary"
msgstr "" msgstr ""
#: lib/mv/membership/members_pdf.ex #: 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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Suspended" msgid "Suspended"
msgstr "" msgstr ""
@ -3701,10 +3698,10 @@ msgstr ""
#: lib/mv/membership/members_pdf.ex #: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.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.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unpaid" msgid "Unpaid"
msgstr "" msgstr ""
@ -3904,6 +3901,7 @@ msgstr ""
msgid "Yearly Interval - Joining Cycle Included" msgid "Yearly Interval - Joining Cycle Included"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/join_request_live/show.ex
@ -3956,8 +3954,7 @@ msgstr ""
msgid "You do not have permission to %{action} members." msgid "You do not have permission to %{action} members."
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this membership fee type" msgid "You do not have permission to access this membership fee type"
msgstr "" msgstr ""
@ -3973,8 +3970,7 @@ msgstr ""
msgid "You do not have permission to delete this member" msgid "You do not have permission to delete this member"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this membership fee type" msgid "You do not have permission to delete this membership fee type"
msgstr "" msgstr ""
@ -4145,3 +4141,75 @@ msgstr "Open"
msgctxt "status" msgctxt "status"
msgid "Open" msgid "Open"
msgstr "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 ""

View file

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

View file

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

View file

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

View file

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

View file

@ -306,7 +306,8 @@ defmodule MvWeb.MemberLive.IndexTest do
# asserted on internal state to preserve the original coverage of the callback. # asserted on internal state to preserve the original coverage of the callback.
assigns = :sys.get_state(view.pid).socket.assigns assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.query == "Friedrich" 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 end
@tag :ui @tag :ui
@ -1069,6 +1070,63 @@ defmodule MvWeb.MemberLive.IndexTest do
assert bcc =~ "scope1%40example.com" assert bcc =~ "scope1%40example.com"
refute bcc =~ "scope2%40example.com" refute bcc =~ "scope2%40example.com"
end 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 end
describe "cycle status filter" do describe "cycle status filter" do
@ -2372,12 +2430,12 @@ defmodule MvWeb.MemberLive.IndexTest do
# Should complete in less than 1 second (1000ms) # Should complete in less than 1 second (1000ms)
assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms" assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
# Verify filtering worked correctly - should show all true members # The overview now keyset-paginates: mount loads only the first page, not
Enum.each(1..75, fn i -> # the whole filtered set (§1.7). The filter still resolves DB-side, so the
assert html =~ "TrueMember#{i}" # loaded page contains only matching (true) members and never a non-matching
end) # (false) one.
assert html =~ "TrueMember"
# Should not show false members
Enum.each(1..75, fn i -> Enum.each(1..75, fn i ->
refute html =~ "FalseMember#{i}" refute html =~ "FalseMember#{i}"
end) end)