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 = ` +
+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'; +}