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 00000000..936979f2 --- /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 7e59f371..1789a46d 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -14,6 +14,7 @@ import { openModal, closeModal } from './modal'; 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, formatTrendTooltipTitle, formatSparseTrendMessage, MIN_TREND_POINTS } from './modules/chart-utils'; // Re-export for backward compatibility (tests and other callers import from dashboard.ts). export { formatTrendAxisTick } from './modules/chart-utils'; @@ -440,21 +441,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 00000000..23675bfa --- /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'; +}