From 58cf6ed2c4b21b167685936c97f9938e9a443a7e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 16:15:40 +0200 Subject: [PATCH 1/4] refactor(frontend/state): add PlansColumnId + plansColumnFilters slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a Plans-scoped per-column filter slice (PlansColumnId, PlansColumnFilter, PlansColumnFilters) with get/set/clear accessors, mirroring the existing Recommendations slice. Kept as a separate slice so the Plans, History, and RI Exchange follow-ups to PR #570 can land in parallel without contending on each other's state shape. In-memory only — survives tab switches within the SPA, resets on full reload (same lifecycle as recommendationsColumnFilters). Refs #166. --- frontend/src/state.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/frontend/src/state.ts b/frontend/src/state.ts index b81351bd1..901cef61b 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -262,3 +262,50 @@ export function setHiddenColumns(hidden: ReadonlySet): const filtered = Array.from(hidden).filter((col) => !fixedColumns.has(col)); hiddenColumns = new Set(filtered); } + +// --------------------------------------------------------------------------- +// Plans / Planned Purchases per-column filters (issue #166 follow-up to #570). +// +// Mirrors RecommendationsColumnFilters but is scoped to the Plans tab's +// Planned Purchases table. Kept as a separate slice (and column-id type) so +// the Plans, History, and RI Exchange follow-up PRs can land in parallel +// without contending on the same state shape. +// --------------------------------------------------------------------------- + +export type PlansColumnId = + | 'provider' | 'service' | 'resource_type' | 'term' | 'payment' | 'status' + | 'count' | 'upfront_cost' | 'estimated_savings'; + +export type PlansColumnFilter = + | { kind: 'set'; values: string[] } + | { kind: 'expr'; expr: string }; + +export type PlansColumnFilters = Partial>; + +// In-memory only; survives tab switches within the SPA, resets on full reload. +// Matches the recommendationsColumnFilters lifecycle. +let plansColumnFilters: PlansColumnFilters = {}; + +export function getPlansColumnFilters(): PlansColumnFilters { + return { ...plansColumnFilters }; +} + +export function setPlansColumnFilter( + column: PlansColumnId, + filter: PlansColumnFilter | null, +): void { + if (filter === null) { + const next = { ...plansColumnFilters }; + delete next[column]; + plansColumnFilters = next; + return; + } + plansColumnFilters = { + ...plansColumnFilters, + [column]: filter, + }; +} + +export function clearAllPlansColumnFilters(): void { + plansColumnFilters = {}; +} From 671b73dc47d9d8cb23c67ea44a19117ec4d0e2ac Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 16:21:19 +0200 Subject: [PATCH 2/4] feat(frontend/plans): inline column filters via shared lib (refs #166) Wires the Planned Purchases table to the shared lib/column-filters primitives extracted in PR #570. Each filterable column gets an inline trigger button in its header; clicking opens a popover with either a multi-select (categorical) or a numeric-expression input. Filters AND together, persist in-memory across the SPA session, and are applied at render time. Columns wired: - categorical: provider, service, resource_type, term, payment, status - numeric: count, upfront_cost, estimated_savings Numeric predicates compare against the rounded display value (roundForDisplay + displayPrecisionForPlan) so the issue #484 exact-match contract is preserved here too. Re-renders go through a cached lastLoadedPurchases module slice so popover commits never re-fetch from the API. Popover lives on document.body and is re-anchored after each table re-render. Mock-state additions in plans*.test.ts and xss-purchase-status.test.ts mirror the new accessors; legacy assertions continue to pass. Refs #166. Sibling follow-ups land in parallel for History and RI Exchange. --- .../src/__tests__/plans-permissions.test.ts | 3 + .../__tests__/plans-range-validation.test.ts | 3 + frontend/src/__tests__/plans.test.ts | 6 + .../src/__tests__/xss-purchase-status.test.ts | 3 + frontend/src/plans.ts | 520 +++++++++++++++++- 5 files changed, 524 insertions(+), 11 deletions(-) diff --git a/frontend/src/__tests__/plans-permissions.test.ts b/frontend/src/__tests__/plans-permissions.test.ts index 2507f6856..89168d7c5 100644 --- a/frontend/src/__tests__/plans-permissions.test.ts +++ b/frontend/src/__tests__/plans-permissions.test.ts @@ -35,6 +35,9 @@ jest.mock('../state', () => ({ subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), getCurrentUser: jest.fn(), + getPlansColumnFilters: jest.fn().mockReturnValue({}), + setPlansColumnFilter: jest.fn(), + clearAllPlansColumnFilters: jest.fn(), })); jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts index 134dde150..6e1bb0b6f 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -47,6 +47,9 @@ jest.mock('../state', () => ({ subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), + getPlansColumnFilters: jest.fn().mockReturnValue({}), + setPlansColumnFilter: jest.fn(), + clearAllPlansColumnFilters: jest.fn(), })); jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 94fe90973..be3f4cd6d 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -54,6 +54,12 @@ jest.mock('../state', () => ({ // because permissions.ts has a top-level import of ./state which is the very // module being mocked (circular init). permissions.test.ts pins the value. getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), + // Issue #166 follow-up: plans.ts now reads per-column filter state via + // these accessors. Default to "no filters" so legacy assertions keep + // passing; tests that exercise the popover override per-case. + getPlansColumnFilters: jest.fn().mockReturnValue({}), + setPlansColumnFilter: jest.fn(), + clearAllPlansColumnFilters: jest.fn(), })); // Mock history module diff --git a/frontend/src/__tests__/xss-purchase-status.test.ts b/frontend/src/__tests__/xss-purchase-status.test.ts index 6b9c388b4..cb19e76ed 100644 --- a/frontend/src/__tests__/xss-purchase-status.test.ts +++ b/frontend/src/__tests__/xss-purchase-status.test.ts @@ -56,6 +56,9 @@ jest.mock('../state', () => ({ subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), + getPlansColumnFilters: jest.fn().mockReturnValue({}), + setPlansColumnFilter: jest.fn(), + clearAllPlansColumnFilters: jest.fn(), })); jest.mock('../history', () => ({ diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index c4b311bf4..d57276156 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -4,7 +4,7 @@ import * as api from './api'; import * as state from './state'; -import { formatDate, formatTerm, getStatusBadge, escapeHtml, formatCurrency } from './utils'; +import { formatDate, formatTerm, getStatusBadge, escapeHtml, formatCurrency, CURRENCY_DEFAULT_DIGITS } from './utils'; import { showToast } from './toast'; import { confirmDialog } from './confirmDialog'; import type { PlansResponse, LocalPlan, SavePlanData } from './types'; @@ -14,6 +14,7 @@ import { populateTermSelect, populatePaymentSelect, isValidCombination, normaliz import { openModal, closeModal } from './modal'; import { showSkeletonTiles, showSkeletonRows, teardownSkeleton } from './lib/skeleton'; import { canAccess } from './permissions'; +import { parseNumericFilter, applyColumnFilters as applyColumnFiltersLib } from './lib/column-filters'; // pendingPlanRecommendations holds the resolved plan target captured at // "Plan from N selected" button-click time. The Plan flow used to re-derive @@ -109,42 +110,70 @@ async function loadPlannedPurchases(): Promise { } } +// Cached last-fetched planned purchases. The filter popover commits re-render +// without re-fetching, so we hold the unfiltered set in module scope. Reset +// on every successful loadPlannedPurchases() and consumed by +// rerenderPlannedPurchases() (called by popover commits + Clear button). +let lastLoadedPurchases: PlannedPurchase[] = []; + /** * Render planned purchases list */ function renderPlannedPurchases(purchases: PlannedPurchase[]): void { + lastLoadedPurchases = [...purchases]; + renderPlannedPurchasesInternal(); +} + +// renderPlannedPurchasesInternal is the actual render — separate from +// renderPlannedPurchases() so popover commits can re-render the table +// against the cached unfiltered set without re-fetching from the API. +function renderPlannedPurchasesInternal(): void { const container = document.getElementById('planned-purchases-list'); if (!container) return; + const purchases = lastLoadedPurchases; if (!purchases || purchases.length === 0) { container.innerHTML = '

No planned purchases. Create a purchase plan to schedule automatic purchases.

'; return; } + const filters = state.getPlansColumnFilters(); + const filtered = applyPlansColumnFilters(purchases, filters); + + const filterBtn = (column: state.PlansColumnId, lbl: string): string => { + const active = filters[column] ? ' active' : ''; + const label = filters[column] ? `Filter ${lbl} — currently active` : `Filter ${lbl}`; + return ``; + }; + + const tbody = filtered.length === 0 + ? `No rows match these filters.` + : filtered.map(purchase => renderPlannedPurchaseRow(purchase)).join(''); + container.innerHTML = ` - - - - - - - - + + + + + + + + - ${purchases.map(purchase => renderPlannedPurchaseRow(purchase)).join('')} + ${tbody}
Plan Scheduled DateProviderServiceResourceCountTermUpfrontEst. SavingsStatusProvider${filterBtn('provider', 'Provider')}Service${filterBtn('service', 'Service')}Resource Type${filterBtn('resource_type', 'Resource Type')}Count${filterBtn('count', 'Count')}Term${filterBtn('term', 'Term')}${filterBtn('payment', 'Payment')}Upfront${filterBtn('upfront_cost', 'Upfront')}Est. Savings${filterBtn('estimated_savings', 'Est. Savings')}Status${filterBtn('status', 'Status')} Actions
`; - // Add event listeners + // Add event listeners for row action buttons container.querySelectorAll('[data-action]').forEach(btn => { btn.addEventListener('click', () => void handlePlannedPurchaseAction( btn.dataset['action'] || '', @@ -152,6 +181,475 @@ function renderPlannedPurchases(purchases: PlannedPurchase[]): void { btn.dataset['planId'] || '' )); }); + + // Per-column filter trigger buttons. e.stopPropagation prevents any future + // surrounding-th handlers (sort etc.) from firing on the same click. + container.querySelectorAll('.column-filter-btn').forEach((btn) => { + const column = btn.dataset['column'] as state.PlansColumnId | undefined; + if (!column) return; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + openPlansColumnPopover(column, btn); + }); + }); + + // Re-anchor any open popover to the freshly-rendered trigger so the + // popover survives table re-renders triggered by other state changes. + rebindOpenPlansPopoverAnchor(); +} + +// --------------------------------------------------------------------------- +// Per-column filter pipeline (issue #166 follow-up to #570). +// +// Mirrors the canonical recommendations.ts wiring: cell extractors map each +// row + column id to its raw value, numeric values are rounded to the cell's +// display precision so filter predicates match what the user sees. +// --------------------------------------------------------------------------- + +function applyPlansColumnFilters( + purchases: readonly PlannedPurchase[], + filters: state.PlansColumnFilters, +): PlannedPurchase[] { + return applyColumnFiltersLib( + purchases, + filters, + { + categorical: categoricalCellValueForPlan, + numeric: (p, col) => roundForDisplay(numericCellValueForPlan(p, col), displayPrecisionForPlan(col)), + }, + ); +} + +function categoricalCellValueForPlan(p: PlannedPurchase, col: state.PlansColumnId): string { + switch (col) { + case 'provider': return p.provider ?? ''; + case 'service': return p.service ?? ''; + case 'resource_type': return p.resource_type ?? ''; + case 'term': return p.term == null ? '' : String(p.term); + case 'payment': return p.payment ?? ''; + case 'status': return p.status ?? ''; + // Numeric columns shouldn't reach this branch; return empty for type-safety. + case 'count': + case 'upfront_cost': + case 'estimated_savings': + return ''; + } +} + +function numericCellValueForPlan(p: PlannedPurchase, col: state.PlansColumnId): number { + switch (col) { + case 'count': return p.count ?? 0; + case 'upfront_cost': return p.upfront_cost ?? 0; + case 'estimated_savings': return p.estimated_savings ?? 0; + case 'provider': + case 'service': + case 'resource_type': + case 'term': + case 'payment': + case 'status': + return Number.NaN; + } +} + +// Issue #484 parity: filter predicates compare against the rounded display +// value so "exact-match" filters work for rows whose raw value rounds to the +// typed value. The planned-purchases table renders count as integer and +// currency cells via formatCurrency (CURRENCY_DEFAULT_DIGITS = 0). +function displayPrecisionForPlan(col: state.PlansColumnId): number { + switch (col) { + case 'count': + return 0; + case 'upfront_cost': + case 'estimated_savings': + return CURRENCY_DEFAULT_DIGITS; + case 'provider': + case 'service': + case 'resource_type': + case 'term': + case 'payment': + case 'status': + return CURRENCY_DEFAULT_DIGITS; + } +} + +function roundForDisplay(n: number, precision: number): number { + if (!Number.isFinite(n)) return n; + return Number(n.toFixed(precision)); +} + +// --------------------------------------------------------------------------- +// Plans column-filter popover (portal pattern — sibling of the one in +// recommendations.ts). Lives appended to document.body so it survives the +// table's innerHTML rewrite on every render. +// --------------------------------------------------------------------------- + +const PLANS_NUMERIC_COLUMNS: ReadonlySet = new Set([ + 'count', 'upfront_cost', 'estimated_savings', +]); + +interface PlansPopoverState { + column: state.PlansColumnId; + el: HTMLDivElement; + checkboxes: Map; + input: HTMLInputElement | null; + errorEl: HTMLElement | null; +} + +let openPlansPopover: PlansPopoverState | null = null; +let plansOutsideClickHandler: ((e: MouseEvent) => void) | null = null; +let plansEscKeyHandler: ((e: KeyboardEvent) => void) | null = null; +let plansResizeHandler: (() => void) | null = null; + +function plansColumnLabel(column: state.PlansColumnId): string { + switch (column) { + case 'provider': return 'Provider'; + case 'service': return 'Service'; + case 'resource_type': return 'Resource Type'; + case 'term': return 'Term'; + case 'payment': return 'Payment'; + case 'status': return 'Status'; + case 'count': return 'Count'; + case 'upfront_cost': return 'Upfront'; + case 'estimated_savings': return 'Est. Savings'; + } +} + +function plansCategoricalDisplayLabel(column: state.PlansColumnId, value: string): string { + if (value === '') return '(empty)'; + if (column === 'term') { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? formatTerm(n) : value; + } + if (column === 'provider') { + return value.toUpperCase(); + } + return value; +} + +function getPlansColumnTriggerButton(column: state.PlansColumnId): HTMLButtonElement | null { + return document.querySelector( + `#planned-purchases-list th .column-filter-btn[data-column="${column}"]`, + ); +} + +function positionPlansPopover(popover: HTMLElement, anchor: HTMLElement): void { + const rect = anchor.getBoundingClientRect(); + popover.style.display = 'block'; + const popRect = popover.getBoundingClientRect(); + const margin = 8; + + let top = rect.bottom + 4; + if (top + popRect.height > window.innerHeight - margin) { + top = Math.max(margin, rect.top - popRect.height - 4); + } + let left = rect.left; + if (left + popRect.width > window.innerWidth - margin) { + left = Math.max(margin, window.innerWidth - margin - popRect.width); + } + popover.style.position = 'absolute'; + popover.style.top = `${top + window.scrollY}px`; + popover.style.left = `${left + window.scrollX}px`; +} + +function plansDistinctValuesForColumn( + purchases: readonly PlannedPurchase[], + column: state.PlansColumnId, +): string[] { + const seen = new Set(); + for (const p of purchases) { + seen.add(categoricalCellValueForPlan(p, column)); + } + return Array.from(seen).sort((a, b) => { + if (a === '' && b !== '') return -1; + if (a !== '' && b === '') return 1; + return a.localeCompare(b); + }); +} + +function buildPlansPopoverContent( + column: state.PlansColumnId, + purchases: readonly PlannedPurchase[], +): { el: HTMLDivElement; checkboxes: Map; input: HTMLInputElement | null; errorEl: HTMLElement | null } { + const popover = document.createElement('div'); + popover.className = 'column-filter-popover'; + popover.setAttribute('role', 'dialog'); + popover.setAttribute('aria-modal', 'false'); + + const headingId = `plans-column-filter-heading-${column}`; + popover.setAttribute('aria-labelledby', headingId); + + const heading = document.createElement('h3'); + heading.id = headingId; + heading.className = 'column-filter-heading'; + heading.textContent = `Filter ${plansColumnLabel(column)}`; + popover.appendChild(heading); + + const checkboxes = new Map(); + let input: HTMLInputElement | null = null; + let errorEl: HTMLElement | null = null; + let commitAllRef: ((target: boolean) => void) | null = null; + + if (PLANS_NUMERIC_COLUMNS.has(column)) { + const label = document.createElement('label'); + label.className = 'column-filter-numeric-label'; + label.textContent = 'Expression'; + input = document.createElement('input'); + input.type = 'text'; + input.className = 'column-filter-numeric-input'; + input.placeholder = 'e.g. >100, 50..200, 5'; + input.setAttribute('aria-describedby', `plans-column-filter-error-${column}`); + label.appendChild(input); + popover.appendChild(label); + + errorEl = document.createElement('div'); + errorEl.id = `plans-column-filter-error-${column}`; + errorEl.className = 'column-filter-error'; + errorEl.setAttribute('role', 'status'); + popover.appendChild(errorEl); + + const commit = (): void => { + const expr = input!.value.trim(); + if (expr === '') { + state.setPlansColumnFilter(column, null); + errorEl!.textContent = ''; + rerenderPlannedPurchases(); + return; + } + const parsed = parseNumericFilter(expr); + if (!parsed.ok) { + errorEl!.textContent = parsed.error; + return; + } + errorEl!.textContent = ''; + state.setPlansColumnFilter(column, { kind: 'expr', expr }); + rerenderPlannedPurchases(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commit(); + } + }); + } else { + const distinct = plansDistinctValuesForColumn(purchases, column); + + const allLabel = document.createElement('label'); + allLabel.className = 'column-filter-all'; + const allBox = document.createElement('input'); + allBox.type = 'checkbox'; + allBox.dataset['role'] = 'all'; + allLabel.appendChild(allBox); + const allText = document.createElement('span'); + allText.textContent = '(All)'; + allLabel.appendChild(allText); + popover.appendChild(allLabel); + + const list = document.createElement('div'); + list.className = 'column-filter-list'; + for (const value of distinct) { + const itemLabel = document.createElement('label'); + itemLabel.className = 'column-filter-item'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.dataset['value'] = value; + itemLabel.appendChild(cb); + const text = document.createElement('span'); + text.textContent = plansCategoricalDisplayLabel(column, value); + itemLabel.appendChild(text); + list.appendChild(itemLabel); + checkboxes.set(value, cb); + } + popover.appendChild(list); + + const updateAllTriState = (): void => { + const total = checkboxes.size; + let checked = 0; + checkboxes.forEach((cb) => { if (cb.checked) checked++; }); + allBox.indeterminate = checked > 0 && checked < total; + allBox.checked = checked === total && total > 0; + }; + + const commit = (): void => { + const selected: string[] = []; + checkboxes.forEach((cb, value) => { if (cb.checked) selected.push(value); }); + if (selected.length === checkboxes.size) { + state.setPlansColumnFilter(column, null); + } else { + state.setPlansColumnFilter(column, { kind: 'set', values: selected }); + } + updateAllTriState(); + rerenderPlannedPurchases(); + }; + + const commitAll = (target: boolean): void => { + checkboxes.forEach((cb) => { cb.checked = target; }); + if (target) { + state.setPlansColumnFilter(column, null); + } else { + state.setPlansColumnFilter(column, { kind: 'set', values: [] }); + } + updateAllTriState(); + rerenderPlannedPurchases(); + }; + commitAllRef = commitAll; + + checkboxes.forEach((cb) => { + cb.addEventListener('change', commit); + }); + allBox.addEventListener('change', () => { + commitAll(allBox.checked); + }); + } + + const footer = document.createElement('div'); + footer.className = 'column-filter-footer'; + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'column-filter-clear'; + clearBtn.textContent = 'Clear'; + clearBtn.addEventListener('click', () => { + if (input) { + state.setPlansColumnFilter(column, null); + input.value = ''; + if (errorEl) errorEl.textContent = ''; + rerenderPlannedPurchases(); + } else { + commitAllRef?.(false); + } + }); + footer.appendChild(clearBtn); + popover.appendChild(footer); + + return { el: popover, checkboxes, input, errorEl }; +} + +function resyncOpenPlansPopover(): void { + if (!openPlansPopover) return; + const f = state.getPlansColumnFilters()[openPlansPopover.column]; + if (openPlansPopover.input) { + if (document.activeElement !== openPlansPopover.input) { + const expr = f && f.kind === 'expr' ? f.expr : ''; + openPlansPopover.input.value = expr; + if (openPlansPopover.errorEl) openPlansPopover.errorEl.textContent = ''; + } + return; + } + if (f == null) { + openPlansPopover.checkboxes.forEach((cb) => { cb.checked = true; }); + } else { + const values: ReadonlySet = f.kind === 'set' ? new Set(f.values) : new Set(); + openPlansPopover.checkboxes.forEach((cb, value) => { + cb.checked = values.has(value); + }); + } + const allBox = openPlansPopover.el.querySelector('input[data-role="all"]'); + if (allBox) { + const total = openPlansPopover.checkboxes.size; + let checked = 0; + openPlansPopover.checkboxes.forEach((cb) => { if (cb.checked) checked++; }); + allBox.indeterminate = checked > 0 && checked < total; + allBox.checked = checked === total && total > 0; + } +} + +function attachPlansPopoverGlobalListeners(): void { + if (plansOutsideClickHandler) return; + plansOutsideClickHandler = (e: MouseEvent): void => { + if (!openPlansPopover) return; + const target = e.target as Node | null; + if (!target) return; + if (openPlansPopover.el.contains(target)) return; + if (target instanceof Element && target.closest('.column-filter-btn')) return; + closePlansPopover(); + }; + plansEscKeyHandler = (e: KeyboardEvent): void => { + if (!openPlansPopover) return; + if (e.key === 'Escape') { + e.preventDefault(); + closePlansPopover(true); + } + }; + plansResizeHandler = (): void => { + if (!openPlansPopover) return; + const trigger = getPlansColumnTriggerButton(openPlansPopover.column); + if (!trigger) { + closePlansPopover(); + return; + } + positionPlansPopover(openPlansPopover.el, trigger); + }; + document.addEventListener('mousedown', plansOutsideClickHandler); + document.addEventListener('keydown', plansEscKeyHandler); + window.addEventListener('resize', plansResizeHandler); +} + +function detachPlansPopoverGlobalListeners(): void { + if (plansOutsideClickHandler) document.removeEventListener('mousedown', plansOutsideClickHandler); + if (plansEscKeyHandler) document.removeEventListener('keydown', plansEscKeyHandler); + if (plansResizeHandler) window.removeEventListener('resize', plansResizeHandler); + plansOutsideClickHandler = null; + plansEscKeyHandler = null; + plansResizeHandler = null; +} + +function openPlansColumnPopover(column: state.PlansColumnId, anchor: HTMLElement): void { + if (openPlansPopover && !openPlansPopover.el.isConnected) { + detachPlansPopoverGlobalListeners(); + openPlansPopover = null; + } + if (openPlansPopover && openPlansPopover.column === column) { + closePlansPopover(true); + return; + } + if (openPlansPopover) closePlansPopover(); + + const built = buildPlansPopoverContent(column, lastLoadedPurchases); + document.body.appendChild(built.el); + openPlansPopover = { + column, + el: built.el, + checkboxes: built.checkboxes, + input: built.input, + errorEl: built.errorEl, + }; + resyncOpenPlansPopover(); + positionPlansPopover(built.el, anchor); + anchor.setAttribute('aria-expanded', 'true'); + attachPlansPopoverGlobalListeners(); + + const firstFocusable = built.input + ?? built.el.querySelector('input[type="checkbox"]'); + firstFocusable?.focus(); +} + +function closePlansPopover(restoreFocus = false): void { + if (!openPlansPopover) return; + const { column, el } = openPlansPopover; + el.remove(); + openPlansPopover = null; + detachPlansPopoverGlobalListeners(); + const trigger = getPlansColumnTriggerButton(column); + if (trigger) { + trigger.setAttribute('aria-expanded', 'false'); + if (restoreFocus) trigger.focus(); + } +} + +function rebindOpenPlansPopoverAnchor(): void { + if (!openPlansPopover) return; + const trigger = getPlansColumnTriggerButton(openPlansPopover.column); + if (!trigger) { + closePlansPopover(); + return; + } + trigger.setAttribute('aria-expanded', 'true'); + positionPlansPopover(openPlansPopover.el, trigger); + resyncOpenPlansPopover(); +} + +function rerenderPlannedPurchases(): void { + renderPlannedPurchasesInternal(); } /** From 691960ecc1815f6ac7b82dcfd2ea5e1827a88c3d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 16:24:10 +0200 Subject: [PATCH 3/4] test(frontend/plans): column-filter regression suite Adds plans-column-filters.test.ts covering the Planned Purchases table integration with lib/column-filters: - every filterable column header carries a trigger button - clicking opens a portal popover detached to document.body - categorical set filter narrows rows (provider=aws) - numeric expression filter narrows rows (count >= 2) - stacked filters AND together - invalid expressions surface the lib's inline error and apply no filter - (All) tri-state restores the full row set after narrowing The shared parseNumericFilter + applyColumnFilters primitives keep their own coverage in column-filters.test.ts; these tests focus on the Plans-specific wiring. Refs #166. --- .../__tests__/plans-column-filters.test.ts | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 frontend/src/__tests__/plans-column-filters.test.ts diff --git a/frontend/src/__tests__/plans-column-filters.test.ts b/frontend/src/__tests__/plans-column-filters.test.ts new file mode 100644 index 000000000..a2c3a197e --- /dev/null +++ b/frontend/src/__tests__/plans-column-filters.test.ts @@ -0,0 +1,284 @@ +/** + * Regression tests for the Plans / Planned Purchases per-column filter + * pipeline wired in the issue #166 follow-up to PR #570. + * + * The shared lib/column-filters primitives are exercised through the + * dedicated column-filters.test.ts suite; these tests focus on the + * Planned Purchases table integration: + * + * - filter trigger button in every filterable header + * - popover opens / lists distinct values / commits filter state + * - numeric expression filter narrows rows + surfaces inline errors + * - categorical set filter narrows rows + * - stacked filters AND together + * - clearing a filter restores the full row set + */ +import { loadPlans } from '../plans'; + +jest.mock('../api', () => ({ + getPlans: jest.fn().mockResolvedValue({ plans: [] }), + getPlannedPurchases: jest.fn(), + runPlannedPurchase: jest.fn(), + pausePlannedPurchase: jest.fn(), + resumePlannedPurchase: jest.fn(), + deletePlannedPurchase: jest.fn(), + createPlannedPurchases: jest.fn(), + listPlanAccounts: jest.fn().mockResolvedValue([]), + setPlanAccounts: jest.fn().mockResolvedValue(undefined), + listAccounts: jest.fn().mockResolvedValue([]), + getAccount: jest.fn().mockResolvedValue(null), + getPlan: jest.fn(), + createPlan: jest.fn(), + updatePlan: jest.fn(), + patchPlan: jest.fn(), + deletePlan: jest.fn(), +})); + +// Module-scoped filter state; the real state module is too coupled to +// other slices to import directly for these tests. The mock backs +// getPlansColumnFilters / setPlansColumnFilter with a simple object +// so the popover commit path round-trips like the real store. +let plansFilters: Record = {}; +jest.mock('../state', () => ({ + getRecommendations: jest.fn().mockReturnValue([]), + getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()), + getVisibleRecommendations: jest.fn().mockReturnValue([]), + setVisibleRecommendations: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue(''), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', role: 'admin' }), + getPlansColumnFilters: jest.fn(() => ({ ...plansFilters })), + setPlansColumnFilter: jest.fn((col: string, filter: unknown) => { + if (filter === null) { + const next = { ...plansFilters }; + delete next[col]; + plansFilters = next; + return; + } + plansFilters = { ...plansFilters, [col]: filter }; + }), + clearAllPlansColumnFilters: jest.fn(() => { plansFilters = {}; }), +})); + +jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); +jest.mock('../commitmentOptions', () => ({ + populateTermSelect: jest.fn(), + populatePaymentSelect: jest.fn(), + isValidCombination: jest.fn().mockReturnValue(true), + normalizePaymentValue: jest.fn((v) => v), +})); +jest.mock('../archera', () => ({ openArcheraOfferModal: jest.fn() })); +jest.mock('../toast', () => ({ showToast: jest.fn(() => ({ dismiss: jest.fn() })) })); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn(() => Promise.resolve(true)) })); + +import * as api from '../api'; + +const seedPurchases = [ + { + id: 'pp-1', plan_id: 'plan-a', plan_name: 'Plan A', scheduled_date: '2026-06-01', + provider: 'aws', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', + count: 1, term: 1, payment: 'all-upfront', upfront_cost: 100, estimated_savings: 10, + status: 'pending', step_number: 1, total_steps: 4, + }, + { + id: 'pp-2', plan_id: 'plan-a', plan_name: 'Plan A', scheduled_date: '2026-06-02', + provider: 'aws', service: 'rds', resource_type: 'db.t3.large', region: 'us-east-1', + count: 3, term: 3, payment: 'no-upfront', upfront_cost: 0, estimated_savings: 50, + status: 'pending', step_number: 2, total_steps: 4, + }, + { + id: 'pp-3', plan_id: 'plan-b', plan_name: 'Plan B', scheduled_date: '2026-06-03', + provider: 'azure', service: 'compute', resource_type: 'D2s_v3', region: 'eastus', + count: 5, term: 3, payment: 'partial-upfront', upfront_cost: 500, estimated_savings: 200, + status: 'paused', step_number: 1, total_steps: 1, + }, +]; + +describe('Planned Purchases column filters (issue #166 follow-up)', () => { + beforeEach(() => { + plansFilters = {}; + document.body.innerHTML = ` +
+
+ `; + jest.clearAllMocks(); + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: seedPurchases }); + }); + + afterEach(() => { + // Detached popover lives on document.body; clean up between tests so + // a leftover from one assertion doesn't bleed into the next. + document.body.querySelectorAll('.column-filter-popover').forEach((n) => n.remove()); + }); + + function countRowsByStatus(): Record { + const rows = document.querySelectorAll( + '#planned-purchases-list tbody tr.planned-purchase-row', + ); + const out: Record = {}; + rows.forEach((r) => { + const status = r.className.split(/\s+/).find((c) => c.startsWith('status-')) ?? 'unknown'; + out[status] = (out[status] ?? 0) + 1; + }); + return out; + } + + function countRows(): number { + return document.querySelectorAll( + '#planned-purchases-list tbody tr.planned-purchase-row', + ).length; + } + + test('every filterable column header has a trigger button', async () => { + await loadPlans(); + const buttons = document.querySelectorAll( + '#planned-purchases-list th .column-filter-btn[data-column]', + ); + const cols = Array.from(buttons).map((b) => b.dataset['column']); + expect(cols.sort()).toEqual( + ['count', 'estimated_savings', 'payment', 'provider', 'resource_type', 'service', 'status', 'term', 'upfront_cost'].sort(), + ); + }); + + test('clicking a trigger opens a popover detached to document.body', async () => { + await loadPlans(); + const providerBtn = document.querySelector( + '#planned-purchases-list th .column-filter-btn[data-column="provider"]', + ); + providerBtn?.click(); + const popover = document.body.querySelector('.column-filter-popover'); + expect(popover).not.toBeNull(); + expect(popover?.closest('table')).toBeNull(); + }); + + test('categorical set filter narrows rows (provider=aws)', async () => { + await loadPlans(); + expect(countRows()).toBe(3); + + // Open the provider popover, uncheck "azure" to narrow to aws-only. + const providerBtn = document.querySelector( + '#planned-purchases-list th .column-filter-btn[data-column="provider"]', + ); + providerBtn?.click(); + const items = Array.from(document.querySelectorAll( + '.column-filter-popover .column-filter-item', + )); + const azureItem = items.find((l) => l.querySelector('input')?.dataset['value'] === 'azure'); + const azureCb = azureItem?.querySelector('input[type="checkbox"]'); + expect(azureCb).not.toBeNull(); + expect(azureCb!.checked).toBe(true); // no filter → all checked + azureCb!.checked = false; + azureCb!.dispatchEvent(new Event('change')); + + expect(countRows()).toBe(2); + document.querySelectorAll( + '#planned-purchases-list tbody tr.planned-purchase-row', + ).forEach((r) => { + expect(r.innerHTML.toLowerCase()).toContain('aws'); + }); + }); + + test('numeric expr filter narrows rows (count >= 2)', async () => { + await loadPlans(); + expect(countRows()).toBe(3); + + const countBtn = document.querySelector( + '#planned-purchases-list th .column-filter-btn[data-column="count"]', + ); + countBtn?.click(); + const input = document.querySelector( + '.column-filter-popover .column-filter-numeric-input', + ); + expect(input).not.toBeNull(); + input!.value = '>=2'; + input!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + // pp-1 has count=1 (filtered out); pp-2 count=3 and pp-3 count=5 remain. + expect(countRows()).toBe(2); + }); + + test('stacked filters AND together (provider=aws AND count >= 2)', async () => { + await loadPlans(); + + // First filter: provider=aws (untick azure). + const providerBtn = document.querySelector( + '#planned-purchases-list th .column-filter-btn[data-column="provider"]', + ); + providerBtn?.click(); + const azureCb = document.querySelector( + '.column-filter-popover .column-filter-item input[data-value="azure"]', + ); + azureCb!.checked = false; + azureCb!.dispatchEvent(new Event('change')); + expect(countRows()).toBe(2); + + // Second filter: count >= 2. + const countBtn = document.querySelector( + '#planned-purchases-list th .column-filter-btn[data-column="count"]', + ); + countBtn?.click(); + const input = document.querySelector( + '.column-filter-popover .column-filter-numeric-input', + ); + input!.value = '>=2'; + input!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + // Only pp-2 (aws + count=3) survives both filters; pp-3 fails on provider, pp-1 on count. + expect(countRows()).toBe(1); + const survivor = document.querySelector( + '#planned-purchases-list tbody tr.planned-purchase-row', + ); + expect(survivor?.innerHTML).toContain('rds'); + }); + + test('invalid numeric expression surfaces the lib error inline (no filter applied)', async () => { + await loadPlans(); + const countBtn = document.querySelector( + '#planned-purchases-list th .column-filter-btn[data-column="count"]', + ); + countBtn?.click(); + const input = document.querySelector( + '.column-filter-popover .column-filter-numeric-input', + ); + input!.value = '>=abc'; + input!.dispatchEvent(new Event('blur')); + + const err = document.querySelector('.column-filter-popover .column-filter-error'); + expect(err?.textContent).toMatch(/Invalid filter term/); + // No filter applied → all 3 rows still rendered. + expect(countRows()).toBe(3); + }); + + test('clearing a filter restores the full row set', async () => { + await loadPlans(); + + // Apply: status=pending (uncheck paused). + const statusBtn = document.querySelector( + '#planned-purchases-list th .column-filter-btn[data-column="status"]', + ); + statusBtn?.click(); + const pausedCb = document.querySelector( + '.column-filter-popover .column-filter-item input[data-value="paused"]', + ); + pausedCb!.checked = false; + pausedCb!.dispatchEvent(new Event('change')); + expect(countRows()).toBe(2); + expect(countRowsByStatus()['status-paused']).toBeUndefined(); + + // Popover is still open after the checkbox commit. Use the (All) + // tri-state to restore "no narrowing" → all 3 rows. + const allBox = document.querySelector( + '.column-filter-popover input[data-role="all"]', + ); + expect(allBox).not.toBeNull(); + allBox!.checked = true; + allBox!.dispatchEvent(new Event('change')); + + expect(countRows()).toBe(3); + }); +}); From d1d4645e74483c495a43b393cc1067943060a3b3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 16:26:44 +0200 Subject: [PATCH 4/4] style(frontend/plans): use unicode escapes for filter-icon + em-dash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the canonical recommendations.ts wiring exactly (⛛ filter icon, — em-dash inside the aria-label). Keeps the rendered DOM identical to the Opportunities tab so the two surfaces are indistinguishable at the screen-reader and visual layers. No behavioural change. --- frontend/src/plans.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index d57276156..f3a3676c0 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -142,8 +142,8 @@ function renderPlannedPurchasesInternal(): void { const filterBtn = (column: state.PlansColumnId, lbl: string): string => { const active = filters[column] ? ' active' : ''; - const label = filters[column] ? `Filter ${lbl} — currently active` : `Filter ${lbl}`; - return ``; + const label = filters[column] ? `Filter ${lbl} \u2014 currently active` : `Filter ${lbl}`; + return ``; }; const tbody = filtered.length === 0