feat(member): stream the overview with keyset infinite scroll instead of loading every member
This commit is contained in:
parent
b09cdf7f3a
commit
b79d7ac9ea
13 changed files with 1067 additions and 451 deletions
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
71
test/mv_web/member_live/index_bulk_scope_test.exs
Normal file
71
test/mv_web/member_live/index_bulk_scope_test.exs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexBulkScopeTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.17 — Bulk "all" scope spans the whole filtered set (re-queried from the DB),
|
||||||
|
not only the loaded/visible page, and the count reflects the full filtered
|
||||||
|
total.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
# More than one keyset page so "loaded" and "all matching" differ.
|
||||||
|
@total 60
|
||||||
|
|
||||||
|
defp seed(n) do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
Enum.each(1..n, fn i ->
|
||||||
|
idx = String.pad_leading(Integer.to_string(i), 3, "0")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Bulk#{idx}", last_name: "Scope", email: "bulk#{idx}@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp scope_badge_text(view) do
|
||||||
|
view
|
||||||
|
|> render()
|
||||||
|
|> LazyHTML.from_fragment()
|
||||||
|
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
|
||||||
|
|> LazyHTML.text()
|
||||||
|
|> String.trim()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "select all selects the whole filtered set, not just the loaded page", %{conn: conn} do
|
||||||
|
seed(@total)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("[phx-click='select_all']") |> render_click()
|
||||||
|
|
||||||
|
# The selection count badge reflects all matching members, not the 50 loaded.
|
||||||
|
assert scope_badge_text(view) == "#{@total}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "copy-emails with no selection copies every matching member's email", %{conn: conn} do
|
||||||
|
seed(@total)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
result = render_hook(view, "copy_emails", %{})
|
||||||
|
|
||||||
|
# All 60 matching members are copied, not only the 50 loaded rows.
|
||||||
|
assert result =~ "#{@total}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "select-all then copy copies the whole filtered set", %{conn: conn} do
|
||||||
|
seed(@total)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("[phx-click='select_all']") |> render_click()
|
||||||
|
result = render_hook(view, "copy_emails", %{})
|
||||||
|
|
||||||
|
assert result =~ "#{@total}"
|
||||||
|
end
|
||||||
|
end
|
||||||
79
test/mv_web/member_live/index_filter_panel_test.exs
Normal file
79
test/mv_web/member_live/index_filter_panel_test.exs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexFilterPanelTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.18 — The existing "Apply filters" panel is unchanged: same fieldset/legend
|
||||||
|
form structure and payment-status controls, no chip/add-filter-builder
|
||||||
|
paradigm (that is #548). Its filters now resolve DB-side via the `:overview`
|
||||||
|
read action.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Panel", last_name: "Member", email: "panel@example.com"},
|
||||||
|
actor: SystemActor.get_system_actor()
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_filter(view) do
|
||||||
|
view
|
||||||
|
|> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
|
||||||
|
|> render_click()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the filter panel keeps its fieldset form structure and payment controls", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
open_filter(view)
|
||||||
|
|
||||||
|
# Semantic form structure (fieldset/legend, radio controls) is preserved.
|
||||||
|
assert has_element?(view, ~s(form[data-testid="member-filter-form"]))
|
||||||
|
assert has_element?(view, "#payment-filter-all")
|
||||||
|
assert has_element?(view, "#payment-filter-paid")
|
||||||
|
assert has_element?(view, "#payment-filter-unpaid")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no chip / add-filter-builder paradigm is present (#548 is out of scope)", %{conn: conn} do
|
||||||
|
{:ok, view, html} = live(conn, ~p"/members")
|
||||||
|
open_filter(view)
|
||||||
|
html = html <> render(view)
|
||||||
|
|
||||||
|
refute html =~ ~s(data-testid="add-filter")
|
||||||
|
refute html =~ ~s(data-testid="filter-chip")
|
||||||
|
refute html =~ "filter-builder"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the panel's payment filter resolves DB-side via :overview", %{conn: conn} do
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
fee_type = Mv.Fixtures.create_fee_type(%{interval: :yearly}, system_actor)
|
||||||
|
last_year_start = Date.new!(Date.utc_today().year - 1, 1, 1)
|
||||||
|
|
||||||
|
{:ok, paid} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{
|
||||||
|
first_name: "PaidPanel",
|
||||||
|
last_name: "X",
|
||||||
|
email: "paidpanel@example.com",
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
Mv.Fixtures.create_cycle(
|
||||||
|
paid,
|
||||||
|
fee_type,
|
||||||
|
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members?cycle_status_filter=paid")
|
||||||
|
|
||||||
|
assert html =~ "PaidPanel"
|
||||||
|
refute html =~ "Panel Member"
|
||||||
|
end
|
||||||
|
end
|
||||||
68
test/mv_web/member_live/index_live_region_test.exs
Normal file
68
test/mv_web/member_live/index_live_region_test.exs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexLiveRegionTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.9 — The polite live region announces the exact total matching count,
|
||||||
|
computed via a count query for the active filter set.
|
||||||
|
§1.10 — The live region element exists in the DOM before it is filled, and the
|
||||||
|
table region carries `aria-busy`.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
defp seed(n) do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
Enum.each(1..n, fn i ->
|
||||||
|
idx = String.pad_leading(Integer.to_string(i), 3, "0")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Lr#{idx}", last_name: "Region", email: "lr#{idx}@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "polite live region exists on first render and announces the exact total", %{conn: conn} do
|
||||||
|
seed(60)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Present-before-fill: the live region element is in the initial DOM.
|
||||||
|
assert has_element?(view, "#members-result-count[aria-live='polite']")
|
||||||
|
# Exact total, not the loaded page size (60 matched, only 50 loaded).
|
||||||
|
region = view |> element("#members-result-count") |> render()
|
||||||
|
assert region =~ "60 members"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "table region binds aria-busy to the loading flag (settled after load)", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# aria-busy is bound to @loading? (`to_string(@loading?)`), not hardcoded: once
|
||||||
|
# the page has loaded the region is not busy. It is set true across a
|
||||||
|
# filter/sort/search reload patch (push_reload/2, §1.10).
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][aria-busy='false']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "count reflects the active filter set, not the whole table", %{conn: conn} do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Findme", last_name: "Unique", email: "findme@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
seed(5)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members?query=Findme")
|
||||||
|
|
||||||
|
# Only the single matching member is counted (1 of 6 total).
|
||||||
|
region = view |> element("#members-result-count") |> render()
|
||||||
|
assert region =~ "1 member"
|
||||||
|
refute region =~ "6 member"
|
||||||
|
end
|
||||||
|
end
|
||||||
96
test/mv_web/member_live/index_pagination_test.exs
Normal file
96
test/mv_web/member_live/index_pagination_test.exs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaginationTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.7 — Mount loads only one keyset page, not the whole table.
|
||||||
|
§1.8 — phx-viewport-bottom fetches and appends the next page; once the last
|
||||||
|
page is reached no further fetch is issued.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
# Matches the :overview default_limit / @page_limit in the LiveView.
|
||||||
|
@page_limit 50
|
||||||
|
|
||||||
|
defp seed_members(n) do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
Enum.each(1..n, fn i ->
|
||||||
|
# Zero-padded names keep first_name-ascending order deterministic.
|
||||||
|
idx = String.pad_leading(Integer.to_string(i), 4, "0")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Page#{idx}", last_name: "Member", email: "page#{idx}@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp row_count(html) do
|
||||||
|
~r/<tr[^>]*id="row-/ |> Regex.scan(html) |> length()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "mount loads exactly one page when more members exist", %{conn: conn} do
|
||||||
|
seed_members(@page_limit + 10)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert row_count(html) == @page_limit
|
||||||
|
# More rows remain, so the infinite-scroll sentinel is armed.
|
||||||
|
assert html =~ ~s(phx-viewport-bottom="load_more")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewport-bottom appends the next page and stops at the last page", %{conn: conn} do
|
||||||
|
seed_members(@page_limit + 10)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert row_count(html) == @page_limit
|
||||||
|
|
||||||
|
# Fetch the next page: the remaining rows are appended to the stream.
|
||||||
|
html_after = render_hook(view, "load_more", %{})
|
||||||
|
assert row_count(html_after) == @page_limit + 10
|
||||||
|
|
||||||
|
# Last page reached: the sentinel is disarmed and a further fetch is a no-op.
|
||||||
|
refute html_after =~ ~s(phx-viewport-bottom="load_more")
|
||||||
|
html_again = render_hook(view, "load_more", %{})
|
||||||
|
assert row_count(html_again) == @page_limit + 10
|
||||||
|
end
|
||||||
|
|
||||||
|
test "selecting a row from a later page re-renders it as checked", %{conn: conn} do
|
||||||
|
seed_members(@page_limit + 10)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Load the second page so its rows are in the loaded window.
|
||||||
|
render_hook(view, "load_more", %{})
|
||||||
|
|
||||||
|
# The highest-index member sorts last and lives on the second page.
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
last_member =
|
||||||
|
Mv.Membership.Member
|
||||||
|
|> Ash.Query.sort(first_name: :asc)
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> List.last()
|
||||||
|
|
||||||
|
html = render_click(view, "select_member", %{"id" => last_member.id})
|
||||||
|
|
||||||
|
# The re-streamed row for a later-page member reflects the selection.
|
||||||
|
assert html =~ ~s(id="row-#{last_member.id}")
|
||||||
|
assert has_element?(view, ~s(tr#row-#{last_member.id} input[type="checkbox"][checked]))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "single page does not arm the infinite-scroll sentinel", %{conn: conn} do
|
||||||
|
seed_members(3)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert row_count(html) == 3
|
||||||
|
refute html =~ ~s(phx-viewport-bottom="load_more")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -306,7 +306,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# asserted on internal state to preserve the original coverage of the callback.
|
# 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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue