From 210de5c75403bf573472468866e974e9f7c6ed88 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 22:08:25 +0000 Subject: [PATCH] fix(frontend): scope Home upcoming purchases to the provider chip The upcoming widget kept listing every provider's scheduled purchases while the rest of Home followed the topbar chips, so it could disagree with the page it sits on. GET /api/dashboard/upcoming takes no filter parameters, but every row carries its plan's provider, so the provider chip is applied client-side and an empty result names the filter ("No upcoming scheduled purchases for GCP"). Rows carry no account, so the account chip cannot be applied here; a short note above the rows says so instead of silently ignoring it. Both can move server-side once the endpoint accepts the chip state. The Retry path from the earlier error-state fix goes through the same renderer, so it applies the same scope. Refs #713 Co-Authored-By: Claude Opus 5.5 --- .../dashboard-upcoming-scope-713.test.ts | 153 ++++++++++++++++++ frontend/src/dashboard.ts | 24 ++- frontend/src/lib/upcoming-scope.ts | 42 +++++ 3 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 frontend/src/__tests__/dashboard-upcoming-scope-713.test.ts create mode 100644 frontend/src/lib/upcoming-scope.ts diff --git a/frontend/src/__tests__/dashboard-upcoming-scope-713.test.ts b/frontend/src/__tests__/dashboard-upcoming-scope-713.test.ts new file mode 100644 index 000000000..936979f2e --- /dev/null +++ b/frontend/src/__tests__/dashboard-upcoming-scope-713.test.ts @@ -0,0 +1,153 @@ +/** + * Issue #713 (second half): the Home upcoming-purchases widget ignored the + * topbar chips. GET /api/dashboard/upcoming takes no filter parameters, so + * the provider chip is applied client-side and an active account chip is + * flagged as not applied rather than silently ignored. + * + * Drives loadDashboard() so the production render path is exercised. + */ + +jest.mock('../toast', () => ({ showToast: jest.fn(() => ({ dismiss: jest.fn() })) })); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn(() => Promise.resolve(true)) })); +jest.mock('chart.js', () => { + const MockChart = jest.fn().mockImplementation(() => ({ destroy: jest.fn() })); + (MockChart as unknown as { register: jest.Mock }).register = jest.fn(); + return { Chart: MockChart, registerables: [] }; +}); +jest.mock('../recommendations', () => ({ + groupRecsByCell: jest.fn(() => new Map()), + pageLevelRange: jest.fn(() => ({ savingsMin: 0, savingsMax: 0, cellCount: 0 })), + formatSavingsRange: jest.fn((min: number, max: number) => `$${min}-$${max}`), + triggerAutoRefreshIfStale: jest.fn(() => Promise.resolve()), +})); +jest.mock('../api', () => ({ + getDashboardSummary: jest.fn().mockResolvedValue({ potential_monthly_savings: 0, by_service: {} }), + getUpcomingPurchases: jest.fn(), + getSavingsAnalytics: jest.fn().mockResolvedValue({ data_points: [] }), + getRecommendations: jest.fn().mockResolvedValue([]), +})); +jest.mock('../state', () => ({ + getCurrentProvider: jest.fn().mockReturnValue(''), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + getSavingsChart: jest.fn().mockReturnValue(null), + setSavingsChart: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getCurrentUser: jest.fn().mockReturnValue(null), +})); + +import { loadDashboard } from '../dashboard'; +import * as api from '../api'; +import * as state from '../state'; +import { scopeUpcomingToProvider, upcomingEmptyMessage, UPCOMING_ACCOUNT_SCOPE_NOTE } from '../lib/upcoming-scope'; +import type { UpcomingPurchase } from '../types'; + +const row = (execution_id: string, provider: UpcomingPurchase['provider'], plan_name: string): UpcomingPurchase => ({ + execution_id, + plan_id: `plan-${execution_id}`, + plan_name, + scheduled_date: '2026-11-01', + provider, + service: 'compute', + step_number: 1, + total_steps: 2, + estimated_savings: 50, +}); + +const ROWS = [row('e-aws', 'aws', 'AWS Plan'), row('e-az', 'azure', 'Azure Plan')]; + +const setScope = (provider: string, accountIDs: string[] = []) => { + (state.getCurrentProvider as jest.Mock).mockReturnValue(provider); + (state.getCurrentAccountIDs as jest.Mock).mockReturnValue(accountIDs); +}; + +const list = () => document.getElementById('upcoming-list') as HTMLElement; +const planNames = () => Array.from(list().querySelectorAll('.upcoming-details h4')).map(h => h.textContent); + +describe('upcoming-scope helpers (#713)', () => { + test('no provider filter keeps every row and does not alias the input', () => { + for (const p of ['', 'all', 'ALL']) { + const out = scopeUpcomingToProvider(ROWS, p); + expect(out).toEqual(ROWS); + expect(out).not.toBe(ROWS); + } + }); + + test('provider filter keeps only matching rows, case-insensitively', () => { + expect(scopeUpcomingToProvider(ROWS, 'azure').map(r => r.execution_id)).toEqual(['e-az']); + expect(scopeUpcomingToProvider(ROWS, 'AWS').map(r => r.execution_id)).toEqual(['e-aws']); + expect(scopeUpcomingToProvider(ROWS, 'gcp')).toEqual([]); + }); + + test('empty message names the provider only when one is filtered', () => { + expect(upcomingEmptyMessage('')).toBe('No upcoming scheduled purchases'); + expect(upcomingEmptyMessage('all')).toBe('No upcoming scheduled purchases'); + expect(upcomingEmptyMessage('gcp')).toBe('No upcoming scheduled purchases for GCP'); + }); +}); + +describe('Home upcoming widget follows the topbar chips (#713)', () => { + beforeEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ` +
+
+ + +
+
+ `; + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: ROWS }); + }); + + test('no chips: every row renders and no scope note is shown', async () => { + setScope(''); + await loadDashboard(); + expect(planNames()).toEqual(['AWS Plan', 'Azure Plan']); + expect(list().querySelector('.upcoming-scope-note')).toBeNull(); + }); + + test('provider chip hides rows from other providers', async () => { + setScope('azure'); + await loadDashboard(); + expect(planNames()).toEqual(['Azure Plan']); + // View Details renders from the scoped index, so the hidden row is gone. + expect(list().querySelectorAll('[data-action="view-purchase"]')).toHaveLength(1); + }); + + test('provider chip with no matching rows shows a filter-aware empty state', async () => { + setScope('gcp'); + await loadDashboard(); + expect(planNames()).toEqual([]); + expect(list().querySelector('.empty')?.textContent).toBe('No upcoming scheduled purchases for GCP'); + }); + + test('account chip cannot be applied, so the widget says so above the rows', async () => { + setScope('', ['acct-1']); + await loadDashboard(); + expect(planNames()).toEqual(['AWS Plan', 'Azure Plan']); + const note = list().querySelector('.upcoming-scope-note'); + expect(note?.textContent).toBe(UPCOMING_ACCOUNT_SCOPE_NOTE); + // The note sits before the first card. + expect(list().firstElementChild).toBe(note); + }); + + test('account chip with nothing to show keeps the plain empty state (no note)', async () => { + setScope('', ['acct-1']); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + await loadDashboard(); + expect(list().querySelector('.upcoming-scope-note')).toBeNull(); + expect(list().querySelector('.empty')?.textContent).toBe('No upcoming scheduled purchases'); + }); + + test('Retry after a failed fetch applies the same provider scope', async () => { + setScope('aws'); + (api.getUpcomingPurchases as jest.Mock).mockRejectedValueOnce(new Error('API error: 503')); + await loadDashboard(); + expect(list().querySelector('.error')).not.toBeNull(); + const retry = list().querySelector('button[data-action="retry-upcoming"]') as HTMLButtonElement; + retry.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(planNames()).toEqual(['AWS Plan']); + }); +}); diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 2a8e61716..92ac02166 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -13,6 +13,7 @@ import { confirmDialog } from './confirmDialog'; import { canAccess } from './permissions'; import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; +import { scopeUpcomingToProvider, upcomingEmptyMessage, UPCOMING_ACCOUNT_SCOPE_NOTE } from './lib/upcoming-scope'; import { formatTrendAxisTick } from './modules/chart-utils'; // Re-export for backward compatibility (tests and other callers import from dashboard.ts). export { formatTrendAxisTick } from './modules/chart-utils'; @@ -506,21 +507,36 @@ async function retryUpcomingPurchases(): Promise { } } -function renderUpcomingPurchases(purchases: UpcomingPurchase[]): void { +function renderUpcomingPurchases(allPurchases: UpcomingPurchase[]): void { const container = document.getElementById('upcoming-list'); if (!container) return; + // The endpoint ignores the topbar chips, so apply the provider chip here + // and flag the account chip as not applied (#713, see lib/upcoming-scope). + const provider = state.getCurrentProvider(); + const purchases = scopeUpcomingToProvider(allPurchases, provider); + // Refresh the in-memory index so viewPurchaseDetails can render its // dialog from local data — there is no execution row to look up yet // (the upcoming list shows plans whose next execution hasn't fired). upcomingPurchasesIndex = new Map(purchases.map(p => [p.execution_id, p])); - if (!purchases || purchases.length === 0) { - container.innerHTML = '

No upcoming scheduled purchases

'; + container.textContent = ''; + if (purchases.length === 0) { + const empty = document.createElement('p'); + empty.className = 'empty'; + empty.textContent = upcomingEmptyMessage(provider); + container.appendChild(empty); return; } - container.textContent = ''; + if (state.getCurrentAccountIDs().length > 0) { + const note = document.createElement('p'); + note.className = 'help-text upcoming-scope-note'; + note.textContent = UPCOMING_ACCOUNT_SCOPE_NOTE; + container.appendChild(note); + } + for (const p of purchases) { const dateParts = getDateParts(p.scheduled_date); diff --git a/frontend/src/lib/upcoming-scope.ts b/frontend/src/lib/upcoming-scope.ts new file mode 100644 index 000000000..23675bfac --- /dev/null +++ b/frontend/src/lib/upcoming-scope.ts @@ -0,0 +1,42 @@ +/** + * Scoping for the Home "Upcoming Scheduled Purchases" widget (issue #713). + * + * GET /api/dashboard/upcoming takes no filter parameters: it is scoped by + * the session only, so without this the widget kept listing every + * provider's purchases while the rest of Home followed the topbar chips. + * Each row carries its plan's provider, so the provider chip is applied + * here. Rows carry no account, so the account chip cannot be applied + * client-side; the widget says so instead of silently disagreeing with the + * rest of the page. Both go away once the endpoint accepts the chip state. + */ + +import type { UpcomingPurchase } from '../types'; + +/** Shown above the rows when account chips are active but cannot apply. */ +export const UPCOMING_ACCOUNT_SCOPE_NOTE = + 'The account filter does not apply here: showing upcoming purchases for all accounts you can access.'; + +function isProviderFilter(provider: string): boolean { + return provider !== '' && provider.toLowerCase() !== 'all'; +} + +/** + * Keep only the rows matching the provider chip. '' / 'all' keep every row. + * The row's provider is the plan's first service provider (see + * upcomingFromExecution in internal/api/handler_dashboard.go). + */ +export function scopeUpcomingToProvider( + purchases: readonly UpcomingPurchase[], + provider: string, +): UpcomingPurchase[] { + if (!isProviderFilter(provider)) return [...purchases]; + const want = provider.toLowerCase(); + return purchases.filter(p => (p.provider || '').toLowerCase() === want); +} + +/** Empty-state text, naming the provider filter when one hid every row. */ +export function upcomingEmptyMessage(provider: string): string { + return isProviderFilter(provider) + ? `No upcoming scheduled purchases for ${provider.toUpperCase()}` + : 'No upcoming scheduled purchases'; +}