From 4c4b2a485fd4b3e6e690484d3e8072c6d55147c2 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 30 May 2026 20:22:53 +0200 Subject: [PATCH 1/2] fix(opportunities): hide checkboxes + action bar for viewer role Viewer role has no purchase/plan actions; the Select-all and per-row checkboxes were misleading. Now hidden for role=viewer; admin/operator render unchanged. Closes #869. --- .../recommendations-permissions.test.ts | 104 +++++++- frontend/src/recommendations.ts | 232 ++++++++++-------- 2 files changed, 229 insertions(+), 107 deletions(-) diff --git a/frontend/src/__tests__/recommendations-permissions.test.ts b/frontend/src/__tests__/recommendations-permissions.test.ts index 3930d22d2..1ec2c659a 100644 --- a/frontend/src/__tests__/recommendations-permissions.test.ts +++ b/frontend/src/__tests__/recommendations-permissions.test.ts @@ -1,14 +1,20 @@ /** - * Recommendations bottom-action-box permission gating (issue #365). + * Recommendations permission gating tests. * - * The bottom-action box stays visible for every signed-in role - * (read-only browsing of recommendations is in scope), but the two - * mutating CTAs inside are hidden when the role lacks the underlying - * verb: - * * #bulk-purchase-btn ("Purchase" one-off) — `execute:purchases` - * * #create-plan-btn ("Create Plan") — `create:plans` + * Issue #365: bottom-action-box CTA gating. + * The bottom-action box stays visible for every signed-in role + * (read-only browsing is in scope), but the two mutating CTAs are + * hidden when the role lacks the underlying verb: + * * #bulk-purchase-btn ("Purchase" one-off) -- `execute:purchases` + * * #create-plan-btn ("Create Plan") -- `create:plans` + * + * Issue #869: checkbox column + row-click gating for viewer (readonly). + * Viewer has no purchase/plan actions so the Select-all checkbox, + * per-row checkboxes, and row-click selection are all hidden/inert. + * Admin and user (operator) roles are unchanged. */ import { loadRecommendations } from '../recommendations'; +import * as api from '../api'; jest.mock('../api', () => ({ getRecommendations: jest.fn().mockResolvedValue({ summary: {}, recommendations: [], regions: [] }), @@ -77,6 +83,23 @@ const setupDom = () => { document.body.replaceChildren(recsTab); }; +// Minimal recommendation fixture for issue #869 checkbox tests. +const sampleRec = { + id: 'r1', + provider: 'aws', + cloud_account_id: 'acct1', + service: 'ec2', + resource_type: 't3.medium', + region: 'us-east-1', + count: 1, + term: 1, + payment: 'all-upfront', + savings: 200, + upfront_cost: 1000, + monthly_cost: null, + on_demand_cost: null, +}; + describe('Recommendations action-box permission gating (issue #365)', () => { beforeEach(() => { jest.clearAllMocks(); @@ -133,3 +156,70 @@ describe('Recommendations action-box permission gating (issue #365)', () => { } }); }); + +describe('Recommendations checkbox + row-click gating for viewer role (issue #869)', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDom(); + // Seed one recommendation so the table actually renders rows. + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, + recommendations: [sampleRec], + regions: [], + }); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([sampleRec]); + }); + + test('readonly role: no select-all checkbox in table header', async () => { + mockUser('readonly'); + await loadRecommendations(); + expect(document.getElementById('select-all-recs')).toBeNull(); + }); + + test('readonly role: no per-row checkboxes in table body', async () => { + mockUser('readonly'); + await loadRecommendations(); + const list = document.getElementById('recommendations-list'); + const rowCheckboxes = list?.querySelectorAll('input[data-rec-id]') ?? []; + expect(rowCheckboxes.length).toBe(0); + }); + + test('readonly role: row click does not trigger selection state change', async () => { + mockUser('readonly'); + await loadRecommendations(); + const list = document.getElementById('recommendations-list'); + const row = list?.querySelector('tr.recommendation-row'); + expect(row).not.toBeNull(); + // Simulate a click on the row body (not on a checkbox, which is absent). + row!.click(); + expect(state.addSelectedRecommendation).not.toHaveBeenCalled(); + }); + + test('admin role: select-all checkbox is present', async () => { + mockUser('admin'); + await loadRecommendations(); + expect(document.getElementById('select-all-recs')).not.toBeNull(); + }); + + test('admin role: per-row checkbox is present', async () => { + mockUser('admin'); + await loadRecommendations(); + const list = document.getElementById('recommendations-list'); + const rowCheckboxes = list?.querySelectorAll('input[data-rec-id]') ?? []; + expect(rowCheckboxes.length).toBeGreaterThan(0); + }); + + test('user (operator) role: select-all checkbox is present', async () => { + mockUser('user'); + await loadRecommendations(); + expect(document.getElementById('select-all-recs')).not.toBeNull(); + }); + + test('user (operator) role: per-row checkbox is present', async () => { + mockUser('user'); + await loadRecommendations(); + const list = document.getElementById('recommendations-list'); + const rowCheckboxes = list?.querySelectorAll('input[data-rec-id]') ?? []; + expect(rowCheckboxes.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index b0ab6f8ce..33a509ff8 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -22,6 +22,13 @@ import type { RecommendationsResponse, LocalRecommendation, RecommendationsSumma import { openModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; import { canAccess } from './permissions'; + +// Issue #869: true when the current session can take any action on +// recommendations (purchase or plan). Readonly/viewer sessions have neither +// permission, so checkboxes and row-click selection are meaningless for them. +function canActOnRecommendations(): boolean { + return canAccess('execute', 'purchases') || canAccess('create', 'plans'); +} import { parseNumericFilter, applyColumnFilters as applyColumnFiltersLib } from './lib/column-filters'; // Re-export the shared primitives so existing consumers that import from // recommendations.ts keep working without import-path churn (issue #166). @@ -2438,6 +2445,7 @@ function buildVariantRowMarkup( selectedRecs: ReadonlySet, isNested: boolean, cols: readonly ColumnDef[] = COLUMN_DEFS, + showCheckboxes = true, ): string { // issue #319: cost-bearing cells scale with the active period; resolved // once per row and threaded through ctx so renderColumnCell doesn't @@ -2453,11 +2461,16 @@ function buildVariantRowMarkup( const pctText = pct === null ? '\u2014' : pct.toFixed(1) + '%'; const nestedClass = isNested ? ' rec-variant-row' : ''; const cellCtx = { accountName, badge, pct, pctClass, pctText, period }; + // Issue #869: omit the checkbox cell entirely for viewer (readonly) sessions. + // The column header also omits the select-all checkbox, so the column is + // visually absent rather than present-but-empty, matching the no-actions + // experience on Plans and Purchases for the same role. + const checkboxCell = showCheckboxes + ? `` + : ''; return ` - - - + ${checkboxCell} ${cols.map((c) => renderColumnCell(c.key, rec, cellCtx)).join('')} `; } @@ -2467,6 +2480,7 @@ function buildListMarkup( recommendations: LocalRecommendation[], selectedRecs: ReadonlySet, visibleCols: readonly ColumnDef[] = COLUMN_DEFS, + showCheckboxes = true, ): string { const sort = state.getRecommendationsSort(); const filters = state.getRecommendationsColumnFilters(); @@ -2509,7 +2523,7 @@ function buildListMarkup( const variants = groups.get(key)!; if (variants.length === 1) { // Single-variant: render flat, no group header, no indent. - rows.push(buildVariantRowMarkup(variants[0]!, selectedRecs, false, visibleCols)); + rows.push(buildVariantRowMarkup(variants[0]!, selectedRecs, false, visibleCols, showCheckboxes)); continue; } @@ -2579,7 +2593,7 @@ function buildListMarkup( if (isExpanded) { const sortedVariants = sortVariantsInCell(variants); for (const v of sortedVariants) { - rows.push(buildVariantRowMarkup(v, selectedRecs, true, visibleCols)); + rows.push(buildVariantRowMarkup(v, selectedRecs, true, visibleCols, showCheckboxes)); } } } @@ -2590,24 +2604,26 @@ function buildListMarkup( // see openColumnPopover wiring + #224). Indeterminate is set via JS in // renderRecommendationsList's post-render hook because HTML attributes // can't express the indeterminate state. - const bestVariants = pickBestVariantPerCell(recommendations); - const bestVariantIds = new Set(bestVariants.map((r) => r.id)); - let selectedBestCount = 0; - selectedRecs.forEach((id) => { if (bestVariantIds.has(id)) selectedBestCount++; }); - const allSelected = bestVariants.length > 0 && selectedBestCount === bestVariants.length; - const selectAllCheckedAttr = allSelected ? ' checked' : ''; - // Threaded to the renderer via a data attribute so the post-render hook - // can flip the .indeterminate property without re-deriving the counts. - const selectAllIndeterminate = selectedBestCount > 0 && selectedBestCount < bestVariants.length; - const selectAllDataIndeterminate = ` data-indeterminate="${selectAllIndeterminate ? 'true' : 'false'}"`; + // Issue #869: skip the tri-state computation entirely for viewer sessions + // to avoid dead-code paths when showCheckboxes is false. + let checkboxColHeader = ''; + if (showCheckboxes) { + const bestVariants = pickBestVariantPerCell(recommendations); + const bestVariantIds = new Set(bestVariants.map((r) => r.id)); + let selectedBestCount = 0; + selectedRecs.forEach((id) => { if (bestVariantIds.has(id)) selectedBestCount++; }); + const allSelected = bestVariants.length > 0 && selectedBestCount === bestVariants.length; + const selectAllCheckedAttr = allSelected ? ' checked' : ''; + const selectAllIndeterminate = selectedBestCount > 0 && selectedBestCount < bestVariants.length; + const selectAllDataIndeterminate = ` data-indeterminate="${selectAllIndeterminate ? 'true' : 'false'}"`; + checkboxColHeader = ``; + } return ` - + ${checkboxColHeader} ${visibleCols.map((c) => sortHeader(c.key)).join('')} @@ -3596,12 +3612,20 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { // so header and row rendering use the same snapshot of column visibility state. const visibleCols = visibleColumns(); + // Issue #869: viewer (readonly) sessions have no purchase/plan actions; + // hide the checkbox column entirely so the table doesn't have a useless + // selection surface. The bottom action box already hides its CTA buttons + // for this role (mountBottomActionBox, issue #365). + const showCheckboxes = canActOnRecommendations(); + const selectedIDs = state.getSelectedRecommendationIDs(); // Dynamic table markup: every caller-provided value passes through // escapeHtml or is a number. The string is built in buildListMarkup. // NOTE: buildListMarkup also populates lastVisibleGroupKeys, so it MUST // run before renderFilterStatusBar (which reads it for the Expand-All button). - container.innerHTML = buildListMarkup(recommendations ?? [], selectedIDs, visibleCols); + // safe: buildListMarkup escapes all API-derived values via escapeHtml. + // nosec: innerHTML is intentional here; see security note above. + container.innerHTML = buildListMarkup(recommendations ?? [], selectedIDs, visibleCols, showCheckboxes); // nosec // Issue #700: when the filter yields zero rows, preserve the by // injecting a hint row into the empty rather than replacing the @@ -3610,8 +3634,8 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { if (emptyResult) { const tbody = container.querySelector('tbody'); if (tbody) { - // colspan = 1 (checkbox col) + all visible data columns. - const colspan = 1 + visibleCols.length; + // colspan = checkbox col (1 when shown, 0 when hidden) + all visible data columns. + const colspan = (showCheckboxes ? 1 : 0) + visibleCols.length; const tr = document.createElement('tr'); const td = document.createElement('td'); td.setAttribute('colspan', String(colspan)); @@ -3684,71 +3708,101 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { }); }); - // Add event listeners - const selectAllCheckbox = document.getElementById('select-all-recs') as HTMLInputElement | null; - if (selectAllCheckbox) { - // Issue #479: indeterminate is a DOM property only, so apply it from - // the data attribute the renderer threaded through. Without this, a - // partial selection shows no visual cue and clicking the header - // repeatedly becomes a no-op because the checkbox's .checked never - // flips. - selectAllCheckbox.indeterminate = selectAllCheckbox.dataset['indeterminate'] === 'true'; - selectAllCheckbox.addEventListener('change', () => { - if (selectAllCheckbox.checked) { - // Issue #224: select-all picks ONE variant per cell (highest-effective- - // savings) rather than every visible row. After PR #195's per-(term, - // payment) fan-out, naive "select every row" produces 6× the intended - // commitments per resource — wrong purchase intent. Clear current - // selection first so a stale choice from a different filter context - // doesn't bleed through. - state.clearSelectedRecommendations(); - for (const r of pickBestVariantPerCell(recommendations)) { - state.addSelectedRecommendation(r.id); + // Issue #869: selection handlers are only meaningful when the session can + // act on recommendations (admin/operator). Skip wiring them for viewer + // (readonly) sessions: the checkboxes are absent from the DOM anyway + // (showCheckboxes === false), and the row-click handler would be inert + // because there is no checkbox to toggle. + if (showCheckboxes) { + // Add event listeners + const selectAllCheckbox = document.getElementById('select-all-recs') as HTMLInputElement | null; + if (selectAllCheckbox) { + // Issue #479: indeterminate is a DOM property only, so apply it from + // the data attribute the renderer threaded through. Without this, a + // partial selection shows no visual cue and clicking the header + // repeatedly becomes a no-op because the checkbox's .checked never + // flips. + selectAllCheckbox.indeterminate = selectAllCheckbox.dataset['indeterminate'] === 'true'; + selectAllCheckbox.addEventListener('change', () => { + if (selectAllCheckbox.checked) { + // Issue #224: select-all picks ONE variant per cell (highest-effective- + // savings) rather than every visible row. After PR #195's per-(term, + // payment) fan-out, naive "select every row" produces 6x the intended + // commitments per resource -- wrong purchase intent. Clear current + // selection first so a stale choice from a different filter context + // doesn't bleed through. + state.clearSelectedRecommendations(); + for (const r of pickBestVariantPerCell(recommendations)) { + state.addSelectedRecommendation(r.id); + } + } else { + state.clearSelectedRecommendations(); } - } else { - state.clearSelectedRecommendations(); - } - renderRecommendationsList(recommendations); - }); - } + renderRecommendationsList(recommendations); + }); + } - // ID-keyed selection toggles. data-rec-id persists across filter - // changes so a stale selection from a previous filter is a no-op - // once the user narrows, rather than pointing at whichever rec - // happens to occupy the old index position. - // - // Issue #224: enforce one-variant-per-cell radio behaviour on check. - // When the user checks a variant, deselect any other variant of the - // same cell that's already selected — a single physical resource - // can only carry one (term, payment) commitment at a time. - container.querySelectorAll('input[data-rec-id]').forEach(cb => { - cb.addEventListener('change', () => { - const id = cb.dataset['recId'] || ''; - if (!id) return; - if (cb.checked) { - const newRec = recommendations.find((r) => r.id === id); - if (newRec) { - const newCell = cellKey(newRec); - const selected = state.getSelectedRecommendationIDs(); - // Scan the full loaded set (not just the filtered view) so that - // hidden siblings (e.g. filtered-out term variants) are also - // deselected, preserving the one-variant-per-cell contract. - const allLoaded = state.getRecommendations() as unknown as LocalRecommendation[]; - for (const r of allLoaded) { - if (r.id !== id && selected.has(r.id) && cellKey(r) === newCell) { - state.removeSelectedRecommendation(r.id); + // ID-keyed selection toggles. data-rec-id persists across filter + // changes so a stale selection from a previous filter is a no-op + // once the user narrows, rather than pointing at whichever rec + // happens to occupy the old index position. + // + // Issue #224: enforce one-variant-per-cell radio behaviour on check. + // When the user checks a variant, deselect any other variant of the + // same cell that's already selected -- a single physical resource + // can only carry one (term, payment) commitment at a time. + container.querySelectorAll('input[data-rec-id]').forEach(cb => { + cb.addEventListener('change', () => { + const id = cb.dataset['recId'] || ''; + if (!id) return; + if (cb.checked) { + const newRec = recommendations.find((r) => r.id === id); + if (newRec) { + const newCell = cellKey(newRec); + const selected = state.getSelectedRecommendationIDs(); + // Scan the full loaded set (not just the filtered view) so that + // hidden siblings (e.g. filtered-out term variants) are also + // deselected, preserving the one-variant-per-cell contract. + const allLoaded = state.getRecommendations() as unknown as LocalRecommendation[]; + for (const r of allLoaded) { + if (r.id !== id && selected.has(r.id) && cellKey(r) === newCell) { + state.removeSelectedRecommendation(r.id); + } } } + state.addSelectedRecommendation(id); + } else { + state.removeSelectedRecommendation(id); } - state.addSelectedRecommendation(id); - } else { - state.removeSelectedRecommendation(id); - } - renderRecommendationsList(recommendations); + renderRecommendationsList(recommendations); + }); }); - }); + + // Row-click toggles selection (issue #344 T4'). Clicking anywhere on + // the row's body now toggles the row's checkbox + dispatches the + // existing change handler -- which already enforces the + // one-variant-per-cell radio behaviour (issue #224) and rerenders. + // Skip clicks on the checkbox itself (its native click already + // toggles) and on any interactive child (button / a / input / label / + // select / [data-action]) so per-row controls keep their own + // semantics. The previous row-click -> openDetailDrawer behaviour was + // dropped: see plan.md T4 (the detail drawer's payload duplicated + // the table, with backend-deferred fields the only differentiators). + container.querySelectorAll('tr.recommendation-row').forEach((tr) => { + tr.addEventListener('click', (e) => { + if (!(e.target instanceof Element)) return; + const target = e.target as HTMLElement; + if (target.closest('input, button, a, label, select, [data-action]')) return; + const cb = tr.querySelector('input[type="checkbox"][data-rec-id]'); + if (!cb) return; + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change', { bubbles: true })); + }); + }); + } // issues #225 + #226: chevron click toggles expand/collapse for a cell group. + // Available for all roles (expand/collapse is a view-only operation). container.querySelectorAll('.rec-cell-chevron').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); @@ -3762,28 +3816,6 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { renderRecommendationsList(loadedRecs); }); }); - - // Row-click toggles selection (issue #344 T4'). Clicking anywhere on - // the row's body now toggles the row's checkbox + dispatches the - // existing change handler — which already enforces the - // one-variant-per-cell radio behaviour (issue #224) and rerenders. - // Skip clicks on the checkbox itself (its native click already - // toggles) and on any interactive child (button / a / input / label / - // select / [data-action]) so per-row controls keep their own - // semantics. The previous row-click → openDetailDrawer behaviour was - // dropped: see plan.md §T4 (the detail drawer's payload duplicated - // the table, with backend-deferred fields the only differentiators). - container.querySelectorAll('tr.recommendation-row').forEach((tr) => { - tr.addEventListener('click', (e) => { - if (!(e.target instanceof Element)) return; - const target = e.target as HTMLElement; - if (target.closest('input, button, a, label, select, [data-action]')) return; - const cb = tr.querySelector('input[type="checkbox"][data-rec-id]'); - if (!cb) return; - cb.checked = !cb.checked; - cb.dispatchEvent(new Event('change', { bubbles: true })); - }); - }); } // resolvePerRecPaymentSeed picks the default Payment value for one rec From 88eacb9dc7614dca2c06a31a58d8a5e732a82c53 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 1 Jun 2026 18:40:37 +0200 Subject: [PATCH 2/2] fix(opportunities): address CR findings on PR #876 - Conditionally render checkbox-col td in multi-variant summary rows only when showCheckboxes is true (viewer role fix); inline chevron button into rec-cell-identity span when showCheckboxes is false so expand/collapse remains accessible without the extra column. - Add grouped-row readonly and admin tests that assert no checkbox-col td in viewer mode and correct effective column alignment via colSpan. --- .../recommendations-permissions.test.ts | 70 +++++++++++++++++++ frontend/src/recommendations.ts | 16 +++-- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/recommendations-permissions.test.ts b/frontend/src/__tests__/recommendations-permissions.test.ts index 1ec2c659a..1e0cf0263 100644 --- a/frontend/src/__tests__/recommendations-permissions.test.ts +++ b/frontend/src/__tests__/recommendations-permissions.test.ts @@ -100,6 +100,11 @@ const sampleRec = { on_demand_cost: null, }; +// Two variants sharing a cell key -- same resource_type/region/service/account but +// different term -- so buildListMarkup groups them into a summary row. +const sampleRecVariantA = { ...sampleRec, id: 'r1', term: 1, payment: 'all-upfront', savings: 200 }; +const sampleRecVariantB = { ...sampleRec, id: 'r2', term: 3, payment: 'no-upfront', savings: 350 }; + describe('Recommendations action-box permission gating (issue #365)', () => { beforeEach(() => { jest.clearAllMocks(); @@ -222,4 +227,69 @@ describe('Recommendations checkbox + row-click gating for viewer role (issue #86 const rowCheckboxes = list?.querySelectorAll('input[data-rec-id]') ?? []; expect(rowCheckboxes.length).toBeGreaterThan(0); }); + + test('readonly role: grouped-row summary has no checkbox-col and column span is aligned', async () => { + // Two variants share the same cell key -- buildListMarkup renders a summary row. + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, + recommendations: [sampleRecVariantA, sampleRecVariantB], + regions: [], + }); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([sampleRecVariantA, sampleRecVariantB]); + mockUser('readonly'); + await loadRecommendations(); + + const list = document.getElementById('recommendations-list'); + const table = list?.querySelector('table'); + expect(table).not.toBeNull(); + + const summaryRow = table!.querySelector('tr.rec-cell-summary-row'); + expect(summaryRow).not.toBeNull(); + + // Header must not contain a checkbox-col th. + const headerCheckboxCols = table!.querySelectorAll('thead tr th.checkbox-col'); + expect(headerCheckboxCols.length).toBe(0); + + // Summary row must not contain a checkbox-col td (the bug this PR fixes). + const summaryCheckboxCols = summaryRow!.querySelectorAll('td.checkbox-col'); + expect(summaryCheckboxCols.length).toBe(0); + + // Effective column count: sum of colspan values in each row must match header th count. + const headerColCount = table!.querySelectorAll('thead tr th').length; + const summaryEffectiveCols = Array.from(summaryRow!.querySelectorAll('td')) + .reduce((sum, td) => sum + (td.colSpan || 1), 0); + expect(summaryEffectiveCols).toBe(headerColCount); + }); + + test('admin role: grouped-row summary retains checkbox-col and column span is aligned', async () => { + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, + recommendations: [sampleRecVariantA, sampleRecVariantB], + regions: [], + }); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([sampleRecVariantA, sampleRecVariantB]); + mockUser('admin'); + await loadRecommendations(); + + const list = document.getElementById('recommendations-list'); + const table = list?.querySelector('table'); + expect(table).not.toBeNull(); + + const summaryRow = table!.querySelector('tr.rec-cell-summary-row'); + expect(summaryRow).not.toBeNull(); + + // Header must have a checkbox-col th for admin. + const headerCheckboxCols = table!.querySelectorAll('thead tr th.checkbox-col'); + expect(headerCheckboxCols.length).toBe(1); + + // Summary row must have a checkbox-col td for admin. + const summaryCheckboxCols = summaryRow!.querySelectorAll('td.checkbox-col'); + expect(summaryCheckboxCols.length).toBe(1); + + // Effective column count must match. + const headerColCount = table!.querySelectorAll('thead tr th').length; + const summaryEffectiveCols = Array.from(summaryRow!.querySelectorAll('td')) + .reduce((sum, td) => sum + (td.colSpan || 1), 0); + expect(summaryEffectiveCols).toBe(headerColCount); + }); }); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 33a509ff8..04d3d590b 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -2574,18 +2574,22 @@ function buildListMarkup( rangeParts.push(`term: ${termDisplay}`); } + const chevronButton = ``; + const chevronCell = showCheckboxes + ? `` + : ''; + const inlineChevron = showCheckboxes ? '' : `${chevronButton} `; + rows.push(` - + ${chevronCell} `);
- -
${chevronButton}
- - ${escapeHtml(providerDisplayName(rep.provider))} ${escapeHtml(accountName)} ${escapeHtml(rep.service)} - ${identityParts.join(' — ')} + ${inlineChevron}${identityParts.join(' — ')} ${rangeParts.length > 0 ? `${rangeParts.join(' · ')}` : ''}