Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions frontend/src/__tests__/dashboard-upcoming-scope-713.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div id="summary"></div>
<section id="savings-by-service-section">
<canvas id="savings-by-service-chart"></canvas>
<p id="savings-by-service-empty" class="empty hidden"></p>
</section>
<div id="upcoming-list"></div>
`;
(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']);
});
});
24 changes: 20 additions & 4 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -440,21 +441,36 @@ async function retryUpcomingPurchases(): Promise<void> {
}
}

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 = '<p class="empty">No upcoming scheduled purchases</p>';
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);

Expand Down
42 changes: 42 additions & 0 deletions frontend/src/lib/upcoming-scope.ts
Original file line number Diff line number Diff line change
@@ -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';
}
Loading