Compare commits
No commits in common. "issue/mitgliederverwaltung-547" and "main" have entirely different histories.
issue/mitg
...
main
75 changed files with 1647 additions and 6729 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -5,18 +5,6 @@ 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/),
|
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).
|
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
|
## [1.3.0] - 2026-06-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
5
Justfile
5
Justfile
|
|
@ -30,11 +30,6 @@ reset-database:
|
||||||
seed-database:
|
seed-database:
|
||||||
mix run priv/repo/seeds.exs
|
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:
|
start-database:
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -678,79 +678,6 @@
|
||||||
|
|
||||||
/* This file is for your main application CSS */
|
/* 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
|
SortableList: drag-and-drop table rows
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
@ -846,104 +773,3 @@
|
||||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
|
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
|
||||||
outline: none;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
307
assets/js/app.js
307
assets/js/app.js
|
|
@ -36,22 +36,6 @@ function getBrowserTimezone() {
|
||||||
// Hooks for LiveView components
|
// Hooks for LiveView components
|
||||||
let Hooks = {}
|
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
|
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
|
||||||
Hooks.CopyToClipboard = {
|
Hooks.CopyToClipboard = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -142,29 +126,6 @@ Hooks.RowSelectionGuard = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width
|
|
||||||
// (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar
|
|
||||||
// lives in a full-width table row that is wider than the viewport when the table scrolls
|
|
||||||
// horizontally; matching the sticky element to the container's visible width keeps its
|
|
||||||
// centered content pinned at the bottom-center of the visible area instead of drifting off
|
|
||||||
// with the scrolled table. The data-scroll-container attribute names the container element id.
|
|
||||||
Hooks.StickyViewportWidth = {
|
|
||||||
mounted() {
|
|
||||||
this.container = document.getElementById(this.el.dataset.scrollContainer)
|
|
||||||
this.sync = () => {
|
|
||||||
if (this.container) this.el.style.width = this.container.clientWidth + "px"
|
|
||||||
}
|
|
||||||
this.sync()
|
|
||||||
window.addEventListener("resize", this.sync)
|
|
||||||
},
|
|
||||||
updated() {
|
|
||||||
this.sync()
|
|
||||||
},
|
|
||||||
destroyed() {
|
|
||||||
window.removeEventListener("resize", this.sync)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
|
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
|
||||||
Hooks.FocusRestore = {
|
Hooks.FocusRestore = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -352,199 +313,6 @@ 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
|
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||||
Hooks.SidebarState = {
|
Hooks.SidebarState = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -592,84 +360,15 @@ 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, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: () => ({
|
params: {
|
||||||
_csrf_token: csrfToken,
|
_csrf_token: csrfToken,
|
||||||
timezone: getBrowserTimezone(),
|
timezone: getBrowserTimezone()
|
||||||
view_settings: getCookie("member_view_settings")
|
},
|
||||||
}),
|
|
||||||
hooks: Hooks
|
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
|
// Listen for custom events from LiveView
|
||||||
window.addEventListener("phx:set-input-value", (e) => {
|
window.addEventListener("phx:set-input-value", (e) => {
|
||||||
const {id, value} = e.detail
|
const {id, value} = e.detail
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,6 @@ defmodule Mv.Membership.CustomFieldValue do
|
||||||
table "custom_field_values"
|
table "custom_field_values"
|
||||||
repo Mv.Repo
|
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
|
references do
|
||||||
reference :member, on_delete: :delete
|
reference :member, on_delete: :delete
|
||||||
reference :custom_field, on_delete: :delete
|
reference :custom_field, on_delete: :delete
|
||||||
|
|
@ -140,16 +133,6 @@ defmodule Mv.Membership.CustomFieldValue do
|
||||||
|
|
||||||
calculations do
|
calculations do
|
||||||
calculate :value_to_string, :string, expr(value[:value] <> "")
|
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
|
end
|
||||||
|
|
||||||
# Ensure a member can only have one custom field value per custom field
|
# Ensure a member can only have one custom field value per custom field
|
||||||
|
|
|
||||||
|
|
@ -266,18 +266,6 @@ defmodule Mv.Membership.Member do
|
||||||
accept [:vereinfacht_contact_id]
|
accept [:vereinfacht_contact_id]
|
||||||
end
|
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
|
# Action to handle fuzzy search on specific fields
|
||||||
read :search do
|
read :search do
|
||||||
argument :query, :string, allow_nil?: true
|
argument :query, :string, allow_nil?: true
|
||||||
|
|
@ -291,7 +279,27 @@ defmodule Mv.Membership.Member do
|
||||||
threshold =
|
threshold =
|
||||||
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
||||||
|
|
||||||
apply_fuzzy_search_filter(query, q, 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
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -730,13 +738,6 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
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
|
# Define identities for upsert operations
|
||||||
identities do
|
identities do
|
||||||
identity :unique_email, [:email]
|
identity :unique_email, [:email]
|
||||||
|
|
@ -1211,43 +1212,6 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
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
|
# Search Input Sanitization
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,7 @@ defmodule Mv.Membership do
|
||||||
resources do
|
resources do
|
||||||
resource Mv.Membership.Member do
|
resource Mv.Membership.Member do
|
||||||
define :create_member, action: :create_member
|
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 :list_members, action: :read
|
||||||
define :overview_members, action: :overview
|
|
||||||
define :update_member, action: :update_member
|
define :update_member, action: :update_member
|
||||||
define :destroy_member, action: :destroy
|
define :destroy_member, action: :destroy
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -13,9 +13,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
- `notes` - Optional notes for this cycle
|
- `notes` - Optional notes for this cycle
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
- **Denormalized cycle_end**: Set at create from cycle_start + interval (fee type).
|
- **No cycle_end field**: Calculated from cycle_start + interval (from 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
|
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
|
||||||
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
|
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
|
||||||
|
|
||||||
|
|
@ -48,14 +46,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
create :create do
|
create :create do
|
||||||
primary? true
|
primary? true
|
||||||
accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
|
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
|
end
|
||||||
|
|
||||||
update :update do
|
update :update do
|
||||||
|
|
@ -116,13 +106,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
description "Start date of the billing cycle"
|
description "Start date of the billing cycle"
|
||||||
end
|
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
|
attribute :amount, :decimal do
|
||||||
allow_nil? false
|
allow_nil? false
|
||||||
public? true
|
public? true
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -145,7 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(custom_field_ids_union)
|
|> load_custom_field_values_query(custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles)
|
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
|> 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 custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||||
|
|
||||||
defp maybe_load_cycles(query, false), do: query
|
defp maybe_load_cycles(query, false, _show_current), do: query
|
||||||
|
|
||||||
defp maybe_load_cycles(query, true) do
|
defp maybe_load_cycles(query, true, show_current) do
|
||||||
MembershipFeeStatus.load_cycles_for_members(query)
|
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_groups(query, false), do: query
|
defp maybe_load_groups(query, false), do: query
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
# Only generate if start_date <= end_date
|
# Only generate if start_date <= end_date
|
||||||
if start_date && Date.compare(start_date, end_date) != :gt do
|
if start_date && Date.compare(start_date, end_date) != :gt do
|
||||||
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
||||||
create_cycles(cycle_starts, member.id, fee_type.id, amount, interval, opts)
|
create_cycles(cycle_starts, member.id, fee_type.id, amount, opts)
|
||||||
else
|
else
|
||||||
{:ok, [], []}
|
{:ok, [], []}
|
||||||
end
|
end
|
||||||
|
|
@ -400,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, interval, opts) do
|
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
|
||||||
actor = get_actor(opts)
|
actor = get_actor(opts)
|
||||||
create_opts = Helpers.ash_actor_opts(actor)
|
create_opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
|
@ -414,10 +414,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
member_id: member_id,
|
member_id: member_id,
|
||||||
membership_fee_type_id: fee_type_id,
|
membership_fee_type_id: fee_type_id,
|
||||||
amount: amount,
|
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(
|
handle_cycle_creation_result(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
defmodule MvWeb.Components.BulkActionsDropdown do
|
defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Single "Actions" dropdown bundling the four member bulk actions, flattened to
|
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
|
||||||
one level: open in email program (mailto), copy email addresses, export to CSV,
|
one level: open in email program (mailto), copy email addresses, export to CSV,
|
||||||
export to PDF.
|
export to PDF.
|
||||||
|
|
||||||
|
|
@ -10,10 +10,10 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
|
|
||||||
## Scope and trigger badge
|
## Scope and trigger badge
|
||||||
|
|
||||||
The trigger reads the `Actions` label followed by a scope badge: an emphasized
|
The trigger reads `Aktionen` followed by a scope badge: an emphasized
|
||||||
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
|
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
|
||||||
badge otherwise — `filtered` when a search term or filter narrows the list,
|
badge otherwise — `gefiltert` when a search term or filter narrows the list,
|
||||||
`all` when nothing is selected and no search/filter is active. Only an actual
|
`alle` 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`
|
selection is emphasized. The badge sits inside the shared `dropdown_menu/1`
|
||||||
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
|
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
|
||||||
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
|
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
|
||||||
|
|
@ -36,13 +36,11 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
|
|
||||||
## Event routing
|
## Event routing
|
||||||
|
|
||||||
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` *un-targeted*, so the
|
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the
|
||||||
parent LiveView owns the `:open` state (passed back in via the `open` assign).
|
component owns its own `:open` state. The copy item carries an *un-targeted*
|
||||||
This lets the parent fetch the mailto recipients lazily when the dropdown opens
|
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's
|
||||||
rather than on every selection/filter change. The copy item likewise carries an
|
`handle_event/3` (which keeps access to `@members`), plus the
|
||||||
un-targeted `phx-click="copy_emails"`, which reaches the parent's
|
`CopyToClipboard` hook.
|
||||||
`handle_event/3` (which keeps access to `@members`), plus the `CopyToClipboard`
|
|
||||||
hook.
|
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
@ -74,13 +72,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|
||||||
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
|
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
|
||||||
|
|
||||||
# The parent owns :open and passes it in. Defaulting to false keeps the
|
# The parent never sets :open (the component owns it via toggle/close).
|
||||||
# component renderable in isolation (render_component/2) for structural tests
|
# Honouring an explicit :open assign keeps the component renderable in
|
||||||
# that omit it.
|
# isolation (render_component/2) for structural tests.
|
||||||
socket =
|
socket =
|
||||||
case Map.fetch(assigns, :open) do
|
case Map.fetch(assigns, :open) do
|
||||||
{:ok, open} -> assign(socket, :open, open)
|
{:ok, open} -> assign(socket, :open, open)
|
||||||
:error -> assign_new(socket, :open, fn -> false end)
|
:error -> socket
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -100,7 +98,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
button_label={gettext("Actions")}
|
button_label={gettext("Actions")}
|
||||||
icon="hero-bolt"
|
icon="hero-bolt"
|
||||||
open={@open}
|
open={@open}
|
||||||
phx_target={nil}
|
phx_target={@myself}
|
||||||
menu_width="w-70"
|
menu_width="w-70"
|
||||||
menu_align="left"
|
menu_align="left"
|
||||||
button_class="btn-secondary gap-2"
|
button_class="btn-secondary gap-2"
|
||||||
|
|
@ -216,7 +214,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
gettext("Too many recipients for this function. Copy the addresses or export the list.")
|
gettext("Too many recipients for this function. Copy the addresses or export the list.")
|
||||||
end
|
end
|
||||||
|
|
||||||
# The trigger scope is shown as a badge after the "Actions" label. Only an
|
# The trigger scope is shown as a badge after the "Aktionen" label. Only an
|
||||||
# actual selection is emphasized (primary); both the "filtered" and "all"
|
# actual selection is emphasized (primary); both the "filtered" and "all"
|
||||||
# scopes are muted (neutral), since neither means members are selected.
|
# scopes are muted (neutral), since neither means members are selected.
|
||||||
defp scope_label(assigns) do
|
defp scope_label(assigns) do
|
||||||
|
|
@ -234,6 +232,12 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Open/close are handled by the parent LiveView (see "Event routing"); the
|
@impl true
|
||||||
# component no longer owns dropdown state.
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -143,18 +143,12 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
attr :size, :string, values: ~w(sm md lg), default: "md"
|
attr :size, :string, values: ~w(sm md lg), default: "md"
|
||||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
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
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def button(assigns) do
|
def button(assigns) do
|
||||||
rest = assigns.rest
|
rest = assigns.rest
|
||||||
variant = assigns[:variant] || "primary"
|
variant = assigns[:variant] || "primary"
|
||||||
size = assigns[:size] || "md"
|
size = assigns[:size] || "md"
|
||||||
active_class = if assigns[:active], do: "btn-active", else: ""
|
|
||||||
|
|
||||||
variant_classes = %{
|
variant_classes = %{
|
||||||
"primary" => "btn-primary",
|
"primary" => "btn-primary",
|
||||||
|
|
@ -175,9 +169,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
base_class = Map.fetch!(variant_classes, variant)
|
base_class = Map.fetch!(variant_classes, variant)
|
||||||
size_class = size_classes[size]
|
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 =
|
||||||
assigns
|
assigns
|
||||||
|
|
@ -222,27 +214,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Renders a non-interactive badge with WCAG-compliant contrast.
|
Renders a non-interactive badge with WCAG-compliant contrast.
|
||||||
|
|
||||||
|
|
@ -279,7 +250,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
attr :size, :any,
|
attr :size, :any,
|
||||||
default: "md",
|
default: "md",
|
||||||
doc: "Badge size: xs | sm | md"
|
doc: "Badge size: sm | md"
|
||||||
|
|
||||||
attr :sr_label, :string,
|
attr :sr_label, :string,
|
||||||
default: nil,
|
default: nil,
|
||||||
|
|
@ -298,7 +269,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
variant_class = "badge-#{variant}"
|
variant_class = "badge-#{variant}"
|
||||||
style_class = badge_style_class(style)
|
style_class = badge_style_class(style)
|
||||||
size_class = badge_size_class(size)
|
size_class = "badge-#{size}"
|
||||||
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
# 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
|
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
||||||
|
|
||||||
|
|
@ -338,13 +309,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
defp badge_style_class("outline"), do: "badge-outline"
|
defp badge_style_class("outline"), do: "badge-outline"
|
||||||
defp badge_style_class(_), do: nil
|
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 """
|
@doc """
|
||||||
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
||||||
|
|
||||||
|
|
@ -479,11 +443,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :selected, :map, default: %{}
|
attr :selected, :map, default: %{}
|
||||||
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
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_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 :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_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)"
|
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
|
||||||
|
|
@ -587,31 +546,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
>
|
>
|
||||||
{gettext("None")}
|
{gettext("None")}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -1004,11 +938,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
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,
|
attr :sticky_header, :boolean,
|
||||||
default: false,
|
default: false,
|
||||||
doc:
|
doc:
|
||||||
|
|
@ -1024,11 +953,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
doc:
|
doc:
|
||||||
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
|
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
|
||||||
|
|
||||||
attr :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
|
slot :col, required: true do
|
||||||
attr :label, :string
|
attr :label, :string
|
||||||
attr :class, :string
|
attr :class, :string
|
||||||
|
|
@ -1040,10 +964,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||||
|
|
||||||
slot :footer,
|
|
||||||
doc:
|
|
||||||
"optional after-rows content rendered as a full-width row in a separate, non-streamed tbody. Combined with 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
|
def table(assigns) do
|
||||||
assigns =
|
assigns =
|
||||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||||
|
|
@ -1066,15 +986,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
assigns =
|
assigns =
|
||||||
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
||||||
|
|
||||||
# Total column span for full-width footer rows (e.g. the infinite-scroll
|
|
||||||
# loading indicator): fixed columns + dynamic custom-field columns + the
|
|
||||||
# optional action column.
|
|
||||||
col_count =
|
|
||||||
length(assigns.col) + length(assigns[:dynamic_cols] || []) +
|
|
||||||
if assigns.action != [], do: 1, else: 0
|
|
||||||
|
|
||||||
assigns = assign(assigns, :col_count, col_count)
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
id={@row_click && "#{@id}-keyboard"}
|
id={@row_click && "#{@id}-keyboard"}
|
||||||
|
|
@ -1082,31 +993,20 @@ defmodule MvWeb.CoreComponents do
|
||||||
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
||||||
phx-hook={@row_click && "TableRowKeydown"}
|
phx-hook={@row_click && "TableRowKeydown"}
|
||||||
>
|
>
|
||||||
<table class={["table table-zebra", @size_class]}>
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||||
class={[
|
class={[
|
||||||
table_th_class(col, @sticky_header),
|
table_th_class(col, @sticky_header),
|
||||||
@sticky_first_col && col_idx == 0 &&
|
@sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
|
||||||
"sticky-first-col-th sticky left-0 z-30 bg-base-100"
|
|
||||||
]}
|
]}
|
||||||
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||||
>
|
>
|
||||||
{col[:label]}
|
{col[:label]}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
|
||||||
: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
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||||
|
|
@ -1121,24 +1021,16 @@ defmodule MvWeb.CoreComponents do
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||||
id={@id}
|
|
||||||
phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}
|
|
||||||
>
|
|
||||||
<tr
|
<tr
|
||||||
:for={row <- @rows}
|
:for={row <- @rows}
|
||||||
id={@row_id && @row_id.(row)}
|
id={@row_id && @row_id.(row)}
|
||||||
class={
|
class={[
|
||||||
[
|
|
||||||
table_row_tr_class(
|
table_row_tr_class(
|
||||||
table_row_selected?(assigns, row),
|
table_row_selected?(assigns, row),
|
||||||
@sticky_first_col
|
@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-row-interactive={@row_click && "true"}
|
||||||
data-selected={table_row_selected?(assigns, row) && "true"}
|
data-selected={table_row_selected?(assigns, row) && "true"}
|
||||||
title={@row_click && @row_tooltip}
|
title={@row_click && @row_tooltip}
|
||||||
|
|
@ -1211,37 +1103,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
<td
|
<td
|
||||||
:for={dyn_col <- @dynamic_cols}
|
:for={dyn_col <- @dynamic_cols}
|
||||||
phx-click={@row_click && @row_click.(row)}
|
phx-click={@row_click && @row_click.(row)}
|
||||||
class={[
|
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
|
||||||
"max-w-xs",
|
|
||||||
dyn_col[:custom_field].value_type != :boolean && "truncate",
|
|
||||||
@row_click && "hover:cursor-pointer"
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<%= 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
|
{if dyn_col[:render] do
|
||||||
rendered = dyn_col[:render].(@row_item.(row))
|
rendered = dyn_col[:render].(@row_item.(row))
|
||||||
|
|
||||||
|
|
@ -1253,7 +1116,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
else
|
else
|
||||||
""
|
""
|
||||||
end}
|
end}
|
||||||
<% end %>
|
|
||||||
</td>
|
</td>
|
||||||
<td :if={@action != []} class="w-0 font-semibold">
|
<td :if={@action != []} class="w-0 font-semibold">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
|
|
@ -1264,23 +1126,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<%!-- After-rows footer in its own, non-streamed tbody so it stays put
|
|
||||||
outside the phx-update="stream" container. When 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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
@ -1315,8 +1160,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
|
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
|
||||||
# bg-base-100 + z-10 keep the sticky header opaque and above body rows on vertical scroll.
|
# hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10)
|
||||||
# Sort tooltips use the Popover API (top layer) and need no header z-lift.
|
# so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background.
|
||||||
defp table_th_class(col, sticky_header) do
|
defp table_th_class(col, sticky_header) do
|
||||||
base = Map.get(col, :class)
|
base = Map.get(col, :class)
|
||||||
sticky = if sticky_header, do: sticky_th_classes(), else: nil
|
sticky = if sticky_header, do: sticky_th_classes(), else: nil
|
||||||
|
|
@ -1328,7 +1173,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
defp table_th_sticky_class(_), do: nil
|
defp table_th_sticky_class(_), do: nil
|
||||||
|
|
||||||
defp sticky_th_classes,
|
defp sticky_th_classes,
|
||||||
do: "lg:sticky lg:top-0 bg-base-100 z-10"
|
do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ defmodule MvWeb.Layouts do
|
||||||
|
|
||||||
<div class="drawer-content flex flex-col relative z-0">
|
<div class="drawer-content flex flex-col relative z-0">
|
||||||
<!-- Mobile Header (only visible on mobile) -->
|
<!-- Mobile Header (only visible on mobile) -->
|
||||||
<header class="lg:hidden sticky top-0 z-40 navbar bg-base-100 shadow-sm">
|
<header class="lg:hidden sticky top-0 z-10 navbar bg-base-100 shadow-sm">
|
||||||
<label
|
<label
|
||||||
for="mobile-drawer"
|
for="mobile-drawer"
|
||||||
class="btn btn-square btn-ghost"
|
class="btn btn-square btn-ghost"
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles)
|
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||||
|
|
||||||
|
|
@ -338,10 +338,10 @@ defmodule MvWeb.MemberExportController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_cycles(query, false), do: query
|
defp maybe_load_cycles(query, false, _show_current), do: query
|
||||||
|
|
||||||
defp maybe_load_cycles(query, true) do
|
defp maybe_load_cycles(query, true, show_current) do
|
||||||
MembershipFeeStatus.load_cycles_for_members(query)
|
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_groups(query, false), do: query
|
defp maybe_load_groups(query, false), do: query
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
<.dropdown_menu
|
<.dropdown_menu
|
||||||
id="field-visibility-menu"
|
id="field-visibility-menu"
|
||||||
icon="hero-adjustments-horizontal"
|
icon="hero-adjustments-horizontal"
|
||||||
button_label={gettext("Columns")}
|
button_label={gettext("Show/Hide Columns")}
|
||||||
items={@all_items}
|
items={@all_items}
|
||||||
checkboxes={true}
|
checkboxes={true}
|
||||||
selected={@selected_fields}
|
selected={@selected_fields}
|
||||||
open={@open}
|
open={@open}
|
||||||
show_select_buttons={true}
|
show_select_buttons={true}
|
||||||
show_reset_button={true}
|
|
||||||
phx_target={@myself}
|
phx_target={@myself}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -116,13 +115,6 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
{:noreply, assign(socket, :selected_fields, all)}
|
{:noreply, assign(socket, :selected_fields, all)}
|
||||||
end
|
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
|
# select none
|
||||||
def handle_event("select_none", _params, socket) do
|
def handle_event("select_none", _params, socket) do
|
||||||
none =
|
none =
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -49,17 +49,6 @@ defmodule MvWeb.Components.SearchBarComponent do
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
phx-debounce="300"
|
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>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
"""
|
"""
|
||||||
|
|
@ -72,10 +61,4 @@ defmodule MvWeb.Components.SearchBarComponent do
|
||||||
send(self(), {:search_changed, q})
|
send(self(), {:search_changed, q})
|
||||||
{:noreply, assign(socket, :query, q)}
|
{:noreply, assign(socket, :query, q)}
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,57 +6,41 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
- label: string() # Column Heading (can be an heex template)
|
- label: string() # Column Heading (can be an heex template)
|
||||||
- sort_field: atom() | nil # current sort field from parent liveview
|
- sort_field: atom() | nil # current sort field from parent liveview
|
||||||
- sort_order: :asc | :desc | nil # current sorting order
|
- 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
|
use MvWeb, :live_component
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
{:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)}
|
{:ok, assign(socket, assigns)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if we can add the aria-sort label directly to the daisyUI header
|
# Check if we can add the aria-sort label directly to the daisyUI header
|
||||||
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
|
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
assigns =
|
|
||||||
assigns
|
|
||||||
|> assign(:tooltip_id, "sort-tooltip-#{assigns.field}")
|
|
||||||
|> assign(:hint, assigns.sort_hint || gettext("Click to sort"))
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div>
|
<div>
|
||||||
<button
|
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
|
||||||
id={"sort-btn-#{@field}"}
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={aria_sort(@field, @sort_field, @sort_order, @hint)}
|
variant="ghost"
|
||||||
aria-describedby={@tooltip_id}
|
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
||||||
class="link link-hover no-underline w-full flex items-center gap-1 text-left text-current select-none"
|
class="select-none"
|
||||||
phx-click="sort"
|
phx-click="sort"
|
||||||
phx-value-field={@field}
|
phx-value-field={@field}
|
||||||
phx-hook="PopoverTooltip"
|
|
||||||
data-tooltip-id={@tooltip_id}
|
|
||||||
data-testid={@field}
|
data-testid={@field}
|
||||||
>
|
>
|
||||||
{@label}
|
{@label}
|
||||||
<%= if @sort_field == @field do %>
|
<%= if @sort_field == @field do %>
|
||||||
<.icon
|
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
|
||||||
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
|
|
||||||
class="sort-icon"
|
|
||||||
/>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<.icon
|
<.icon
|
||||||
name="hero-chevron-up-down"
|
name="hero-chevron-up-down"
|
||||||
class="sort-icon opacity-40"
|
class="opacity-40"
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
</button>
|
</.button>
|
||||||
<div id={@tooltip_id} popover="manual" role="tooltip" class="popover-tooltip">
|
</.tooltip>
|
||||||
{aria_sort(@field, @sort_field, @sort_order, @hint)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -64,13 +48,13 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# Hilfsfunktionen für ARIA Attribute & Icon SVG
|
# Hilfsfunktionen für ARIA Attribute & Icon SVG
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
defp aria_sort(field, sort_field, dir, hint) when field == sort_field do
|
defp aria_sort(field, sort_field, dir) when field == sort_field do
|
||||||
case dir do
|
case dir do
|
||||||
:asc -> gettext("ascending")
|
:asc -> gettext("ascending")
|
||||||
:desc -> gettext("descending")
|
:desc -> gettext("descending")
|
||||||
nil -> hint
|
nil -> gettext("Click to sort")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp aria_sort(_, _, _, hint), do: hint
|
defp aria_sort(_, _, _), do: gettext("Click to sort")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
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
|
|
@ -5,7 +5,6 @@
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.BulkActionsDropdown}
|
module={MvWeb.Components.BulkActionsDropdown}
|
||||||
id="bulk-actions-dropdown"
|
id="bulk-actions-dropdown"
|
||||||
open={@bulk_actions_open}
|
|
||||||
export_payload_json={@export_payload_json}
|
export_payload_json={@export_payload_json}
|
||||||
selected_count={@selected_count}
|
selected_count={@selected_count}
|
||||||
scope={@scope}
|
scope={@scope}
|
||||||
|
|
@ -40,7 +39,7 @@
|
||||||
boolean_filters={@boolean_custom_field_filters}
|
boolean_filters={@boolean_custom_field_filters}
|
||||||
date_custom_fields={@date_custom_fields}
|
date_custom_fields={@date_custom_fields}
|
||||||
date_filters={@date_filters}
|
date_filters={@date_filters}
|
||||||
member_count={@total_count}
|
member_count={length(@members)}
|
||||||
/>
|
/>
|
||||||
<.tooltip
|
<.tooltip
|
||||||
content={
|
content={
|
||||||
|
|
@ -53,9 +52,7 @@
|
||||||
<.button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="gap-2"
|
class={["gap-2", @show_current_cycle && "btn-active"]}
|
||||||
active={@show_current_cycle}
|
|
||||||
aria-pressed={to_string(@show_current_cycle)}
|
|
||||||
phx-click="toggle_cycle_view"
|
phx-click="toggle_cycle_view"
|
||||||
data-testid="toggle-cycle-view"
|
data-testid="toggle-cycle-view"
|
||||||
aria-label={
|
aria-label={
|
||||||
|
|
@ -74,7 +71,6 @@
|
||||||
</span>
|
</span>
|
||||||
</.button>
|
</.button>
|
||||||
</.tooltip>
|
</.tooltip>
|
||||||
<div class="ml-auto flex items-center gap-4">
|
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||||
id="field-visibility-dropdown"
|
id="field-visibility-dropdown"
|
||||||
|
|
@ -82,28 +78,6 @@
|
||||||
custom_fields={@all_custom_fields}
|
custom_fields={@all_custom_fields}
|
||||||
selected_fields={@user_field_selection}
|
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>
|
</div>
|
||||||
|
|
||||||
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
|
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
|
||||||
|
|
@ -114,94 +88,49 @@
|
||||||
data-testid="members-table-scroll"
|
data-testid="members-table-scroll"
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={gettext("Members table")}
|
aria-label={gettext("Members table")}
|
||||||
aria-busy={to_string(@loading?)}
|
|
||||||
data-density={@density}
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
rows={@streams.members}
|
rows={@members}
|
||||||
row_item={fn {_dom_id, member} -> member end}
|
|
||||||
wrapper_overflow_class="overflow-visible"
|
wrapper_overflow_class="overflow-visible"
|
||||||
sticky_header={true}
|
sticky_header={true}
|
||||||
sticky_first_col={true}
|
sticky_first_col={true}
|
||||||
infinite_scroll={@more?}
|
row_id={fn member -> "row-#{member.id}" end}
|
||||||
row_click={
|
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
|
||||||
fn {_dom_id, member} ->
|
|
||||||
JS.push("select_row_and_navigate", value: %{id: member.id})
|
|
||||||
end
|
|
||||||
}
|
|
||||||
row_tooltip={gettext("Click for member details")}
|
row_tooltip={gettext("Click for member details")}
|
||||||
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
|
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
|
||||||
dynamic_cols={@dynamic_cols}
|
dynamic_cols={@dynamic_cols}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
|
|
||||||
>
|
>
|
||||||
|
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<input
|
<.input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="select-all-checkbox"
|
|
||||||
name="select_all"
|
name="select_all"
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
phx-click="select_all"
|
phx-click="select_all"
|
||||||
phx-hook="IndeterminateCheckbox"
|
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
||||||
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")}
|
aria-label={gettext("Select all members")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<input
|
<.input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={member.id}
|
name={member.id}
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
checked={MapSet.member?(@selected_members, member.id)}
|
checked={MapSet.member?(@selected_members, member.id)}
|
||||||
aria-label={gettext("Select member")}
|
aria-label={gettext("Select member")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
/>
|
/>
|
||||||
</:col>
|
</: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
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:first_name in @member_fields_visible}
|
:if={:first_name in @member_fields_visible}
|
||||||
sort_field={:first_name}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -220,7 +149,6 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:last_name in @member_fields_visible}
|
:if={:last_name in @member_fields_visible}
|
||||||
sort_field={:last_name}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -239,7 +167,6 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:email in @member_fields_visible}
|
:if={:email in @member_fields_visible}
|
||||||
sort_field={:email}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -255,127 +182,9 @@
|
||||||
>
|
>
|
||||||
{member.email}
|
{member.email}
|
||||||
</:col>
|
</: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
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:join_date in @member_fields_visible}
|
:if={:join_date in @member_fields_visible}
|
||||||
sort_field={:join_date}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -394,7 +203,6 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:exit_date in @member_fields_visible}
|
:if={:exit_date in @member_fields_visible}
|
||||||
sort_field={:exit_date}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -420,7 +228,6 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:country in @member_fields_visible}
|
:if={:country in @member_fields_visible}
|
||||||
sort_field={:country}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -436,10 +243,81 @@
|
||||||
>
|
>
|
||||||
{member.country}
|
{member.country}
|
||||||
</:col>
|
</: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
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_start_date in @member_fields_visible}
|
:if={:membership_fee_start_date in @member_fields_visible}
|
||||||
sort_field={:membership_fee_start_date}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -458,7 +336,6 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_type in @member_fields_visible}
|
:if={:membership_fee_type in @member_fields_visible}
|
||||||
sort_field={:membership_fee_type}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -495,7 +372,6 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:groups in @member_fields_visible}
|
:if={:groups in @member_fields_visible}
|
||||||
sort_field={:groups}
|
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -510,7 +386,6 @@
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||||
<div class="flex flex-wrap gap-1 w-max">
|
|
||||||
<%= for group <- (member.groups || []) do %>
|
<%= for group <- (member.groups || []) do %>
|
||||||
<.badge
|
<.badge
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
@ -520,7 +395,6 @@
|
||||||
{group.name}
|
{group.name}
|
||||||
</.badge>
|
</.badge>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
|
||||||
</.maybe_value>
|
</.maybe_value>
|
||||||
</:col>
|
</:col>
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
|
|
@ -530,37 +404,6 @@
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
</:action>
|
</:action>
|
||||||
<:footer>
|
|
||||||
<%!-- Invisible sentinel: starts the next-page load as it approaches the
|
|
||||||
viewport (see the LoadMorePrefetch hook), so rows load ahead of reaching
|
|
||||||
the very bottom. Only rendered while more pages exist (with the footer). --%>
|
|
||||||
<div
|
|
||||||
id="members-load-more-sentinel"
|
|
||||||
phx-hook="LoadMorePrefetch"
|
|
||||||
data-scroll-container="members-table-guard"
|
|
||||||
data-event="load_more"
|
|
||||||
data-margin="600px"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-0"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<%!-- 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>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -271,54 +271,6 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Returns the UUID string keys of `filters` that name an active (at-least-one-
|
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`
|
bound-set) custom date field. The UUID must appear in `date_custom_fields`
|
||||||
|
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -29,8 +29,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.Cookie
|
|
||||||
|
|
||||||
@cookie_name "member_field_selection"
|
@cookie_name "member_field_selection"
|
||||||
@cookie_max_age 365 * 24 * 60 * 60
|
@cookie_max_age 365 * 24 * 60 * 60
|
||||||
@session_key "member_field_selection"
|
@session_key "member_field_selection"
|
||||||
|
|
@ -82,7 +80,7 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
%{}
|
%{}
|
||||||
|
|
||||||
[cookie_header | _rest] ->
|
[cookie_header | _rest] ->
|
||||||
cookies = Cookie.parse_header(cookie_header)
|
cookies = parse_cookie_header(cookie_header)
|
||||||
|
|
||||||
case Map.get(cookies, @cookie_name) do
|
case Map.get(cookies, @cookie_name) do
|
||||||
nil -> %{}
|
nil -> %{}
|
||||||
|
|
@ -92,6 +90,19 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Saves field selection to cookie.
|
Saves field selection to cookie.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,197 +29,51 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
|
|
||||||
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
# 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).
|
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
|
||||||
# :name and :address are composite display-only columns (Name = name + email,
|
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
|
||||||
# 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/API may accept this as alias; must not appear in the UI options list.
|
||||||
@export_only_alias :payment_status
|
@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
|
defp overview_member_fields do
|
||||||
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Builds field selection from URL, scoped to the columns offered for the given
|
Gets all available fields for selection.
|
||||||
view settings (`:compact_member` / `:member_include_email` / `:compact_address`
|
|
||||||
in `opts`): fields in `url_selection` are visible, all others false.
|
|
||||||
|
|
||||||
Use when `?fields=...` is in the URL so column visibility is not merged with
|
Returns a list of field identifiers:
|
||||||
global settings.
|
- 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)
|
||||||
"""
|
"""
|
||||||
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
|
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
||||||
String.t() => boolean()
|
def get_all_available_fields(custom_fields) do
|
||||||
}
|
member_fields =
|
||||||
def selection_from_url_only(url_selection, custom_fields, opts)
|
overview_member_fields()
|
||||||
when is_map(url_selection) and is_list(opts) do
|
|> Enum.reject(fn field -> field == @export_only_alias end)
|
||||||
{cm, ie, ca} = view_opts(opts)
|
|
||||||
do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca))
|
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||||
|
|
||||||
|
member_fields ++ custom_field_names
|
||||||
end
|
end
|
||||||
|
|
||||||
def selection_from_url_only(_, _, _), do: %{}
|
@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()]) :: %{
|
||||||
|
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)
|
||||||
|
|
||||||
defp do_selection_from_url_only(url_selection, all_fields) do
|
|
||||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||||
field_string = field_to_string(field)
|
field_string = field_to_string(field)
|
||||||
visible = Map.get(url_selection, field_string, false)
|
visible = Map.get(url_selection, field_string, false)
|
||||||
|
|
@ -227,38 +81,40 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp view_opts(opts) do
|
def selection_from_url_only(_, _), do: %{}
|
||||||
{
|
|
||||||
Keyword.get(opts, :compact_member, true),
|
|
||||||
Keyword.get(opts, :member_include_email, false),
|
|
||||||
Keyword.get(opts, :compact_address, true)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Merges user field selection with global settings, scoped to the columns offered
|
Merges user field selection with global settings.
|
||||||
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 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
|
user selection, the global setting is used. If a field is not in global settings,
|
||||||
settings, it defaults to visible. Only offered columns appear in the result, so
|
it defaults to `true` (visible).
|
||||||
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()], 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
|
## Parameters
|
||||||
global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set)
|
|
||||||
|
- `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
|
||||||
|
"""
|
||||||
|
@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)
|
||||||
|
|
||||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||||
field_string = field_to_string(field)
|
field_string = field_to_string(field)
|
||||||
|
|
@ -273,6 +129,35 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end)
|
end)
|
||||||
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 """
|
@doc """
|
||||||
Gets visible member fields from field selection.
|
Gets visible member fields from field selection.
|
||||||
|
|
||||||
|
|
@ -374,15 +259,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
def get_visible_custom_fields(_), do: []
|
def get_visible_custom_fields(_), do: []
|
||||||
|
|
||||||
# Gets global visibility map from settings
|
# Gets global visibility map from settings
|
||||||
defp get_global_visibility_map(settings, custom_fields, default_set) do
|
defp get_global_visibility_map(settings, custom_fields) do
|
||||||
member_visibility = get_member_field_visibility_from_settings(settings, default_set)
|
member_visibility = get_member_field_visibility_from_settings(settings)
|
||||||
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
||||||
|
|
||||||
Map.merge(member_visibility, custom_field_visibility)
|
Map.merge(member_visibility, custom_field_visibility)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
||||||
defp get_member_field_visibility_from_settings(settings, default_set) do
|
defp get_member_field_visibility_from_settings(settings) do
|
||||||
visibility_config =
|
visibility_config =
|
||||||
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
||||||
|
|
||||||
|
|
@ -391,13 +276,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
domain_map =
|
domain_map =
|
||||||
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
||||||
field_string = Atom.to_string(field)
|
field_string = Atom.to_string(field)
|
||||||
default_visibility = MapSet.member?(default_set, field)
|
default_visibility = if field == :exit_date, do: false, else: true
|
||||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||||
Map.put(acc, field_string, show_in_overview)
|
Map.put(acc, field_string, show_in_overview)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
||||||
Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field))
|
Map.put(acc, Atom.to_string(field), true)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,411 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
- `query` - Ash query for members
|
- `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
|
## Returns
|
||||||
|
|
||||||
|
|
@ -31,8 +33,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
Uses Ash.Query.load to efficiently preload cycles in a single query.
|
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`.
|
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
|
||||||
"""
|
"""
|
||||||
@spec load_cycles_for_members(Ash.Query.t()) :: Ash.Query.t()
|
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
|
||||||
def load_cycles_for_members(query) do
|
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
|
||||||
# Load membership_fee_type and cycles
|
# Load membership_fee_type and cycles
|
||||||
query
|
query
|
||||||
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
||||||
|
|
@ -156,6 +158,30 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
end)
|
end)
|
||||||
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
|
# Private helper function to format status label
|
||||||
defp format_status_label(:paid), do: gettext("Paid")
|
defp format_status_label(:paid), do: gettext("Paid")
|
||||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||||
|
|
@ -32,8 +32,6 @@ defmodule MvWeb.Translations.MemberFields do
|
||||||
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
||||||
def label(:membership_fee_type), do: gettext("Fee Type")
|
def label(:membership_fee_type), do: gettext("Fee Type")
|
||||||
def label(:groups), do: gettext("Groups")
|
def label(:groups), do: gettext("Groups")
|
||||||
def label(:name), do: gettext("Name")
|
|
||||||
def label(:address), do: gettext("Address")
|
|
||||||
|
|
||||||
# Fallback for unknown fields
|
# Fallback for unknown fields
|
||||||
def label(field) do
|
def label(field) do
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,7 @@ msgstr "Feld hinzufügen"
|
||||||
msgid "Add members"
|
msgid "Add members"
|
||||||
msgstr "Mitglieder hinzufügen"
|
msgstr "Mitglieder hinzufügen"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr "Adresse"
|
msgstr "Adresse"
|
||||||
|
|
@ -194,7 +192,8 @@ msgid "An account with this email already exists. Please verify your password to
|
||||||
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen."
|
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen."
|
||||||
|
|
||||||
#: lib/mv_web/helpers/ash_error_helpers.ex
|
#: lib/mv_web/helpers/ash_error_helpers.ex
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#: lib/mv_web/live/role_live/helpers.ex
|
#: lib/mv_web/live/role_live/helpers.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "An error occurred"
|
msgid "An error occurred"
|
||||||
|
|
@ -1518,7 +1517,6 @@ msgstr "Mitglieder filtern"
|
||||||
msgid "First Name"
|
msgid "First Name"
|
||||||
msgstr "Vorname"
|
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/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2004,7 +2002,6 @@ msgstr "Letzter Zyklus"
|
||||||
msgid "Last Name"
|
msgid "Last Name"
|
||||||
msgstr "Nachname"
|
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/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2291,12 +2288,14 @@ msgstr "Beitragseinstellungen"
|
||||||
msgid "Membership fee start"
|
msgid "Membership fee start"
|
||||||
msgstr "Beitragsbeginn"
|
msgstr "Beitragsbeginn"
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership fee type deleted"
|
msgid "Membership fee type deleted"
|
||||||
msgstr "Mitgliedsbeitragsart gelöscht"
|
msgstr "Mitgliedsbeitragsart gelöscht"
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership fee type not found"
|
msgid "Membership fee type not found"
|
||||||
msgstr "Mitgliedsbeitragsart nicht gefunden"
|
msgstr "Mitgliedsbeitragsart nicht gefunden"
|
||||||
|
|
@ -2357,7 +2356,6 @@ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen"
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
@ -2408,7 +2406,6 @@ msgstr "Neue*r Benutzer*in"
|
||||||
msgid "New amount"
|
msgid "New amount"
|
||||||
msgstr "Neuer Betrag"
|
msgstr "Neuer Betrag"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
|
|
@ -2696,10 +2693,10 @@ msgstr "Optionen"
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Paid"
|
msgid "Paid"
|
||||||
msgstr "Bezahlt"
|
msgstr "Bezahlt"
|
||||||
|
|
@ -3296,6 +3293,11 @@ msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr "In der Übersicht anzeigen"
|
msgstr "In der Übersicht anzeigen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Show/Hide Columns"
|
||||||
|
msgstr "Spalten ein-/ausblenden"
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
|
|
@ -3389,10 +3391,10 @@ msgid "Summary"
|
||||||
msgstr "Zusammenfassung"
|
msgstr "Zusammenfassung"
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr "Pausiert"
|
msgstr "Pausiert"
|
||||||
|
|
@ -3698,10 +3700,10 @@ msgstr "Aufhebung der Verknüpfung geplant"
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr "Unbezahlt"
|
msgstr "Unbezahlt"
|
||||||
|
|
@ -3902,7 +3904,6 @@ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
msgid "Yearly Interval - Joining Cycle Included"
|
||||||
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
|
|
@ -3955,7 +3956,8 @@ msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linkte
|
||||||
msgid "You do not have permission to %{action} members."
|
msgid "You do not have permission to %{action} members."
|
||||||
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
|
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to access this membership fee type"
|
msgid "You do not have permission to access this membership fee type"
|
||||||
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen."
|
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen."
|
||||||
|
|
@ -3971,7 +3973,8 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
|
||||||
msgid "You do not have permission to delete this member"
|
msgid "You do not have permission to delete this member"
|
||||||
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen."
|
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen."
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this membership fee type"
|
msgid "You do not have permission to delete this membership fee type"
|
||||||
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen."
|
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen."
|
||||||
|
|
@ -4142,100 +4145,3 @@ msgstr "Öffnen"
|
||||||
msgctxt "status"
|
msgctxt "status"
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Offen"
|
msgstr "Offen"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{count} member"
|
|
||||||
msgid_plural "%{count} members"
|
|
||||||
msgstr[0] "%{count} Mitglied"
|
|
||||||
msgstr[1] "%{count} Mitglieder"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/search_bar_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Clear search"
|
|
||||||
msgstr "Suche zurücksetzen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Compact address field"
|
|
||||||
msgstr "Kompaktes Adressfeld"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Compact mode"
|
|
||||||
msgstr "Kompakter Modus"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View settings"
|
|
||||||
msgstr "Ansichtseinstellungen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "include email"
|
|
||||||
msgstr "inklusive E-Mail"
|
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Reset to default"
|
|
||||||
msgstr "Auf Standard zurücksetzen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Columns"
|
|
||||||
msgstr "Spalten"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Loading more members …"
|
|
||||||
msgstr "Weitere Mitglieder werden geladen …"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View"
|
|
||||||
msgstr "Ansicht"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Compact Member field"
|
|
||||||
msgstr "Kompaktes Mitglied-Feld"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Click to sort by city"
|
|
||||||
msgstr "Klicke, um nach Ort zu sortieren"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/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"
|
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,7 @@ msgstr ""
|
||||||
msgid "Add members"
|
msgid "Add members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1519,7 +1517,6 @@ msgstr ""
|
||||||
msgid "First Name"
|
msgid "First Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_name_sort_header.ex
|
|
||||||
#: lib/mv_web/live/join_request_live/index.ex
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2005,7 +2002,6 @@ msgstr ""
|
||||||
msgid "Last Name"
|
msgid "Last Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_name_sort_header.ex
|
|
||||||
#: lib/mv_web/live/join_request_live/index.ex
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2358,7 +2354,6 @@ msgstr ""
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2409,7 +2404,6 @@ msgstr ""
|
||||||
msgid "New amount"
|
msgid "New amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
|
|
@ -2697,10 +2691,10 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Paid"
|
msgid "Paid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3297,6 +3291,11 @@ msgstr ""
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Show/Hide Columns"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
|
|
@ -3390,10 +3389,10 @@ msgid "Summary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3699,10 +3698,10 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3902,7 +3901,6 @@ msgstr ""
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
msgid "Yearly Interval - Joining Cycle Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
|
|
@ -4142,100 +4140,3 @@ msgstr ""
|
||||||
msgctxt "status"
|
msgctxt "status"
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{count} member"
|
|
||||||
msgid_plural "%{count} members"
|
|
||||||
msgstr[0] ""
|
|
||||||
msgstr[1] ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/search_bar_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Clear search"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Compact address field"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Compact mode"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "include email"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Reset to default"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Columns"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Loading more members …"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Compact Member field"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Click to sort by city"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/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 ""
|
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,7 @@ msgstr ""
|
||||||
msgid "Add members"
|
msgid "Add members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -195,7 +193,8 @@ msgid "An account with this email already exists. Please verify your password to
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/ash_error_helpers.ex
|
#: lib/mv_web/helpers/ash_error_helpers.ex
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#: lib/mv_web/live/role_live/helpers.ex
|
#: lib/mv_web/live/role_live/helpers.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "An error occurred"
|
msgid "An error occurred"
|
||||||
|
|
@ -1519,7 +1518,6 @@ msgstr ""
|
||||||
msgid "First Name"
|
msgid "First Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_name_sort_header.ex
|
|
||||||
#: lib/mv_web/live/join_request_live/index.ex
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -2005,7 +2003,6 @@ msgstr ""
|
||||||
msgid "Last Name"
|
msgid "Last Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_name_sort_header.ex
|
|
||||||
#: lib/mv_web/live/join_request_live/index.ex
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -2292,12 +2289,14 @@ msgstr ""
|
||||||
msgid "Membership fee start"
|
msgid "Membership fee start"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Membership fee type deleted"
|
msgid "Membership fee type deleted"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Membership fee type not found"
|
msgid "Membership fee type not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2358,7 +2357,6 @@ msgstr ""
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2409,7 +2407,6 @@ msgstr ""
|
||||||
msgid "New amount"
|
msgid "New amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
|
|
@ -2697,10 +2694,10 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Paid"
|
msgid "Paid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3297,6 +3294,11 @@ msgstr ""
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Show/Hide Columns"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
|
|
@ -3390,10 +3392,10 @@ msgid "Summary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3699,10 +3701,10 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3902,7 +3904,6 @@ msgstr ""
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
msgid "Yearly Interval - Joining Cycle Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
|
|
@ -3955,7 +3956,8 @@ msgstr ""
|
||||||
msgid "You do not have permission to %{action} members."
|
msgid "You do not have permission to %{action} members."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "You do not have permission to access this membership fee type"
|
msgid "You do not have permission to access this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3971,7 +3973,8 @@ msgstr ""
|
||||||
msgid "You do not have permission to delete this member"
|
msgid "You do not have permission to delete this member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "You do not have permission to delete this membership fee type"
|
msgid "You do not have permission to delete this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -4142,100 +4145,3 @@ msgstr "Open"
|
||||||
msgctxt "status"
|
msgctxt "status"
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Open"
|
msgstr "Open"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{count} member"
|
|
||||||
msgid_plural "%{count} members"
|
|
||||||
msgstr[0] ""
|
|
||||||
msgstr[1] ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/search_bar_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Clear search"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Compact address field"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Compact mode"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "include email"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Reset to default"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Columns"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Loading more members …"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Compact Member field"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Click to sort by city"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/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 ""
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
# 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.")
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -4,13 +4,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
|
||||||
|
|
||||||
describe "field visibility dropdown in member view" do
|
describe "field visibility dropdown in member view" do
|
||||||
test "renders and toggles visibility", %{conn: conn} do
|
test "renders and toggles visibility", %{conn: conn} do
|
||||||
# Email is folded into the Member cell by default; unfold it so it appears
|
conn = conn_with_oidc_user(conn)
|
||||||
# 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")
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
# Renders Dropdown
|
# Renders Dropdown
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
import Phoenix.LiveViewTest
|
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
|
describe "rendering" do
|
||||||
test "renders with correct attributes", %{conn: conn} do
|
test "renders with correct attributes", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Test that the component renders with correct attributes
|
# Test that the component renders with correct attributes
|
||||||
assert has_element?(view, "[data-testid='first_name']")
|
assert has_element?(view, "[data-testid='first_name']")
|
||||||
|
|
@ -32,8 +14,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders all sortable headers", %{conn: conn} do
|
test "renders all sortable headers", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
sortable_fields = [
|
sortable_fields = [
|
||||||
:first_name,
|
:first_name,
|
||||||
|
|
@ -52,8 +34,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders correct labels", %{conn: conn} do
|
test "renders correct labels", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Test specific labels
|
# Test specific labels
|
||||||
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
|
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
|
||||||
|
|
@ -64,8 +46,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "sort icons" do
|
describe "sort icons" do
|
||||||
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
|
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# The neutral icon has the opcity class we can test for
|
# The neutral icon has the opcity class we can test for
|
||||||
# Test that EMAIL field specifically shows neutral icon
|
# Test that EMAIL field specifically shows neutral icon
|
||||||
|
|
@ -76,10 +58,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
|
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
|
||||||
{: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 that FIRST_NAME field specifically shows ascending icon
|
||||||
# Test CSS classes - no opacity for active state
|
# Test CSS classes - no opacity for active state
|
||||||
|
|
@ -99,10 +79,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
|
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||||
{: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
|
# Count occurrences to ensure only one descending sort icon. Dropdown
|
||||||
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
|
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
|
||||||
|
|
@ -113,10 +91,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple fields can have different icon states", %{conn: conn} do
|
test "multiple fields can have different icon states", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
|
||||||
|
|
||||||
# CITY field should be active (ascending)
|
# CITY field should be active (ascending)
|
||||||
refute has_element?(view, "[data-testid='city'] .opacity-40")
|
refute has_element?(view, "[data-testid='city'] .opacity-40")
|
||||||
|
|
@ -132,13 +108,12 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "icon state changes correctly when clicking different fields", %{conn: conn} do
|
test "icon state changes correctly when clicking different fields", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Start: all fields neutral except the default sort field (last name)
|
# Start: all fields neutral except first name as default
|
||||||
assert has_element?(view, "[data-testid='city'] .opacity-40")
|
assert has_element?(view, "[data-testid='city'] .opacity-40")
|
||||||
refute has_element?(view, "[data-testid='last_name'] .opacity-40")
|
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
|
||||||
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
|
|
||||||
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
||||||
|
|
||||||
# Click city - should become active
|
# Click city - should become active
|
||||||
|
|
@ -161,31 +136,27 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "specific field shows correct icon for each sort state", %{conn: conn} do
|
test "specific field shows correct icon for each sort state", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
# Test EMAIL field specifically
|
# Test EMAIL field specifically
|
||||||
{:ok, view, html_asc} =
|
{:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=asc")
|
|
||||||
|
|
||||||
assert html_asc =~ "hero-chevron-up"
|
assert html_asc =~ "hero-chevron-up"
|
||||||
refute has_element?(view, "[data-testid='email'] .opacity-40")
|
refute has_element?(view, "[data-testid='email'] .opacity-40")
|
||||||
|
|
||||||
{:ok, view, html_desc} =
|
{:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
|
||||||
|
|
||||||
assert html_desc =~ "hero-chevron-down"
|
assert html_desc =~ "hero-chevron-down"
|
||||||
refute has_element?(view, "[data-testid='email'] .opacity-40")
|
refute has_element?(view, "[data-testid='email'] .opacity-40")
|
||||||
|
|
||||||
{:ok, view, html_neutral} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, html_neutral} = live(conn, "/members")
|
||||||
assert html_neutral =~ "hero-chevron-up-down"
|
assert html_neutral =~ "hero-chevron-up-down"
|
||||||
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
# Test neutral state - only one field should have active sort icon
|
# Test neutral state - only one field should have active sort icon
|
||||||
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
|
{:ok, _view, html_neutral} = live(conn, "/members")
|
||||||
|
|
||||||
# Count active icons (should be exactly 1 - ascending for default sort field)
|
# Count active icons (should be exactly 1 - ascending for default sort field)
|
||||||
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||||
|
|
@ -195,8 +166,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
||||||
|
|
||||||
# Test descending state
|
# Test descending state
|
||||||
{:ok, _view, html_desc} =
|
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=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)
|
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||||
down_count = active_sort_down_count(html_desc)
|
down_count = active_sort_down_count(html_desc)
|
||||||
|
|
@ -219,38 +189,32 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "accessibility" do
|
describe "accessibility" do
|
||||||
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Check aria-label for unsorted state
|
# Check aria-label for unsorted state
|
||||||
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
|
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets aria-label correctly for ascending sort", %{conn: conn} do
|
test "sets aria-label correctly for ascending sort", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
|
||||||
|
|
||||||
# Check aria-label for ascending sort
|
# Check aria-label for ascending sort
|
||||||
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
|
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets aria-label correctly for descending sort", %{conn: conn} do
|
test "sets aria-label correctly for descending sort", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
|
|
||||||
|
|
||||||
# Check aria-label for descending sort
|
# Check aria-label for descending sort
|
||||||
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
|
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "includes tooltip with correct aria-label", %{conn: conn} do
|
test "includes tooltip with correct aria-label", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
|
||||||
|
|
||||||
# Check that tooltip div exists with correct data-tip
|
# Check that tooltip div exists with correct data-tip
|
||||||
assert has_element?(view, "[data-testid='first_name']")
|
assert has_element?(view, "[data-testid='first_name']")
|
||||||
|
|
@ -258,8 +222,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "aria-labels work for all sortable fields", %{conn: conn} do
|
test "aria-labels work for all sortable fields", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
|
||||||
|
|
||||||
# Test aria-labels for different fields
|
# Test aria-labels for different fields
|
||||||
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
|
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
|
||||||
|
|
@ -275,8 +239,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "component behavior" do
|
describe "component behavior" do
|
||||||
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Click on the first name sort header
|
# Click on the first name sort header
|
||||||
view
|
view
|
||||||
|
|
@ -288,8 +252,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "component handles different field types correctly", %{conn: conn} do
|
test "component handles different field types correctly", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Test that different field types render correctly
|
# Test that different field types render correctly
|
||||||
assert has_element?(view, "button[phx-value-field='first_name']")
|
assert has_element?(view, "button[phx-value-field='first_name']")
|
||||||
|
|
@ -300,21 +264,17 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "edge cases" do
|
describe "edge cases" do
|
||||||
test "handles invalid sort field gracefully", %{conn: conn} do
|
test "handles invalid sort field gracefully", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
|
||||||
|
|
||||||
{:ok, view, html} =
|
# Should not crash and should default sorting for first name
|
||||||
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"
|
assert html =~ "hero-chevron-up-down"
|
||||||
refute has_element?(view, "[data-testid='last_name'] .opacity-40")
|
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles invalid sort order gracefully", %{conn: conn} do
|
test "handles invalid sort order gracefully", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
|
||||||
{:ok, view, html} =
|
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
|
|
||||||
|
|
||||||
# Should default to ascending
|
# Should default to ascending
|
||||||
assert html =~ "hero-chevron-up"
|
assert html =~ "hero-chevron-up"
|
||||||
|
|
@ -322,8 +282,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles empty sort parameters", %{conn: conn} do
|
test "handles empty sort parameters", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
|
{:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
|
||||||
|
|
||||||
# Should show neutral icons
|
# Should show neutral icons
|
||||||
assert html =~ "hero-chevron-up-down"
|
assert html =~ "hero-chevron-up-down"
|
||||||
|
|
@ -333,8 +293,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "icon state transitions" do
|
describe "icon state transitions" do
|
||||||
test "icon changes when sorting state changes", %{conn: conn} do
|
test "icon changes when sorting state changes", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Start with neutral state
|
# Start with neutral state
|
||||||
assert has_element?(view, "[data-testid='city'] .opacity-40")
|
assert has_element?(view, "[data-testid='city'] .opacity-40")
|
||||||
|
|
@ -349,8 +309,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple fields can be tested for icon states", %{conn: conn} do
|
test "multiple fields can be tested for icon states", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
{:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc")
|
||||||
|
|
||||||
# Email should be active (descending)
|
# Email should be active (descending)
|
||||||
assert html =~ "hero-chevron-down"
|
assert html =~ "hero-chevron-down"
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -6,55 +6,247 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
|
||||||
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
|
# Mock custom field structs for testing
|
||||||
test "compact member + compact address: Name, E-Mail, Adresse lead" do
|
defp create_custom_field(id, name, show_in_overview \\ true) do
|
||||||
result = FieldVisibility.offered_member_fields(true, false, true)
|
%{
|
||||||
|
id: id,
|
||||||
assert Enum.take(result, 3) == [:name, :email, :address]
|
name: name,
|
||||||
# the composite constituents are not offered while their composite is on
|
show_in_overview: show_in_overview
|
||||||
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
|
end
|
||||||
|
|
||||||
test "compact member with email folded in: no separate E-Mail column" do
|
describe "get_all_available_fields/1" do
|
||||||
result = FieldVisibility.offered_member_fields(true, true, true)
|
test "returns member fields and custom fields" do
|
||||||
|
custom_fields = [
|
||||||
assert Enum.take(result, 2) == [:name, :address]
|
create_custom_field("cf1", "Custom Field 1"),
|
||||||
refute :email in result
|
create_custom_field("cf2", "Custom Field 2")
|
||||||
end
|
|
||||||
|
|
||||||
test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do
|
|
||||||
result = FieldVisibility.offered_member_fields(false, false, true)
|
|
||||||
|
|
||||||
# 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 "non-compact member + non-compact address: name, email, then the address block, then rest" do
|
|
||||||
result = FieldVisibility.offered_member_fields(false, false, false)
|
|
||||||
|
|
||||||
assert Enum.take(result, 7) == [
|
|
||||||
:first_name,
|
|
||||||
:last_name,
|
|
||||||
:email,
|
|
||||||
:street,
|
|
||||||
:house_number,
|
|
||||||
:postal_code,
|
|
||||||
:city
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# join_date and later Datenfelder columns never precede the address block
|
result = FieldVisibility.get_all_available_fields(custom_fields)
|
||||||
assert index_of(result, :city) < index_of(result, :join_date)
|
|
||||||
refute :name in result
|
# Should include all member fields
|
||||||
refute :address in result
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles empty custom fields list" do
|
||||||
|
result = FieldVisibility.get_all_available_fields([])
|
||||||
|
|
||||||
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
|
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
|
||||||
|
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}
|
||||||
|
|
||||||
|
result = FieldVisibility.get_visible_fields(selection)
|
||||||
|
|
||||||
|
assert :first_name in result
|
||||||
|
assert :email in result
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps custom fields as strings" do
|
||||||
|
selection = %{"custom_field_abc-123" => true}
|
||||||
|
|
||||||
|
result = FieldVisibility.get_visible_fields(selection)
|
||||||
|
|
||||||
|
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) == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "get_visible_member_fields/1" do
|
describe "get_visible_member_fields/1" do
|
||||||
test "returns only member fields that are visible" do
|
test "returns only member fields that are visible" do
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
|
|
||||||
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
@ -25,6 +26,35 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
member
|
member
|
||||||
end
|
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
|
describe "get_cycle_status_for_member/2" do
|
||||||
test "returns status of last completed cycle" do
|
test "returns status of last completed cycle" do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
|
@ -309,5 +339,27 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
assert length(filtered) == 1
|
assert length(filtered) == 1
|
||||||
assert List.first(filtered).id == member2.id
|
assert List.first(filtered).id == member2.id
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -203,28 +203,17 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
conn: conn,
|
conn: conn,
|
||||||
field_string: field
|
field_string: field
|
||||||
} do
|
} do
|
||||||
# Email is folded into the Member cell by default; turn the fold off so email
|
conn = conn_with_oidc_user(conn)
|
||||||
# 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} =
|
{:ok, view, _html} =
|
||||||
live(
|
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
||||||
conn,
|
|
||||||
"/members?fields=email&query=&sort_field=custom_field_#{field.id}&sort_order=desc"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Click on email column
|
# Click on email column
|
||||||
view
|
view
|
||||||
|> element("[data-testid='email']")
|
|> element("[data-testid='email']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# The fields param rides along on the patch, so assert the sort outcome.
|
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
|
||||||
path = assert_patch(view)
|
|
||||||
assert path =~ "sort_field=email"
|
|
||||||
assert path =~ "sort_order=asc"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "clicking custom field column after regular column works", %{
|
test "clicking custom field column after regular column works", %{
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -22,17 +22,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
|
|
||||||
require Ash.Query
|
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
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
@ -123,9 +112,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays all member fields in dropdown", %{conn: conn} do
|
test "displays all member fields in dropdown", %{conn: conn} do
|
||||||
# The individual name/address constituents are only offered when the
|
conn = conn_with_oidc_user(conn)
|
||||||
# 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")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
|
|
@ -161,23 +148,23 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# The curated Name column carries the member names, so they are visible.
|
# Verify email is visible initially
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ "Anderson"
|
assert html =~ "alice@example.com"
|
||||||
|
|
||||||
# Open dropdown and hide the Name column
|
# Open dropdown and hide email
|
||||||
view
|
view
|
||||||
|> element("button[aria-controls='field-visibility-menu']")
|
|> element("button[aria-controls='field-visibility-menu']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# The Name column (and the names it carries) is no longer visible
|
# Email should no longer be visible
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "Anderson"
|
refute html =~ "alice@example.com"
|
||||||
refute html =~ "Brown"
|
refute html =~ "bob@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
||||||
|
|
@ -255,9 +242,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
|
|
||||||
describe "URL parameter persistence" do
|
describe "URL parameter persistence" do
|
||||||
test "field selection is persisted in URL", %{conn: conn} do
|
test "field selection is persisted in URL", %{conn: conn} do
|
||||||
# Email is offered as a togglable column only when it is not folded into the
|
conn = conn_with_oidc_user(conn)
|
||||||
# Member cell; unfold it so the dropdown lists it.
|
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Open dropdown and hide email
|
# Open dropdown and hide email
|
||||||
|
|
@ -313,8 +298,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
||||||
# The curated Name and Address columns are visible by default.
|
# All fields should be visible by default
|
||||||
assert html =~ "Anderson"
|
assert html =~ "alice@example.com"
|
||||||
assert html =~ "Main St"
|
assert html =~ "Main St"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -324,17 +309,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Hide the curated Name column (which carries the names) via dropdown
|
# Hide a field via dropdown
|
||||||
view
|
view
|
||||||
|> element("button[aria-controls='field-visibility-menu']")
|
|> element("button[aria-controls='field-visibility-menu']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "Anderson"
|
refute html =~ "alice@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -343,16 +328,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members?fields=")
|
{:ok, _view, html} = live(conn, "/members?fields=")
|
||||||
|
|
||||||
# Should fall back to global settings (curated Name column visible)
|
# Should fall back to global settings
|
||||||
assert html =~ "Anderson"
|
assert html =~ "alice@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles invalid field names in URL", %{conn: conn} do
|
test "handles invalid field names in URL", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
|
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
|
||||||
|
|
||||||
# Should ignore invalid fields and use defaults (curated Name column visible)
|
# Should ignore invalid fields and use defaults
|
||||||
assert html =~ "Anderson"
|
assert html =~ "alice@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles custom field that doesn't exist", %{conn: conn} do
|
test "handles custom field that doesn't exist", %{conn: conn} do
|
||||||
|
|
@ -376,7 +361,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles rapid toggling", %{conn: conn} do
|
test "handles rapid toggling", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
|
|
@ -444,23 +429,23 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# The curated Name column carries the names, so they are visible initially.
|
# Verify email is visible initially
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ "Anderson"
|
assert html =~ "alice@example.com"
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
view
|
view
|
||||||
|> element("button[aria-controls='field-visibility-menu']")
|
|> element("button[aria-controls='field-visibility-menu']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Simulate Enter key press on the Name field button
|
# Simulate Enter key press on email field button
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||||
|> render_keydown(%{key: "Enter"})
|
|> render_keydown(%{key: "Enter"})
|
||||||
|
|
||||||
# The Name column (and the names it carries) is no longer visible
|
# Email should no longer be visible
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "Anderson"
|
refute html =~ "alice@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -20,16 +20,6 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
||||||
|
|
||||||
require Ash.Query
|
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
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
@ -132,13 +122,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
||||||
member1: member1,
|
member1: member1,
|
||||||
group1: group1
|
group1: group1
|
||||||
} do
|
} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
{:ok, view, html} =
|
{:ok, view, html} =
|
||||||
live(
|
live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in")
|
||||||
conn,
|
|
||||||
"/members?fields=first_name&sort_field=first_name&sort_order=desc&group_#{group1.id}=in"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert html =~ member1.first_name
|
assert html =~ member1.first_name
|
||||||
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")
|
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -38,30 +38,27 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{: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
|
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
||||||
assert html =~ field
|
assert html =~ field
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "respects show_in_overview config", %{conn: conn} do
|
test "respects show_in_overview config", %{conn: conn, member1: m} 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()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
fields_to_hide = [:street, :house_number]
|
||||||
|
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
Mv.Membership.update_settings(settings, %{
|
Mv.Membership.update_settings(settings, %{
|
||||||
member_field_visibility: %{"join_date" => false}
|
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
|
||||||
})
|
})
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
# Use search query to filter to only the expected member (Alice)
|
# Use search query to filter to only the expected member (Alice)
|
||||||
# This significantly improves test performance by avoiding loading all members from other tests
|
# 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")
|
||||||
|
|
||||||
refute has_element?(view, "[data-testid='join_date']")
|
assert html =~ "Email"
|
||||||
# The composite Name column (and its email content) is unaffected.
|
assert html =~ m.email
|
||||||
assert has_element?(view, "[data-testid='member-name']")
|
refute html =~ m.street
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
html = render(view)
|
html = render(view)
|
||||||
# Should show suspended status (current cycle)
|
# Should show suspended status (current cycle)
|
||||||
assert html =~ "hero-pause-circle" || html =~ "suspended"
|
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
|
end
|
||||||
|
|
||||||
test "shows correct color coding for paid status", %{conn: conn} do
|
test "shows correct color coding for paid status", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -13,19 +13,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
alias Mv.Membership.CustomFieldValue
|
alias Mv.Membership.CustomFieldValue
|
||||||
alias MvWeb.MemberLive.Index, as: MemberIndex
|
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
|
describe "desktop layout: scroll container and sticky table header" do
|
||||||
@describetag :ui
|
@describetag :ui
|
||||||
|
|
||||||
|
|
@ -167,17 +154,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
describe "sorting integration" do
|
describe "sorting integration" do
|
||||||
@describetag :ui
|
@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
|
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
|
||||||
# Email is folded into the Member cell by default; unfold it to a separate
|
conn = conn_with_oidc_user(conn)
|
||||||
# sortable column for this column-sort test.
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
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
|
# The component data test ids are built with the name of the field
|
||||||
# First click – should sort ASC
|
# First click – should sort ASC
|
||||||
|
|
@ -185,47 +164,35 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> element("[data-testid='email']")
|
|> element("[data-testid='email']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
path_asc = assert_patch(view)
|
# The LiveView pushes a patch with the new query params
|
||||||
assert path_asc =~ "sort_field=email"
|
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
|
||||||
assert path_asc =~ "sort_order=asc"
|
|
||||||
|
|
||||||
# Second click – toggles to DESC
|
# Second click – toggles to DESC
|
||||||
view
|
view
|
||||||
|> element("[data-testid='email']")
|
|> element("[data-testid='email']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
path_desc = assert_patch(view)
|
assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
|
||||||
assert path_desc =~ "sort_field=email"
|
|
||||||
assert path_desc =~ "sort_order=desc"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "clicking different column header resets order to ascending", %{conn: conn} do
|
test "clicking different column header resets order to ascending", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
|
|
||||||
|
|
||||||
# Click on a different column
|
# Click on a different column
|
||||||
view
|
view
|
||||||
|> element("[data-testid='first_name']")
|
|> element("[data-testid='first_name']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
path = assert_patch(view)
|
assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
|
||||||
assert path =~ "sort_field=first_name"
|
|
||||||
assert path =~ "sort_order=asc"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "all sortable columns work correctly", %{conn: conn} do
|
test "all sortable columns work correctly", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
{:ok, view, _html} =
|
# default ascending sorting with first name
|
||||||
live(
|
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
|
||||||
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 = [
|
sortable_fields = [
|
||||||
:email,
|
:email,
|
||||||
|
|
@ -242,70 +209,54 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> element("[data-testid='#{field}']")
|
|> element("[data-testid='#{field}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
path = assert_patch(view)
|
assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
|
||||||
assert path =~ "sort_field=#{field}"
|
|
||||||
assert path =~ "sort_order=asc"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sorting works with search query", %{conn: conn} do
|
test "sorting works with search query", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=email&query=test")
|
{:ok, view, _html} = live(conn, "/members?query=test")
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("[data-testid='email']")
|
|> element("[data-testid='email']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
path = assert_patch(view)
|
assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
|
||||||
assert path =~ "query=test"
|
|
||||||
assert path =~ "sort_field=email"
|
|
||||||
assert path =~ "sort_order=asc"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sorting maintains search query when toggling order", %{conn: conn} do
|
test "sorting maintains search query when toggling order", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc")
|
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("[data-testid='email']")
|
|> element("[data-testid='email']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
path = assert_patch(view)
|
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
|
||||||
assert path =~ "query=test"
|
|
||||||
assert path =~ "sort_field=email"
|
|
||||||
assert path =~ "sort_order=desc"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "URL param handling" do
|
describe "URL param handling" do
|
||||||
@describetag :ui
|
@describetag :ui
|
||||||
test "handle_params reads sort query and applies it", %{conn: conn} do
|
test "handle_params reads sort query and applies it", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc")
|
|
||||||
|
|
||||||
# Check that the sort state is correctly applied
|
# Check that the sort state is correctly applied
|
||||||
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
|
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
|
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc")
|
||||||
|
|
||||||
{:ok, view, _html} =
|
# Should not crash and should show default first name order
|
||||||
live(conn, "/members?fields=last_name&query=&sort_field=invalid_field&sort_order=asc")
|
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
|
||||||
|
|
||||||
# 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
|
end
|
||||||
|
|
||||||
test "handle_params preserves search query with sort params", %{conn: conn} do
|
test "handle_params preserves search query with sort params", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=desc")
|
|
||||||
|
|
||||||
# Both search and sort should be preserved
|
# Both search and sort should be preserved
|
||||||
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
|
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
|
||||||
|
|
@ -315,10 +266,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
describe "search and sort integration" do
|
describe "search and sort integration" do
|
||||||
@describetag :ui
|
@describetag :ui
|
||||||
test "search maintains sort state", %{conn: conn} do
|
test "search maintains sort state", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc")
|
|
||||||
|
|
||||||
# Perform search
|
# Perform search
|
||||||
view
|
view
|
||||||
|
|
@ -330,10 +279,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sort maintains search state", %{conn: conn} do
|
test "sort maintains search state", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc")
|
|
||||||
|
|
||||||
# Perform sort
|
# Perform sort
|
||||||
view
|
view
|
||||||
|
|
@ -341,10 +288,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Search state should be maintained
|
# Search state should be maintained
|
||||||
path = assert_patch(view)
|
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
|
||||||
assert path =~ "query=test"
|
|
||||||
assert path =~ "sort_field=email"
|
|
||||||
assert path =~ "sort_order=desc"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -362,8 +306,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# asserted on internal state to preserve the original coverage of the callback.
|
# asserted on internal state to preserve the original coverage of the callback.
|
||||||
assigns = :sys.get_state(view.pid).socket.assigns
|
assigns = :sys.get_state(view.pid).socket.assigns
|
||||||
assert assigns.query == "Friedrich"
|
assert assigns.query == "Friedrich"
|
||||||
# Loaded members are held as a `%{id => member}` map (the restream lookup window).
|
assert is_list(assigns.members)
|
||||||
assert is_map(assigns.members)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :ui
|
@tag :ui
|
||||||
|
|
@ -710,6 +653,27 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Flash message should appear
|
# Flash message should appear
|
||||||
assert has_element?(view, "#flash-group")
|
assert has_element?(view, "#flash-group")
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "copy_emails empty-recipient feedback" do
|
describe "copy_emails empty-recipient feedback" do
|
||||||
|
|
@ -1105,63 +1069,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
assert bcc =~ "scope1%40example.com"
|
assert bcc =~ "scope1%40example.com"
|
||||||
refute bcc =~ "scope2%40example.com"
|
refute bcc =~ "scope2%40example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "selecting a member does not read the DB for mailto recipients (deferred to open)",
|
|
||||||
%{conn: conn, member1: member1} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# A checkbox toggle is a non-search interaction; it must not issue a member
|
|
||||||
# read just to precompute a mailto link the user may never open. The
|
|
||||||
# recipient list is fetched lazily when the bulk-actions dropdown opens.
|
|
||||||
member_reads =
|
|
||||||
capture_member_select_queries(fn ->
|
|
||||||
render_click(view, "select_member", %{"id" => member1.id})
|
|
||||||
end)
|
|
||||||
|
|
||||||
assert member_reads == [],
|
|
||||||
"select_member must not read members for the mailto recipients; got: #{inspect(member_reads)}"
|
|
||||||
|
|
||||||
# Opening the dropdown still surfaces the correct recipients for the selection.
|
|
||||||
bcc = mailto_bcc(view)
|
|
||||||
assert bcc =~ "scope1%40example.com"
|
|
||||||
refute bcc =~ "scope2%40example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Captures every SELECT against the members table emitted while `fun` runs.
|
|
||||||
defp capture_member_select_queries(fun) do
|
|
||||||
test_pid = self()
|
|
||||||
handler_id = "test-member-select-#{System.unique_integer([:positive])}"
|
|
||||||
|
|
||||||
:telemetry.attach(
|
|
||||||
handler_id,
|
|
||||||
[:mv, :repo, :query],
|
|
||||||
fn _event, _measurements, metadata, _config ->
|
|
||||||
sql = metadata[:query] || ""
|
|
||||||
|
|
||||||
if String.contains?(sql, "SELECT") and String.contains?(sql, "\"members\"") do
|
|
||||||
send(test_pid, {:member_query, sql})
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
nil
|
|
||||||
)
|
|
||||||
|
|
||||||
try do
|
|
||||||
fun.()
|
|
||||||
after
|
|
||||||
:telemetry.detach(handler_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
collect_member_queries([])
|
|
||||||
end
|
|
||||||
|
|
||||||
defp collect_member_queries(acc) do
|
|
||||||
receive do
|
|
||||||
{:member_query, sql} -> collect_member_queries([sql | acc])
|
|
||||||
after
|
|
||||||
0 -> Enum.reverse(acc)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "cycle status filter" do
|
describe "cycle status filter" do
|
||||||
|
|
@ -1609,11 +1516,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filters are preserved during navigation actions", %{conn: conn} do
|
test "boolean filters are preserved during navigation actions", %{conn: conn} do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?fields=email&bf_#{boolean_field.id}=true")
|
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||||
|
|
||||||
# Test sort toggle preserves filter
|
# Test sort toggle preserves filter
|
||||||
view
|
view
|
||||||
|
|
@ -1781,6 +1688,153 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> Ash.load!(:custom_field_values, actor: actor)
|
|> Ash.load!(:custom_field_values, actor: actor)
|
||||||
end
|
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
|
# Tests for apply_boolean_custom_field_filters/2
|
||||||
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
||||||
%{conn: _conn} do
|
%{conn: _conn} do
|
||||||
|
|
@ -2318,12 +2372,12 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Should complete in less than 1 second (1000ms)
|
# Should complete in less than 1 second (1000ms)
|
||||||
assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
|
assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
|
||||||
|
|
||||||
# The overview now keyset-paginates: mount loads only the first page, not
|
# Verify filtering worked correctly - should show all true members
|
||||||
# the whole filtered set (§1.7). The filter still resolves DB-side, so the
|
Enum.each(1..75, fn i ->
|
||||||
# loaded page contains only matching (true) members and never a non-matching
|
assert html =~ "TrueMember#{i}"
|
||||||
# (false) one.
|
end)
|
||||||
assert html =~ "TrueMember"
|
|
||||||
|
|
||||||
|
# Should not show false members
|
||||||
Enum.each(1..75, fn i ->
|
Enum.each(1..75, fn i ->
|
||||||
refute html =~ "FalseMember#{i}"
|
refute html =~ "FalseMember#{i}"
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue