Compare commits

..

27 commits

Author SHA1 Message Date
2c8a403cc0 fix(member-live): guard the composite sort event and apply review cleanup
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-07-16 09:39:18 +02:00
f2312c24a1 fix(member-live): stop infinite scroll from cascading into loading every page 2026-07-16 09:39:14 +02:00
f847ae10af test(member-live): update component tests for the new defaults and composite sort 2026-07-16 09:39:13 +02:00
95c25142f0 feat(seeds): add an opt-in bulk member seed for load testing 2026-07-16 09:39:12 +02:00
c482a2630b fix(member-live): keep overview badges a consistent size across densities 2026-07-16 09:39:11 +02:00
f7005f395f feat(member-live): sort the composite Member column by first or last name 2026-07-16 09:39:07 +02:00
ca2aaa069c feat(member-live): unify the overview settings menu and add a reset-button tooltip 2026-07-16 09:39:04 +02:00
561463725a feat(member-live): keep the compact overview font legible 2026-07-16 09:39:02 +02:00
e16ef1bea4 fix(member-live): correct the overview pinned-column layering 2026-07-16 09:38:59 +02:00
4d100aef22 feat(member-live): default the overview to comfortable density with folded-in email 2026-07-16 09:38:57 +02:00
0a23d03d9d fix(member-export): split composite Name and Address into component columns on export 2026-07-16 09:38:54 +02:00
a64835fe6d fix(member-live): re-read view-settings cookie on live navigation 2026-07-16 09:38:53 +02:00
fe6d1c52f9 Merge remote-tracking branch 'origin/main' into issue/mitgliederverwaltung-547
# Conflicts:
#	lib/mv_web/live/member_live/index.html.heex
2026-07-12 12:29:47 +02:00
cdb8b03ce1 test(member): move the cycle-status test beside its OverviewQuery
Some checks failed
continuous-integration/drone/push Build is failing
2026-07-06 15:42:58 +02:00
c9dd900a5f docs(member): clarify the :overview vs :read read actions 2026-07-06 15:42:32 +02:00
4adb6be5e5 refactor(member): drop the unused second-sticky-column table option 2026-07-06 15:42:01 +02:00
0cc37f7e9b docs(changelog): record member overview data foundation and redesign under Unreleased
Some checks failed
continuous-integration/drone/push Build is failing
2026-07-06 11:01:05 +02:00
1c67527c56 docs(member-live): document the overview and use English throughout its internals 2026-07-06 11:00:29 +02:00
77fc11a0b0 refactor(member-live): modularize the overview and drop the superseded in-memory paths
Extract the cookie parser, export-payload builder and fee-status helper
into their own modules and remove the filter/sort/load helpers the
database pushdown made dead, so the overview LiveView stays a thin
coordinator over focused units.
2026-07-06 11:00:29 +02:00
c2cb3edab8 feat(member): bring the overview table up to WCAG 2.2 AA 2026-07-06 11:00:29 +02:00
0c375234c8 feat(member): make overview columns sortable through accessible sort headers 2026-07-06 10:48:28 +02:00
dfc616257d feat(member): let members tailor overview density and visible columns
The View dropdown drives row density and whether the Member and Address
fields render as composite cells or split into their underlying columns;
the column manager toggles visibility and resets to the curated default.
All choices persist per browser through the URL/session/cookie/global
chain, so a saved layout survives reloads without a per-account store.
2026-07-06 10:45:53 +02:00
af2cc2e0d4 feat(member): condense the overview into composite name and address cells 2026-07-03 11:35:46 +02:00
b79d7ac9ea feat(member): stream the overview with keyset infinite scroll instead of loading every member 2026-07-03 11:31:44 +02:00
b09cdf7f3a feat(member): resolve custom-field filters and sorting in PostgreSQL
Push the boolean and date custom-field predicates and custom-field
sorting down to JSONB expressions so the overview stops classifying
custom-field values in memory. A GIN index on custom_field_values.value
keeps the boolean membership predicates index-served.
2026-07-03 11:19:59 +02:00
e64f55c36a feat(member): back the overview with a keyset-paginated :overview read action 2026-07-03 11:12:38 +02:00
b745b13ca5 feat(membership-fees): denormalize cycle_end so fee status filters run in the database
Store the cycle's end date alongside its start so the payment-status
filter can be a plain date comparison in SQL instead of loading every
cycle and classifying it in memory. The value is fixed when the cycle
is created (the fee type's interval is immutable and the update action
never changes the start date), so the denormalized column can never
drift from the computed end and needs no write-time reconciliation.
2026-07-03 11:04:43 +02:00
75 changed files with 6728 additions and 1646 deletions

View file

@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **Members overview view settings** A new "View" dropdown switches the table between compact and comfortable density and controls how the Member and Address fields are shown: as combined composite cells or split into their underlying columns (Vorname/Nachname/E-Mail resp. Straße/PLZ/Ort). Every choice is remembered per browser.
- **Members overview reset columns to default** The column-visibility menu gained a reset button that restores the curated default column set.
### Changed
- **Members overview loads on demand** The overview no longer reads every member into memory. Filtering, sorting and pagination now run in the database, and rows stream in via infinite scroll as you reach the bottom of the list, so large member lists open quickly. The exact number of matching members is announced (for example "250 Members").
- **Members overview condensed name and address** By default a member shows as a composite name cell and a two-line address cell (street / postal code + city); both columns are sortable and city and postal code stay findable via search. A clear (×) button was added to the search field.
- **Members overview bulk actions span the full result set** Select-all, copy e-mail addresses, and export now act on the complete filtered set rather than only the rows currently loaded, and the count reflects the full filtered total.
- **Members overview sticky header, pinned column and sortable headers** The table keeps a sticky header and pinned identifier column, sort headers that expose the active sort direction, boolean columns rendered as ✓/✗ icons, larger touch targets in both densities, and result-count and loading changes announced through a polite live region — meeting WCAG 2.2 AA for accessibility.
## [1.3.0] - 2026-06-16
### Added

View file

@ -30,6 +30,11 @@ reset-database:
seed-database:
mix run priv/repo/seeds.exs
# Seed a large batch of members for load / infinite-scroll testing (opt-in; NOT
# part of `seed-database`). Set BULK_MEMBER_COUNT to change the count (default 300).
seed-members-bulk:
mix run priv/repo/seeds_bulk_members.exs
start-database:
docker compose up -d

View file

@ -678,6 +678,79 @@
/* This file is for your main application CSS */
/* ============================================
Table header density scaling
============================================ */
/*
* DaisyUI table size modifiers (table-xs, table-sm, etc.) scope their font-size
* rule to ":not(thead, tfoot) tr", so the header row stays at the base 0.875 rem
* regardless of the chosen density. We extend each modifier to thead so the
* header scales together with the body.
*/
.table.table-xs thead tr { font-size: 0.6875rem; }
.table.table-sm thead tr { font-size: 0.75rem; }
.table.table-lg thead tr { font-size: 1.125rem; }
.table.table-xl thead tr { font-size: 1.375rem; }
/*
* Compact density keeps table-xs's tight row spacing but lifts the OVERVIEW's
* font off the tiny xs size (0.6875rem) so member names stay comfortably legible,
* especially on high-DPI displays: body cells at sm (0.875rem), header a touch
* smaller. Scoped to #members-keyboard so other table-xs tables keep DaisyUI's
* default xs sizing.
*/
#members-keyboard .table.table-xs :not(thead, tfoot) tr { font-size: 0.875rem; }
#members-keyboard .table.table-xs thead tr { font-size: 0.8125rem; }
/*
* Overview badges (fee status, groups) keep a CONSISTENT size across both
* densities. Compact mode now differs from comfortable only in row spacing, not
* font size (see the table-xs font rule above), so the badges match in both
* otherwise a compact badge would read smaller than the text beside it. The size
* is driven from the table size class in CSS (not a per-row `size` attr) because
* the streamed rows are not re-rendered on a density toggle. Values mirror
* daisyUI's .badge-md; scoped to #members-keyboard so other badges are untouched.
*/
#members-keyboard .table.table-xs .badge,
#members-keyboard .table.table-md .badge {
--size: calc(var(--size-selector, 0.25rem) * 6);
font-size: 0.875rem;
padding-inline: calc(0.25rem * 3 - var(--border));
}
/* Sort glyphs scale with the header text (11px compact / 14px comfortable). */
#members-keyboard .table.table-md thead .sort-icon {
width: 1.5rem;
height: 1.5rem;
}
#members-keyboard .table.table-xs thead .sort-icon {
width: 1.125rem;
height: 1.125rem;
}
/*
* Infinite-scroll loading row.
*
* The loading indicator must read like a full, accented data row rather than a
* narrow pinned bar. The accent (base-200 background + a top separator) and the
* padding live on the full-width footer cell, which spans every column via
* colspan, so the accent is visible edge-to-edge even while the table is scrolled
* horizontally. The spinner + label inside stay pinned to the visible viewport
* width (StickyViewportWidth hook) and transparent, so they remain centered on
* horizontal scroll while the cell accent shows through behind them.
*
* The row height tracks the active density (table-xs compact / table-md
* comfortable) so it matches a normal data row in each mode.
*/
#members-footer > tr > td {
background-color: var(--color-base-200);
border-top: 1px solid var(--color-base-300);
padding: 0;
}
.table.table-xs #members-loading-bar { min-height: 3.375rem; }
.table.table-md #members-loading-bar { min-height: 4.1875rem; }
/* ============================================
SortableList: drag-and-drop table rows
============================================ */
@ -773,3 +846,104 @@
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
outline: none;
}
/*
* Sticky checkbox column: border-collapse: separate avoids the Chromium artifact
* where a collapsed row border is repainted (a spurious 1px line) under a
* position: sticky cell. The row separators are moved onto the cells so they
* still render in the separate model (matching DaisyUI's faint divider color).
*/
[data-sticky-first-col-rows="true"] .table.table-zebra {
border-collapse: separate;
border-spacing: 0;
}
[data-sticky-first-col-rows="true"]
.table.table-zebra
:where(thead tr, tbody tr:not(:last-child))
> :where(th, td) {
border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
}
/*
* Horizontal-scroll fade for the pinned checkbox column HEADER only: as the
* title cells scroll left behind the pinned checkbox they fade softly into the
* opaque base-100 header (gradient on the right edge of the sticky header cell)
* instead of a hard cut. Body rows deliberately have NO such fade they are
* simply covered by the opaque (zebra) checkbox cell, so nothing tints the strip
* right of the body checkboxes. A gradient there would fade to the row's zebra
* colour and read as a grey shimmer beside the checkboxes (most visible while
* scrolling), which is not wanted.
*/
[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 100%;
width: 1.25rem;
pointer-events: none;
background: linear-gradient(to right, var(--color-base-100), transparent);
}
/* Compact density: narrower fade in the tighter table-xs header cell. */
[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs thead th.sticky-first-col-th::after {
width: 0.5rem;
}
/*
* Popover tooltip (sortable column headers, column-manager reset button).
* Rendered via the native Popover API (see the PopoverTooltip JS hook), so it
* lives in the browser TOP LAYER: it is not clipped by the members table's
* overflow (overflow-x:auto forces overflow-y:auto, which used to clip a CSS
* pseudo-tooltip below the header) and paints above the sticky header, neighbor
* cells, and the pinned checkbox column's fade without any z-index hacks. This
* block only resets the popover UA defaults and gives it a small daisyUI-
* flavoured tooltip look; the JS hook sets top/left each show.
*/
.popover-tooltip {
position: fixed;
inset: auto;
margin: 0;
border: 0;
padding: 0.25rem 0.5rem;
width: max-content;
max-width: 16rem;
overflow: visible;
background: var(--color-neutral, oklch(0.2 0 0));
color: var(--color-neutral-content, oklch(0.98 0 0));
font-size: 0.75rem;
line-height: 1rem;
border-radius: 0.25rem;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.3);
pointer-events: none;
}
/*
* Sub-field sort menu (composite Member column). A native Popover-API element in
* the browser TOP LAYER so it escapes the members table's overflow; placement is
* pure CSS anchor positioning (the "⋮" trigger carries anchor-name, this popover
* carries position-anchor). `position-area` drops it just below the trigger,
* right-aligned, and `position-try-fallbacks` flips it above when there is no
* room below. The SubfieldMenu hook only toggles it no coordinate math.
*/
.popover-menu {
margin: 0;
position-area: bottom span-left;
position-try-fallbacks: flip-block;
}
/*
* Vertically center the row checkbox in the sticky first column using the
* table-native vertical-align: middle. This keeps display: table-cell intact so
* the cell background fills the full row height (no white gap at the bottom).
* The fieldset margin-bottom reset removes the mb-2 from the shared input
* component so no residual margin shifts the checkbox off-center.
*/
[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell,
[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th {
vertical-align: middle;
}
[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell fieldset,
[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th fieldset {
margin-bottom: 0;
}

View file

@ -36,6 +36,22 @@ function getBrowserTimezone() {
// Hooks for LiveView components
let Hooks = {}
// IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS
// property, not an HTML attribute, so it cannot be set from the server render.
// Mirror it from the data-indeterminate attribute on mount and every update
// (e.g. select-all reflecting a partial selection — WCAG select-all semantics).
Hooks.IndeterminateCheckbox = {
updateIndeterminate() {
this.el.indeterminate = this.el.dataset.indeterminate === "true"
},
mounted() {
this.updateIndeterminate()
},
updated() {
this.updateIndeterminate()
}
}
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
Hooks.CopyToClipboard = {
mounted() {
@ -126,6 +142,29 @@ Hooks.RowSelectionGuard = {
}
}
// StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width
// (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar
// lives in a full-width table row that is wider than the viewport when the table scrolls
// horizontally; matching the sticky element to the container's visible width keeps its
// centered content pinned at the bottom-center of the visible area instead of drifting off
// with the scrolled table. The data-scroll-container attribute names the container element id.
Hooks.StickyViewportWidth = {
mounted() {
this.container = document.getElementById(this.el.dataset.scrollContainer)
this.sync = () => {
if (this.container) this.el.style.width = this.container.clientWidth + "px"
}
this.sync()
window.addEventListener("resize", this.sync)
},
updated() {
this.sync()
},
destroyed() {
window.removeEventListener("resize", this.sync)
}
}
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
Hooks.FocusRestore = {
mounted() {
@ -313,6 +352,199 @@ Hooks.SortableList = {
}
}
// Guarded native Popover API toggles: showPopover()/hidePopover() throw on a
// detached element or a redundant open/close, so the popover hooks below share
// these wrappers instead of repeating the try/catch + :popover-open check.
function openPopover(el) {
try {
if (el && !el.matches(":popover-open")) el.showPopover()
} catch (_e) {}
}
function closePopover(el) {
try {
if (el && el.matches(":popover-open")) el.hidePopover()
} catch (_e) {}
}
// PopoverTooltip hook: shows a hint (referenced by data-tooltip-id) in the
// browser TOP LAYER via the native Popover API, so it escapes the members
// table's overflow clipping (overflow-x:auto forces overflow-y:auto, which clips
// a CSS tooltip below the header) and paints above the sticky header, neighbor
// cells, and the pinned checkbox column's fade — no z-index fight. Shown on hover
// AND keyboard focus, positioned next to the trigger with getBoundingClientRect
// (portable; CSS anchor-positioning is not yet supported in Safari/Firefox).
// Used by the sortable column headers and the column-manager reset button.
Hooks.PopoverTooltip = {
mounted() {
this.tip = document.getElementById(this.el.dataset.tooltipId)
// Feature-detect: without Popover API support fall back to no visual tooltip
// (the button's aria-label still conveys the hint to assistive tech).
if (!this.tip || typeof this.tip.showPopover !== "function") return
this.position = () => {
const btn = this.el.getBoundingClientRect()
const tip = this.tip.getBoundingClientRect()
const gap = 6
// Prefer just below the header; flip above when there is no room below.
let top = btn.bottom + gap
if (top + tip.height > window.innerHeight && btn.top - gap - tip.height >= 0) {
top = btn.top - gap - tip.height
}
// Left-align to the header, but keep the whole bubble on-screen.
let left = btn.left
const maxLeft = window.innerWidth - tip.width - gap
if (left > maxLeft) left = maxLeft
if (left < gap) left = gap
this.tip.style.top = `${Math.round(top)}px`
this.tip.style.left = `${Math.round(left)}px`
}
this.show = () => {
openPopover(this.tip)
// Popover is measurable once open; positioning in the same tick avoids a
// visible flash (JS runs before the browser paints).
try {
this.position()
} catch (_e) {}
}
this.hide = () => closePopover(this.tip)
this.onKey = (e) => { if (e.key === "Escape") this.hide() }
this.el.addEventListener("mouseenter", this.show)
this.el.addEventListener("focus", this.show)
this.el.addEventListener("mouseleave", this.hide)
this.el.addEventListener("blur", this.hide)
this.el.addEventListener("keydown", this.onKey)
// Hide on activation: a click (sort/reset) triggers a LiveView re-render that
// re-emits the tooltip element without the JS-set inline top/left, which would
// otherwise leave the still-open popover stuck at the default (0,0) position,
// covering the header. Closing it here keeps it out of the re-render; the next
// hover repositions it cleanly.
this.el.addEventListener("click", this.hide)
},
destroyed() {
if (this.hide) this.hide()
this.el.removeEventListener("mouseenter", this.show)
this.el.removeEventListener("focus", this.show)
this.el.removeEventListener("mouseleave", this.hide)
this.el.removeEventListener("blur", this.hide)
this.el.removeEventListener("keydown", this.onKey)
this.el.removeEventListener("click", this.hide)
}
}
// SubfieldMenu hook: toggles a native Popover menu (top layer, so it escapes the
// overview table's overflow clipping) for choosing the composite Member column's
// sort sub-field. Attached to the "⋮" trigger; data-menu-id names the <ul
// popover> menu. Handles click / keyboard open, roving arrow-key focus,
// Escape / click-away close, and closes after a selection (the LiveView
// re-renders on sort_composite anyway). The menu and its hint are placed via CSS
// anchor positioning (see .popover-menu / .popover-tooltip in app.css), so this
// hook does no coordinate math — it only toggles visibility, focus and keyboard.
Hooks.SubfieldMenu = {
mounted() {
this.menu = document.getElementById(this.el.dataset.menuId)
if (!this.menu || typeof this.menu.showPopover !== "function") return
// Hover/focus hint on the trigger, shown while the menu is closed. Same
// .popover-tooltip look as the sort-header tooltips; placement is CSS anchor
// positioning (no coordinate math).
this.tip = document.getElementById(this.el.dataset.tooltipId)
// Only hint while the menu is closed.
this.showTip = () => {
if (!this.menu.matches(":popover-open")) openPopover(this.tip)
}
this.hideTip = () => closePopover(this.tip)
this.items = () => [...this.menu.querySelectorAll('[role="menuitemradio"]')]
// Placement is pure CSS anchor positioning (see `.popover-menu` in app.css);
// this hook only toggles visibility, focus and keyboard interaction.
this.open = ({focusFirst = true} = {}) => {
this.hideTip()
if (!this.menu.matches(":popover-open")) this.menu.showPopover()
this.el.setAttribute("aria-expanded", "true")
if (focusFirst) {
const active = this.menu.querySelector('[aria-checked="true"]') || this.items()[0]
if (active) active.focus()
}
}
this.close = ({focusTrigger = false} = {}) => {
if (this.menu.matches(":popover-open")) this.menu.hidePopover()
this.el.setAttribute("aria-expanded", "false")
if (focusTrigger) this.el.focus()
}
this.onTriggerClick = (e) => {
e.preventDefault()
e.stopPropagation()
this.menu.matches(":popover-open") ? this.close() : this.open()
}
this.onTriggerKey = (e) => {
if (["ArrowDown", "Enter", " "].includes(e.key)) {
e.preventDefault()
this.open()
} else if (e.key === "ArrowUp") {
e.preventDefault()
this.open({focusFirst: false})
const items = this.items()
if (items.length) items[items.length - 1].focus()
}
}
this.onMenuKey = (e) => {
const items = this.items()
const i = items.indexOf(document.activeElement)
if (e.key === "ArrowDown") {
e.preventDefault()
;(items[i + 1] || items[0]).focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
;(items[i - 1] || items[items.length - 1]).focus()
} else if (e.key === "Escape") {
e.preventDefault()
this.close({focusTrigger: true})
} else if (e.key === "Tab") {
this.close()
}
}
// A selection fires the LiveView sort; drop the menu.
this.onMenuClick = () => this.close()
this.onDocPointer = (e) => {
if (!this.el.contains(e.target) && !this.menu.contains(e.target)) this.close()
}
this.el.addEventListener("click", this.onTriggerClick)
this.el.addEventListener("keydown", this.onTriggerKey)
this.el.addEventListener("mouseenter", this.showTip)
this.el.addEventListener("focus", this.showTip)
this.el.addEventListener("mouseleave", this.hideTip)
this.el.addEventListener("blur", this.hideTip)
this.menu.addEventListener("keydown", this.onMenuKey)
this.menu.addEventListener("click", this.onMenuClick)
document.addEventListener("pointerdown", this.onDocPointer, true)
},
destroyed() {
this.el.removeEventListener("click", this.onTriggerClick)
this.el.removeEventListener("keydown", this.onTriggerKey)
this.el.removeEventListener("mouseenter", this.showTip)
this.el.removeEventListener("focus", this.showTip)
this.el.removeEventListener("mouseleave", this.hideTip)
this.el.removeEventListener("blur", this.hideTip)
this.hideTip()
if (this.menu) {
this.menu.removeEventListener("keydown", this.onMenuKey)
this.menu.removeEventListener("click", this.onMenuClick)
if (this.menu.matches(":popover-open")) this.menu.hidePopover()
}
document.removeEventListener("pointerdown", this.onDocPointer, true)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = {
mounted() {
@ -360,15 +592,84 @@ 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. This is the SOLE load
// trigger: the table deliberately carries no phx-viewport-bottom binding, whose
// built-in InfiniteScroll would scroll the sentinel row back into view after each
// load and cascade into loading every page. Duplicate loads are still harmless —
// 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()
}
}
// Reads a browser cookie value by name (used to echo the persisted member
// view-settings to the server on connect, since the live socket's connect-info
// does not expose cookies).
function getCookie(name) {
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"))
return match ? decodeURIComponent(match[1]) : null
}
// `params` is a function so it is re-evaluated on every channel join, not just
// at socket construction. This matters for the member view settings: a live
// navigation back to the overview re-mounts the LiveView on the same socket, so
// the cookie must be re-read at that point — a static params object would echo
// the stale value captured at initial page load (view settings would appear to
// reset until a hard reload).
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {
params: () => ({
_csrf_token: csrfToken,
timezone: getBrowserTimezone()
},
timezone: getBrowserTimezone(),
view_settings: getCookie("member_view_settings")
}),
hooks: Hooks
})
// Persist the member-overview view settings per browser/device (§1.5/§1.20). The
// LiveView pushes "store-view-settings" (density + compact field toggles) on
// change; we write a long-lived cookie that the LiveView reads back on the dead
// render (via the request cookie) and on every connected mount/join (via the
// connect params, re-evaluated per join — see the LiveSocket params function).
// 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
})
window.addEventListener("phx:store-view-settings", (e) => {
const json = e.detail && e.detail.view_settings
if (typeof json === "string" && json.length > 0) {
const maxAge = 365 * 24 * 60 * 60
document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax`
}
})
// Listen for custom events from LiveView
window.addEventListener("phx:set-input-value", (e) => {
const {id, value} = e.detail

View file

@ -48,6 +48,13 @@ defmodule Mv.Membership.CustomFieldValue do
table "custom_field_values"
repo Mv.Repo
custom_indexes do
# GIN index on the JSONB value to serve boolean custom-field containment
# predicates (`value @> '{"value": <bool>}'`) used by the member overview.
# Default jsonb_ops supports the `@>` operator we rely on.
index ["value"], name: "custom_field_values_value_gin_index", using: "gin"
end
references do
reference :member, on_delete: :delete
reference :custom_field, on_delete: :delete
@ -133,6 +140,16 @@ defmodule Mv.Membership.CustomFieldValue do
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
# Typed sort keys for DB-side ordering of the member overview by a custom
# field. The stored JSONB value (`{"type": ..., "value": ...}`) is cast per
# the field's value type so integers sort numerically and dates
# chronologically. Empty strings collapse to NULL so they sort last, matching
# the previous in-memory `CustomFieldSort` behaviour.
calculate :sort_text, :string, expr(fragment("nullif(?->>'value','')", value))
calculate :sort_numeric, :decimal, expr(fragment("nullif(?->>'value','')::numeric", value))
calculate :sort_date, :date, expr(fragment("nullif(?->>'value','')::date", value))
calculate :sort_boolean, :boolean, expr(fragment("nullif(?->>'value','')::boolean", value))
end
# Ensure a member can only have one custom field value per custom field

View file

@ -266,6 +266,18 @@ defmodule Mv.Membership.Member do
accept [:vereinfacht_contact_id]
end
# Keyset-paginated read for the member overview. Filters and sort are
# composed onto the query by `MvWeb.MemberLive.Index.OverviewQuery`; this
# action only enables keyset pagination with a unique `id` tie-breaker so
# pages never skip or duplicate rows.
read :overview do
# Distinct from the plain `:read` (`list_members`), which returns the full
# unpaginated set: `:overview` (`overview_members`) is keyset-paginated and
# left intentionally bare so `OverviewQuery` composes filters/sort onto it.
description "Keyset-paginated, externally-composed member overview read"
pagination keyset?: true, default_limit: 50, required?: false
end
# Action to handle fuzzy search on specific fields
read :search do
argument :query, :string, allow_nil?: true
@ -279,27 +291,7 @@ defmodule Mv.Membership.Member do
threshold =
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
q2_sanitized = sanitize_search_query(q2)
pat = "%" <> q2_sanitized <> "%"
# Build search filters grouped by search type for maintainability
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
fts_match = build_fts_filter(q2)
substring_match = build_substring_filter(q2_sanitized, pat)
custom_field_match = build_custom_field_filter(pat)
fuzzy_match = build_fuzzy_filter(q2, threshold)
query
|> Ash.Query.filter(
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
)
else
query
end
apply_fuzzy_search_filter(query, q, threshold)
end
end
@ -738,6 +730,13 @@ defmodule Mv.Membership.Member do
end
end
aggregates do
# Alphabetically-first group name, used to sort the overview by group DB-side.
# Mirrors the previous in-memory "first group name" sort key; members with no
# groups yield nil (sorted last ascending via NULLS LAST).
min :first_group_name, :groups, :name
end
# Define identities for upsert operations
identities do
identity :unique_email, [:email]
@ -1212,6 +1211,43 @@ defmodule Mv.Membership.Member do
end
end
@doc """
Applies the same fuzzy/full-text search filter as the `:search` action as a
plain filter on the given query, without switching the query's read action.
Used by the overview query builder so search composes with the `:overview`
keyset-paginated read action instead of replacing it.
"""
@spec apply_overview_search(Ash.Query.t(), String.t() | nil) :: Ash.Query.t()
def apply_overview_search(query, q) do
apply_fuzzy_search_filter(query, q || "", @default_similarity_threshold)
end
# Shared by the `:search` action and `apply_overview_search/2`.
defp apply_fuzzy_search_filter(query, q, threshold) do
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
q2_sanitized = sanitize_search_query(q2)
pat = "%" <> q2_sanitized <> "%"
# Build search filters grouped by search type for maintainability
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
fts_match = build_fts_filter(q2)
substring_match = build_substring_filter(q2_sanitized, pat)
custom_field_match = build_custom_field_filter(pat)
fuzzy_match = build_fuzzy_filter(q2, threshold)
Ash.Query.filter(
query,
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
)
else
query
end
end
# ============================================================================
# Search Input Sanitization
# ============================================================================

View file

@ -44,7 +44,10 @@ defmodule Mv.Membership do
resources do
resource Mv.Membership.Member do
define :create_member, action: :create_member
# `list_members` returns the full unpaginated set; `overview_members` is the
# keyset-paginated base for the member overview (composed by OverviewQuery).
define :list_members, action: :read
define :overview_members, action: :overview
define :update_member, action: :update_member
define :destroy_member, action: :destroy
end

View file

@ -0,0 +1,53 @@
defmodule Mv.MembershipFees.CycleEndBackfill do
@moduledoc """
One-time backfill for the denormalized `cycle_end` column on
`membership_fee_cycles`.
For every cycle whose `cycle_end` is still `NULL`, sets it to
`CalendarCycles.calculate_cycle_end(cycle_start, interval)` using the cycle's
own fee-type interval. Idempotent: rows that already have a `cycle_end` are
left untouched, so it is safe to re-run.
Invoked from the `cycle_end` migration. Kept as a module function (rather than
inline migration SQL) so the exact calendar arithmetic is reused from
`CalendarCycles` and can be tested directly.
"""
import Ecto.Query
alias Mv.MembershipFees.CalendarCycles
alias Mv.Repo
@doc """
Backfills `cycle_end` for all cycles missing it. Returns `{:ok, updated_count}`.
"""
@spec run() :: {:ok, non_neg_integer()}
def run do
rows =
from(c in "membership_fee_cycles",
join: t in "membership_fee_types",
on: c.membership_fee_type_id == t.id,
where: is_nil(c.cycle_end),
select: {c.id, c.cycle_start, t.interval}
)
|> Repo.all()
count =
Enum.reduce(rows, 0, fn {id, cycle_start, interval}, acc ->
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, normalize_interval(interval))
from(c in "membership_fee_cycles", where: c.id == ^id)
|> Repo.update_all(set: [cycle_end: cycle_end])
acc + 1
end)
{:ok, count}
end
# The raw query returns the enum column as a string.
defp normalize_interval(interval) when is_atom(interval), do: interval
defp normalize_interval(interval) when is_binary(interval),
do: String.to_existing_atom(interval)
end

View file

@ -13,7 +13,9 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
- `notes` - Optional notes for this cycle
## Design Decisions
- **No cycle_end field**: Calculated from cycle_start + interval (from fee type)
- **Denormalized cycle_end**: Set at create from cycle_start + interval (fee type).
Because the fee type interval and the cycle_start are immutable after create,
cycle_end never drifts, so it can be filtered/sorted in SQL.
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
@ -46,6 +48,14 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
create :create do
primary? true
accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
argument :interval, :atom do
allow_nil? true
description "Optional pre-resolved fee-type interval; when given, SetCycleEnd skips the fee-type lookup"
end
change Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd
end
update :update do
@ -106,6 +116,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
description "Start date of the billing cycle"
end
attribute :cycle_end, :date do
allow_nil? false
public? true
description "Denormalized end date of the cycle (cycle_start + interval), set at create"
end
attribute :amount, :decimal do
allow_nil? false
public? true

View file

@ -0,0 +1,68 @@
defmodule Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd do
@moduledoc """
Ash change that denormalizes `cycle_end` on a membership fee cycle at create time.
`cycle_end` is computed as `CalendarCycles.calculate_cycle_end(cycle_start, interval)`,
where `interval` is read from the cycle's `membership_fee_type`. Because a fee type's
`interval` is immutable and the cycle's `cycle_start` is never updated, the stored
`cycle_end` is fixed at create time and never drifts.
When the caller already knows the interval (e.g. the bulk cycle generator, which
resolves the fee type once for a whole batch), it may pass it via the `:interval`
create argument; the change then uses that value and issues no fee-type query,
avoiding an N+1 lookup across a batch of cycles.
Otherwise the fee-type interval is read with `authorize?: false`: it is an internal
lookup of immutable reference data needed to populate a derived column, not a
user-facing read, and cycle creation is itself a systemic operation. Reading it under
the caller's actor is unreliable here (the actor is not always present in the change
context, e.g. when a changeset is built with `for_create/3` and the actor is only
supplied to `Ash.create/2`).
When `cycle_start` or `membership_fee_type_id` are missing the changeset is returned
unchanged so the normal `allow_nil?` validations surface the error.
"""
use Ash.Resource.Change
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeType
@impl true
def change(changeset, _opts, _context) do
with {:ok, cycle_start} <- fetch(changeset, :cycle_start),
{:ok, fee_type_id} <- fetch(changeset, :membership_fee_type_id),
{:ok, interval} <- resolve_interval(changeset, fee_type_id) do
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
Ash.Changeset.force_change_attribute(changeset, :cycle_end, cycle_end)
else
_ -> changeset
end
end
defp fetch(changeset, field) do
case Ash.Changeset.fetch_change(changeset, field) do
{:ok, value} when not is_nil(value) ->
{:ok, value}
_ ->
case Map.get(changeset.data, field) do
nil -> :error
value -> {:ok, value}
end
end
end
defp resolve_interval(changeset, fee_type_id) do
case Ash.Changeset.get_argument(changeset, :interval) do
nil -> fetch_interval(fee_type_id)
interval -> {:ok, interval}
end
end
defp fetch_interval(fee_type_id) do
case Ash.get(MembershipFeeType, fee_type_id, authorize?: false) do
{:ok, %{interval: interval}} -> {:ok, interval}
_ -> :error
end
end
end

View file

@ -145,7 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_cycles(need_cycles)
|> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
@ -359,10 +359,10 @@ defmodule Mv.Membership.MemberExport.Build do
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, false), do: query
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
defp maybe_load_cycles(query, true) do
MembershipFeeStatus.load_cycles_for_members(query)
end
defp maybe_load_groups(query, false), do: query

View file

@ -305,7 +305,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount, opts)
create_cycles(cycle_starts, member.id, fee_type.id, amount, interval, opts)
else
{:ok, [], []}
end
@ -400,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, interval, opts) do
actor = get_actor(opts)
create_opts = Helpers.ash_actor_opts(actor)
@ -414,7 +414,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
member_id: member_id,
membership_fee_type_id: fee_type_id,
amount: amount,
status: :unpaid
status: :unpaid,
# Pass the already-resolved interval so SetCycleEnd does not re-fetch the
# fee type for every cycle in the batch (avoids an N+1 lookup).
interval: interval
}
handle_cycle_creation_result(

View file

@ -1,6 +1,6 @@
defmodule MvWeb.Components.BulkActionsDropdown do
@moduledoc """
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
Single "Actions" dropdown bundling the four member bulk actions, flattened to
one level: open in email program (mailto), copy email addresses, export to CSV,
export to PDF.
@ -10,10 +10,10 @@ defmodule MvWeb.Components.BulkActionsDropdown do
## Scope and trigger badge
The trigger reads `Aktionen` followed by a scope badge: an emphasized
The trigger reads the `Actions` label followed by a scope badge: an emphasized
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
badge otherwise `gefiltert` when a search term or filter narrows the list,
`alle` when nothing is selected and no search/filter is active. Only an actual
badge otherwise `filtered` when a search term or filter narrows the list,
`all` when nothing is selected and no search/filter is active. Only an actual
selection is emphasized. The badge sits inside the shared `dropdown_menu/1`
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
@ -36,11 +36,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do
## Event routing
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the
component owns its own `:open` state. The copy item carries an *un-targeted*
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's
`handle_event/3` (which keeps access to `@members`), plus the
`CopyToClipboard` hook.
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` *un-targeted*, so the
parent LiveView owns the `:open` state (passed back in via the `open` assign).
This lets the parent fetch the mailto recipients lazily when the dropdown opens
rather than on every selection/filter change. The copy item likewise carries an
un-targeted `phx-click="copy_emails"`, which reaches the parent's
`handle_event/3` (which keeps access to `@members`), plus the `CopyToClipboard`
hook.
"""
use MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext
@ -72,13 +74,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
# The parent never sets :open (the component owns it via toggle/close).
# Honouring an explicit :open assign keeps the component renderable in
# isolation (render_component/2) for structural tests.
# The parent owns :open and passes it in. Defaulting to false keeps the
# component renderable in isolation (render_component/2) for structural tests
# that omit it.
socket =
case Map.fetch(assigns, :open) do
{:ok, open} -> assign(socket, :open, open)
:error -> socket
:error -> assign_new(socket, :open, fn -> false end)
end
{:ok, socket}
@ -98,7 +100,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
button_label={gettext("Actions")}
icon="hero-bolt"
open={@open}
phx_target={@myself}
phx_target={nil}
menu_width="w-70"
menu_align="left"
button_class="btn-secondary gap-2"
@ -214,7 +216,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
gettext("Too many recipients for this function. Copy the addresses or export the list.")
end
# The trigger scope is shown as a badge after the "Aktionen" label. Only an
# The trigger scope is shown as a badge after the "Actions" label. Only an
# actual selection is emphasized (primary); both the "filtered" and "all"
# scopes are muted (neutral), since neither means members are selected.
defp scope_label(assigns) do
@ -232,12 +234,6 @@ defmodule MvWeb.Components.BulkActionsDropdown do
end
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
# Open/close are handled by the parent LiveView (see "Event routing"); the
# component no longer owns dropdown state.
end

View file

@ -143,12 +143,18 @@ defmodule MvWeb.CoreComponents do
attr :size, :string, values: ~w(sm md lg), default: "md"
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
attr :active, :boolean,
default: false,
doc: "Renders the button in its active/pressed (toggled-on) state"
slot :inner_block, required: true
def button(assigns) do
rest = assigns.rest
variant = assigns[:variant] || "primary"
size = assigns[:size] || "md"
active_class = if assigns[:active], do: "btn-active", else: ""
variant_classes = %{
"primary" => "btn-primary",
@ -169,7 +175,9 @@ defmodule MvWeb.CoreComponents do
base_class = Map.fetch!(variant_classes, variant)
size_class = size_classes[size]
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
btn_class =
[base_class, size_class, active_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
assigns =
assigns
@ -214,6 +222,27 @@ defmodule MvWeb.CoreComponents do
end
end
@doc """
Renders a loading spinner for inline "busy" states.
Wraps the DaisyUI `loading` classes so views never spell them out. Mark it
`aria-hidden="true"` when an adjacent text label already conveys the busy state.
## Examples
<.spinner />
<.spinner size="lg" aria-hidden="true" />
"""
attr :size, :string, values: ~w(xs sm md lg), default: "sm"
attr :class, :any, default: nil, doc: "Additional layout classes"
attr :rest, :global
def spinner(assigns) do
~H"""
<span class={["loading loading-spinner", "loading-#{@size}", @class]} {@rest}></span>
"""
end
@doc """
Renders a non-interactive badge with WCAG-compliant contrast.
@ -250,7 +279,7 @@ defmodule MvWeb.CoreComponents do
attr :size, :any,
default: "md",
doc: "Badge size: sm | md"
doc: "Badge size: xs | sm | md"
attr :sr_label, :string,
default: nil,
@ -269,7 +298,7 @@ defmodule MvWeb.CoreComponents do
variant_class = "badge-#{variant}"
style_class = badge_style_class(style)
size_class = "badge-#{size}"
size_class = badge_size_class(size)
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
outline_bg = if style == "outline", do: "bg-base-100", else: nil
@ -309,6 +338,13 @@ defmodule MvWeb.CoreComponents do
defp badge_style_class("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil
# Literal strings so Tailwind's content scanner sees badge-xs / badge-sm / badge-md / badge-lg
# and does not purge them from the CSS bundle (string interpolation "badge-#{size}" would be invisible).
defp badge_size_class("xs"), do: "badge-xs"
defp badge_size_class("sm"), do: "badge-sm"
defp badge_size_class("lg"), do: "badge-lg"
defp badge_size_class(_), do: "badge-md"
@doc """
Renders a visually empty table cell with screen-reader-only text (WCAG).
@ -443,6 +479,11 @@ defmodule MvWeb.CoreComponents do
attr :selected, :map, default: %{}
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
attr :show_reset_button, :boolean,
default: false,
doc: "Show a reset-to-default icon button next to All/None (emits reset_fields)"
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
@ -546,6 +587,31 @@ defmodule MvWeb.CoreComponents do
>
{gettext("None")}
</button>
<button
:if={@show_reset_button}
id={"#{@testid}-reset"}
type="button"
role="menuitem"
aria-label={gettext("Reset to default")}
aria-describedby={"#{@testid}-reset-tooltip"}
phx-click="reset_fields"
phx-target={@phx_target}
phx-hook="PopoverTooltip"
data-tooltip-id={"#{@testid}-reset-tooltip"}
class="btn btn-xs btn-ghost"
data-testid="dropdown-reset"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<div
:if={@show_reset_button}
id={"#{@testid}-reset-tooltip"}
popover="manual"
role="tooltip"
class="popover-tooltip"
>
{gettext("Reset to default")}
</div>
</div>
</div>
</li>
@ -938,6 +1004,11 @@ defmodule MvWeb.CoreComponents do
attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order"
attr :size_class, :string,
default: "",
doc:
"optional DaisyUI table-size class controlling row density (e.g. table-xs, table-md); driven by the view-settings density value"
attr :sticky_header, :boolean,
default: false,
doc:
@ -953,6 +1024,11 @@ defmodule MvWeb.CoreComponents do
doc:
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
attr :infinite_scroll, :boolean,
default: false,
doc:
"when true, renders the :footer slot as a full-width infinite-scroll sentinel row (typically only while more pages remain). The caller wires the actual load trigger via a hook in the slot (e.g. an IntersectionObserver sentinel). This component deliberately adds NO phx-viewport-bottom binding: LiveView's built-in InfiniteScroll scrolls the last child of that element into view after each load, which — on a sentinel/loading row — keeps it in view and cascades into loading every page. An IntersectionObserver sentinel has no such side effect."
slot :col, required: true do
attr :label, :string
attr :class, :string
@ -964,6 +1040,10 @@ defmodule MvWeb.CoreComponents do
slot :action, doc: "the slot for showing user actions in the last table column"
slot :footer,
doc:
"optional after-rows content rendered as a full-width row in a separate, non-streamed tbody. Combined with infinite_scroll it holds the infinite-scroll sentinel (a hook in the slot fires the load event, plus e.g. a loading indicator) and disappears once no more pages remain."
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
@ -986,6 +1066,15 @@ defmodule MvWeb.CoreComponents do
assigns =
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
# Total column span for full-width footer rows (e.g. the infinite-scroll
# loading indicator): fixed columns + dynamic custom-field columns + the
# optional action column.
col_count =
length(assigns.col) + length(assigns[:dynamic_cols] || []) +
if assigns.action != [], do: 1, else: 0
assigns = assign(assigns, :col_count, col_count)
~H"""
<div
id={@row_click && "#{@id}-keyboard"}
@ -993,20 +1082,31 @@ defmodule MvWeb.CoreComponents do
data-sticky-first-col-rows={@sticky_first_col && "true"}
phx-hook={@row_click && "TableRowKeydown"}
>
<table class="table table-zebra">
<table class={["table table-zebra", @size_class]}>
<thead>
<tr>
<th
:for={{col, col_idx} <- Enum.with_index(@col)}
class={[
table_th_class(col, @sticky_header),
@sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
@sticky_first_col && col_idx == 0 &&
"sticky-first-col-th sticky left-0 z-30 bg-base-100"
]}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
>
{col[:label]}
</th>
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
<th
:for={dyn_col <- @dynamic_cols}
class={table_th_sticky_class(@sticky_header)}
aria-sort={
table_th_aria_sort(
%{sort_field: "custom_field_#{dyn_col[:custom_field].id}"},
@sort_field,
@sort_order
)
}
>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
@ -1021,16 +1121,24 @@ defmodule MvWeb.CoreComponents do
</th>
</tr>
</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
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class={[
table_row_tr_class(
table_row_selected?(assigns, row),
@sticky_first_col
)
]}
class={
[
table_row_tr_class(
table_row_selected?(assigns, row),
@sticky_first_col
),
# WCAG 2.4.11: keep a keyboard-focused row from being hidden under
# the sticky header by reserving header-height scroll margin.
@sticky_header && "scroll-mt-16"
]
}
data-row-interactive={@row_click && "true"}
data-selected={table_row_selected?(assigns, row) && "true"}
title={@row_click && @row_tooltip}
@ -1103,19 +1211,49 @@ defmodule MvWeb.CoreComponents do
<td
:for={dyn_col <- @dynamic_cols}
phx-click={@row_click && @row_click.(row)}
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
class={[
"max-w-xs",
dyn_col[:custom_field].value_type != :boolean && "truncate",
@row_click && "hover:cursor-pointer"
]}
>
{if dyn_col[:render] do
rendered = dyn_col[:render].(@row_item.(row))
<%= if dyn_col[:custom_field].value_type == :boolean do %>
<% val = dyn_col[:render] && dyn_col[:render].(@row_item.(row)) %>
<%= cond do %>
<% val == true -> %>
<div class="flex justify-center">
<.icon
name="hero-check-circle"
class="size-4 text-success"
aria-hidden="true"
/>
<span class="sr-only">{gettext("Yes")}</span>
</div>
<% val == false -> %>
<div class="flex justify-center">
<.icon
name="hero-x-circle"
class="size-4 text-error"
aria-hidden="true"
/>
<span class="sr-only">{gettext("No")}</span>
</div>
<% true -> %>
<.empty_cell sr_text={gettext("Not specified")} />
<% end %>
<% else %>
{if dyn_col[:render] do
rendered = dyn_col[:render].(@row_item.(row))
if rendered == "" do
""
if rendered == "" do
""
else
rendered
end
else
rendered
end
else
""
end}
""
end}
<% end %>
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
@ -1126,6 +1264,23 @@ defmodule MvWeb.CoreComponents do
</td>
</tr>
</tbody>
<%!-- After-rows footer in its own, non-streamed tbody so it stays put
outside the phx-update="stream" container. When infinite_scroll is set it
holds the load sentinel (a hook lives in the :footer slot): rendered only
while more pages remain, so once the last page loads it disappears and the
sentinel stops firing. No phx-viewport-bottom here on purpose LiveView's
built-in InfiniteScroll would scroll this row into view after each load and
cascade into loading everything. --%>
<tbody
:if={@footer != [] && @infinite_scroll}
id={"#{@id}-footer"}
>
<tr>
<td colspan={@col_count}>
{render_slot(@footer)}
</td>
</tr>
</tbody>
</table>
</div>
"""
@ -1160,8 +1315,8 @@ defmodule MvWeb.CoreComponents do
end
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
# hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10)
# so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background.
# bg-base-100 + z-10 keep the sticky header opaque and above body rows on vertical scroll.
# Sort tooltips use the Popover API (top layer) and need no header z-lift.
defp table_th_class(col, sticky_header) do
base = Map.get(col, :class)
sticky = if sticky_header, do: sticky_th_classes(), else: nil
@ -1173,7 +1328,7 @@ defmodule MvWeb.CoreComponents do
defp table_th_sticky_class(_), do: nil
defp sticky_th_classes,
do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20"
do: "lg:sticky lg:top-0 bg-base-100 z-10"
@doc """
Renders a reorderable table (sortable list) with drag handle and keyboard support.

View file

@ -161,7 +161,7 @@ defmodule MvWeb.Layouts do
<div class="drawer-content flex flex-col relative z-0">
<!-- Mobile Header (only visible on mobile) -->
<header class="lg:hidden sticky top-0 z-10 navbar bg-base-100 shadow-sm">
<header class="lg:hidden sticky top-0 z-40 navbar bg-base-100 shadow-sm">
<label
for="mobile-drawer"
class="btn btn-square btn-ghost"

View file

@ -294,7 +294,7 @@ defmodule MvWeb.MemberExportController do
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_cycles(need_cycles)
|> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
@ -338,10 +338,10 @@ defmodule MvWeb.MemberExportController do
end
end
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, false), do: query
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
defp maybe_load_cycles(query, true) do
MembershipFeeStatus.load_cycles_for_members(query)
end
defp maybe_load_groups(query, false), do: query

View file

@ -69,12 +69,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
<.dropdown_menu
id="field-visibility-menu"
icon="hero-adjustments-horizontal"
button_label={gettext("Show/Hide Columns")}
button_label={gettext("Columns")}
items={@all_items}
checkboxes={true}
selected={@selected_fields}
open={@open}
show_select_buttons={true}
show_reset_button={true}
phx_target={@myself}
/>
</div>
@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
{:noreply, assign(socket, :selected_fields, all)}
end
# reset to the curated default column set (the parent owns the settings needed
# to compute the default, so it recomputes and re-applies the selection)
def handle_event("reset_fields", _params, socket) do
send(self(), {:fields_reset})
{:noreply, socket}
end
# select none
def handle_event("select_none", _params, socket) do
none =

View file

@ -0,0 +1,143 @@
defmodule MvWeb.Components.MemberNameSortHeader do
@moduledoc """
Header for the composite "Member" overview column (§1.21 / U3).
The column shows the full name (and, when folded in, the email), so a plain
"sort by X" is ambiguous. This header keeps the familiar one-click sort of the
other columns a click toggles the direction of the currently active sub-field
(last name by default) and adds a small sub-field menu (the "" button) to
switch between sorting by last name and first name. The active sub-field is
shown next to the direction chevron only while this column is the active sort,
so the header stays uncluttered otherwise but is never ambiguous when it does
sort.
The sub-field menu is rendered as a native Popover (top layer) so it escapes
the overview table's overflow clipping; the `SubfieldMenu` JS hook toggles and
positions it. Menu items emit `sort_composite` (always ascending by the chosen
field); the header click emits `sort` (toggles direction of the active field).
"""
use MvWeb, :html
@subfields [:first_name, :last_name]
attr :sort_field, :any, required: true, doc: "current sort field (atom or string)"
attr :sort_order, :atom, required: true, doc: ":asc or :desc"
def composite_name_sort_header(assigns) do
active = active_subfield(assigns.sort_field)
assigns =
assigns
|> assign(:active, active)
|> assign(:subfields, @subfields)
# A click on the header sorts by the active sub-field (toggling direction);
# when this column is not the active sort it starts from last name.
|> assign(:click_field, active || :last_name)
~H"""
<div class="flex items-center gap-1">
<button
id="sort-btn-member"
type="button"
phx-click="sort"
phx-value-field={@click_field}
aria-describedby="sort-tooltip-member"
phx-hook="PopoverTooltip"
data-tooltip-id="sort-tooltip-member"
class="link link-hover no-underline flex items-center gap-1 text-left text-current select-none"
data-testid="name"
>
{gettext("Member")}
<span :if={@active} class="opacity-70 font-normal">
· {subfield_label(@active)}
</span>
<.icon
:if={@active && @sort_order == :asc}
name="hero-chevron-up"
class="sort-icon"
/>
<.icon
:if={@active && @sort_order == :desc}
name="hero-chevron-down"
class="sort-icon"
/>
<.icon :if={!@active} name="hero-chevron-up-down" class="sort-icon opacity-40" />
</button>
<div id="sort-tooltip-member" popover="manual" role="tooltip" class="popover-tooltip">
{tooltip_text(@active)}
</div>
<button
id="member-subfield-btn"
type="button"
style="anchor-name: --member-sort-anchor"
aria-label={gettext("Choose sort field")}
aria-describedby="member-subfield-tooltip"
aria-haspopup="menu"
aria-controls="member-subfield-menu"
aria-expanded="false"
phx-hook="SubfieldMenu"
data-menu-id="member-subfield-menu"
data-tooltip-id="member-subfield-tooltip"
class="btn btn-ghost btn-xs btn-square"
data-testid="member-sort-subfield"
>
<.icon name="hero-ellipsis-vertical" class="size-4" />
</button>
<div
id="member-subfield-tooltip"
popover="manual"
role="tooltip"
style="position-anchor: --member-sort-anchor; position-area: bottom span-left; inset: auto"
class="popover-tooltip"
>
{gettext("Choose sort field")}
</div>
<ul
id="member-subfield-menu"
popover="manual"
role="menu"
aria-label={gettext("Sort members by")}
style="position-anchor: --member-sort-anchor"
class="popover-menu w-44 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li role="none" class="mb-1 px-2">
<span class="font-semibold">{gettext("Sort by")}</span>
</li>
<li role="separator" class="divider my-1"></li>
<li :for={field <- @subfields} role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@active == field)}
phx-click="sort_composite"
phx-value-field={field}
class="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1 text-left font-normal hover:bg-base-200"
data-testid={"member-sort-#{field}"}
>
<.icon
name="hero-check"
class={"size-4 text-primary #{if @active != field, do: "invisible"}"}
/>
{subfield_label(field)}
</button>
</li>
</ul>
</div>
"""
end
# Which sub-field this column currently sorts by, or nil when another column is
# the active sort. Accepts atoms or the string form that arrives via the URL.
defp active_subfield(sort_field) do
Enum.find(@subfields, fn f -> to_string(sort_field) == Atom.to_string(f) end)
end
defp subfield_label(:first_name), do: gettext("First name")
defp subfield_label(:last_name), do: gettext("Last name")
# The header already shows the active sub-field and direction, so the tooltip
# only needs to convey the click affordance.
defp tooltip_text(nil), do: gettext("Click to sort by last name")
defp tooltip_text(_active), do: gettext("Click to reverse the sort direction")
end

View file

@ -49,6 +49,17 @@ defmodule MvWeb.Components.SearchBarComponent do
phx-target={@myself}
phx-debounce="300"
/>
<button
:if={@query not in [nil, ""]}
type="button"
phx-click="clear_search"
phx-target={@myself}
data-testid="search-clear"
aria-label={gettext("Clear search")}
class="opacity-50 hover:opacity-100 cursor-pointer"
>
<.icon name="hero-x-mark" class="h-[1em]" />
</button>
</label>
</form>
"""
@ -61,4 +72,10 @@ defmodule MvWeb.Components.SearchBarComponent do
send(self(), {:search_changed, q})
{:noreply, assign(socket, :query, q)}
end
# Clears the query and resets the result set to the unfiltered list (§1.3).
def handle_event("clear_search", _params, socket) do
send(self(), {:search_changed, ""})
{:noreply, assign(socket, :query, "")}
end
end

View file

@ -6,41 +6,57 @@ defmodule MvWeb.Components.SortHeaderComponent do
- label: string() # Column Heading (can be an heex template)
- sort_field: atom() | nil # current sort field from parent liveview
- sort_order: :asc | :desc | nil # current sorting order
- sort_hint: string() | nil # optional; overrides the generic "click
# to sort" tooltip/aria-label for columns
# whose sort key is non-obvious (e.g. the
# Name column sorts by last name)
"""
use MvWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
{:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)}
end
# Check if we can add the aria-sort label directly to the daisyUI header
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:tooltip_id, "sort-tooltip-#{assigns.field}")
|> assign(:hint, assigns.sort_hint || gettext("Click to sort"))
~H"""
<div>
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
<.button
type="button"
variant="ghost"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="select-none"
phx-click="sort"
phx-value-field={@field}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<% else %>
<.icon
name="hero-chevron-up-down"
class="opacity-40"
/>
<% end %>
</.button>
</.tooltip>
<button
id={"sort-btn-#{@field}"}
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order, @hint)}
aria-describedby={@tooltip_id}
class="link link-hover no-underline w-full flex items-center gap-1 text-left text-current select-none"
phx-click="sort"
phx-value-field={@field}
phx-hook="PopoverTooltip"
data-tooltip-id={@tooltip_id}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
class="sort-icon"
/>
<% else %>
<.icon
name="hero-chevron-up-down"
class="sort-icon opacity-40"
/>
<% end %>
</button>
<div id={@tooltip_id} popover="manual" role="tooltip" class="popover-tooltip">
{aria_sort(@field, @sort_field, @sort_order, @hint)}
</div>
</div>
"""
end
@ -48,13 +64,13 @@ defmodule MvWeb.Components.SortHeaderComponent do
# -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG
# -------------------------------------------------
defp aria_sort(field, sort_field, dir) when field == sort_field do
defp aria_sort(field, sort_field, dir, hint) when field == sort_field do
case dir do
:asc -> gettext("ascending")
:desc -> gettext("descending")
nil -> gettext("Click to sort")
nil -> hint
end
end
defp aria_sort(_, _, _), do: gettext("Click to sort")
defp aria_sort(_, _, _, hint), do: hint
end

View file

@ -0,0 +1,130 @@
defmodule MvWeb.Components.ViewSettingsDropdownComponent do
@moduledoc """
LiveComponent for the member-overview view settings (§1.20).
Renders an icon button with a tooltip that opens a dropdown of toggles:
* Compact mode table row density (compact comfortable)
* Compact Member field composite last+first name cell, with a sub-toggle
"include email"
* Compact address field composite street / postal+city cell
Turning a composite field off surfaces the underlying separate columns in the
table. The component is display-only: it emits `{:view_setting_toggled, key}`
to the parent LiveView, which owns and persists the settings.
## Props
- `:density` `:compact` or `:comfortable`
- `:compact_member` boolean
- `:member_include_email` boolean
- `:compact_address` boolean
- `:id` component id
"""
use MvWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:open, fn -> false end)}
end
@impl true
def render(assigns) do
~H"""
<div>
<.dropdown_menu
id="view-settings-menu"
icon="hero-cog-6-tooth"
button_label={gettext("View")}
open={@open}
phx_target={@myself}
menu_width="w-72"
testid="view-settings"
button_testid="view-settings-button"
>
<li role="none" class="mb-1 px-2">
<span class="font-semibold">{gettext("View settings")}</span>
</li>
<li role="separator" class="divider my-1"></li>
<.toggle_item
setting="density"
label={gettext("Compact mode")}
checked={@density == :compact}
target={@myself}
/>
<.toggle_item
setting="compact_member"
label={gettext("Compact Member field")}
checked={@compact_member}
target={@myself}
/>
<%!-- The email line lives inside the composite Member cell, so this
sub-toggle is only meaningful while that cell is compact. When the
composite is off, email is its own column and this toggle is omitted. --%>
<li :if={@compact_member} role="none" class="pl-6">
<.toggle_item
setting="member_include_email"
label={gettext("include email")}
checked={@member_include_email}
target={@myself}
/>
</li>
<.toggle_item
setting="compact_address"
label={gettext("Compact address field")}
checked={@compact_address}
target={@myself}
/>
</.dropdown_menu>
</div>
"""
end
attr :setting, :string, required: true
attr :label, :string, required: true
attr :checked, :boolean, required: true
attr :target, :any, required: true
defp toggle_item(assigns) do
~H"""
<button
type="button"
role="menuitemcheckbox"
aria-checked={to_string(@checked)}
aria-label={@label}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded w-full text-left cursor-pointer hover:bg-base-200"
phx-click="toggle"
phx-value-setting={@setting}
phx-target={@target}
data-testid={"view-setting-#{String.replace(@setting, "_", "-")}"}
>
<span class={if @checked, do: "text-primary", else: "text-base-300"} aria-hidden="true">
<.icon name="hero-check" class="size-4 shrink-0" />
</span>
<span>{@label}</span>
</button>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
def handle_event("toggle", %{"setting" => setting}, socket) do
send(self(), {:view_setting_toggled, String.to_existing_atom(setting)})
{:noreply, socket}
end
end

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@
<.live_component
module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown"
open={@bulk_actions_open}
export_payload_json={@export_payload_json}
selected_count={@selected_count}
scope={@scope}
@ -39,7 +40,7 @@
boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
member_count={length(@members)}
member_count={@total_count}
/>
<.tooltip
content={
@ -52,7 +53,9 @@
<.button
type="button"
variant="secondary"
class={["gap-2", @show_current_cycle && "btn-active"]}
class="gap-2"
active={@show_current_cycle}
aria-pressed={to_string(@show_current_cycle)}
phx-click="toggle_cycle_view"
data-testid="toggle-cycle-view"
aria-label={
@ -71,13 +74,36 @@
</span>
</.button>
</.tooltip>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<div class="ml-auto flex items-center gap-4">
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<.live_component
module={MvWeb.Components.ViewSettingsDropdownComponent}
id="view-settings-dropdown"
density={@density}
compact_member={@compact_member}
member_include_email={@member_include_email}
compact_address={@compact_address}
/>
</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. --%>
@ -88,49 +114,94 @@
data-testid="members-table-scroll"
role="region"
aria-label={gettext("Members table")}
aria-busy={to_string(@loading?)}
data-density={@density}
tabindex="0"
>
<.table
id="members"
rows={@members}
rows={@streams.members}
row_item={fn {_dom_id, member} -> member end}
wrapper_overflow_class="overflow-visible"
sticky_header={true}
sticky_first_col={true}
row_id={fn member -> "row-#{member.id}" end}
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
infinite_scroll={@more?}
row_click={
fn {_dom_id, member} ->
JS.push("select_row_and_navigate", value: %{id: member.id})
end
}
row_tooltip={gettext("Click for member details")}
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
<:col
:let={member}
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
label={
~H"""
<.input
<input
type="checkbox"
id="select-all-checkbox"
name="select_all"
class="checkbox checkbox-sm"
phx-click="select_all"
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
phx-hook="IndeterminateCheckbox"
data-indeterminate={to_string(@selected_count > 0 and @selected_count < @total_count)}
checked={@total_count > 0 and @selected_count == @total_count}
aria-label={gettext("Select all members")}
role="checkbox"
/>
"""
}
>
<.input
<input
type="checkbox"
name={member.id}
class="checkbox checkbox-sm"
checked={MapSet.member?(@selected_members, member.id)}
aria-label={gettext("Select member")}
role="checkbox"
/>
</:col>
<:col
:let={member}
:if={:name in @member_fields_visible and @compact_member}
sort_field={if @sort_field in [:first_name, :last_name], do: @sort_field}
label={
~H"""
<MvWeb.Components.MemberNameSortHeader.composite_name_sort_header
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<% full_name =
[member.first_name, member.last_name]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %>
<div data-testid="member-name">
<div class="font-medium truncate" title={full_name}>
{full_name}
</div>
<div
:if={@member_include_email and member.email not in [nil, ""]}
data-testid="member-name-email"
class="opacity-70 truncate"
title={member.email}
>
{member.email}
</div>
</div>
</:col>
<:col
:let={member}
:if={:first_name in @member_fields_visible}
sort_field={:first_name}
label={
~H"""
<.live_component
@ -149,6 +220,7 @@
<:col
:let={member}
:if={:last_name in @member_fields_visible}
sort_field={:last_name}
label={
~H"""
<.live_component
@ -167,6 +239,7 @@
<:col
:let={member}
:if={:email in @member_fields_visible}
sort_field={:email}
label={
~H"""
<.live_component
@ -182,9 +255,127 @@
>
{member.email}
</:col>
<:col
:let={member}
:if={:address in @member_fields_visible and @compact_address}
sort_field={:address}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_address}
field={:address}
label={gettext("Address")}
sort_field={@sort_field}
sort_order={@sort_order}
sort_hint={gettext("Click to sort by city")}
/>
"""
}
>
<% line1 =
[member.street, member.house_number]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %>
<% line2 =
[member.postal_code, member.city]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %>
<.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}>
<div data-testid="member-address">
<div class="truncate" title={line1}>{line1}</div>
<div class="opacity-70 truncate" title={line2}>{line2}</div>
</div>
</.maybe_value>
</:col>
<:col
:let={member}
:if={:street in @member_fields_visible}
sort_field={:street}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}>
{member.street}
</.maybe_value>
</:col>
<:col
:let={member}
:if={:house_number in @member_fields_visible}
sort_field={:house_number}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}>
{member.house_number}
</.maybe_value>
</:col>
<:col
:let={member}
:if={:postal_code in @member_fields_visible}
sort_field={:postal_code}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}>
{member.postal_code}
</.maybe_value>
</:col>
<:col
:let={member}
:if={:city in @member_fields_visible}
sort_field={:city}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}>
{member.city}
</.maybe_value>
</:col>
<:col
:let={member}
:if={:join_date in @member_fields_visible}
sort_field={:join_date}
label={
~H"""
<.live_component
@ -203,6 +394,7 @@
<:col
:let={member}
:if={:exit_date in @member_fields_visible}
sort_field={:exit_date}
label={
~H"""
<.live_component
@ -228,6 +420,7 @@
<:col
:let={member}
:if={:country in @member_fields_visible}
sort_field={:country}
label={
~H"""
<.live_component
@ -243,81 +436,10 @@
>
{member.country}
</:col>
<:col
:let={member}
:if={:city in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.city}
</:col>
<:col
:let={member}
:if={:street in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.street}
</:col>
<:col
:let={member}
:if={:house_number in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.house_number}
</:col>
<:col
:let={member}
:if={:postal_code in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.postal_code}
</:col>
<:col
:let={member}
:if={:membership_fee_start_date in @member_fields_visible}
sort_field={:membership_fee_start_date}
label={
~H"""
<.live_component
@ -336,6 +458,7 @@
<:col
:let={member}
:if={:membership_fee_type in @member_fields_visible}
sort_field={:membership_fee_type}
label={
~H"""
<.live_component
@ -372,6 +495,7 @@
<:col
:let={member}
:if={:groups in @member_fields_visible}
sort_field={:groups}
label={
~H"""
<.live_component
@ -386,15 +510,17 @@
}
>
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
<%= for group <- (member.groups || []) do %>
<.badge
variant="primary"
style="outline"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.badge>
<% end %>
<div class="flex flex-wrap gap-1 w-max">
<%= for group <- (member.groups || []) do %>
<.badge
variant="primary"
style="outline"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.badge>
<% end %>
</div>
</.maybe_value>
</:col>
<:action :let={member}>
@ -404,6 +530,37 @@
</.link>
</div>
</: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>
<%!-- The accent (background + top separator) and row height live on the
full-width footer cell (see #members-footer in app.css) so they span the
whole table edge-to-edge and match the active density. This inner element
stays pinned to the visible viewport width via StickyViewportWidth and is
transparent, so the cell accent shows through while the spinner/label stay
centered during horizontal scroll. --%>
<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 text-base-content/70"
data-testid="members-loading-row"
>
<.spinner size="sm" aria-hidden="true" />
{gettext("Loading more members …")}
</div>
</:footer>
</.table>
</div>
</Layouts.app>

View file

@ -0,0 +1,28 @@
defmodule MvWeb.MemberLive.Index.Cookie do
@moduledoc """
Shared cookie-header parsing for the member-overview persistence chain.
Both `MvWeb.MemberLive.Index.ViewSettings` and
`MvWeb.MemberLive.Index.FieldSelection` read their persisted value from the
raw request `Cookie:` header on the disconnected mount, so the parsing lives
here once.
"""
@doc """
Parses a raw `Cookie:` request-header string into a `%{name => value}` map,
URL-decoding each value. A valueless entry (`"key"`) maps to `""`; malformed
segments are skipped.
"""
@spec parse_header(String.t()) :: %{String.t() => String.t()}
def parse_header(cookie_header) when is_binary(cookie_header) do
cookie_header
|> String.split(";")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Enum.reduce(%{}, fn
[key, value], acc -> Map.put(acc, key, URI.decode(value))
[key], acc -> Map.put(acc, key, "")
_, acc -> acc
end)
end
end

View file

@ -271,6 +271,54 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
end
end
@doc """
DB-side equivalent of the custom-date portion of `apply_in_memory/3`.
For each active custom-date filter, adds a predicate keeping members that have
a stored date value for the field within the inclusive `[from, to]` bounds.
The value is read from the JSONB union (`value->>'type' = 'date'`, then
`(value->>'value')::date`). Members without a date value row for the field are
excluded, mirroring the in-memory behaviour exactly.
"""
@spec apply_custom_date_ash_filter(Ash.Query.t(), map(), [map()]) :: Ash.Query.t()
def apply_custom_date_ash_filter(%Ash.Query{} = query, filters, date_custom_fields)
when is_map(filters) and is_list(date_custom_fields) do
filters
|> active_custom_date_filters(date_custom_fields)
|> Enum.reduce(query, fn {id, bounds}, q -> apply_one_custom_date_filter(q, id, bounds) end)
end
defp apply_one_custom_date_filter(query, id, %{from: from, to: to}) do
case Ecto.UUID.cast(id) do
{:ok, uuid} -> filter_custom_date(query, uuid, from, to)
_ -> query
end
end
defp filter_custom_date(query, uuid, from, to) do
Ash.Query.filter(
query,
expr(
exists(
custom_field_values,
custom_field_id == ^uuid and
fragment("?->>'type' = 'date'", value) and
^custom_date_bounds(from, to)
)
)
)
end
defp custom_date_bounds(nil, to), do: expr(fragment("(?->>'value')::date <= ?", value, ^to))
defp custom_date_bounds(from, nil), do: expr(fragment("(?->>'value')::date >= ?", value, ^from))
defp custom_date_bounds(from, to) do
expr(
fragment("(?->>'value')::date >= ?", value, ^from) and
fragment("(?->>'value')::date <= ?", value, ^to)
)
end
@doc """
Returns the UUID string keys of `filters` that name an active (at-least-one-
bound-set) custom date field. The UUID must appear in `date_custom_fields`

View file

@ -0,0 +1,190 @@
defmodule MvWeb.MemberLive.Index.ExportPayload do
@moduledoc """
Builds the member-overview export descriptor from the LiveView assigns.
The overview hands this payload (as JSON) to the export flow so a CSV export
reproduces exactly the columns, order, filters, and sort the user currently
sees. It is pure: it reads the overview assigns and returns a plain map; the
LiveView owns encoding and assigning it.
Column order mirrors the rendered table:
* DB member fields in `Mv.Constants.member_fields/0` order;
* `membership_fee_type` / `membership_fee_status` inserted right after
`membership_fee_start_date` when visible;
* `groups` appended before the custom fields when visible;
* custom fields in the table's custom-field order.
"""
alias MvWeb.MemberLive.Index.FieldVisibility
@doc """
Builds the export descriptor map from the overview assigns.
"""
@spec build(map()) :: map()
def build(assigns) do
visible_custom_field_ids = assigns[:visible_custom_field_ids] || []
member_fields_db = assigns[:member_fields_visible_db] || []
member_fields_computed = assigns[:member_fields_visible_computed] || []
member_fields_visible = assigns[:member_fields_visible] || []
# The composite Name/Address cells have no DB column of their own, so they are
# not in `member_fields_visible_db` — and their constituents are hidden from
# it precisely because the composite replaces them in the table. When a
# composite is the visible variant, export its constituent DB columns instead
# so the CSV/PDF still carries name and address, split into components.
composite_constituents = composite_export_constituents(member_fields_visible, assigns)
# Order DB member fields exactly like the table/constants.
ordered_member_fields_db =
Mv.Constants.member_fields()
|> Enum.filter(&(&1 in member_fields_db or &1 in composite_constituents))
# Order computed fields in canonical order.
ordered_computed_fields =
FieldVisibility.computed_member_fields()
|> Enum.filter(&(&1 in member_fields_computed))
member_fields_with_groups =
build_member_fields_list(ordered_member_fields_db, member_fields_visible)
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order).
ordered_custom_field_ids =
assigns.all_custom_fields
|> Enum.map(&to_string(&1.id))
|> Enum.filter(&(&1 in visible_custom_field_ids))
%{
selected_ids: assigns.selected_members |> MapSet.to_list(),
member_fields:
Enum.map(member_fields_with_groups, fn
f when is_atom(f) -> Atom.to_string(f)
f when is_binary(f) -> f
end),
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
custom_field_ids: ordered_custom_field_ids,
column_order:
column_order(
ordered_member_fields_db,
ordered_computed_fields,
ordered_custom_field_ids,
:membership_fee_type in member_fields_visible,
:groups in member_fields_visible
),
query: assigns[:query] || nil,
sort_field: sort_field(assigns[:sort_field]),
sort_order: sort_order(assigns[:sort_order]),
show_current_cycle: assigns[:show_current_cycle] || false,
cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]),
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
}
end
@doc "Serializes a sort field (atom or string) to its export string, or nil."
@spec sort_field(atom() | String.t() | nil) :: String.t() | nil
def sort_field(nil), do: nil
def sort_field(f) when is_atom(f), do: Atom.to_string(f)
def sort_field(f) when is_binary(f), do: f
@doc "Serializes a sort order (atom or string) to its export string, or nil."
@spec sort_order(atom() | String.t() | nil) :: String.t() | nil
def sort_order(nil), do: nil
def sort_order(:asc), do: "asc"
def sort_order(:desc), do: "desc"
def sort_order(o) when is_binary(o), do: o
defp cycle_status_filter(nil), do: nil
defp cycle_status_filter(:paid), do: "paid"
defp cycle_status_filter(:unpaid), do: "unpaid"
defp cycle_status_filter(_), do: nil
# DB constituent columns to add for whichever composite cells are currently
# visible. Email is a name constituent only while folded into the compact
# Member cell (`member_include_email`); otherwise it is exported as its own
# visible column and needs no expansion here.
defp composite_export_constituents(member_fields_visible, assigns) do
name =
if :name in member_fields_visible do
FieldVisibility.name_constituents(assigns[:member_include_email] || false)
else
[]
end
address =
if :address in member_fields_visible,
do: FieldVisibility.address_constituents(),
else: []
name ++ address
end
defp build_member_fields_list(ordered_db, member_fields_visible) do
member_fields_visible = member_fields_visible || []
with_extras =
Enum.flat_map(ordered_db, fn f ->
if f == :membership_fee_start_date and :membership_fee_type in member_fields_visible do
[f, :membership_fee_type]
else
[f]
end
end)
# If fee type is visible but start_date was not in the list, append it.
with_extras =
if :membership_fee_type in member_fields_visible and
:membership_fee_type not in with_extras do
with_extras ++ [:membership_fee_type]
else
with_extras
end
if :groups in member_fields_visible, do: with_extras ++ [:groups], else: with_extras
end
defp expand_db_string(f, membership_fee_type_visible, computed_strings) do
if f == "membership_fee_start_date" do
extra =
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
[f] ++ extra
else
[f]
end
end
defp column_order(
ordered_member_fields_db,
ordered_computed_fields,
ordered_custom_field_ids,
membership_fee_type_visible,
groups_visible
) do
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present.
db_with_extras =
Enum.flat_map(
db_strings,
&expand_db_string(&1, membership_fee_type_visible, computed_strings)
)
# If fee type is visible but start_date was not in the list, append it before computed/groups.
db_with_extras =
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
db_with_extras ++ ["membership_fee_type"]
else
db_with_extras
end
# Any remaining computed fields not inserted above (future-proof).
remaining_computed = Enum.reject(computed_strings, &(&1 in db_with_extras))
result = db_with_extras ++ remaining_computed
result = if groups_visible, do: result ++ ["groups"], else: result
result ++ ordered_custom_field_ids
end
end

View file

@ -29,6 +29,8 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
"""
alias MvWeb.MemberLive.Index.Cookie
@cookie_name "member_field_selection"
@cookie_max_age 365 * 24 * 60 * 60
@session_key "member_field_selection"
@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
%{}
[cookie_header | _rest] ->
cookies = parse_cookie_header(cookie_header)
cookies = Cookie.parse_header(cookie_header)
case Map.get(cookies, @cookie_name) do
nil -> %{}
@ -90,19 +92,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
end
end
# Parses cookie header string into a map
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
cookie_header
|> String.split(";")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Enum.reduce(%{}, fn
[key, value], acc -> Map.put(acc, key, URI.decode(value))
[key], acc -> Map.put(acc, key, "")
_, acc -> acc
end)
end
@doc """
Saves field selection to cookie.

View file

@ -29,51 +29,197 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
# :name and :address are composite display-only columns (Name = name + email,
# Address = street/house number over postal code/city).
# Order mirrors the overview table / export column order (fee type before fee
# status), so the Columns dropdown and the table agree.
@pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address]
# The composite display columns and the constituent columns they replace. The
# column manager offers exactly one variant per group, selected by the view
# settings: the composite when the matching "compact" setting is on, otherwise
# the constituents (see `offered_member_fields/2`).
@name_composite :name
@name_constituents [:first_name, :last_name, :email]
@address_composite :address
@address_constituents [:street, :house_number, :postal_code, :city]
# Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status
# Curated default-visible column set (§1.2): the columns shown on first visit
# when there is no persisted selection and no global override. Everything else
# (the individual name/address sub-fields, notes, dates other than join date)
# defaults to hidden so the table fits common desktop widths.
@default_visible_fields MapSet.new([
:name,
:address,
:membership_fee_type,
:membership_fee_status,
:groups,
:join_date
])
# The curated default-visible column set for the given view settings. In
# compact mode the composite `:name` / `:address` columns are default; when a
# composite is switched off, its constituent columns take over as the defaults
# instead (§7b). While the Member composite is compact, `include_email` decides
# where the email lives: folded into the Member cell (`true`, no separate
# column) or as a default-visible `:email` column (`false`).
defp default_visible_set(compact_member, include_email, compact_address) do
@default_visible_fields
|> apply_name_default(compact_member, include_email)
|> apply_group_default(compact_address, @address_composite, @address_constituents)
end
# Compact Member cell with email folded in: only the composite is default.
defp apply_name_default(set, true = _compact, true = _include_email), do: set
# Compact Member cell without folded email: the composite plus a separate
# default-visible email column.
defp apply_name_default(set, true = _compact, false = _include_email),
do: MapSet.put(set, :email)
# Non-compact: drop the composite and make first/last name and email default.
defp apply_name_default(set, false = _compact, _include_email) do
set
|> MapSet.delete(@name_composite)
|> MapSet.union(MapSet.new(@name_constituents))
end
# Compact: the composite stays the default. Non-compact: drop the composite and
# make the constituents default-visible.
defp apply_group_default(set, true, _composite, _constituents), do: set
defp apply_group_default(set, false, composite, constituents) do
set
|> MapSet.delete(composite)
|> MapSet.union(MapSet.new(constituents))
end
@doc """
Member-field atoms the column manager offers for the given view settings, in
dropdown/table order.
The identity block always leads, regardless of whether each group is compact:
1. the name group the composite `:name` when compact, otherwise its
constituents `:first_name`, `:last_name`;
2. the `:email` column, when offered as a separate column (it is folded into
the Member cell only while the composite is compact and `include_email` is
on);
3. the address group the composite `:address` when compact, otherwise its
constituents `:street`, `:house_number`, `:postal_code`, `:city`.
Everything else follows in the configured data-fields order. This keeps the
name group ahead of the address group even when the name group is expanded
into `:first_name`/`:last_name` while the address stays compact.
"""
@spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()]
def offered_member_fields(compact_member, include_email, compact_address) do
name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name]
email_group = if email_offered?(compact_member, include_email), do: [:email], else: []
address_group =
if compact_address, do: [@address_composite], else: @address_constituents
leading = name_group ++ email_group ++ address_group
rest =
Enum.reject(overview_member_fields(), fn field ->
field == @export_only_alias or
hidden_variant?(field, compact_member, include_email, compact_address) or
field in leading
end)
leading ++ rest
end
@doc """
The DB constituent columns the composite `:name` cell stands in for, in
canonical order. `:email` is a constituent only while it is folded into the
compact Member cell (`include_email?` true); otherwise it is its own column.
"""
@spec name_constituents(boolean()) :: [atom()]
def name_constituents(include_email?) do
if include_email?, do: @name_constituents, else: @name_constituents -- [:email]
end
@doc "The DB constituent columns the composite `:address` cell stands in for, in canonical order."
@spec address_constituents() :: [:street | :house_number | :postal_code | :city, ...]
def address_constituents, do: @address_constituents
# The email is a separate offered column except when it is folded into the
# compact Member cell (compact + include_email).
defp email_offered?(compact_member, include_email), do: not (compact_member and include_email)
# The variant of a composite group that is not offered in the current mode.
defp hidden_variant?(field, compact_member, include_email, compact_address) do
name_variant_hidden?(field, compact_member, include_email) or
address_variant_hidden?(field, compact_address)
end
# Compact hides first-name/last-name (folded into the composite). The email is
# hidden only when it is folded into the cell (compact + include_email); with
# include_email off it stays offered as a separate column. Non-compact hides the
# composite in favour of the constituents.
defp name_variant_hidden?(@name_composite, compact_member, _include_email),
do: not compact_member
defp name_variant_hidden?(:email, compact_member, include_email),
do: compact_member and include_email
defp name_variant_hidden?(field, compact_member, _include_email)
when field in [:first_name, :last_name],
do: compact_member
defp name_variant_hidden?(_field, _compact_member, _include_email), do: false
defp address_variant_hidden?(@address_composite, compact_address),
do: not compact_address
defp address_variant_hidden?(field, compact_address)
when field in [:street, :house_number, :postal_code, :city],
do: compact_address
defp address_variant_hidden?(_field, _compact_address), do: false
@doc """
All fields the column manager offers for the given view settings: the offered
member fields (see `offered_member_fields/3`) followed by the custom fields.
"""
@spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [
atom() | String.t()
]
def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names
end
defp overview_member_fields do
Mv.Constants.member_fields() ++ @pseudo_member_fields
end
@doc """
Gets all available fields for selection.
Builds field selection from URL, scoped to the columns offered for the given
view settings (`:compact_member` / `:member_include_email` / `:compact_address`
in `opts`): fields in `url_selection` are visible, all others false.
Returns a list of field identifiers:
- Member fields as atoms (e.g., `:first_name`, `:email`)
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
## Parameters
- `custom_fields` - List of CustomField resources that are available
## Returns
List of field identifiers (atoms and strings)
Use when `?fields=...` is in the URL so column visibility is not merged with
global settings.
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
member_fields =
overview_member_fields()
|> Enum.reject(fn field -> field == @export_only_alias end)
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
end
@doc """
Builds field selection from URL only: fields in `url_selection` are visible, all others false.
Use when `?fields=...` is in the URL so column visibility is not merged with global settings.
"""
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()]) :: %{
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
String.t() => boolean()
}
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
all_fields = get_all_available_fields(custom_fields)
def selection_from_url_only(url_selection, custom_fields, opts)
when is_map(url_selection) and is_list(opts) do
{cm, ie, ca} = view_opts(opts)
do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca))
end
def selection_from_url_only(_, _, _), do: %{}
defp do_selection_from_url_only(url_selection, all_fields) do
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
visible = Map.get(url_selection, field_string, false)
@ -81,40 +227,38 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
def selection_from_url_only(_, _), do: %{}
defp view_opts(opts) do
{
Keyword.get(opts, :compact_member, true),
Keyword.get(opts, :member_include_email, false),
Keyword.get(opts, :compact_address, true)
}
end
@doc """
Merges user field selection with global settings.
Merges user field selection with global settings, scoped to the columns offered
for the given view settings (`:compact_member` / `:member_include_email` /
`:compact_address` in `opts`).
User selection takes priority over global settings. If a field is not in the
user selection, the global setting is used. If a field is not in global settings,
it defaults to `true` (visible).
## Parameters
- `user_selection` - Map of field names (strings) to boolean visibility
- `global_settings` - Settings struct with `member_field_visibility` field
- `custom_fields` - List of CustomField resources
## Returns
Map of field names (strings) to boolean visibility values
## Examples
iex> user_selection = %{"first_name" => false}
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
iex> merge_with_global_settings(user_selection, settings, [])
%{"first_name" => false, "email" => true} # User selection overrides global
user selection, the global setting is used; if a field is not in global
settings, it defaults to visible. Only offered columns appear in the result, so
a column that is not applicable in the current mode (e.g. `:first_name` while
the composite "Name" is on) never leaks into the visible set. Defaults follow
the mode via `default_visible_set/3`.
"""
@spec merge_with_global_settings(
%{String.t() => boolean()},
map(),
[struct()]
) :: %{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
all_fields = get_all_available_fields(custom_fields)
global_visibility = get_global_visibility_map(global_settings, custom_fields)
@spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
%{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields, opts)
when is_list(opts) do
{cm, ie, ca} = view_opts(opts)
all_fields = get_offered_fields(custom_fields, cm, ie, ca)
default_set = default_visible_set(cm, ie, ca)
do_merge(user_selection, global_settings, custom_fields, all_fields, default_set)
end
defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do
global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set)
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
@ -129,35 +273,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
@doc """
Gets the list of visible fields from a field selection map.
Returns only fields where visibility is `true`.
## Parameters
- `field_selection` - Map of field names to boolean visibility
## Returns
List of field identifiers (atoms for member fields, strings for custom fields)
## Examples
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
iex> get_visible_fields(selection)
[:first_name, :street]
"""
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
def get_visible_fields(field_selection) when is_map(field_selection) do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_fields(_), do: []
@doc """
Gets visible member fields from field selection.
@ -259,15 +374,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
def get_visible_custom_fields(_), do: []
# Gets global visibility map from settings
defp get_global_visibility_map(settings, custom_fields) do
member_visibility = get_member_field_visibility_from_settings(settings)
defp get_global_visibility_map(settings, custom_fields, default_set) do
member_visibility = get_member_field_visibility_from_settings(settings, default_set)
custom_field_visibility = get_custom_field_visibility(custom_fields)
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
defp get_member_field_visibility_from_settings(settings) do
defp get_member_field_visibility_from_settings(settings, default_set) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
@ -276,13 +391,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
default_visibility = if field == :exit_date, do: false, else: true
default_visibility = MapSet.member?(default_set, field)
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
Map.put(acc, Atom.to_string(field), true)
Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field))
end)
end

View file

@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
## Parameters
- `query` - Ash query for members
- `show_current` - If true, get current cycle status; if false, get last completed cycle status (currently unused, kept for API compatibility)
- `today` - Optional date to use as reference (currently unused, kept for API compatibility)
## Returns
@ -33,8 +31,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
Uses Ash.Query.load to efficiently preload cycles in a single query.
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
"""
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
@spec load_cycles_for_members(Ash.Query.t()) :: Ash.Query.t()
def load_cycles_for_members(query) do
# Load membership_fee_type and cycles
query
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
@ -158,30 +156,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
end)
end
@doc """
Filters members by unpaid cycle status.
Returns members that have unpaid cycles in either the last completed cycle
or the current cycle, depending on `show_current`.
## Parameters
- `members` - List of member structs with loaded cycles
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
## Returns
List of members with unpaid cycles
## Deprecated
This function is kept for backwards compatibility. Use `filter_members_by_cycle_status/3` instead.
"""
@spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
def filter_unpaid_members(members, show_current \\ false) do
filter_members_by_cycle_status(members, :unpaid, show_current)
end
# Private helper function to format status label
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")

View file

@ -0,0 +1,411 @@
defmodule MvWeb.MemberLive.Index.OverviewQuery do
@moduledoc """
Builds the Ash query for the member overview from the LiveView's filter/sort
state, so every filter and sort resolves in PostgreSQL via the `:overview`
keyset-paginated read action.
All previously in-memory passes (cycle status, boolean/date custom fields,
group sort) are expressed here as DB filters/sorts. A unique `id` tie-breaker
is always appended to the sort so keyset pages never skip or duplicate rows.
`build/1` returns an unread `Ash.Query`; the caller supplies `page:`/`actor:`
options to `Ash.read/2`.
"""
import Ash.Expr
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.DateFilter
require Ash.Query
require Ash.Sort
@type opts :: %{optional(atom()) => term()}
@doc """
Builds the `:overview` query from the given filter/sort options.
Recognised keys (all optional):
* `:search` full-text search string
* `:group_filters` / `:groups` `%{group_id => :in | :not_in}` and the valid groups
* `:fee_type_filters` / `:fee_types` `%{fee_type_id => :in | :not_in}` and valid fee types
* `:date_filters` built-in join/exit date filter map (see `DateFilter`)
* `:sort_field` / `:sort_order` sort key and direction
* `:today` reference date for cycle math (defaults to `Date.utc_today/0`)
"""
@spec build(opts()) :: Ash.Query.t()
def build(opts \\ %{}) do
Member
|> Ash.Query.for_read(:overview)
|> apply_search(opts[:search])
|> apply_group_filters(opts[:group_filters], opts[:groups])
|> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types])
|> apply_boolean_custom_field_filters(
opts[:boolean_custom_field_filters],
opts[:boolean_custom_fields]
)
|> apply_date_filters(opts[:date_filters])
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|> apply_cycle_status_filter(
opts[:cycle_status_filter],
opts[:show_current_cycle],
today(opts)
)
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
end
defp today(opts), do: opts[:today] || Date.utc_today()
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
defp apply_search(query, search) when is_binary(search),
do: Member.apply_overview_search(query, search)
defp apply_search(query, _), do: query
# ---------------------------------------------------------------------------
# Group filters (AND across selected groups)
# ---------------------------------------------------------------------------
defp apply_group_filters(query, group_filters, _groups)
when group_filters in [nil, %{}],
do: query
defp apply_group_filters(query, group_filters, groups) do
valid_ids = valid_id_set(groups)
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
if MapSet.member?(valid_ids, group_id_str) do
apply_one_group_filter(q, group_id_str, value)
else
q
end
end)
end
defp apply_one_group_filter(query, group_id_str, :in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^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, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid)))
_ -> query
end
end
defp apply_one_group_filter(query, _id, _value), do: query
# ---------------------------------------------------------------------------
# Fee-type filters (:in OR; :not_in AND)
# ---------------------------------------------------------------------------
defp apply_fee_type_filters(query, fee_type_filters, _fee_types)
when fee_type_filters in [nil, %{}],
do: query
defp apply_fee_type_filters(query, fee_type_filters, fee_types) do
valid_ids = valid_id_set(fee_types)
{in_filters, 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 = cast_uuids(Enum.map(in_filters, fn {id_str, _} -> id_str 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 {id_str, _}, q ->
case Ecto.UUID.cast(id_str) do
{:ok, uuid} ->
Ash.Query.filter(
q,
expr(membership_fee_type_id != ^uuid or is_nil(membership_fee_type_id))
)
_ ->
q
end
end)
end
# ---------------------------------------------------------------------------
# Boolean custom-field filters (AND across selected fields)
#
# Mirrors the previous in-memory behaviour: a member matches a `{field => bool}`
# filter only if it has a stored value row for that field whose boolean value
# equals `bool`. A member with no value row is excluded. Uses a JSONB
# containment predicate (`value @> '{"value": <bool>}'`), which the GIN index on
# `custom_field_values.value` can serve.
# ---------------------------------------------------------------------------
defp apply_boolean_custom_field_filters(query, filters, _boolean_fields)
when filters in [nil, %{}],
do: query
defp apply_boolean_custom_field_filters(query, filters, boolean_fields) do
valid_ids = valid_id_set(boolean_fields)
filters
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|> Enum.reduce(query, fn {id_str, bool}, q ->
case Ecto.UUID.cast(id_str) do
{:ok, uuid} -> apply_one_boolean_filter(q, uuid, bool)
_ -> q
end
end)
end
defp apply_one_boolean_filter(query, uuid, bool) when is_boolean(bool) do
Ash.Query.filter(
query,
expr(
exists(
custom_field_values,
custom_field_id == ^uuid and
fragment("? @> jsonb_build_object('value', ?::boolean)", value, ^bool)
)
)
)
end
defp apply_one_boolean_filter(query, _uuid, _bool), do: query
# ---------------------------------------------------------------------------
# Cycle-status filter (paid/unpaid, current or last-completed cycle)
#
# Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member
# matches only when its selected-cycle status equals the requested status;
# members with no matching cycle (nil aggregate) are excluded — exactly as the
# previous in-memory classifier behaved.
# ---------------------------------------------------------------------------
defp apply_cycle_status_filter(query, status, _show_current, _today)
when status not in [:paid, :unpaid],
do: query
defp apply_cycle_status_filter(query, status, true = _show_current, today) do
# Current cycle: contains today; when several would, the one with the latest
# cycle_start wins. Keep members whose winning current cycle has `status`.
Ash.Query.filter(
query,
expr(
exists(
membership_fee_cycles,
cycle_start <= ^today and cycle_end >= ^today and status == ^status and
not exists(
member.membership_fee_cycles,
cycle_start <= ^today and cycle_end >= ^today and
cycle_start > parent(cycle_start)
)
)
)
)
end
defp apply_cycle_status_filter(query, status, _show_current, today) do
# Last completed cycle: most recent cycle that has ended (cycle_end < today).
Ash.Query.filter(
query,
expr(
exists(
membership_fee_cycles,
cycle_end < ^today and status == ^status and
not exists(
member.membership_fee_cycles,
cycle_end < ^today and cycle_start > parent(cycle_start)
)
)
)
)
end
# ---------------------------------------------------------------------------
# Built-in date filters (join/exit)
# ---------------------------------------------------------------------------
defp apply_date_filters(query, nil), do: query
defp apply_date_filters(query, filters) when is_map(filters),
do: DateFilter.apply_ash_filter(query, filters)
defp apply_custom_date_filters(query, filters, custom_fields)
when is_map(filters) and is_list(custom_fields),
do: DateFilter.apply_custom_date_ash_filter(query, filters, custom_fields)
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
# ---------------------------------------------------------------------------
# Sort (always append the unique id tie-breaker for keyset stability)
# ---------------------------------------------------------------------------
defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc)
# Composite "Member" column sub-fields (§1.21). Sorting by one name part uses
# the other name part (then id) as a stable tie-breaker; the email that shares
# the cell is display-only and never a sort key. Shared by the composite
# sub-field menu and the separate first/last-name columns.
defp apply_sort(query, field, order, _custom_fields) when field in [:last_name, "last_name"] do
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
end
defp apply_sort(query, field, order, _custom_fields)
when field in [:first_name, "first_name"] do
Ash.Query.sort(query, [{:first_name, order}, {:last_name, order}, {:id, :asc}])
end
# Composite "Address" column: sort by city, then postal code, then street.
defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do
Ash.Query.sort(query, [
{:city, order},
{:postal_code, order},
{:street, order},
{:id, :asc}
])
end
defp apply_sort(query, field, order, custom_fields) do
case sort_key(field) do
nil -> apply_custom_field_sort(query, field, order, custom_fields)
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
end
end
# Sorts by a custom field's JSONB value DB-side. The value is read from the
# first matching `custom_field_values` row for the field, cast per the field's
# value type so integers sort numerically and dates chronologically. Empty
# strings and members without a value row sort last in both directions
# (NULLS LAST), mirroring the previous in-memory `CustomFieldSort` behaviour.
defp apply_custom_field_sort(query, field, order, custom_fields) do
with id_str when is_binary(id_str) <- custom_field_id(field),
%{} = cf <- Enum.find(custom_fields, &(to_string(&1.id) == id_str)) do
nils_order = if order == :desc, do: :desc_nils_last, else: :asc_nils_last
Ash.Query.sort(query, [
{custom_field_sort_calc(cf.value_type, cf.id), nils_order},
{:id, :asc}
])
else
_ -> Ash.Query.sort(query, id: :asc)
end
end
defp custom_field_id(field) when is_atom(field), do: custom_field_id(Atom.to_string(field))
defp custom_field_id(field) when is_binary(field) do
case String.split(field, Mv.Constants.custom_field_prefix()) do
["", id_str] -> id_str
_ -> nil
end
end
defp custom_field_id(_), do: nil
# Maps the field's value type to the first matching custom-field value's typed
# sort key (scoped to the field), backed by the `sort_*` calculations on
# `CustomFieldValue` (a raw fragment is not allowed as an aggregate `field:`).
defp custom_field_sort_calc(:integer, id),
do:
Ash.Sort.expr_sort(
first(custom_field_values,
field: :sort_numeric,
query: [filter: expr(custom_field_id == ^id)]
),
:decimal
)
defp custom_field_sort_calc(:date, id),
do:
Ash.Sort.expr_sort(
first(custom_field_values,
field: :sort_date,
query: [filter: expr(custom_field_id == ^id)]
),
:date
)
defp custom_field_sort_calc(:boolean, id),
do:
Ash.Sort.expr_sort(
first(custom_field_values,
field: :sort_boolean,
query: [filter: expr(custom_field_id == ^id)]
),
:boolean
)
defp custom_field_sort_calc(_type, id),
do:
Ash.Sort.expr_sort(
first(custom_field_values,
field: :sort_text,
query: [filter: expr(custom_field_id == ^id)]
),
:string
)
# Resolves a sort field (atom or string) to a DB sort key, or nil if it is a
# computed field handled elsewhere / not sortable.
defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"],
do: "membership_fee_type.name"
defp sort_key(field) when field in [:groups, "groups"], do: :first_group_name
defp sort_key(field) when is_atom(field) do
if field in member_sort_fields(), do: field, else: nil
end
defp sort_key(field) when is_binary(field) do
allowed = MapSet.new(member_sort_fields(), &Atom.to_string/1)
if MapSet.member?(allowed, field), do: String.to_existing_atom(field), else: nil
end
defp sort_key(_), do: nil
defp member_sort_fields do
Mv.Constants.member_fields() -- [:notes]
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp valid_id_set(records) when is_list(records) do
records
|> Enum.map(&to_string(&1.id))
|> Enum.map(&normalize_uuid/1)
|> Enum.reject(&is_nil/1)
|> MapSet.new()
end
defp valid_id_set(_), do: MapSet.new()
defp normalize_uuid(raw) when is_binary(raw) do
case Ecto.UUID.cast(String.trim(raw)) do
{:ok, uuid} -> to_string(uuid)
_ -> nil
end
end
defp normalize_uuid(_), do: nil
defp cast_uuids(id_strs) do
id_strs
|> Enum.map(&Ecto.UUID.cast/1)
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(fn {:ok, uuid} -> uuid end)
end
end

View file

@ -0,0 +1,175 @@
defmodule MvWeb.MemberLive.Index.ViewSettings do
@moduledoc """
Resolves and persists the member-overview view settings per browser/device.
A view-settings value is a map of four keys:
* `:density` `:compact` or `:comfortable` (the row-spacing / compact mode)
* `:compact_member` composite "Member" cell (last+first name) vs separate
first-name/last-name columns
* `:member_include_email` whether the email is surfaced (email line in the
composite cell, or a separate Email column when the member field is split)
* `:compact_address` composite Address cell (street / postal+city) vs
separate street/postal-code/city columns
Persistence is per browser (not per account), resolved in priority order from
the LiveView connect params, the session, the request cookie, and finally the
global defaults (`defaults/0`). The chosen value is written to a long-lived
cookie by a small client-side listener; on the next connected mount the client
echoes the cookie back through the socket connect params (the connect-info map
on a live socket does not expose cookies), and on the disconnected render the
cookie is read directly from the request conn.
"""
alias MvWeb.MemberLive.Index.Cookie
@cookie_name "member_view_settings"
@session_key "member_view_settings"
@connect_param "view_settings"
@cookie_max_age 365 * 24 * 60 * 60
@defaults %{
density: :comfortable,
compact_member: true,
member_include_email: true,
compact_address: true
}
@densities [:compact, :comfortable]
@bool_keys [:compact_member, :member_include_email, :compact_address]
@type t :: %{
density: :compact | :comfortable,
compact_member: boolean(),
member_include_email: boolean(),
compact_address: boolean()
}
@doc "The global default view settings used when nothing is stored."
@spec defaults() :: %{
density: :comfortable,
compact_member: true,
member_include_email: true,
compact_address: true
}
def defaults, do: @defaults
@doc "The cookie name the settings are persisted under (used by the client listener)."
@spec cookie_name() :: String.t()
def cookie_name, do: @cookie_name
@doc "The cookie max-age in seconds (365 days)."
@spec cookie_max_age() :: 31_536_000
def cookie_max_age, do: @cookie_max_age
@doc """
Parses a raw map (string or atom keys, string/boolean values) into a validated
partial settings map with atom keys. Unknown keys and invalid values are
dropped, so a caller can safely `Map.merge/2` the result over another source.
"""
@spec parse(term()) :: %{optional(atom()) => term()}
def parse(raw) when is_map(raw) do
Enum.reduce(raw, %{}, fn {key, value}, acc ->
case parse_pair(to_string(key), value) do
{k, v} -> Map.put(acc, k, v)
:error -> acc
end
end)
end
def parse(_), do: %{}
defp parse_pair("density", value) do
case parse_density(value) do
nil -> :error
density -> {:density, density}
end
end
defp parse_pair(key, value)
when key in ~w(compact_member member_include_email compact_address) do
case parse_bool(value) do
nil -> :error
bool -> {String.to_existing_atom(key), bool}
end
end
defp parse_pair(_key, _value), do: :error
defp parse_density(value) when value in @densities, do: value
defp parse_density("compact"), do: :compact
defp parse_density("comfortable"), do: :comfortable
defp parse_density(_), do: nil
defp parse_bool(value) when is_boolean(value), do: value
defp parse_bool("true"), do: true
defp parse_bool("false"), do: false
defp parse_bool(_), do: nil
@doc "Reads partial settings from the LiveView session map."
@spec get_from_session(map()) :: %{optional(atom()) => term()}
def get_from_session(session) when is_map(session),
do: parse_json(Map.get(session, @session_key))
def get_from_session(_), do: %{}
@doc "Reads partial settings from the request cookie header of a connect-info conn."
@spec get_from_cookie(Plug.Conn.t() | nil) :: %{optional(atom()) => term()}
def get_from_cookie(%Plug.Conn{} = conn) do
case Plug.Conn.get_req_header(conn, "cookie") do
[cookie_header | _rest] ->
cookie_header
|> Cookie.parse_header()
|> Map.get(@cookie_name)
|> parse_json()
_ ->
%{}
end
end
def get_from_cookie(_), do: %{}
@doc "Reads partial settings from the LiveView connect params (raw JSON string)."
@spec get_from_connect_params(map() | nil) :: %{optional(atom()) => term()}
def get_from_connect_params(params) when is_map(params),
do: parse_json(Map.get(params, @connect_param))
def get_from_connect_params(_), do: %{}
@doc """
Resolves the effective view settings for a mount, merging the sources by
priority (connect params > session > cookie), falling back per key to the
global defaults.
"""
@spec resolve(map(), Plug.Conn.t() | nil, map() | nil) :: t()
def resolve(session, conn, connect_params \\ nil) do
@defaults
|> Map.merge(get_from_cookie(conn))
|> Map.merge(get_from_session(session))
|> Map.merge(get_from_connect_params(connect_params))
end
@doc "Serializes settings to a JSON string for the client-side cookie writer."
@spec to_json(map()) :: String.t()
def to_json(settings) when is_map(settings) do
settings
|> Map.take([:density | @bool_keys])
|> Map.new(fn
{:density, density} -> {:density, to_string(density)}
{key, value} -> {key, value}
end)
|> Jason.encode!()
end
defp parse_json(nil), do: %{}
defp parse_json(json) when is_binary(json) do
case Jason.decode(json) do
{:ok, decoded} -> parse(decoded)
_ -> %{}
end
end
defp parse_json(_), do: %{}
end

View file

@ -32,6 +32,8 @@ defmodule MvWeb.Translations.MemberFields do
def label(:membership_fee_status), do: gettext("Membership Fee Status")
def label(:membership_fee_type), do: gettext("Fee Type")
def label(:groups), do: gettext("Groups")
def label(:name), do: gettext("Name")
def label(:address), do: gettext("Address")
# Fallback for unknown fields
def label(field) do

View file

@ -126,7 +126,9 @@ msgstr "Feld hinzufügen"
msgid "Add members"
msgstr "Mitglieder hinzufügen"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Address"
msgstr "Adresse"
@ -192,8 +194,7 @@ msgid "An account with this email already exists. Please verify your password to
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen."
#: lib/mv_web/helpers/ash_error_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "An error occurred"
@ -1517,6 +1518,7 @@ msgstr "Mitglieder filtern"
msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
@ -2002,6 +2004,7 @@ msgstr "Letzter Zyklus"
msgid "Last Name"
msgstr "Nachname"
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
@ -2288,14 +2291,12 @@ msgstr "Beitragseinstellungen"
msgid "Membership fee start"
msgstr "Beitragsbeginn"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type deleted"
msgstr "Mitgliedsbeitragsart gelöscht"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type not found"
msgstr "Mitgliedsbeitragsart nicht gefunden"
@ -2356,6 +2357,7 @@ msgstr "Monatliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
@ -2406,6 +2408,7 @@ msgstr "Neue*r Benutzer*in"
msgid "New amount"
msgstr "Neuer Betrag"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
@ -2693,10 +2696,10 @@ msgstr "Optionen"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
@ -3293,11 +3296,6 @@ msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
msgid "Show in overview"
msgstr "In der Übersicht anzeigen"
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Show/Hide Columns"
msgstr "Spalten ein-/ausblenden"
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Sign in"
@ -3391,10 +3389,10 @@ msgid "Summary"
msgstr "Zusammenfassung"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr "Pausiert"
@ -3700,10 +3698,10 @@ msgstr "Aufhebung der Verknüpfung geplant"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr "Unbezahlt"
@ -3904,6 +3902,7 @@ msgstr "Jährliches Intervall Beitrittszeitraum nicht einbezogen"
msgid "Yearly Interval - Joining Cycle Included"
msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
@ -3956,8 +3955,7 @@ msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linkte
msgid "You do not have permission to %{action} members."
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen."
@ -3973,8 +3971,7 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
msgid "You do not have permission to delete this member"
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen."
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen."
@ -4145,3 +4142,100 @@ msgstr "Öffnen"
msgctxt "status"
msgid "Open"
msgstr "Offen"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "%{count} member"
msgid_plural "%{count} members"
msgstr[0] "%{count} Mitglied"
msgstr[1] "%{count} Mitglieder"
#: lib/mv_web/live/components/search_bar_component.ex
#, elixir-autogen, elixir-format
msgid "Clear search"
msgstr "Suche zurücksetzen"
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Compact address field"
msgstr "Kompaktes Adressfeld"
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Compact mode"
msgstr "Kompakter Modus"
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "View settings"
msgstr "Ansichtseinstellungen"
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "include email"
msgstr "inklusive E-Mail"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reset to default"
msgstr "Auf Standard zurücksetzen"
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Columns"
msgstr "Spalten"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Loading more members …"
msgstr "Weitere Mitglieder werden geladen …"
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "View"
msgstr "Ansicht"
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Compact Member field"
msgstr "Kompaktes Mitglied-Feld"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click to sort by city"
msgstr "Klicke, um nach Ort zu sortieren"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, 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"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Choose sort field"
msgstr "Sortierfeld wählen"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort members by"
msgstr "Mitglieder sortieren nach"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to reverse the sort direction"
msgstr "Klicke, um die Sortierrichtung umzukehren"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort by"
msgstr "Sortieren nach"

View file

@ -127,7 +127,9 @@ msgstr ""
msgid "Add members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Address"
msgstr ""
@ -1517,6 +1519,7 @@ msgstr ""
msgid "First Name"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
@ -2002,6 +2005,7 @@ msgstr ""
msgid "Last Name"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
@ -2354,6 +2358,7 @@ msgstr ""
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -2404,6 +2409,7 @@ msgstr ""
msgid "New amount"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
@ -2691,10 +2697,10 @@ msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@ -3291,11 +3297,6 @@ msgstr ""
msgid "Show in overview"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Show/Hide Columns"
msgstr ""
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Sign in"
@ -3389,10 +3390,10 @@ msgid "Summary"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr ""
@ -3698,10 +3699,10 @@ msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr ""
@ -3901,6 +3902,7 @@ msgstr ""
msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
@ -4140,3 +4142,100 @@ msgstr ""
msgctxt "status"
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "%{count} member"
msgid_plural "%{count} members"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/components/search_bar_component.ex
#, elixir-autogen, elixir-format
msgid "Clear search"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Compact address field"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Compact mode"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "View settings"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "include email"
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reset to default"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Columns"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Loading more members …"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "View"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Compact Member field"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click to sort by city"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, 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 ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Choose sort field"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Member"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort members by"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to reverse the sort direction"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort by"
msgstr ""

View file

@ -127,7 +127,9 @@ msgstr ""
msgid "Add members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Address"
msgstr ""
@ -193,8 +195,7 @@ msgid "An account with this email already exists. Please verify your password to
msgstr ""
#: lib/mv_web/helpers/ash_error_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "An error occurred"
@ -1518,6 +1519,7 @@ msgstr ""
msgid "First Name"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
@ -2003,6 +2005,7 @@ msgstr ""
msgid "Last Name"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
@ -2289,14 +2292,12 @@ msgstr ""
msgid "Membership fee start"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type deleted"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type not found"
msgstr ""
@ -2357,6 +2358,7 @@ msgstr ""
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -2407,6 +2409,7 @@ msgstr ""
msgid "New amount"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
@ -2694,10 +2697,10 @@ msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@ -3294,11 +3297,6 @@ msgstr ""
msgid "Show in overview"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Show/Hide Columns"
msgstr ""
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Sign in"
@ -3392,10 +3390,10 @@ msgid "Summary"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr ""
@ -3701,10 +3699,10 @@ msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr ""
@ -3904,6 +3902,7 @@ msgstr ""
msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
@ -3956,8 +3955,7 @@ msgstr ""
msgid "You do not have permission to %{action} members."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this membership fee type"
msgstr ""
@ -3973,8 +3971,7 @@ msgstr ""
msgid "You do not have permission to delete this member"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this membership fee type"
msgstr ""
@ -4145,3 +4142,100 @@ msgstr "Open"
msgctxt "status"
msgid "Open"
msgstr "Open"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "%{count} member"
msgid_plural "%{count} members"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/components/search_bar_component.ex
#, elixir-autogen, elixir-format
msgid "Clear search"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Compact address field"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Compact mode"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "View settings"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "include email"
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reset to default"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Columns"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Loading more members …"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "View"
msgstr ""
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Compact Member field"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click to sort by city"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, 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 ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Choose sort field"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort members by"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to reverse the sort direction"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort by"
msgstr ""

View file

@ -0,0 +1,32 @@
defmodule Mv.Repo.Migrations.AddCycleEndToMembershipFeeCycles do
@moduledoc """
Adds the denormalized `cycle_end` column to `membership_fee_cycles`.
The column is added nullable, backfilled from `cycle_start + interval` for every
existing row (via `Mv.MembershipFees.CycleEndBackfill`), and only then made
`NOT NULL`. Going forward the column is populated at create time by the
`SetCycleEnd` change.
"""
use Ecto.Migration
def up do
alter table(:membership_fee_cycles) do
add :cycle_end, :date
end
flush()
{:ok, _count} = Mv.MembershipFees.CycleEndBackfill.run()
alter table(:membership_fee_cycles) do
modify :cycle_end, :date, null: false
end
end
def down do
alter table(:membership_fee_cycles) do
remove :cycle_end
end
end
end

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddCustomFieldValuesValueGinIndex do
@moduledoc """
Adds a GIN index on `custom_field_values.value` (JSONB) to serve the boolean
custom-field containment predicates used by the member overview.
"""
use Ecto.Migration
def up do
create index(:custom_field_values, ["value"],
name: "custom_field_values_value_gin_index",
using: "gin"
)
end
def down do
drop_if_exists index(:custom_field_values, ["value"],
name: "custom_field_values_value_gin_index"
)
end
end

View file

@ -0,0 +1,136 @@
# Optional BULK member seed for load / infinite-scroll testing.
#
# This is NOT part of the default dev seed (`mix run priv/repo/seeds.exs`), so the
# normal dev database stays small. Run it explicitly when you need many members:
#
# just seed-members-bulk
# BULK_MEMBER_COUNT=500 mix run priv/repo/seeds_bulk_members.exs
#
# Requires the bootstrap seeds (admin user + fee types) to have run first. Members
# are upserted by email ("bulk-member-<n>@example.de"), so re-running is idempotent
# and does not duplicate members or their fee cycles.
alias Mv.Accounts
alias Mv.Membership
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
admin_user_with_role =
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, user} when not is_nil(user) ->
user |> Ash.load!(:role, authorize?: false)
_ ->
raise "Bulk seed requires bootstrap: admin user not found (#{admin_email})"
end
all_fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|> Enum.to_list()
count =
case Integer.parse(System.get_env("BULK_MEMBER_COUNT") || "300") do
{n, _} when n > 0 -> n
_ -> 300
end
first_names =
~w(Anna Bruno Clara David Elena Felix Greta Henrik Ines Jakob Laura Max Nina
Oliver Paula Quirin Rosa Stefan Thea Uwe Vera Wolf Xenia Yannick Zoe Ada
Ben Carl Dora Emil)
last_names =
~w(Schmidt Müller Fischer Weber Wagner Becker Schulz Hoffmann Koch Richter Klein
Wolf Neumann Schwarz Zimmermann Braun Krüger Hartmann Lange Werner Krause
Lehmann Köhler Herrmann Walter)
cities = [
{"München", "80331"},
{"Hamburg", "20095"},
{"Berlin", "10435"},
{"Köln", "50667"},
{"Frankfurt", "60313"},
{"Stuttgart", "70173"},
{"Düsseldorf", "40212"},
{"Dortmund", "44137"},
{"Essen", "45127"},
{"Dresden", "01069"},
{"Nürnberg", "90402"},
{"Bremen", "28195"}
]
statuses = [:paid, :unpaid, :suspended]
IO.puts("Seeding #{count} bulk members (set BULK_MEMBER_COUNT to change)…")
Enum.each(1..count, fn i ->
{city, postal_code} = Enum.at(cities, rem(i, length(cities)))
attrs = %{
first_name: Enum.at(first_names, rem(i, length(first_names))),
last_name: Enum.at(last_names, rem(div(i, length(first_names)) + i, length(last_names))),
email: "bulk-member-#{i}@example.de",
join_date: Date.add(~D[2020-01-01], rem(i * 7, 2000)),
city: city,
street: "Teststraße",
house_number: Integer.to_string(rem(i, 200) + 1),
postal_code: postal_code,
country: "Deutschland"
}
attrs =
case all_fee_types do
[] -> attrs
types -> Map.put(attrs, :membership_fee_type_id, Enum.at(types, rem(i, length(types))).id)
end
member =
Membership.create_member!(attrs,
upsert?: true,
upsert_identity: :unique_email,
actor: admin_user_with_role
)
# Give the member fee cycles + a round-robin status so the fee-status column is
# populated at scale. Cycles are only generated when none exist yet; the status
# is (re-)applied on every run so a run interrupted between generation and the
# status update still converges (idempotent — the update is a no-op once set).
if not is_nil(member.membership_fee_type_id) do
member = Ash.load!(member, :membership_fee_cycles, actor: admin_user_with_role)
cycles =
if Enum.empty?(member.membership_fee_cycles) do
{:ok, generated, _} =
CycleGenerator.generate_cycles_for_member(member.id,
skip_lock?: true,
actor: admin_user_with_role
)
generated
else
member.membership_fee_cycles
end
status = Enum.at(statuses, rem(i, length(statuses)))
Enum.each(cycles, fn cycle ->
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
end
end)
end
if rem(i, 50) == 0, do: IO.puts("#{i}/#{count}")
end)
IO.puts("✅ Bulk seed complete: #{count} members.")

View file

@ -0,0 +1,148 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value",
"type": "map"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "custom_field_values_member_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "custom_field_values_custom_field_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "custom_fields"
},
"scale": null,
"size": null,
"source": "custom_field_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"value"
],
"fields": [
{
"type": "string",
"value": "value"
}
],
"include": null,
"message": null,
"name": "custom_field_values_value_gin_index",
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": "gin",
"where": null
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "6B19604F0AA26F503094E6AF5086FDAE0D054096B24099376EFF4BBCFF7E3CB5",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_field_values_unique_custom_field_per_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "custom_field_id"
}
],
"name": "unique_custom_field_per_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_field_values"
}

View file

@ -0,0 +1,173 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "cycle_start",
"type": "date"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "cycle_end",
"type": "date"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": 2,
"size": null,
"source": "amount",
"type": "decimal"
},
{
"allow_nil?": false,
"default": "\"unpaid\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "status",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "membership_fee_cycles_member_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "membership_fee_cycles_membership_fee_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "membership_fee_types"
},
"scale": null,
"size": null,
"source": "membership_fee_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "455B6D49C804301DF13F950318644BF28B491535A94F4ABF7805AA1A53287401",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "membership_fee_cycles_unique_cycle_per_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "cycle_start"
}
],
"name": "unique_cycle_per_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "membership_fee_cycles"
}

View file

@ -0,0 +1,98 @@
defmodule Mv.MembershipFees.CycleEndTest do
@moduledoc """
Tests for the denormalized `cycle_end` column on membership fee cycles:
the create-time change that sets it, and the one-time backfill helper.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2]
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.CycleEndBackfill
alias Mv.MembershipFees.MembershipFeeCycle
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
defp create_member(fee_type, actor) do
member_fixture_with_actor(%{membership_fee_type_id: fee_type.id}, actor)
end
describe "create sets cycle_end" do
for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do
test "create sets cycle_end from cycle_start + #{interval} interval", %{actor: actor} do
interval = unquote(interval)
fee_type = create_fee_type(%{interval: interval}, actor)
member = create_member(fee_type, actor)
cycle_start = ~D[2024-02-01]
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: cycle_start,
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: actor)
assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, interval)
end
end
end
describe "create with interval argument" do
test "uses the passed interval and skips the fee-type lookup", %{actor: actor} do
# Fee type carries :yearly, but the caller passes :monthly explicitly.
# If the interval argument is honored (no per-row fee-type lookup), the
# stored cycle_end reflects the argument, not the fee type's interval.
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(fee_type, actor)
cycle_start = ~D[2024-02-01]
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: cycle_start,
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
interval: :monthly
})
|> Ash.create!(actor: actor)
assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, :monthly)
end
end
describe "backfill" do
test "fills cycle_end for pre-existing rows matching calculate_cycle_end/2", %{actor: actor} do
fee_type = create_fee_type(%{interval: :quarterly}, actor)
member = create_member(fee_type, actor)
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2024-04-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: actor)
# Simulate a pre-existing row that predates the cycle_end column.
Repo.query!("ALTER TABLE membership_fee_cycles ALTER COLUMN cycle_end DROP NOT NULL")
Repo.query!("UPDATE membership_fee_cycles SET cycle_end = NULL WHERE id = $1", [
Ecto.UUID.dump!(cycle.id)
])
assert {:ok, count} = CycleEndBackfill.run()
assert count >= 1
reloaded = Ash.get!(MembershipFeeCycle, cycle.id, actor: actor)
assert reloaded.cycle_end == CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly)
end
end
end

View file

@ -4,7 +4,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
describe "field visibility dropdown in member view" do
test "renders and toggles visibility", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Email is folded into the Member cell by default; unfold it so it appears
# as its own togglable column in the dropdown.
conn =
conn
|> conn_with_oidc_user()
|> Plug.Conn.put_session("member_view_settings", ~s({"member_include_email":false}))
{:ok, view, _html} = live(conn, ~p"/members")
# Renders Dropdown

View file

@ -2,10 +2,28 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
# The curated default columns hide the individual name/address sort headers,
# so these component tests make every sortable field visible via ?fields=.
# The individual name/address constituents are only *offered* when the compact
# Member / Address view settings are off, so the session opts them out of the
# composite columns before selecting the fields via ?fields=.
@cols "first_name,last_name,email,street,house_number,postal_code,city,country,join_date"
# Disables the compact Member/Address composites so first_name/last_name/email
# and street/house_number/postal_code/city are offered as individual sortable
# columns (persisted per browser via the view-settings session key).
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
describe "rendering" do
test "renders with correct attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that the component renders with correct attributes
assert has_element?(view, "[data-testid='first_name']")
@ -14,8 +32,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "renders all sortable headers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
sortable_fields = [
:first_name,
@ -34,8 +52,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "renders correct labels", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test specific labels
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
@ -46,8 +64,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "sort icons" do
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# The neutral icon has the opcity class we can test for
# Test that EMAIL field specifically shows neutral icon
@ -58,8 +76,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
# Test that FIRST_NAME field specifically shows ascending icon
# Test CSS classes - no opacity for active state
@ -79,8 +99,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, _view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
# Count occurrences to ensure only one descending sort icon. Dropdown
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
@ -91,8 +113,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "multiple fields can have different icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
# CITY field should be active (ascending)
refute has_element?(view, "[data-testid='city'] .opacity-40")
@ -108,12 +132,13 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "icon state changes correctly when clicking different fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start: all fields neutral except first name as default
# Start: all fields neutral except the default sort field (last name)
assert has_element?(view, "[data-testid='city'] .opacity-40")
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
refute has_element?(view, "[data-testid='last_name'] .opacity-40")
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Click city - should become active
@ -136,27 +161,31 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "specific field shows correct icon for each sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
# Test EMAIL field specifically
{:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
{:ok, view, html_asc} =
live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=asc")
assert html_asc =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
{:ok, view, html_desc} =
live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
assert html_desc =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_neutral} = live(conn, "/members")
{:ok, view, html_neutral} = live(conn, "/members?fields=#{@cols}")
assert html_neutral =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
# Test neutral state - only one field should have active sort icon
{:ok, _view, html_neutral} = live(conn, "/members")
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
# Count active icons (should be exactly 1 - ascending for default sort field)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
@ -166,7 +195,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
# Test descending state
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
{:ok, _view, html_desc} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = active_sort_down_count(html_desc)
@ -189,32 +219,38 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Check aria-label for unsorted state
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
end
test "sets aria-label correctly for ascending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
# Check aria-label for ascending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
end
test "sets aria-label correctly for descending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
# Check aria-label for descending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
end
test "includes tooltip with correct aria-label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
# Check that tooltip div exists with correct data-tip
assert has_element?(view, "[data-testid='first_name']")
@ -222,8 +258,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "aria-labels work for all sortable fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
# Test aria-labels for different fields
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
@ -239,8 +275,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "component behavior" do
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Click on the first name sort header
view
@ -252,8 +288,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "component handles different field types correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that different field types render correctly
assert has_element?(view, "button[phx-value-field='first_name']")
@ -264,17 +300,21 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
conn = conn |> conn_with_oidc_user() |> non_compact()
# Should not crash and should default sorting for first name
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
# Should not crash and should fall back to the default sort (last name)
assert html =~ "hero-chevron-up-down"
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
refute has_element?(view, "[data-testid='last_name'] .opacity-40")
end
test "handles invalid sort order gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
# Should default to ascending
assert html =~ "hero-chevron-up"
@ -282,8 +322,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "handles empty sort parameters", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
# Should show neutral icons
assert html =~ "hero-chevron-up-down"
@ -293,8 +333,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "icon state transitions" do
test "icon changes when sorting state changes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start with neutral state
assert has_element?(view, "[data-testid='city'] .opacity-40")
@ -309,8 +349,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "multiple fields can be tested for icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
# Email should be active (descending)
assert html =~ "hero-chevron-down"

View file

@ -0,0 +1,22 @@
defmodule MvWeb.MemberLive.Index.CookieTest do
@moduledoc """
Tests for the shared cookie-header parser used by the overview persistence chain.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.Cookie
describe "parse_header/1" do
test "splits pairs and URL-decodes values" do
assert Cookie.parse_header("a=1; b=hello%20world") == %{"a" => "1", "b" => "hello world"}
end
test "maps a valueless entry to an empty string" do
assert Cookie.parse_header("flag; a=1") == %{"flag" => "", "a" => "1"}
end
test "keeps only the first '=' as the separator" do
assert Cookie.parse_header("token=a=b=c") == %{"token" => "a=b=c"}
end
end
end

View file

@ -0,0 +1,141 @@
defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
@moduledoc """
Tests for the member-overview export descriptor builder: the column order it
emits must mirror the rendered table (DB fields in constants order, fee
type/status after the fee start date, groups before custom fields), and the
filter/sort/selection state must be serialized faithfully.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.ExportPayload
defp custom_field(id), do: %{id: id}
describe "build/1" do
test "orders columns like the table: db fields, fee type/status, groups, custom fields" do
assigns = %{
selected_members: MapSet.new(["m1", "m2"]),
member_fields_visible_db: [:first_name, :membership_fee_start_date, :last_name],
member_fields_visible_computed: [:membership_fee_status],
member_fields_visible: [
:first_name,
:last_name,
:membership_fee_start_date,
:membership_fee_type,
:membership_fee_status,
:groups
],
visible_custom_field_ids: ["cf-a"],
all_custom_fields: [custom_field("cf-a"), custom_field("cf-b")],
query: "ada",
sort_field: :last_name,
sort_order: :desc,
show_current_cycle: true,
cycle_status_filter: :unpaid,
boolean_custom_field_filters: %{"cf-x" => true}
}
payload = ExportPayload.build(assigns)
# DB fields follow Mv.Constants.member_fields/0 order (first_name, last_name,
# then membership_fee_start_date), membership_fee_type + status are inserted
# right after the fee start date, groups precede the custom fields, and custom
# fields keep table order (only visible ones).
assert payload.column_order == [
"first_name",
"last_name",
"membership_fee_start_date",
"membership_fee_type",
"membership_fee_status",
"groups",
"cf-a"
]
assert Enum.sort(payload.selected_ids) == ["m1", "m2"]
assert payload.custom_field_ids == ["cf-a"]
assert payload.query == "ada"
assert payload.sort_field == "last_name"
assert payload.sort_order == "desc"
assert payload.show_current_cycle == true
assert payload.cycle_status_filter == "unpaid"
assert payload.boolean_filters == %{"cf-x" => true}
end
test "defaults optional state and omits groups/custom fields when not visible" do
assigns = %{
selected_members: MapSet.new(),
member_fields_visible_db: [:first_name],
member_fields_visible_computed: [],
member_fields_visible: [:first_name],
visible_custom_field_ids: [],
all_custom_fields: [custom_field("cf-a")]
}
payload = ExportPayload.build(assigns)
assert payload.column_order == ["first_name"]
assert payload.selected_ids == []
assert payload.custom_field_ids == []
assert payload.query == nil
assert payload.sort_field == nil
assert payload.sort_order == nil
assert payload.show_current_cycle == false
assert payload.cycle_status_filter == nil
assert payload.boolean_filters == %{}
end
test "expands visible composite Name/Address cells into their constituent db columns" do
# Compact mode: the composite :name / :address cells are visible and their
# constituent columns are hidden from member_fields_visible_db. The export
# must still carry name and address, split into their db components.
assigns = %{
selected_members: MapSet.new(),
member_fields_visible_db: [:join_date],
member_fields_visible_computed: [],
member_fields_visible: [:name, :address, :join_date],
member_include_email: true,
visible_custom_field_ids: [],
all_custom_fields: []
}
payload = ExportPayload.build(assigns)
# Constituents are emitted in Mv.Constants.member_fields/0 order, interleaved
# with the ordinary visible db field (join_date sits between the name and
# address groups per constants order).
assert payload.column_order == [
"first_name",
"last_name",
"email",
"join_date",
"city",
"street",
"house_number",
"postal_code"
]
# The composite pseudo-columns themselves are never exported.
refute "name" in payload.member_fields
refute "address" in payload.member_fields
end
test "omits email from the Name composite expansion when it is not folded into the cell" do
# include_email off: email is its own column (exported only if separately
# visible), so the Name composite expands to first/last name only.
assigns = %{
selected_members: MapSet.new(),
member_fields_visible_db: [:join_date],
member_fields_visible_computed: [],
member_fields_visible: [:name, :join_date],
member_include_email: false,
visible_custom_field_ids: [],
all_custom_fields: []
}
payload = ExportPayload.build(assigns)
assert payload.column_order == ["first_name", "last_name", "join_date"]
refute "email" in payload.column_order
end
end
end

View file

@ -6,248 +6,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
alias MvWeb.MemberLive.Index.FieldVisibility
# Mock custom field structs for testing
defp create_custom_field(id, name, show_in_overview \\ true) do
%{
id: id,
name: name,
show_in_overview: show_in_overview
}
end
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
test "compact member + compact address: Name, E-Mail, Adresse lead" do
result = FieldVisibility.offered_member_fields(true, false, true)
describe "get_all_available_fields/1" do
test "returns member fields and custom fields" do
custom_fields = [
create_custom_field("cf1", "Custom Field 1"),
create_custom_field("cf2", "Custom Field 2")
]
result = FieldVisibility.get_all_available_fields(custom_fields)
# Should include all member fields
assert :first_name in result
assert :email in result
assert :street in result
# Should include custom fields as strings
assert "custom_field_cf1" in result
assert "custom_field_cf2" in result
assert Enum.take(result, 3) == [:name, :email, :address]
# the composite constituents are not offered while their composite is on
refute :first_name in result
refute :street in result
# the Datenfelder-ordered rest trails the identity block
assert index_of(result, :address) < index_of(result, :join_date)
end
test "handles empty custom fields list" do
result = FieldVisibility.get_all_available_fields([])
test "compact member with email folded in: no separate E-Mail column" do
result = FieldVisibility.offered_member_fields(true, true, true)
# Should only have member fields
assert :first_name in result
assert :email in result
refute Enum.any?(result, fn field ->
is_binary(field) and String.starts_with?(field, "custom_field_")
end)
end
test "includes all member fields from constants" do
custom_fields = []
result = FieldVisibility.get_all_available_fields(custom_fields)
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert field in result
end)
end
test "includes pseudo member fields (membership_fee_status, membership_fee_type, groups)" do
result = FieldVisibility.get_all_available_fields([])
assert :membership_fee_status in result
assert :membership_fee_type in result
assert :groups in result
end
end
describe "merge_with_global_settings/3" do
test "user selection overrides global settings" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{first_name: true, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "falls back to global settings when user selection is empty" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "defaults to true when field not in settings" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# first_name from settings
assert result["first_name"] == false
# email defaults to true (not in settings)
assert result["email"] == true
end
test "handles custom fields visibility" do
user_selection = %{}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true),
create_custom_field("cf2", "Custom 2", false)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == true
assert result["custom_field_cf2"] == false
end
test "user selection overrides custom field visibility" do
user_selection = %{"custom_field_cf1" => false}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == false
end
test "handles string keys in settings (JSONB format)" do
user_selection = %{}
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles mixed atom and string keys in settings" do
user_selection = %{}
# Use string keys only (as JSONB would return)
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles nil settings gracefully" do
user_selection = %{}
settings = %{member_field_visibility: nil}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should default all fields to true
assert result["first_name"] == true
assert result["email"] == true
end
test "handles missing member_field_visibility key" do
user_selection = %{}
settings = %{}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should default all fields to true
assert result["first_name"] == true
assert result["email"] == true
end
test "includes all fields in result" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{email: true}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should include all member fields
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Map.has_key?(result, Atom.to_string(field))
end)
# Should include custom fields
assert Map.has_key?(result, "custom_field_cf1")
end
end
describe "get_visible_fields/1" do
test "returns only fields with true visibility" do
selection = %{
"first_name" => true,
"email" => false,
"street" => true,
"custom_field_123" => false
}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :street in result
assert Enum.take(result, 2) == [:name, :address]
refute :email in result
refute "custom_field_123" in result
end
test "converts member field strings to atoms" do
selection = %{"first_name" => true, "email" => true}
test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do
result = FieldVisibility.offered_member_fields(false, false, true)
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :email in result
# the reported bug: the composite address must not jump ahead of the
# expanded name constituents
assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address]
assert index_of(result, :address) < index_of(result, :join_date)
end
test "keeps custom fields as strings" do
selection = %{"custom_field_abc-123" => true}
test "non-compact member + non-compact address: name, email, then the address block, then rest" do
result = FieldVisibility.offered_member_fields(false, false, false)
result = FieldVisibility.get_visible_fields(selection)
assert Enum.take(result, 7) == [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city
]
assert "custom_field_abc-123" in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_fields(%{}) == []
end
test "handles all fields hidden" do
selection = %{"first_name" => false, "email" => false}
assert FieldVisibility.get_visible_fields(selection) == []
end
test "handles invalid input" do
assert FieldVisibility.get_visible_fields(nil) == []
# join_date and later Datenfelder columns never precede the address block
assert index_of(result, :city) < index_of(result, :join_date)
refute :name in result
refute :address in result
end
end
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
describe "get_visible_member_fields/1" do
test "returns only member fields that are visible" do
selection = %{

View file

@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.MembershipFeeStatus
require Ash.Query
@ -26,35 +25,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
member
end
describe "load_cycles_for_members/2" do
test "efficiently loads cycles for members" do
fee_type = create_fee_type(%{interval: :yearly})
member1 = create_member(%{membership_fee_type_id: fee_type.id})
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
query =
Member
|> Ash.Query.filter(id in [^member1.id, ^member2.id])
|> MembershipFeeStatus.load_cycles_for_members()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = Ash.read!(query, actor: system_actor)
assert length(members) == 2
# Verify cycles are loaded
member1_loaded = Enum.find(members, &(&1.id == member1.id))
member2_loaded = Enum.find(members, &(&1.id == member2.id))
assert member1_loaded.membership_fee_cycles != nil
assert member2_loaded.membership_fee_cycles != nil
end
end
describe "get_cycle_status_for_member/2" do
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
@ -339,27 +309,5 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
assert length(filtered) == 1
assert List.first(filtered).id == member2.id
end
test "returns all members when filter is nil" do
fee_type = create_fee_type(%{interval: :yearly})
member1 = create_member(%{membership_fee_type_id: fee_type.id})
member2 = create_member(%{membership_fee_type_id: fee_type.id})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
# filter_unpaid_members should still work for backwards compatibility
filtered = MembershipFeeStatus.filter_unpaid_members(members, false)
# Both members have no cycles, so both should be filtered out
assert Enum.empty?(filtered)
end
end
end

View file

@ -0,0 +1,78 @@
defmodule MvWeb.MemberLive.Index.OverviewQueryCycleStatusTest do
@moduledoc """
§1.13 the paid/unpaid cycle-status filter, under the current or last-completed
cycle view, returns the same members as the previous in-memory classifier,
computed DB-side via the `cycle_end`-backed aggregates.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
@today ~D[2024-07-15]
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
ft = create_fee_type(%{interval: :yearly}, actor)
# current 2024 paid, last-completed 2023 unpaid
paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
# current 2024 unpaid, last-completed 2023 paid
unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor)
# no cycles
_no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
%{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now}
end
defp db_ids(status, show_current, actor) do
OverviewQuery.build(%{
cycle_status_filter: status,
show_current_cycle: show_current,
today: @today
})
|> Ash.read!(actor: actor)
|> MapSet.new(& &1.id)
end
defp oracle_ids(status, show_current, actor) do
Member
|> Ash.Query.load(membership_fee_cycles: [:membership_fee_type])
|> Ash.Query.load(:membership_fee_type)
|> Ash.read!(actor: actor)
|> Enum.filter(fn m ->
MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status
end)
|> MapSet.new(& &1.id)
end
# {status, show_current, member-key expected in the result}
@cases [
{:paid, true, :paid_now},
{:unpaid, true, :unpaid_now},
{:paid, false, :unpaid_now},
{:unpaid, false, :paid_now}
]
for {status, show_current, expected_key} <- @cases do
test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle",
%{actor: actor} = ctx do
status = unquote(status)
show_current = unquote(show_current)
expected = Map.fetch!(ctx, unquote(expected_key))
db = db_ids(status, show_current, actor)
assert db == oracle_ids(status, show_current, actor)
assert expected.id in db
end
end
end

View file

@ -0,0 +1,135 @@
defmodule MvWeb.MemberLive.Index.OverviewQueryTest do
@moduledoc """
Resource-level tests for the DB-backed overview query builder: keyset
pagination and the standard DB filters/sort pushed down from the LiveView.
"""
use Mv.DataCase, async: false
import Mv.Fixtures,
only: [member_fixture_with_actor: 2, create_fee_type: 2, group_fixture: 1]
alias Mv.Membership
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
defp ids(results), do: Enum.map(results, & &1.id) |> MapSet.new()
defp read_all_pages(query, actor, limit) do
page = Ash.read!(query, page: [limit: limit], actor: actor)
collect_pages(page, [page.results])
end
defp collect_pages(%{more?: true} = page, acc) do
next = Ash.page!(page, :next)
collect_pages(next, [next.results | acc])
end
defp collect_pages(_page, acc), do: acc |> Enum.reverse() |> List.flatten()
describe "keyset pagination" do
test "concatenated pages cover the full set once with no duplicates", %{actor: actor} do
members =
for _ <- 1..5, do: member_fixture_with_actor(%{}, actor)
expected = ids(members)
query = OverviewQuery.build(%{sort_field: :inserted_at, sort_order: :asc})
all = read_all_pages(query, actor, 2)
assert ids(all) == expected
assert length(all) == length(Enum.uniq_by(all, & &1.id))
end
end
describe "DB filters" do
test "search matches name DB-side", %{actor: actor} do
hit = member_fixture_with_actor(%{first_name: "Zorblax", last_name: "Quux"}, actor)
_miss = member_fixture_with_actor(%{first_name: "Otto", last_name: "Normal"}, actor)
results =
OverviewQuery.build(%{search: "Zorblax"})
|> Ash.read!(actor: actor)
assert hit.id in Enum.map(results, & &1.id)
refute Enum.any?(results, &(&1.first_name == "Otto"))
end
test "group :in and :not_in resolve DB-side", %{actor: actor} do
group = group_fixture(%{name: "Board #{System.unique_integer([:positive])}"})
in_member = member_fixture_with_actor(%{}, actor)
out_member = member_fixture_with_actor(%{}, actor)
{:ok, _} =
Membership.create_member_group(%{member_id: in_member.id, group_id: group.id},
actor: actor
)
opts = %{group_filters: %{to_string(group.id) => :in}, groups: [group]}
in_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
assert in_member.id in in_ids
refute out_member.id in in_ids
not_opts = %{group_filters: %{to_string(group.id) => :not_in}, groups: [group]}
out_ids = OverviewQuery.build(not_opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
refute in_member.id in out_ids
assert out_member.id in out_ids
end
test "fee-type :in resolves DB-side", %{actor: actor} do
ft = create_fee_type(%{}, actor)
with_ft = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
without_ft = member_fixture_with_actor(%{}, actor)
opts = %{fee_type_filters: %{to_string(ft.id) => :in}, fee_types: [ft]}
result_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
assert with_ft.id in result_ids
refute without_ft.id in result_ids
end
end
describe "group sort (DB aggregate)" do
test "sorts by first group name DB-side, members with groups before those without", %{
actor: actor
} do
g_a = group_fixture(%{name: "AAA #{System.unique_integer([:positive])}"})
g_z = group_fixture(%{name: "ZZZ #{System.unique_integer([:positive])}"})
m_a = member_fixture_with_actor(%{}, actor)
m_z = member_fixture_with_actor(%{}, actor)
m_none = member_fixture_with_actor(%{}, actor)
{:ok, _} =
Membership.create_member_group(%{member_id: m_a.id, group_id: g_a.id}, actor: actor)
{:ok, _} =
Membership.create_member_group(%{member_id: m_z.id, group_id: g_z.id}, actor: actor)
asc =
OverviewQuery.build(%{sort_field: :groups, sort_order: :asc})
|> Ash.read!(actor: actor)
|> Enum.map(& &1.id)
|> Enum.filter(&(&1 in [m_a.id, m_z.id, m_none.id]))
# group members first (AAA before ZZZ), member without groups last
assert asc == [m_a.id, m_z.id, m_none.id]
end
end
describe "sort" do
test "sort by last_name is deterministic with id tie-breaker", %{actor: actor} do
a = member_fixture_with_actor(%{last_name: "Same"}, actor)
b = member_fixture_with_actor(%{last_name: "Same"}, actor)
results =
OverviewQuery.build(%{sort_field: :last_name, sort_order: :asc})
|> Ash.read!(actor: actor)
ordered = results |> Enum.filter(&(&1.id in [a.id, b.id])) |> Enum.map(& &1.id)
assert ordered == Enum.sort([a.id, b.id])
end
end
end

View file

@ -0,0 +1,109 @@
defmodule MvWeb.MemberLive.Index.ViewSettingsTest do
@moduledoc """
§1.20 / §1.5 the member-overview view settings (density + the compact
member/address field toggles) and their per-browser persistence chain
(connect params > session > cookie > global default).
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.ViewSettings
test "defaults are comfortable density with the compact cells and email line on" do
assert ViewSettings.defaults() == %{
density: :comfortable,
compact_member: true,
member_include_email: true,
compact_address: true
}
end
describe "parse/1" do
test "reads a partial map with string keys and string/bool values" do
assert ViewSettings.parse(%{
"density" => "comfortable",
"compact_member" => false,
"member_include_email" => true
}) == %{
density: :comfortable,
compact_member: false,
member_include_email: true
}
end
test "ignores unknown keys and invalid values" do
assert ViewSettings.parse(%{"density" => "huge", "bogus" => true}) == %{}
end
test "returns empty map for non-maps" do
assert ViewSettings.parse(nil) == %{}
assert ViewSettings.parse("x") == %{}
end
end
describe "get_from_* sources" do
test "session JSON string" do
session = %{"member_view_settings" => ~s({"density":"comfortable"})}
assert ViewSettings.get_from_session(session) == %{density: :comfortable}
assert ViewSettings.get_from_session(%{}) == %{}
end
test "cookie header" do
json = ~s({"compact_address":false})
conn = %Plug.Conn{req_headers: [{"cookie", "member_view_settings=#{URI.encode(json)}"}]}
assert ViewSettings.get_from_cookie(conn) == %{compact_address: false}
assert ViewSettings.get_from_cookie(%Plug.Conn{req_headers: []}) == %{}
assert ViewSettings.get_from_cookie(nil) == %{}
end
test "connect params (raw JSON string under view_settings)" do
params = %{"view_settings" => ~s({"density":"comfortable","compact_member":false})}
assert ViewSettings.get_from_connect_params(params) == %{
density: :comfortable,
compact_member: false
}
assert ViewSettings.get_from_connect_params(nil) == %{}
end
end
describe "resolve/3 priority connect_params > session > cookie > default" do
test "falls back to defaults when nothing is stored" do
assert ViewSettings.resolve(%{}, nil, nil) == ViewSettings.defaults()
end
test "cookie overrides default, session overrides cookie, connect params win" do
cookie_json = ~s({"density":"comfortable","compact_member":false,"compact_address":false})
conn = %Plug.Conn{
req_headers: [{"cookie", "member_view_settings=#{URI.encode(cookie_json)}"}]
}
session = %{"member_view_settings" => ~s({"compact_member":true})}
connect_params = %{"view_settings" => ~s({"density":"compact"})}
assert ViewSettings.resolve(session, conn, connect_params) == %{
# from connect params (highest)
density: :compact,
# from session (overrides cookie)
compact_member: true,
# from cookie
compact_address: false,
# default
member_include_email: true
}
end
end
test "to_json/1 round-trips through parse/1" do
settings = %{
density: :comfortable,
compact_member: false,
member_include_email: true,
compact_address: false
}
assert settings |> ViewSettings.to_json() |> Jason.decode!() |> ViewSettings.parse() ==
settings
end
end

View file

@ -0,0 +1,82 @@
defmodule MvWeb.MemberLive.IndexA11yHardeningTest do
@moduledoc """
§3.5 WCAG 2.2 AA posture: 24px interactive targets in both densities,
scroll-margin on rows so a focused row is not obscured by the sticky header,
select-all `indeterminate` for partial selection, and reflow markup keeping the
table in its own focusable scroll region.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
@moduletag :ui
setup %{conn: conn} do
actor = SystemActor.get_system_actor()
members =
for i <- 1..3 do
{:ok, m} =
Mv.Membership.create_member(
%{first_name: "Wcag#{i}", last_name: "Row", email: "wcag#{i}@example.com"},
actor: actor
)
m
end
%{conn: conn_with_oidc_user(conn), members: members}
end
test "rows reserve scroll-margin under the sticky header", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/members")
assert html =~ "scroll-mt-16"
end
test "the table lives in its own focusable scroll region that reflows", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/members")
# Focusable region (1.4.10: table scrolls within its own focusable region).
assert html =~ ~r/data-testid="members-table-scroll"[^>]*tabindex="0"/
# The toolbar reflows to one column on narrow viewports.
assert html =~ "flex-wrap"
end
test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# The row/select-all checkboxes are pinned to 1.5rem (24px) in both densities
# via the `#members-table-guard input.checkbox` CSS rule (WCAG 2.5.8), which
# overrides DaisyUI's density-proportional sizing. The faithful proxy in a
# LiveView test is that the guarded checkbox markup exists in both densities.
assert has_element?(view, "#members-table-guard input[type='checkbox'].checkbox")
# Toggle density via the view-settings dropdown (default is comfortable, so
# this switches to compact) to check the other density too.
view |> element("[data-testid='view-settings-button']") |> render_click()
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "#members-table-guard input[type='checkbox'].checkbox")
end
test "select-all reflects indeterminate for a partial selection", %{
conn: conn,
members: members
} do
{:ok, view, _html} = live(conn, ~p"/members")
# No selection: not indeterminate.
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
# Select one of several -> partial -> indeterminate.
[m | _] = members
render_click(view, "select_member", %{"id" => m.id})
assert has_element?(view, "#select-all-checkbox[data-indeterminate='true']")
# Select all -> fully checked, not indeterminate.
view |> element("[phx-click='select_all']") |> render_click()
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
assert has_element?(view, "#select-all-checkbox[checked]")
end
end

View file

@ -0,0 +1,87 @@
defmodule MvWeb.MemberLive.IndexAddressCellTest do
@moduledoc """
§1.1 The address renders as one composite cell: line 1 = street + house
number, line 2 = postal code + city.
§1.12 City and postal code stay findable via search despite the composite
cell.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
defp create_member(attrs) do
{:ok, member} =
Mv.Membership.create_member(
Map.merge(
%{first_name: "Addr", last_name: "Tester", email: "addr@example.com"},
attrs
),
actor: SystemActor.get_system_actor()
)
member
end
test "address shows as one cell with street/house on line 1 and postal/city on line 2", %{
conn: conn
} do
member =
create_member(%{
street: "Hauptstraße",
house_number: "12a",
postal_code: "10115",
city: "Berlin"
})
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
cell =
view
|> element("#row-#{member.id} [data-testid='member-address']")
|> render()
assert cell =~ "Hauptstraße"
assert cell =~ "12a"
assert cell =~ "10115"
assert cell =~ "Berlin"
# Composite, not four separate sortable address columns.
refute has_element?(view, "[data-testid='street']")
refute has_element?(view, "[data-testid='city']")
end
test "member with no address parts renders a screen-reader label, not a blank cell", %{
conn: conn
} do
member = create_member(%{email: "no-address@example.com"})
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
row = view |> element("#row-#{member.id}") |> render()
assert row =~ "No address"
refute has_element?(view, "#row-#{member.id} [data-testid='member-address']")
end
test "members stay findable by city via search", %{conn: conn} do
member = create_member(%{city: "Hamburg", email: "city-search@example.com"})
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members?query=Hamburg")
assert has_element?(view, "#row-#{member.id}")
end
test "members stay findable by postal code via search", %{conn: conn} do
member = create_member(%{postal_code: "99999", email: "postal-search@example.com"})
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members?query=99999")
assert has_element?(view, "#row-#{member.id}")
end
end

View file

@ -0,0 +1,83 @@
defmodule MvWeb.MemberLive.IndexBooleanCustomFieldFilterTest do
@moduledoc """
§1.15 DB-backed boolean custom-field filter (in/not_in) must match the
previous in-memory behaviour exactly, including a member with no stored value
row for the field.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "newsletter_#{System.unique_integer([:positive])}",
value_type: :boolean,
show_in_overview: true
})
|> Ash.create(actor: actor)
yes = member_fixture_with_actor(%{}, actor)
no = member_fixture_with_actor(%{}, actor)
none = member_fixture_with_actor(%{}, actor)
set_bool(yes, field, true, actor)
set_bool(no, field, false, actor)
%{actor: actor, field: field, yes: yes, no: no, none: none}
end
defp set_bool(member, field, bool, actor) do
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field.id,
value: %{"_union_type" => "boolean", "_union_value" => bool}
})
|> Ash.create!(actor: actor)
end
defp db_ids(filters, field, actor) do
OverviewQuery.build(%{
boolean_custom_field_filters: filters,
boolean_custom_fields: [field]
})
|> Ash.read!(actor: actor)
|> MapSet.new(& &1.id)
end
defp oracle_ids(filters, field, actor) do
members =
Member
|> Ash.Query.load(custom_field_values: [:custom_field])
|> Ash.read!(actor: actor)
Index.apply_boolean_custom_field_filters(members, filters, [field])
|> MapSet.new(& &1.id)
end
test "filter true matches only members with stored true value", ctx do
filters = %{to_string(ctx.field.id) => true}
assert db_ids(filters, ctx.field, ctx.actor) == oracle_ids(filters, ctx.field, ctx.actor)
assert ctx.yes.id in db_ids(filters, ctx.field, ctx.actor)
refute ctx.no.id in db_ids(filters, ctx.field, ctx.actor)
refute ctx.none.id in db_ids(filters, ctx.field, ctx.actor)
end
test "filter false matches only members with stored false value (not missing rows)", ctx do
filters = %{to_string(ctx.field.id) => false}
assert db_ids(filters, ctx.field, ctx.actor) == oracle_ids(filters, ctx.field, ctx.actor)
assert ctx.no.id in db_ids(filters, ctx.field, ctx.actor)
refute ctx.yes.id in db_ids(filters, ctx.field, ctx.actor)
refute ctx.none.id in db_ids(filters, ctx.field, ctx.actor)
end
end

View file

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

View file

@ -0,0 +1,76 @@
defmodule MvWeb.MemberLive.IndexColumnManagerTest do
@moduledoc """
§1.6 The column manager sits in the filter toolbar, offers a per-column
visibility toggle plus All and None controls styled as buttons (not links),
and applies changes while preserving the existing persistence chain.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Manager", last_name: "Columns", email: "manager@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Both the filter panel trigger and the column-manager trigger render in the
# same toolbar.
assert has_element?(view, ~s(button[aria-label="Filter members"]))
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
end
test "All and None are buttons, not links, and per-column toggles are checkboxes", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
# All / None render as buttons with button styling.
assert has_element?(view, "button.btn[phx-click='select_all']", "All")
assert has_element?(view, "button.btn[phx-click='select_none']", "None")
refute has_element?(view, "a[phx-click='select_all']")
refute has_element?(view, "a[phx-click='select_none']")
# Per-column visibility toggles are accessible checkbox menu items.
assert has_element?(view, "button[role='menuitemcheckbox'][phx-value-item='name']")
end
test "toggling a column applies and is reflected in the URL (persistence)", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
view |> element("button[phx-value-item='join_date']") |> render_click()
# The selection is pushed to the URL fields param so it survives reloads.
path = assert_patch(view)
assert path =~ "fields="
refute has_element?(view, "[data-testid='join_date']")
end
test "a reset button restores the curated default column set", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
# Reset is a button styled like All/None, sitting next to them, with a tooltip.
assert has_element?(view, "button.btn[phx-click='reset_fields']")
# Hide a curated-default column, then reset restores it.
view |> element("button[phx-value-item='join_date']") |> render_click()
assert_patch(view)
refute has_element?(view, "[data-testid='join_date']")
view |> element("button[phx-click='reset_fields']") |> render_click()
assert_patch(view)
assert has_element?(view, "[data-testid='join_date']")
end
end

View file

@ -203,17 +203,28 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
conn: conn,
field_string: field
} do
conn = conn_with_oidc_user(conn)
# Email is folded into the Member cell by default; turn the fold off so email
# is a separate, sortable column for this cross-column-sort test.
conn =
conn
|> conn_with_oidc_user()
|> Plug.Conn.put_session("member_view_settings", ~s({"member_include_email":false}))
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
live(
conn,
"/members?fields=email&query=&sort_field=custom_field_#{field.id}&sort_order=desc"
)
# Click on email column
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
# The fields param rides along on the patch, so assert the sort outcome.
path = assert_patch(view)
assert path =~ "sort_field=email"
assert path =~ "sort_order=asc"
end
test "clicking custom field column after regular column works", %{

View file

@ -0,0 +1,91 @@
defmodule MvWeb.MemberLive.IndexDateCustomFieldFilterTest do
@moduledoc """
§1.16 DB-backed date custom-field range filter must match the previous
in-memory behaviour exactly, including inclusive boundaries and members with
no stored value row (excluded).
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "birthday_#{System.unique_integer([:positive])}",
value_type: :date,
show_in_overview: true
})
|> Ash.create(actor: actor)
on_lower = member_with_date(field, ~D[2024-01-01], actor)
inside = member_with_date(field, ~D[2024-06-15], actor)
on_upper = member_with_date(field, ~D[2024-12-31], actor)
below = member_with_date(field, ~D[2023-12-31], actor)
above = member_with_date(field, ~D[2025-01-01], actor)
none = member_fixture_with_actor(%{}, actor)
%{
actor: actor,
field: field,
on_lower: on_lower,
inside: inside,
on_upper: on_upper,
below: below,
above: above,
none: none
}
end
defp member_with_date(field, date, actor) do
member = member_fixture_with_actor(%{}, actor)
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field.id,
value: %{"_union_type" => "date", "_union_value" => date}
})
|> Ash.create!(actor: actor)
member
end
defp filters(field), do: %{to_string(field.id) => %{from: ~D[2024-01-01], to: ~D[2024-12-31]}}
defp db_ids(field, actor) do
OverviewQuery.build(%{date_filters: filters(field), date_custom_fields: [field]})
|> Ash.read!(actor: actor)
|> MapSet.new(& &1.id)
end
defp oracle_ids(field, actor) do
members =
Member
|> Ash.Query.load(custom_field_values: [:custom_field])
|> Ash.read!(actor: actor)
DateFilter.apply_in_memory(members, filters(field), [field])
|> MapSet.new(& &1.id)
end
test "inclusive range matches in-memory oracle, boundaries included", ctx do
db = db_ids(ctx.field, ctx.actor)
assert db == oracle_ids(ctx.field, ctx.actor)
assert ctx.on_lower.id in db
assert ctx.inside.id in db
assert ctx.on_upper.id in db
refute ctx.below.id in db
refute ctx.above.id in db
refute ctx.none.id in db
end
end

View file

@ -0,0 +1,65 @@
defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
@moduledoc """
§1.2 With no persisted column selection, exactly the curated default columns
are visible: selection checkbox, Name (composite, no in-cell email line),
E-Mail (its own column while the compact Member field keeps the email line off),
Address (composite), fee type, fee status, groups, join date.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Col",
last_name: "Default",
email: "col@example.com",
street: "Musterweg",
house_number: "1",
postal_code: "10115",
city: "Berlin"
},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
test "curated columns are visible by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Composite Name + Address cells.
assert has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='member-address']")
# E-Mail is folded into the Member cell by default (include-email on), so it
# shows as the in-cell email line rather than a separate column.
assert has_element?(view, "[data-testid='member-name-email']")
# Fee type, fee status, groups, join date headers.
assert has_element?(view, "[data-testid='membership_fee_type']")
assert has_element?(view, "[data-testid='join_date']")
assert has_element?(view, "th", "Membership Fee Status")
assert has_element?(view, "[data-testid='groups']")
end
test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# The composite Name/Address cells replace their constituents by default, and
# the email is folded into the Member cell (include-email on), so no separate
# email column shows either.
for field <- ~w(first_name last_name email city street house_number postal_code country) do
refute has_element?(view, "[data-testid='#{field}']")
end
end
test "an explicit field selection still overrides the curated default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members?fields=join_date")
assert has_element?(view, "[data-testid='join_date']")
refute has_element?(view, "[data-testid='member-name']")
end
end

View file

@ -0,0 +1,73 @@
defmodule MvWeb.MemberLive.IndexDensityTest do
@moduledoc """
§1.4 The density toggle switches the table row-spacing token and reflects the
active density in the control state.
§1.5 Density persists across reload per browser (session/cookie), with the
global default applying when nothing is stored.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Dense", last_name: "Row", email: "dense@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp open_view_settings(view) do
view |> element("[data-testid='view-settings-button']") |> render_click()
view
end
test "defaults to comfortable when nothing is stored", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
# The compact-mode toggle reflects the active (comfortable, i.e. not compact) state.
open_view_settings(view)
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='false']")
end
test "toggle switches the density token and the control state", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='true']")
# Toggling again returns to comfortable.
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
end
test "restores a persisted density from the session on reload", %{conn: conn} do
conn =
Plug.Test.init_test_session(conn, %{
"member_view_settings" => ~s({"density":"comfortable"})
})
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
end
test "restores a persisted density on the connected mount via connect params", %{conn: conn} do
# Faithful reload round-trip: the connected mount cannot read the cookie
# (the live socket's connect-info map has no cookies), so the client echoes
# the persisted value through connect params. This is the path that was
# broken in the R1 accept (cookie written but not restored on mount).
conn = put_connect_params(conn, %{"view_settings" => ~s({"density":"comfortable"})})
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
end
end

View file

@ -22,6 +22,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
require Ash.Query
# Disables the compact Member/Address composites (per-browser view setting) so
# the individual constituent columns (first_name/last_name/email, street/…) are
# offered by the column manager and available via ?fields=.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
end
test "displays all member fields in dropdown", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# The individual name/address constituents are only offered when the
# compact Member/Address composites are off, so opt out of them first.
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
@ -148,23 +161,23 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify email is visible initially
# The curated Name column carries the member names, so they are visible.
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Anderson"
# Open dropdown and hide email
# Open dropdown and hide the Name column
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_click()
# Email should no longer be visible
# The Name column (and the names it carries) is no longer visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "bob@example.com"
refute html =~ "Anderson"
refute html =~ "Brown"
end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
@ -242,7 +255,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
describe "URL parameter persistence" do
test "field selection is persisted in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Email is offered as a togglable column only when it is not folded into the
# Member cell; unfold it so the dropdown lists it.
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown and hide email
@ -298,8 +313,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# All fields should be visible by default
assert html =~ "alice@example.com"
# The curated Name and Address columns are visible by default.
assert html =~ "Anderson"
assert html =~ "Main St"
end
@ -309,17 +324,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Hide a field via dropdown
# Hide the curated Name column (which carries the names) via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_click()
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "Anderson"
end
end
@ -328,16 +343,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=")
# Should fall back to global settings
assert html =~ "alice@example.com"
# Should fall back to global settings (curated Name column visible)
assert html =~ "Anderson"
end
test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
# Should ignore invalid fields and use defaults
assert html =~ "alice@example.com"
# Should ignore invalid fields and use defaults (curated Name column visible)
assert html =~ "Anderson"
end
test "handles custom field that doesn't exist", %{conn: conn} do
@ -361,7 +376,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
end
test "handles rapid toggling", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
@ -429,23 +444,23 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify email is visible initially
# The curated Name column carries the names, so they are visible initially.
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Anderson"
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Simulate Enter key press on email field button
# Simulate Enter key press on the Name field button
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_keydown(%{key: "Enter"})
# Email should no longer be visible
# The Name column (and the names it carries) is no longer visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "Anderson"
end
end
end

View file

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

View file

@ -0,0 +1,245 @@
defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
@moduledoc """
§2.1 Filter result-set parity.
For generated member populations × filter-parameter combinations, the set of
member ids returned by the DB-backed `:overview` query equals the set returned
by the previous in-memory implementation (the reference oracle).
The unchanged DB filters (group, fee-type) are applied identically in both
arms; the property isolates the filters that moved from memory to the DB
(boolean custom fields, date custom fields, paid/unpaid cycle status) by
applying them DB-side in one arm and in-memory in the other.
"""
use Mv.DataCase, async: false
use ExUnitProperties
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
@today ~D[2024-07-15]
@cycle_starts [~D[2023-01-01], ~D[2024-01-01], ~D[2025-01-01]]
@date_values [~D[2023-06-01], ~D[2024-06-15], ~D[2025-06-01]]
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
ft1 = create_fee_type(%{interval: :yearly}, actor)
ft2 = create_fee_type(%{interval: :yearly}, actor)
{:ok, bool_cf} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "flag_#{System.unique_integer([:positive])}",
value_type: :boolean,
show_in_overview: true
})
|> Ash.create(actor: actor)
{:ok, date_cf} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "dt_#{System.unique_integer([:positive])}",
value_type: :date,
show_in_overview: true
})
|> Ash.create(actor: actor)
%{actor: actor, ft1: ft1, ft2: ft2, bool_cf: bool_cf, date_cf: date_cf}
end
# ----- generators ---------------------------------------------------------
defp status_gen, do: StreamData.member_of([:unpaid, :paid, :suspended])
defp member_spec_gen do
StreamData.fixed_map(%{
fee_type: StreamData.member_of([:ft1, :ft2]),
cycles:
StreamData.list_of(
StreamData.tuple({StreamData.member_of(@cycle_starts), status_gen()}),
max_length: 3
),
bool: StreamData.member_of([:none, true, false]),
date: StreamData.member_of([:none | @date_values]),
in_group: StreamData.boolean()
})
end
defp filter_params_gen do
StreamData.fixed_map(%{
cycle_status: StreamData.member_of([nil, :paid, :unpaid]),
show_current: StreamData.boolean(),
bool: StreamData.member_of([nil, true, false]),
date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]),
group: StreamData.member_of([nil, :in, :not_in]),
fee_type: StreamData.member_of([nil, :in, :not_in])
})
end
# ----- helpers ------------------------------------------------------------
defp clear_members(actor) do
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
end
defp create_population(specs, ctx) do
Enum.map(specs, fn spec ->
ft = if spec.fee_type == :ft1, do: ctx.ft1, else: ctx.ft2
member = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, ctx.actor)
spec.cycles
|> Enum.uniq_by(fn {start, _} -> start end)
|> Enum.each(fn {start, status} ->
create_cycle(member, ft, %{cycle_start: start, status: status}, ctx.actor)
end)
maybe_set_value(member, ctx.bool_cf, spec.bool, "boolean", ctx.actor)
maybe_set_value(member, ctx.date_cf, spec.date, "date", ctx.actor)
if spec.in_group, do: add_to_group(member, ctx, ctx.actor)
{member.id, spec}
end)
end
defp maybe_set_value(_member, _cf, :none, _type, _actor), do: :ok
defp maybe_set_value(member, cf, value, type, actor) do
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: cf.id,
value: %{"_union_type" => type, "_union_value" => value}
})
|> Ash.create!(actor: actor)
end
defp add_to_group(member, ctx, actor) do
Mv.Membership.create_member_group(%{member_id: member.id, group_id: ctx.group.id},
actor: actor
)
end
defp base_opts(params, ctx) do
%{}
|> put_group(params.group, ctx)
|> put_fee_type(params.fee_type, ctx)
end
defp put_group(opts, nil, _ctx), do: opts
defp put_group(opts, dir, ctx),
do: Map.merge(opts, %{group_filters: %{to_string(ctx.group.id) => dir}, groups: [ctx.group]})
defp put_fee_type(opts, nil, _ctx), do: opts
defp put_fee_type(opts, dir, ctx),
do:
Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]})
defp moved_opts(params, ctx) do
%{today: @today}
|> put_cycle(params.cycle_status, params.show_current)
|> put_bool(params.bool, ctx)
|> put_date(params.date_range, ctx)
end
defp put_cycle(opts, nil, _show), do: opts
defp put_cycle(opts, status, show),
do: Map.merge(opts, %{cycle_status_filter: status, show_current_cycle: show})
defp put_bool(opts, nil, _ctx), do: opts
defp put_bool(opts, bool, ctx),
do:
Map.merge(opts, %{
boolean_custom_field_filters: %{to_string(ctx.bool_cf.id) => bool},
boolean_custom_fields: [ctx.bool_cf]
})
defp put_date(opts, nil, _ctx), do: opts
defp put_date(opts, {from, to}, ctx),
do:
Map.merge(opts, %{
date_filters: %{to_string(ctx.date_cf.id) => %{from: from, to: to}},
date_custom_fields: [ctx.date_cf]
})
defp db_ids(params, ctx) do
base_opts(params, ctx)
|> Map.merge(moved_opts(params, ctx))
|> OverviewQuery.build()
|> Ash.read!(actor: ctx.actor)
|> MapSet.new(& &1.id)
end
defp oracle_ids(params, ctx) do
base_members =
base_opts(params, ctx)
|> OverviewQuery.build()
|> Ash.Query.load([
:membership_fee_type,
{:custom_field_values, [:custom_field]},
{:membership_fee_cycles, [:membership_fee_type]}
])
|> Ash.read!(actor: ctx.actor)
base_members
|> apply_oracle_bool(params, ctx)
|> apply_oracle_date(params, ctx)
|> apply_oracle_cycle(params)
|> MapSet.new(& &1.id)
end
defp apply_oracle_bool(members, %{bool: nil}, _ctx), do: members
defp apply_oracle_bool(members, %{bool: bool}, ctx) do
Index.apply_boolean_custom_field_filters(
members,
%{to_string(ctx.bool_cf.id) => bool},
[ctx.bool_cf]
)
end
defp apply_oracle_date(members, %{date_range: nil}, _ctx), do: members
defp apply_oracle_date(members, %{date_range: {from, to}}, ctx) do
DateFilter.apply_in_memory(
members,
%{to_string(ctx.date_cf.id) => %{from: from, to: to}},
[ctx.date_cf]
)
end
defp apply_oracle_cycle(members, %{cycle_status: nil}), do: members
defp apply_oracle_cycle(members, %{cycle_status: status, show_current: show}) do
Enum.filter(members, fn m ->
MembershipFeeStatus.get_cycle_status_for_member(m, show, @today) == status
end)
end
property "DB-backed filter result set equals the in-memory oracle", ctx do
check all(
specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5),
params <- filter_params_gen(),
max_runs: 30
) do
clear_members(ctx.actor)
group = Mv.Fixtures.group_fixture(%{name: "G#{System.unique_integer([:positive])}"})
ctx = Map.put(ctx, :group, group)
_population = create_population(specs, ctx)
assert db_ids(params, ctx) == oracle_ids(params, ctx)
end
end
end

View file

@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
require Ash.Query
# Disables the compact Member composite (per-browser view setting) so the
# individual first_name column is offered and selectable via ?fields=.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -122,10 +132,13 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in")
live(
conn,
"/members?fields=first_name&sort_field=first_name&sort_order=desc&group_#{group1.id}=in"
)
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")

View file

@ -0,0 +1,86 @@
defmodule MvWeb.MemberLive.IndexKeysetPropertyTest do
@moduledoc """
§2.2 Keyset pagination completeness: concatenating every keyset page yields
exactly the full filtered set once each (no duplicates, no gaps) in a stable
total order.
§2.3 Sort determinism with the unique `id` tie-breaker: members equal on the
sort key are ordered by `id`, and the result set equals the unsorted set.
"""
use Mv.DataCase, async: false
use ExUnitProperties
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
defp clear_members(actor) do
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
end
defp read_all_pages(query, actor, limit) do
page = Ash.read!(query, page: [limit: limit], actor: actor)
collect(page, page.results)
end
defp collect(%{more?: true} = page, acc) do
next = Ash.page!(page, :next)
collect(next, acc ++ next.results)
end
defp collect(_page, acc), do: acc
property "keyset pages cover the full set once, no dupes or gaps", %{actor: actor} do
check all(
n <- integer(1..12),
limit <- integer(1..5),
sort <- member_of([:last_name, :join_date, :inserted_at]),
order <- member_of([:asc, :desc]),
max_runs: 25
) do
clear_members(actor)
# Deliberate ties on last_name so the tie-breaker is exercised.
members =
for i <- 1..n do
member_fixture_with_actor(%{last_name: "Tie#{rem(i, 3)}"}, actor)
end
expected = MapSet.new(members, & &1.id)
query = OverviewQuery.build(%{sort_field: sort, sort_order: order})
paged = read_all_pages(query, actor, limit)
paged_ids = Enum.map(paged, & &1.id)
assert MapSet.new(paged_ids) == expected
assert length(paged_ids) == MapSet.size(expected)
end
end
property "sort with ties is a deterministic total order broken by id", %{actor: actor} do
check all(
n <- integer(2..10),
order <- member_of([:asc, :desc]),
max_runs: 25
) do
clear_members(actor)
members =
for _ <- 1..n do
member_fixture_with_actor(%{last_name: "Same"}, actor)
end
ordered =
OverviewQuery.build(%{sort_field: :last_name, sort_order: order})
|> Ash.read!(actor: actor)
|> Enum.map(& &1.id)
# All last_names equal -> order is fully determined by the id tie-breaker (asc).
assert ordered == Enum.sort(Enum.map(members, & &1.id))
end
end
end

View file

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

View file

@ -38,27 +38,30 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# The composite Member cell carries first + last name and the in-cell email
# line (include-email on by default), so all three values are rendered.
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
assert html =~ field
end
end
test "respects show_in_overview config", %{conn: conn, member1: m} do
test "respects show_in_overview config", %{conn: conn} do
# Global settings still drive column visibility: hiding a curated default
# column via settings removes it, while unaffected columns stay visible.
{:ok, settings} = Mv.Membership.get_settings()
fields_to_hide = [:street, :house_number]
{:ok, _} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
member_field_visibility: %{"join_date" => false}
})
conn = conn_with_oidc_user(conn)
# Use search query to filter to only the expected member (Alice)
# This significantly improves test performance by avoiding loading all members from other tests
{:ok, _view, html} = live(conn, "/members?query=Alice")
{:ok, view, _html} = live(conn, "/members?query=Alice")
assert html =~ "Email"
assert html =~ m.email
refute html =~ m.street
refute has_element?(view, "[data-testid='join_date']")
# The composite Name column (and its email content) is unaffected.
assert has_element?(view, "[data-testid='member-name']")
end
end

View file

@ -95,6 +95,9 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
html = render(view)
# Should show suspended status (current cycle)
assert html =~ "hero-pause-circle" || html =~ "suspended"
# The toggle button exposes its pressed state to assistive tech.
assert has_element?(view, ~s([data-testid=toggle-cycle-view][aria-pressed="true"]))
end
test "shows correct color coding for paid status", %{conn: conn} do

View file

@ -0,0 +1,98 @@
defmodule MvWeb.MemberLive.IndexOverviewQuerySortTest do
@moduledoc """
§2.3 Sort determinism for custom-field sorts pushed to the DB.
The keyset property test covers standard fields; these examples pin the
type-aware custom-field ordering (numeric vs lexical, chronological dates) and
the NULLS-LAST behaviour for missing/empty values in both directions, which
the previous in-memory `CustomFieldSort` guaranteed.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
defp clear_members(actor) do
Mv.Membership.Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
end
defp create_field(value_type, actor) do
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "sort_#{value_type}_#{System.unique_integer([:positive])}",
value_type: value_type,
show_in_overview: true
})
|> Ash.create(actor: actor)
field
end
defp set_value(member, field, union_type, union_value, actor) do
{:ok, _} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field.id,
value: %{"_union_type" => union_type, "_union_value" => union_value}
})
|> Ash.create(actor: actor)
end
defp sorted_ids(field, order, actor) do
OverviewQuery.build(%{
sort_field: "custom_field_#{field.id}",
sort_order: order,
custom_fields: [field]
})
|> Ash.read!(actor: actor, page: [limit: 100])
|> then(& &1.results)
|> Enum.map(& &1.id)
end
test "integer custom field sorts numerically, missing value last in both directions", %{
actor: actor
} do
clear_members(actor)
field = create_field(:integer, actor)
a = member_fixture_with_actor(%{first_name: "A"}, actor)
b = member_fixture_with_actor(%{first_name: "B"}, actor)
none = member_fixture_with_actor(%{first_name: "N"}, actor)
# 9 vs 10: lexical order would place "10" before "9"; numeric must not.
set_value(a, field, "integer", 10, actor)
set_value(b, field, "integer", 9, actor)
assert sorted_ids(field, :asc, actor) == [b.id, a.id, none.id]
assert sorted_ids(field, :desc, actor) == [a.id, b.id, none.id]
end
test "date custom field sorts chronologically", %{actor: actor} do
clear_members(actor)
field = create_field(:date, actor)
older = member_fixture_with_actor(%{first_name: "Older"}, actor)
newer = member_fixture_with_actor(%{first_name: "Newer"}, actor)
set_value(older, field, "date", "1981-01-29", actor)
set_value(newer, field, "date", "1986-07-02", actor)
assert sorted_ids(field, :asc, actor) == [older.id, newer.id]
end
test "empty string value sorts last like a missing value", %{actor: actor} do
clear_members(actor)
field = create_field(:string, actor)
filled = member_fixture_with_actor(%{first_name: "Filled"}, actor)
empty = member_fixture_with_actor(%{first_name: "Empty"}, actor)
set_value(filled, field, "string", "AAA", actor)
set_value(empty, field, "string", "", actor)
assert sorted_ids(field, :asc, actor) == [filled.id, empty.id]
end
end

View file

@ -0,0 +1,96 @@
defmodule MvWeb.MemberLive.IndexPaginationTest do
@moduledoc """
§1.7 Mount loads only one keyset page, not the whole table.
§1.8 phx-viewport-bottom fetches and appends the next page; once the last
page is reached no further fetch is issued.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
require Ash.Query
# 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(id="members-load-more-sentinel")
end
test "the sentinel 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(id="members-load-more-sentinel")
html_again = render_hook(view, "load_more", %{})
assert row_count(html_again) == @page_limit + 10
end
test "selecting a row from a later page re-renders it as checked", %{conn: conn} do
seed_members(@page_limit + 10)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
# Load the second page so its rows are in the loaded window.
render_hook(view, "load_more", %{})
# The highest-index member sorts last and lives on the second page.
actor = SystemActor.get_system_actor()
last_member =
Mv.Membership.Member
|> Ash.Query.sort(first_name: :asc)
|> Ash.read!(actor: actor)
|> List.last()
html = render_click(view, "select_member", %{"id" => last_member.id})
# The re-streamed row for a later-page member reflects the selection.
assert html =~ ~s(id="row-#{last_member.id}")
assert has_element?(view, ~s(tr#row-#{last_member.id} input[type="checkbox"][checked]))
end
test "single page does not arm the infinite-scroll sentinel", %{conn: conn} do
seed_members(3)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
assert row_count(html) == 3
refute html =~ ~s(phx-viewport-bottom="load_more")
end
end

View file

@ -0,0 +1,56 @@
defmodule MvWeb.MemberLive.IndexSearchClearTest do
@moduledoc """
§1.3 Activating the search clear (×) control clears the query, removes the
URL search param, and resets the result set to the unfiltered list.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
actor = SystemActor.get_system_actor()
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Findable", last_name: "One", email: "findable@example.com"},
actor: actor
)
{:ok, other} =
Mv.Membership.create_member(
%{first_name: "Other", last_name: "Two", email: "other@example.com"},
actor: actor
)
%{conn: conn_with_oidc_user(conn), other: other}
end
test "clear control only appears when a query is present", %{conn: conn} do
{:ok, no_query, _} = live(conn, ~p"/members")
refute has_element?(no_query, "[data-testid='search-clear']")
{:ok, with_query, _} = live(conn, ~p"/members?query=Findable")
assert has_element?(with_query, "[data-testid='search-clear']")
end
test "clear resets query, URL param and the result set", %{conn: conn, other: other} do
{:ok, view, _html} = live(conn, ~p"/members?query=Findable")
# The filtered list excludes the non-matching member.
refute has_element?(view, "#row-#{other.id}")
view |> element("[data-testid='search-clear']") |> render_click()
# Query cleared in the URL (search param reset to empty).
path = assert_patch(view)
assert path =~ "query="
refute path =~ "query=Findable"
# Result set resets to the unfiltered list (the previously excluded member returns).
assert has_element?(view, "#row-#{other.id}")
# The input no longer carries the query.
refute has_element?(view, "[data-testid='search-input'][value='Findable']")
end
end

View file

@ -0,0 +1,55 @@
defmodule MvWeb.MemberLive.IndexSortA11yTest do
@moduledoc """
§1.11 When a column is sorted, exactly one `th` carries `aria-sort` with the
correct direction, a non-color sort glyph is present, and the sort is reflected
in the URL.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
@moduletag :ui
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Sortable", last_name: "Member", email: "sortable@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp aria_sort_count(html) do
~r/aria-sort="/ |> Regex.scan(html) |> length()
end
test "exactly one th carries aria-sort ascending with a non-color glyph", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=asc")
assert aria_sort_count(html) == 1
assert has_element?(view, "th[aria-sort='ascending']")
# Non-color shape glyph for the active ascending sort.
assert html =~ "hero-chevron-up"
end
test "aria-sort reflects descending and stays unique", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=desc")
assert aria_sort_count(html) == 1
assert has_element?(view, "th[aria-sort='descending']")
assert html =~ "hero-chevron-down"
end
test "clicking a header reflects the sort in the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[data-testid='join_date']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=join_date"
assert path =~ "sort_order=asc"
end
end

View file

@ -0,0 +1,139 @@
defmodule MvWeb.MemberLive.IndexSortNameAddressTest do
@moduledoc """
§1.21 the composite Member (Name) and Address columns are sortable DB-side:
Name by last name first name (email in the cell is display-only), Address by
city postal code street. Ascending/descending, reflected in the URL and in
`aria-sort` (§1.11).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
alias MvWeb.MemberLive.Index.OverviewQuery
setup %{conn: conn} do
actor = SystemActor.get_system_actor()
# Same last name, different first name — proves the first-name tie-breaker.
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Bruno",
last_name: "Meyer",
email: "z-bruno@example.com",
city: "Aachen",
postal_code: "52062",
street: "Zebra"
},
actor: actor
)
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Anna",
last_name: "Meyer",
email: "a-anna@example.com",
city: "Zwickau",
postal_code: "08056",
street: "Alpha"
},
actor: actor
)
%{conn: conn_with_oidc_user(conn), actor: actor}
end
defp read_names(query, actor) do
query
|> Ash.read!(actor: actor)
|> Enum.map(&{&1.last_name, &1.first_name})
end
test "name sort orders by last name then first name, ignoring email", %{actor: actor} do
asc = OverviewQuery.build(%{sort_field: :last_name, sort_order: :asc}) |> read_names(actor)
# Both share last name "Meyer"; the first-name tie-breaker orders Anna before
# Bruno, even though Bruno's email sorts first alphabetically.
assert asc == [{"Meyer", "Anna"}, {"Meyer", "Bruno"}]
desc = OverviewQuery.build(%{sort_field: :last_name, sort_order: :desc}) |> read_names(actor)
assert desc == [{"Meyer", "Bruno"}, {"Meyer", "Anna"}]
end
test "address sort orders by city, then postal code, then street", %{actor: actor} do
asc =
OverviewQuery.build(%{sort_field: :address, sort_order: :asc})
|> Ash.read!(actor: actor)
|> Enum.map(& &1.city)
assert asc == ["Aachen", "Zwickau"]
desc =
OverviewQuery.build(%{sort_field: :address, sort_order: :desc})
|> Ash.read!(actor: actor)
|> Enum.map(& &1.city)
assert desc == ["Zwickau", "Aachen"]
end
test "clicking the Name header sorts and reflects state in URL and aria-sort", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# The composite header sorts by its active sub-field (last name by default).
view |> element("button[phx-click='sort'][data-testid='name']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=last_name"
# Last name is already the default sort (ascending), so the click toggles it
# to descending. Exactly one column carries aria-sort (§1.11).
assert has_element?(view, "th[aria-sort='descending']")
assert render(view) |> aria_sort_count() == 1
end
test "the sub-field menu sorts ascending by the chosen field without toggling", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Pick "first name" from the composite sub-field menu.
view |> element("[data-testid='member-sort-first_name']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=first_name"
assert path =~ "sort_order=asc"
assert has_element?(view, "th[aria-sort='ascending']")
# Selecting the same sub-field again keeps it ascending: the menu switches the
# sort key, it does not toggle direction (that is what a header click does).
view |> element("[data-testid='member-sort-first_name']") |> render_click()
path2 = assert_patch(view)
assert path2 =~ "sort_field=first_name"
assert path2 =~ "sort_order=asc"
end
test "after choosing a sub-field, a header click toggles that field's direction", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[data-testid='member-sort-first_name']") |> render_click()
assert_patch(view)
# Now sorting by first name ascending; a click on the header toggles to desc.
view |> element("button[phx-click='sort'][data-testid='name']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=first_name"
assert path =~ "sort_order=desc"
end
test "clicking the Address header sorts and reflects state in URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[phx-click='sort'][data-testid='address']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=address"
assert has_element?(view, "th[aria-sort='ascending']")
end
defp aria_sort_count(html) do
html |> String.split("aria-sort=") |> length() |> Kernel.-(1)
end
end

View file

@ -0,0 +1,60 @@
defmodule MvWeb.MemberLive.IndexStickyPinnedTest do
@moduledoc """
§1.19 When the table scrolls vertically and horizontally, the header stays
sticky. Horizontal pinning is limited to the checkbox column (with a scroll
fade marking the boundary) rather than also pinning the Name column;
overflowing cell content truncates with ellipsis and exposes a hover/focus
tooltip.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
@moduletag :ui
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Reginald",
last_name: "Worthington-Smythe",
email: "reginald@example.com",
street: "A Very Long Street Name That Overflows",
house_number: "123",
postal_code: "10115",
city: "Berlin"
},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
test "header is sticky and only the checkbox column is pinned", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/members")
# Sticky header (desktop).
assert html =~ "lg:sticky"
assert html =~ "lg:top-0"
# Only the checkbox column is pinned (left-0) now; a scroll fade marks the
# boundary instead of also pinning the Name column at left-12.
assert html =~ "left-0"
assert html =~ "sticky-first-col-th"
refute html =~ "left-12"
end
test "composite cells truncate and expose a tooltip via title", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
name_cell = view |> element("[data-testid='member-name']") |> render()
assert name_cell =~ "truncate"
assert name_cell =~ ~s(title="Reginald Worthington-Smythe")
address_cell = view |> element("[data-testid='member-address']") |> render()
assert address_cell =~ "truncate"
assert address_cell =~ ~s(title="A Very Long Street Name That Overflows 123")
end
end

View file

@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do
alias Mv.Membership.CustomFieldValue
alias MvWeb.MemberLive.Index, as: MemberIndex
# Disables the compact Member/Address composites (per-browser view setting) so
# the individual constituent columns (first_name/last_name, street/…) are
# offered by the column manager and selectable via ?fields=. The composite
# E-Mail column is offered even in compact mode, so email-only tests do not
# need this.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
describe "desktop layout: scroll container and sticky table header" do
@describetag :ui
@ -154,9 +167,17 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "sorting integration" do
@describetag :ui
# The curated default columns no longer expose the individual name/address
# sort headers. The composite E-Mail column is offered even in compact mode
# (so email-only tests just add it via ?fields=), but the name/address
# constituents are only offered once their composite view setting is off —
# those tests additionally opt out via `non_compact/1`. Either way a `fields`
# param rides on every push_patch, hence the relaxed substring assertions.
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Email is folded into the Member cell by default; unfold it to a separate
# sortable column for this column-sort test.
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=email")
# The component data test ids are built with the name of the field
# First click should sort ASC
@ -164,35 +185,47 @@ defmodule MvWeb.MemberLive.IndexTest do
|> element("[data-testid='email']")
|> render_click()
# The LiveView pushes a patch with the new query params
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
path_asc = assert_patch(view)
assert path_asc =~ "sort_field=email"
assert path_asc =~ "sort_order=asc"
# Second click toggles to DESC
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
path_desc = assert_patch(view)
assert path_desc =~ "sort_field=email"
assert path_desc =~ "sort_order=desc"
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
# Click on a different column
view
|> element("[data-testid='first_name']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
path = assert_patch(view)
assert path =~ "sort_field=first_name"
assert path =~ "sort_order=asc"
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
conn = conn |> conn_with_oidc_user() |> non_compact()
# default ascending sorting with first name
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
{:ok, view, _html} =
live(
conn,
"/members?fields=last_name,first_name,email,street,house_number,postal_code,city,country,join_date"
)
# default ascending sorting on the default sort field (last name)
assert has_element?(view, "[data-testid='last_name'][aria-label='ascending']")
sortable_fields = [
:email,
@ -209,54 +242,70 @@ defmodule MvWeb.MemberLive.IndexTest do
|> element("[data-testid='#{field}']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
path = assert_patch(view)
assert path =~ "sort_field=#{field}"
assert path =~ "sort_order=asc"
end
end
test "sorting works with search query", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=email&query=test")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
path = assert_patch(view)
assert path =~ "query=test"
assert path =~ "sort_field=email"
assert path =~ "sort_order=asc"
end
test "sorting maintains search query when toggling order", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
path = assert_patch(view)
assert path =~ "query=test"
assert path =~ "sort_field=email"
assert path =~ "sort_order=desc"
end
end
describe "URL param handling" do
@describetag :ui
test "handle_params reads sort query and applies it", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc")
# Check that the sort state is correctly applied
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc")
conn = conn |> conn_with_oidc_user() |> non_compact()
# Should not crash and should show default first name order
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
{:ok, view, _html} =
live(conn, "/members?fields=last_name&query=&sort_field=invalid_field&sort_order=asc")
# Should not crash and should fall back to the default sort (last name)
assert has_element?(view, "[data-testid='last_name'][aria-label='ascending']")
end
test "handle_params preserves search query with sort params", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=desc")
# Both search and sort should be preserved
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
@ -266,8 +315,10 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "search and sort integration" do
@describetag :ui
test "search maintains sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc")
# Perform search
view
@ -279,8 +330,10 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "sort maintains search state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc")
# Perform sort
view
@ -288,7 +341,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|> render_click()
# Search state should be maintained
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
path = assert_patch(view)
assert path =~ "query=test"
assert path =~ "sort_field=email"
assert path =~ "sort_order=desc"
end
end
@ -306,7 +362,8 @@ defmodule MvWeb.MemberLive.IndexTest do
# asserted on internal state to preserve the original coverage of the callback.
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.query == "Friedrich"
assert is_list(assigns.members)
# Loaded members are held as a `%{id => member}` map (the restream lookup window).
assert is_map(assigns.members)
end
@tag :ui
@ -653,27 +710,6 @@ defmodule MvWeb.MemberLive.IndexTest do
# Flash message should appear
assert has_element?(view, "#flash-group")
end
test "copy excludes a member whose email is blank from the recipient list", %{conn: conn} do
# The Member create action requires an email, so a blank-email member cannot
# be persisted; we exercise the preserved defensive filter in
# format_selected_member_emails/2 directly. One member has an email, the
# other has a blank one — only the former is a recipient (§1.10).
with_email = %{
id: Ecto.UUID.generate(),
first_name: "Has",
last_name: "Mail",
email: "has@example.com"
}
blank_email = %{id: Ecto.UUID.generate(), first_name: "Blank", last_name: "Mail", email: ""}
selected = MapSet.new([with_email.id, blank_email.id])
emails = MemberIndex.format_selected_member_emails([with_email, blank_email], selected)
assert emails == ["Has Mail <has@example.com>"]
_ = conn
end
end
describe "copy_emails empty-recipient feedback" do
@ -1069,6 +1105,63 @@ defmodule MvWeb.MemberLive.IndexTest do
assert bcc =~ "scope1%40example.com"
refute bcc =~ "scope2%40example.com"
end
test "selecting a member does not read the DB for mailto recipients (deferred to open)",
%{conn: conn, member1: member1} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# A checkbox toggle is a non-search interaction; it must not issue a member
# read just to precompute a mailto link the user may never open. The
# recipient list is fetched lazily when the bulk-actions dropdown opens.
member_reads =
capture_member_select_queries(fn ->
render_click(view, "select_member", %{"id" => member1.id})
end)
assert member_reads == [],
"select_member must not read members for the mailto recipients; got: #{inspect(member_reads)}"
# Opening the dropdown still surfaces the correct recipients for the selection.
bcc = mailto_bcc(view)
assert bcc =~ "scope1%40example.com"
refute bcc =~ "scope2%40example.com"
end
# Captures every SELECT against the members table emitted while `fun` runs.
defp capture_member_select_queries(fun) do
test_pid = self()
handler_id = "test-member-select-#{System.unique_integer([:positive])}"
:telemetry.attach(
handler_id,
[:mv, :repo, :query],
fn _event, _measurements, metadata, _config ->
sql = metadata[:query] || ""
if String.contains?(sql, "SELECT") and String.contains?(sql, "\"members\"") do
send(test_pid, {:member_query, sql})
end
end,
nil
)
try do
fun.()
after
:telemetry.detach(handler_id)
end
collect_member_queries([])
end
defp collect_member_queries(acc) do
receive do
{:member_query, sql} -> collect_member_queries([sql | acc])
after
0 -> Enum.reverse(acc)
end
end
end
describe "cycle status filter" do
@ -1516,11 +1609,11 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "boolean filters are preserved during navigation actions", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
boolean_field = create_boolean_custom_field()
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
live(conn, "/members?fields=email&bf_#{boolean_field.id}=true")
# Test sort toggle preserves filter
view
@ -1688,153 +1781,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.load!(:custom_field_values, actor: actor)
end
# Tests for get_boolean_custom_field_value/2
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
# Test the function (will fail until implemented)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == true
end
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == false
end
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
%{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
# Reload member with custom field values
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == true
end
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Member has no custom field value for this field
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create CustomFieldValue with nil value (edge case)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: boolean_field.id,
value: nil
})
|> Ash.create(actor: system_actor)
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
string_field = create_string_custom_field()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create string custom field value (not boolean)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create(actor: system_actor)
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
# Try to get boolean value from string field - should return nil
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
# Tests for apply_boolean_custom_field_filters/2
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
%{conn: _conn} do
@ -2372,12 +2318,12 @@ defmodule MvWeb.MemberLive.IndexTest do
# Should complete in less than 1 second (1000ms)
assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
# Verify filtering worked correctly - should show all true members
Enum.each(1..75, fn i ->
assert html =~ "TrueMember#{i}"
end)
# The overview now keyset-paginates: mount loads only the first page, not
# the whole filtered set (§1.7). The filter still resolves DB-side, so the
# loaded page contains only matching (true) members and never a non-matching
# (false) one.
assert html =~ "TrueMember"
# Should not show false members
Enum.each(1..75, fn i ->
refute html =~ "FalseMember#{i}"
end)

View file

@ -0,0 +1,105 @@
defmodule MvWeb.MemberLive.IndexViewSettingsTest do
@moduledoc """
§1.20 the view-settings dropdown toggles: the composite "Member" and address
fields, the "include email" sub-toggle and their effect on the rendered
columns. Turning a composite off surfaces the underlying separate columns.
§1.2 email is folded into the Member cell by default (include-email on).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Ada",
last_name: "Lovelace",
email: "ada@example.com",
street: "Baker Street",
house_number: "221",
postal_code: "10115",
city: "Berlin"
},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp open_view_settings(view) do
view |> element("[data-testid='view-settings-button']") |> render_click()
view
end
test "member cell shows the email line by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='member-name-email']", "ada@example.com")
end
test "the include-email sub-toggle removes the email line from the member cell", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Email is folded into the Member cell by default; the sub-toggle turns it off.
assert has_element?(view, "[data-testid='member-name-email']", "ada@example.com")
open_view_settings(view)
view |> element("[data-testid='view-setting-member-include-email']") |> render_click()
refute has_element?(view, "[data-testid='member-name-email']")
end
test "turning off the compact member field shows separate name columns", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-compact-member']") |> render_click()
refute has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "[data-testid='last_name']")
# With the composite off, the email is surfaced as its own column.
assert has_element?(view, "[data-testid='email']")
end
test "with the compact member field off, the include-email sub-toggle is hidden",
%{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
# While the composite Member field is on, the sub-toggle is available.
assert has_element?(view, "[data-testid='view-setting-member-include-email']")
view |> element("[data-testid='view-setting-compact-member']") |> render_click()
# Once the composite is off, the email is a separate column, so the
# in-cell "include email" sub-toggle no longer applies and is hidden.
refute has_element?(view, "[data-testid='view-setting-member-include-email']")
assert has_element?(view, "[data-testid='email']")
end
test "the compact address field renders a composite address cell by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='member-address']")
refute has_element?(view, "[data-testid='street']")
refute has_element?(view, "[data-testid='postal_code']")
refute has_element?(view, "[data-testid='city']")
end
test "turning off the compact address field shows separate address columns", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-compact-address']") |> render_click()
refute has_element?(view, "[data-testid='member-address']")
assert has_element?(view, "[data-testid='street']")
assert has_element?(view, "[data-testid='postal_code']")
assert has_element?(view, "[data-testid='city']")
end
end