diff --git a/frontend/src/__tests__/recommendations-permissions.test.ts b/frontend/src/__tests__/recommendations-permissions.test.ts index f1f962fa..7ef6fdcc 100644 --- a/frontend/src/__tests__/recommendations-permissions.test.ts +++ b/frontend/src/__tests__/recommendations-permissions.test.ts @@ -17,7 +17,7 @@ * Plan modal with the row's rec pre-seeded. The button follows the * same create:plans permission gate. */ -import { loadRecommendations } from '../recommendations'; +import { loadRecommendations, resetExpandedCells } from '../recommendations'; import * as api from '../api'; jest.mock('../api', () => ({ @@ -387,3 +387,82 @@ describe('Per-row Plan button deep-link (issue #120)', () => { expect((calledWith as { id: string }[])[0]!.id).toBe('rec-120'); }); }); + +// Issue #135 + #869: SP plan-type group child rows must honor showCheckboxes. +// Two SP recs of different plan types in the same (provider, account, region) +// scope group under one SP parent row. When expanded, each per-plan-type child +// cell renders a variant row via buildVariantRowMarkup. Those nested calls must +// forward showCheckboxes so readonly sessions get no checkbox cells, matching +// the non-SP path and the column header (which omits the select-all checkbox). +describe('Recommendations SP-group child-row checkbox gating (issue #135 + #869)', () => { + // Two AWS savings-plans recs with distinct plan-type slugs but the same + // scope -> two cell keys -> one SP group with 2 plan types. + const spRecCompute = { + ...sampleRec, + id: 'sp1', + service: 'savings-plans-compute', + resource_type: 'compute', + savings: 300, + }; + const spRecEc2 = { + ...sampleRec, + id: 'sp2', + service: 'savings-plans-ec2instance', + resource_type: 'ec2instance', + savings: 250, + }; + + beforeEach(() => { + jest.clearAllMocks(); + // expandedSpGroups is module-level state; reset so the SP group starts + // collapsed in every test and the chevron click reliably expands it. + resetExpandedCells(); + setupDom(); + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, + recommendations: [spRecCompute, spRecEc2], + regions: [], + }); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([spRecCompute, spRecEc2]); + }); + + const expandSpGroup = (): HTMLTableElement => { + const list = document.getElementById('recommendations-list'); + const table = list?.querySelector('table') as HTMLTableElement | null; + expect(table).not.toBeNull(); + const chevron = table!.querySelector('.rec-sp-group-chevron'); + expect(chevron).not.toBeNull(); + chevron!.click(); + return list!.querySelector('table') as HTMLTableElement; + }; + + test('readonly role: expanded SP-group child variant rows have no checkbox-col', async () => { + mockUser('readonly'); + await loadRecommendations(); + const table = expandSpGroup(); + + // The SP group expanded into per-plan-type child variant rows. + const childRows = table.querySelectorAll('tr.rec-variant-row'); + expect(childRows.length).toBeGreaterThan(0); + + // The bug this guards: child rows must not render a checkbox cell for + // readonly sessions (showCheckboxes must be threaded into the nested call). + for (const row of Array.from(childRows)) { + expect(row.querySelectorAll('td.checkbox-col').length).toBe(0); + expect(row.querySelectorAll('input[data-rec-id]').length).toBe(0); + } + }); + + test('admin role: expanded SP-group child variant rows retain checkbox-col', async () => { + mockUser('admin'); + await loadRecommendations(); + const table = expandSpGroup(); + + const childRows = table.querySelectorAll('tr.rec-variant-row'); + expect(childRows.length).toBeGreaterThan(0); + + for (const row of Array.from(childRows)) { + expect(row.querySelectorAll('td.checkbox-col').length).toBe(1); + } + }); +}); diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 5a10e695..108f4838 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -1,7 +1,7 @@ /** * Recommendations module tests */ -import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS, isHomogeneousSelection, renderUsageSparkline, loadColumnFilters, saveColumnFilters, resetColumnFiltersState } from '../recommendations'; +import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS, isHomogeneousSelection, renderUsageSparkline, loadColumnFilters, saveColumnFilters, resetColumnFiltersState, spGroupKey, groupSpCellKeys } from '../recommendations'; import type { CostPeriod } from '../state'; // Mock the api module @@ -6859,3 +6859,305 @@ describe('Column filters localStorage persistence (issue #163)', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Issue #135: SP plan-type row grouping in the Recommendations table +// --------------------------------------------------------------------------- + +const mkSpRec = (service: string, overrides: Partial = {}): LocalRecommendation => ({ + id: 'sp-' + service + '-' + Math.random().toString(36).slice(2), + provider: 'aws', + service, + resource_type: 'sp', + region: 'us-east-1', + cloud_account_id: 'acct-1', + count: 1, + term: 1, + payment: 'no-upfront', + savings: 100, + upfront_cost: 0, + ...overrides, +} as unknown as LocalRecommendation); + +describe('Issue #135: spGroupKey helper', () => { + test('returns sp-group| prefix with provider, account_id, region', () => { + const rec = mkSpRec('savings-plans-compute'); + expect(spGroupKey(rec)).toBe('sp-group|aws|acct-1|us-east-1'); + }); + + test('uses empty string for absent cloud_account_id', () => { + const rec = mkSpRec('savings-plans-compute', { cloud_account_id: undefined }); + expect(spGroupKey(rec)).toBe('sp-group|aws||us-east-1'); + }); + + test('different providers produce different keys', () => { + const awsRec = mkSpRec('savings-plans-compute', { provider: 'aws' as never }); + const azureRec = mkSpRec('savingsplans', { provider: 'azure' as never }); + expect(spGroupKey(awsRec)).not.toBe(spGroupKey(azureRec)); + }); +}); + +describe('Issue #135: groupSpCellKeys helper', () => { + test('returns empty map when no SP recs are present', () => { + const recs = [mkRec({ id: 'e1', service: 'ec2' })]; + const groups = groupRecsByCell(recs); + const sortedKeys = Array.from(groups.keys()); + const result = groupSpCellKeys(sortedKeys, groups); + expect(result.size).toBe(0); + }); + + test('returns empty map for a single SP plan type (no grouping needed)', () => { + const recs = [mkSpRec('savings-plans-compute')]; + const groups = groupRecsByCell(recs); + const sortedKeys = Array.from(groups.keys()); + const result = groupSpCellKeys(sortedKeys, groups); + expect(result.size).toBe(0); + }); + + test('groups two SP plan types sharing provider+account+region into one entry', () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c' }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s' }), + ]; + const groups = groupRecsByCell(recs); + const sortedKeys = Array.from(groups.keys()); + const result = groupSpCellKeys(sortedKeys, groups); + expect(result.size).toBe(1); + const [key, cellKeys] = Array.from(result.entries())[0]!; + expect(key).toBe('sp-group|aws|acct-1|us-east-1'); + expect(cellKeys).toHaveLength(2); + }); + + test('preserves sort order within each group', () => { + const recs = [ + mkSpRec('savings-plans-sagemaker', { id: 'sp-s', savings: 300 }), + mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 }), + mkSpRec('savings-plans-ec2instance', { id: 'sp-e', savings: 200 }), + ]; + const groups = groupRecsByCell(recs); + // Present in insertion order (no sort) for this test + const sortedKeys = Array.from(groups.keys()); + const result = groupSpCellKeys(sortedKeys, groups); + expect(result.size).toBe(1); + const cellKeys = Array.from(result.values())[0]!; + // order must match sortedKeys order (insertion order here) + expect(cellKeys).toHaveLength(3); + expect(cellKeys).toEqual(sortedKeys); + }); + + test('does not group SP recs from different accounts', () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-a', cloud_account_id: 'acct-A' }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-b', cloud_account_id: 'acct-B' }), + ]; + const groups = groupRecsByCell(recs); + const sortedKeys = Array.from(groups.keys()); + const result = groupSpCellKeys(sortedKeys, groups); + // Different accounts -> two singleton scopes -> no parent row + expect(result.size).toBe(0); + }); + + test('does not group SP recs from different regions', () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-a', region: 'us-east-1' }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-b', region: 'eu-west-1' }), + ]; + const groups = groupRecsByCell(recs); + const sortedKeys = Array.from(groups.keys()); + const result = groupSpCellKeys(sortedKeys, groups); + expect(result.size).toBe(0); + }); + + test('non-SP recs in same scope are excluded from the SP group', () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c' }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s' }), + mkRec({ id: 'ec2-1', service: 'ec2', cloud_account_id: 'acct-1', region: 'us-east-1' }), + ]; + const groups = groupRecsByCell(recs); + const sortedKeys = Array.from(groups.keys()); + const result = groupSpCellKeys(sortedKeys, groups); + // Only the two SP cells form a group; EC2 is not in any SP group. + expect(result.size).toBe(1); + const cellKeys = Array.from(result.values())[0]!; + expect(cellKeys).toHaveLength(2); + }); +}); + +describe('Issue #135: SP group parent row rendered in table', () => { + const setupDom = (): void => { + document.body.innerHTML = [ + '
', + '
', + '
', + '
', + '', + ].join(''); + }; + + beforeEach(() => { + setupDom(); + jest.clearAllMocks(); + (state.getHiddenColumns as jest.Mock).mockReturnValue(new Set()); + (state.getRecommendationsSort as jest.Mock).mockReturnValue({ column: 'savings', direction: 'desc' }); + (state.getCostPeriod as jest.Mock).mockReturnValue('monthly'); + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set()); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + last_collection_error: null, + }); + resetExpandedCells(); + }); + + test('two SP plan types render one parent group row and no flat child rows (collapsed)', async () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s', savings: 200 }), + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + + await loadRecommendations(); + + // One parent group row + const groupRows = document.querySelectorAll('.rec-sp-group-row'); + expect(groupRows.length).toBe(1); + + // No flat recommendation-row children rendered yet (collapsed) + const recRows = document.querySelectorAll('.recommendation-row'); + expect(recRows.length).toBe(0); + }); + + test('parent row service badge shows combined plan-type label', async () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s', savings: 200 }), + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + + await loadRecommendations(); + + const badge = document.querySelector('.rec-sp-group-badge'); + expect(badge).not.toBeNull(); + const text = badge!.textContent ?? ''; + expect(text).toContain('Savings Plans'); + expect(text).toContain('Compute'); + expect(text).toContain('SageMaker'); + }); + + test('parent row badge shows plan type count badge', async () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s', savings: 200 }), + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + + await loadRecommendations(); + + const countBadge = document.querySelector('.rec-sp-plan-count'); + expect(countBadge).not.toBeNull(); + expect(countBadge!.textContent).toContain('2 plan types'); + }); + + test('parent row chevron starts collapsed (aria-expanded=false)', async () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s', savings: 200 }), + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + + await loadRecommendations(); + + const chevron = document.querySelector('.rec-sp-group-chevron'); + expect(chevron).not.toBeNull(); + expect(chevron!.getAttribute('aria-expanded')).toBe('false'); + }); + + test('clicking SP group chevron expands and shows child rows', async () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s', savings: 200 }), + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + + await loadRecommendations(); + + const chevron = document.querySelector('.rec-sp-group-chevron'); + chevron!.click(); + + // After expand: child rows should appear + const recRows = document.querySelectorAll('.recommendation-row'); + expect(recRows.length).toBe(2); + + // aria-expanded should flip + const updatedChevron = document.querySelector('.rec-sp-group-chevron'); + expect(updatedChevron!.getAttribute('aria-expanded')).toBe('true'); + + // Collapse back: child rows should hide again and aria-expanded should reset. + updatedChevron!.click(); + expect(document.querySelectorAll('.recommendation-row').length).toBe(0); + const collapsedChevron = document.querySelector('.rec-sp-group-chevron'); + expect(collapsedChevron!.getAttribute('aria-expanded')).toBe('false'); + }); + + test('single SP plan type renders as a flat row with no group parent', async () => { + const recs = [mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 })]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + + await loadRecommendations(); + + // No SP group parent row + expect(document.querySelectorAll('.rec-sp-group-row').length).toBe(0); + // One flat recommendation row rendered directly + expect(document.querySelectorAll('.recommendation-row').length).toBe(1); + }); + + test('non-SP rows are not absorbed into the SP group', async () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s', savings: 200 }), + mkRec({ id: 'ec2-1', service: 'ec2', cloud_account_id: 'acct-1', region: 'us-east-1', savings: 50 }), + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + + await loadRecommendations(); + + // One SP group parent row + expect(document.querySelectorAll('.rec-sp-group-row').length).toBe(1); + // The EC2 row renders as a flat recommendation-row (not inside SP group) + expect(document.querySelectorAll('.recommendation-row').length).toBe(1); + }); + + test('SP group savings text shows aggregated savings from all plan types', async () => { + const recs = [ + mkSpRec('savings-plans-compute', { id: 'sp-c', savings: 100 }), + mkSpRec('savings-plans-sagemaker', { id: 'sp-s', savings: 200 }), + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + + await loadRecommendations(); + + const groupSavings = document.querySelector('.rec-sp-group-savings'); + expect(groupSavings).not.toBeNull(); + // Should reflect sum: 100 + 200 = 300 + expect(groupSavings!.textContent).toContain('$300'); + }); +}); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 8f232a68..c3807b6e 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -67,6 +67,11 @@ const expandedCells = new Set(); // Expand-All button handler to populate expandedCells without re-computing. let lastVisibleGroupKeys: string[] = []; +// issue #135: expand/collapse state for SP plan-type group rows. +// Contains the spGroupKey strings the user has explicitly expanded. +// Cleared together with expandedCells on page load / full refresh. +const expandedSpGroups = new Set(); + // #272 (CR follow-up): cache of the most-recent API-derived summary so // rerenders triggered by column-filter changes can keep total_count / // total_upfront_cost / avg_payback_months stable while the savings card @@ -138,12 +143,13 @@ export function seedGlobalDefaults(term: 1 | 3, payment: CompatPayment): void { } /** - * Reset cell expand/collapse state. Exported for testing only — not part of - * the public API. Call in beforeEach to ensure tests don't share state. + * Reset cell and SP-group expand/collapse state. Exported for testing only. + * Call in beforeEach to ensure tests don't share module-level state. */ export function resetExpandedCells(): void { expandedCells.clear(); lastVisibleGroupKeys = []; + expandedSpGroups.clear(); } /** @@ -854,6 +860,59 @@ export function groupRecsByCell(recs: readonly LocalRecommendation[]): Map cellKey[] for every SP group that contains 2 or + * more distinct per-plan-type cell keys. Groups with only one cell key are not + * included (they render as a regular flat/cell row with no SP parent). + * + * Preserves the relative order of cell keys within each group as they appear in + * sortedKeys, so the rendered children respect the active sort order. + * Exported for tests. + */ +export function groupSpCellKeys( + sortedKeys: readonly string[], + groups: ReadonlyMap, +): Map { + // First pass: collect SP cell keys per scope key, in sort order. + const byScope = new Map(); + for (const key of sortedKeys) { + const recs = groups.get(key); + if (!recs || recs.length === 0) continue; + const rep = recs[0]!; + if (!isSavingsPlanService(rep.service)) continue; + const sk = spGroupKey(rep); + const existing = byScope.get(sk); + if (existing) { + existing.push(key); + } else { + byScope.set(sk, [key]); + } + } + // Second pass: drop singletons (one cell key = no parent row needed). + const result = new Map(); + for (const [sk, cellKeys] of byScope) { + if (cellKeys.length >= 2) { + result.set(sk, cellKeys); + } + } + return result; +} + /** Summary metrics for a single cell's variants. Exported for tests. */ export interface CellRangeSummary { savingsMin: number; @@ -2812,23 +2871,147 @@ function buildListMarkup( const summaryColspan = 1 + visibleToggleableCols.length; const visibleKeys = new Set(visibleCols.map((c) => c.key)); + // issue #135: build SP plan-type group map for visual grouping. + // spCellGroups maps spGroupKey -> cellKey[] for scopes with 2+ SP cell keys. + const spCellGroups = groupSpCellKeys(sortedKeys, groups); + // Reverse index: cellKey -> spGroupKey, for O(1) lookup during row iteration. + const cellKeyToSpGroup = new Map(); + for (const [sgk, cellKeys] of spCellGroups) { + for (const ck of cellKeys) cellKeyToSpGroup.set(ck, sgk); + } + // Track which SP groups have already had their parent row emitted. + const renderedSpGroups = new Set(); + // Build tbody rows: grouped for multi-variant cells, flat for single-variant. + // SP groups get an additional parent row that wraps the per-plan-type cells. const rows: string[] = []; for (const key of sortedKeys) { const variants = groups.get(key)!; + const sgk = cellKeyToSpGroup.get(key); + + if (sgk !== undefined) { + // This cell key belongs to an SP group with 2+ plan types. + if (renderedSpGroups.has(sgk)) { + // Already rendered the parent row on a previous iteration; skip. + continue; + } + renderedSpGroups.add(sgk); + + // Render the SP group parent row, then (if expanded) all child cell rows. + const childCellKeys = spCellGroups.get(sgk)!; + const spRep = groups.get(childCellKeys[0]!)![0]!; + const sgAccountName = spRep.cloud_account_id + ? (accountNamesCache.get(spRep.cloud_account_id) || spRep.cloud_account_id) + : '\u2014'; + const isSpExpanded = expandedSpGroups.has(sgk); + const spChevron = isSpExpanded ? '\u25bc' : '\u25b6'; + const spSlugs = childCellKeys.map((ck) => groups.get(ck)![0]!.service); + const spLabel = savingsPlansBucketLabel(spSlugs); + const planTypeCount = childCellKeys.length; + // Aggregate savings: sum the best-variant savings across all child cells. + const sfxLabel = periodSuffix(period); + let spSavingsTotal = 0; + for (const ck of childCellKeys) { + const cv = groups.get(ck)!; + spSavingsTotal += Math.max(...cv.map((r) => r.savings)); + } + const scaledSpSavings = scaleCost(spSavingsTotal, period) ?? spSavingsTotal; + const spSavingsText = `${formatCostForPeriod(scaledSpSavings, period)}${sfxLabel}`; + const sgkAttr = escapeHtml(sgk); + rows.push(` + + + + + ${escapeHtml(providerDisplayName(spRep.provider))} + ${escapeHtml(sgAccountName)} + ${escapeHtml(spLabel)} +${planTypeCount} plan types + + ${spSavingsText} + + `); + + if (!isSpExpanded) continue; + + // Expanded: render each child cell (possibly itself multi-variant). + for (const ck of childCellKeys) { + const childVariants = groups.get(ck)!; + if (childVariants.length === 1) { + rows.push(buildVariantRowMarkup(childVariants[0]!, selectedRecs, true, visibleCols, showCheckboxes)); + continue; + } + + // Multi-variant child cell inside an expanded SP group. + const isCellExpanded = expandedCells.has(ck); + const childSummary = cellSummary(childVariants); + const childRep = childVariants[0]!; + const childAccountName = childRep.cloud_account_id + ? (accountNamesCache.get(childRep.cloud_account_id) || childRep.cloud_account_id) + : '\u2014'; + const selectedChildVariant = childVariants.find((r) => selectedRecs.has(r.id)); + const scaledChildSavingsMin = scaleCost(childSummary.savingsMin, period) ?? childSummary.savingsMin; + const scaledChildSavingsMax = scaleCost(childSummary.savingsMax, period) ?? childSummary.savingsMax; + const childSavingsDisplay = selectedChildVariant + ? `${formatCostForPeriod(selectedChildVariant.savings, period)}${sfxLabel} (+${childVariants.length - 1} variants)` + : `${formatScaledRange(scaledChildSavingsMin, scaledChildSavingsMax, period)}${sfxLabel}`; + const childUpfrontDisplay = selectedChildVariant + ? formatCurrency(selectedChildVariant.upfront_cost) + : formatSavingsRange(childSummary.upfrontMin, childSummary.upfrontMax); + const childTermDisplay = selectedChildVariant + ? formatTerm(selectedChildVariant.term) + : formatTermRange(childSummary.termMin, childSummary.termMax); + const cellChevron = isCellExpanded ? '\u25bc' : '\u25b6'; + const childIdentityParts = [ + `${escapeHtml(childRep.resource_type)}${childRep.engine ? ` (${escapeHtml(childRep.engine)})` : ''}`, + ]; + if (visibleKeys.has('region')) childIdentityParts.push(escapeHtml(childRep.region)); + childIdentityParts.push(`${childVariants.length} variants`); + const childRangeParts: string[] = []; + if (visibleKeys.has('savings')) childRangeParts.push(childSavingsDisplay); + if (visibleKeys.has('upfront_cost')) childRangeParts.push(`upfront: ${childUpfrontDisplay}`); + if (visibleKeys.has('term')) childRangeParts.push(`term: ${childTermDisplay}`); + const ckAttr = escapeHtml(ck); + rows.push(` + + + + + ${escapeHtml(providerDisplayName(childRep.provider))} + ${escapeHtml(childAccountName)} + ${escapeHtml(childRep.service)} + + ${childIdentityParts.join(' — ')} + ${childRangeParts.length > 0 ? `${childRangeParts.join(' · ')}` : ''} + + `); + if (isCellExpanded) { + const sortedChildVariants = sortVariantsInCell(childVariants); + for (const v of sortedChildVariants) { + rows.push(buildVariantRowMarkup(v, selectedRecs, true, visibleCols, showCheckboxes)); + } + } + } + continue; + } + + // Non-SP cell (or SP singleton scope -- only one plan type in scope). if (variants.length === 1) { // Single-variant: render flat, no group header, no indent. rows.push(buildVariantRowMarkup(variants[0]!, selectedRecs, false, visibleCols, showCheckboxes)); continue; } - // Multi-variant cell. + // Multi-variant cell (non-SP). const isExpanded = expandedCells.has(key); const summary = cellSummary(variants); const rep = variants[0]!; const accountName = rep.cloud_account_id ? (accountNamesCache.get(rep.cloud_account_id) || rep.cloud_account_id) : '\u2014'; - // Selected variant in this cell (if any) — used to show selected values on summary row. + // Selected variant in this cell (if any) -- used to show selected values on summary row. const selectedVariant = variants.find((r) => selectedRecs.has(r.id)); // Summary row savings display: selected variant value if one is selected; @@ -4216,6 +4399,21 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { void openCreatePlanFromBottomBox([rec]); }); }); + + // issue #135: SP group chevron click toggles expand/collapse for the plan-type group. + container.querySelectorAll('.rec-sp-group-chevron').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const key = btn.dataset['spGroupKey'] ?? ''; + if (!key) return; + if (expandedSpGroups.has(key)) { + expandedSpGroups.delete(key); + } else { + expandedSpGroups.add(key); + } + renderRecommendationsList(loadedRecs); + }); + }); } // resolvePerRecPaymentSeed picks the default Payment value for one rec