diff --git a/frontend/src/__tests__/dashboard-onboarding.test.ts b/frontend/src/__tests__/dashboard-onboarding.test.ts new file mode 100644 index 00000000..6dd8269a --- /dev/null +++ b/frontend/src/__tests__/dashboard-onboarding.test.ts @@ -0,0 +1,111 @@ +/** + * Issue #756: Home shows a first-run onboarding card when nothing has been + * loaded and nothing is committed yet. + */ +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: [] }; +}); + +const mockStartRefresh = jest.fn((_onReload: unknown) => Promise.resolve()); +jest.mock('../recommendations', () => ({ + groupRecsByCell: (recs: unknown[]) => new Map(recs.length ? [['cell-1', recs]] : []), + pageLevelRange: (groups: Map) => + (groups.size === 0 ? { savingsMin: 0, savingsMax: 0, cellCount: 0 } : { savingsMin: 1, savingsMax: 2, cellCount: 1 }), + formatSavingsRange: () => '$1 – $2', + triggerAutoRefreshIfStale: jest.fn(() => Promise.resolve()), + startRecommendationsRefresh: (onReload: unknown) => mockStartRefresh(onReload), +})); + +jest.mock('../api/recommendations', () => ({ + getRecommendationsFreshness: jest.fn().mockResolvedValue({ last_collected_at: null, last_collection_error: null }), +})); + +jest.mock('../api', () => ({ + getDashboardSummary: jest.fn(), + getUpcomingPurchases: jest.fn().mockResolvedValue({ purchases: [] }), + getRecommendations: jest.fn(), + listAccountsMinimal: jest.fn(), + getSavingsAnalytics: jest.fn().mockResolvedValue({ data_points: [] }), +})); + +jest.mock('../state', () => ({ + getCurrentProvider: jest.fn().mockReturnValue(''), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + getSavingsChart: jest.fn().mockReturnValue(null), + setSavingsChart: jest.fn(), + subscribeProvider: jest.fn(), + subscribeAccount: jest.fn(), + getCurrentUser: jest.fn().mockReturnValue({ + id: 'admin-user', email: 'admin@example.com', + groups: ['00000000-0000-5000-8000-000000000001'], + effectivePermissions: [{ action: 'admin', resource: '*' }], + }), +})); + +import { loadDashboard } from '../dashboard'; +import * as api from '../api'; +import { setOpenAccountsHandler } from '../first-run-empty-state'; + +const host = (): HTMLElement => document.getElementById('home-onboarding')!; +const EMPTY_SUMMARY = { total_recommendations: 0, active_commitments: 0, committed_monthly: 0, ytd_savings: 0, by_service: {} }; + +beforeEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ` + +
+
+ `; + (api.getDashboardSummary as jest.Mock).mockResolvedValue(EMPTY_SUMMARY); + (api.getRecommendations as jest.Mock).mockResolvedValue({ recommendations: [] }); + (api.listAccountsMinimal as jest.Mock).mockResolvedValue([]); + setOpenAccountsHandler(jest.fn()); +}); + +describe('Home onboarding card (issue #756)', () => { + test('first run with no accounts: visible card with connect CTA', async () => { + await loadDashboard(); + expect(host().classList.contains('hidden')).toBe(false); + expect(host().querySelector('h3')!.textContent).toBe('No cloud accounts connected yet'); + expect(host().querySelector('button')!.textContent).toBe('Connect a cloud account'); + // The KPI tiles still render underneath. + expect(document.querySelectorAll('#summary .kpi-tile').length).toBe(4); + }); + + test('accounts connected but no recs: refresh reloads the dashboard', async () => { + (api.listAccountsMinimal as jest.Mock).mockResolvedValue([{ id: 'a', name: 'A' }]); + await loadDashboard(); + expect(host().querySelector('h3')!.textContent).toBe('No opportunities yet'); + host().querySelector('button')!.click(); + expect(mockStartRefresh).toHaveBeenCalledWith(loadDashboard); + }); + + test('hidden once recommendations exist', async () => { + (api.getRecommendations as jest.Mock).mockResolvedValue({ recommendations: [{ id: 'r1' }] }); + await loadDashboard(); + expect(host().classList.contains('hidden')).toBe(true); + expect(host().childElementCount).toBe(0); + }); + + test('hidden when commitments exist even without recommendations', async () => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ ...EMPTY_SUMMARY, active_commitments: 3 }); + await loadDashboard(); + expect(host().classList.contains('hidden')).toBe(true); + }); + + test('hidden when the recommendations fetch failed (unknown, not empty)', async () => { + (api.getRecommendations as jest.Mock).mockRejectedValue(new Error('down')); + await loadDashboard(); + expect(host().classList.contains('hidden')).toBe(true); + }); + + test('account list failure degrades to the generic copy', async () => { + (api.listAccountsMinimal as jest.Mock).mockRejectedValue(new Error('403')); + await loadDashboard(); + expect(host().querySelector('h3')!.textContent).toBe('No opportunities yet'); + }); +}); diff --git a/frontend/src/__tests__/first-run-empty-state.test.ts b/frontend/src/__tests__/first-run-empty-state.test.ts new file mode 100644 index 00000000..8e74e76e --- /dev/null +++ b/frontend/src/__tests__/first-run-empty-state.test.ts @@ -0,0 +1,131 @@ +/** + * Issue #756: first-run empty state card shared by Opportunities and Home. + */ +import { + buildFirstRunEmptyState, + describeFreshness, + isScopeFiltered, + setOpenAccountsHandler, +} from '../first-run-empty-state'; +import { isAdmin } from '../permissions'; + +jest.mock('../permissions', () => ({ + isAdmin: jest.fn().mockReturnValue(true), +})); + +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); +const buttons = (el: HTMLElement): string[] => + [...el.querySelectorAll('button')].map((b) => b.textContent ?? ''); + +beforeEach(() => { + jest.clearAllMocks(); + (isAdmin as jest.Mock).mockReturnValue(true); + setOpenAccountsHandler(null); +}); + +describe('isScopeFiltered', () => { + test.each([ + ['', [], false], + ['all', [], false], + ['ALL', [], false], + ['aws', [], true], + ['', ['acct-1'], true], + ] as const)('provider=%p accounts=%p -> %p', (provider, accounts, expected) => { + expect(isScopeFiltered(provider, accounts)).toBe(expected); + }); +}); + +describe('describeFreshness', () => { + test('in-flight collection wins over timestamps', () => { + expect(describeFreshness({ + last_collected_at: '2026-10-01T10:00:00Z', + last_collection_error: null, + last_collection_started_at: '2026-10-01T11:00:00Z', + })).toBe('A recommendations collection is running now.'); + }); + + test('never collected', () => { + expect(describeFreshness({ last_collected_at: null, last_collection_error: null })) + .toBe('Recommendations have not been collected yet.'); + }); + + test('last collected time plus error', () => { + const text = describeFreshness({ last_collected_at: '2026-10-01T10:00:00Z', last_collection_error: 'AccessDenied' }); + expect(text).toMatch(/^Last collected Oct 1, 2026, \d\d:\d\d\./); + expect(text).toContain('The last collection reported errors: AccessDenied'); + }); +}); + +describe('buildFirstRunEmptyState', () => { + test('no accounts, admin: connect CTA opens Admin > Accounts, no refresh', () => { + const open = jest.fn(); + setOpenAccountsHandler(open); + const card = buildFirstRunEmptyState({ hasAccounts: false, scopeFiltered: false, onRefresh: jest.fn() }); + expect(card.classList.contains('empty-state')).toBe(true); + expect(card.querySelector('h3')?.textContent).toBe('No cloud accounts connected yet'); + expect(card.textContent).toContain('Connect an AWS, Azure or GCP account'); + expect(buttons(card)).toEqual(['Connect a cloud account']); + card.querySelector('button')!.click(); + expect(open).toHaveBeenCalledTimes(1); + }); + + test('no accounts, non-admin: points at an administrator instead of a button', () => { + (isAdmin as jest.Mock).mockReturnValue(false); + setOpenAccountsHandler(jest.fn()); + const card = buildFirstRunEmptyState({ hasAccounts: false, scopeFiltered: false }); + expect(buttons(card)).toEqual([]); + expect(card.textContent).toContain('Ask an administrator to connect one under Admin > Accounts.'); + }); + + test('accounts connected: "No opportunities yet" with refresh and manage accounts', async () => { + setOpenAccountsHandler(jest.fn()); + let resolve: () => void = () => undefined; + const onRefresh = jest.fn(() => new Promise((r) => { resolve = r; })); + const card = buildFirstRunEmptyState({ hasAccounts: true, scopeFiltered: false, onRefresh }); + expect(card.querySelector('h3')?.textContent).toBe('No opportunities yet'); + expect(buttons(card)).toEqual(['Refresh recommendations', 'Manage cloud accounts']); + const refresh = card.querySelector('button')!; + refresh.click(); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(refresh.disabled).toBe(true); + resolve(); + await flush(); + expect(refresh.disabled).toBe(false); + }); + + test('unknown account state (fetch failed) does not claim no accounts', () => { + const card = buildFirstRunEmptyState({ hasAccounts: null, scopeFiltered: false }); + expect(card.querySelector('h3')?.textContent).toBe('No opportunities yet'); + }); + + test('topbar scope filter: explains the scope instead of onboarding', () => { + const card = buildFirstRunEmptyState({ hasAccounts: false, scopeFiltered: true, onRefresh: jest.fn() }); + expect(card.querySelector('h3')?.textContent).toBe('No opportunities for the selected provider or account'); + expect(card.textContent).toContain('All Providers and All Accounts'); + expect(buttons(card)).toEqual(['Refresh recommendations']); + }); + + test('freshness line fills in asynchronously and escapes the error text', async () => { + const card = buildFirstRunEmptyState({ + hasAccounts: true, + scopeFiltered: false, + loadFreshness: () => Promise.resolve({ last_collected_at: null, last_collection_error: '' }), + }); + const line = card.querySelector('.first-run-freshness')!; + expect(line.hidden).toBe(true); + await flush(); + expect(line.hidden).toBe(false); + expect(line.textContent).toContain(''); + expect(card.querySelector('img')).toBeNull(); + }); + + test('freshness failure leaves the line hidden', async () => { + const card = buildFirstRunEmptyState({ + hasAccounts: true, + scopeFiltered: false, + loadFreshness: () => Promise.reject(new Error('down')), + }); + await flush(); + expect(card.querySelector('.first-run-freshness')!.hidden).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/recommendations-first-run.test.ts b/frontend/src/__tests__/recommendations-first-run.test.ts new file mode 100644 index 00000000..7057f026 --- /dev/null +++ b/frontend/src/__tests__/recommendations-first-run.test.ts @@ -0,0 +1,177 @@ +/** + * Issue #756: Opportunities first-run empty state vs filtered-to-zero hint. + * + * Zero loaded rows with no column filter render an onboarding card (and hide + * the summary cards, filter bar and Purchase bar). Rows filtered away keep the + * table hint and gain a "Clear filters" button. + */ +import { loadRecommendations, resetAutoRefreshInFlight } from '../recommendations'; +import * as api from '../api'; +import { refreshRecommendations as refreshRecsAPI } from '../api/recommendations'; +import { ADMINISTRATORS_GROUP_ID } from '../permissions'; +import { setOpenAccountsHandler } from '../first-run-empty-state'; + +jest.mock('../api', () => ({ + getRecommendations: jest.fn(), + refreshRecommendations: jest.fn(), + getConfig: jest.fn().mockResolvedValue({ global: {} }), + updateConfig: jest.fn().mockResolvedValue({}), + listAccounts: jest.fn().mockResolvedValue([]), + listAccountsMinimal: jest.fn().mockResolvedValue([]), + listAccountServiceOverrides: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../api/recommendations', () => ({ + getRecommendationsFreshness: jest.fn().mockResolvedValue({ + last_collected_at: new Date().toISOString(), + last_collection_error: null, + }), + refreshRecommendations: jest.fn().mockResolvedValue({}), +})); + +jest.mock('../state', () => ({ + getCurrentProvider: jest.fn().mockReturnValue(''), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + getRecommendations: jest.fn().mockReturnValue([]), + getRecommendationByID: jest.fn().mockReturnValue(undefined), + setRecommendations: jest.fn(), + getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()), + clearSelectedRecommendations: jest.fn(), + addSelectedRecommendation: jest.fn(), + removeSelectedRecommendation: jest.fn(), + getRecommendationsSort: jest.fn().mockReturnValue({ column: 'savings', direction: 'desc' }), + setRecommendationsSort: jest.fn(), + getRecommendationsColumnFilters: jest.fn().mockReturnValue({}), + setRecommendationsColumnFilter: jest.fn(), + clearAllRecommendationsColumnFilters: jest.fn(), + getVisibleRecommendations: jest.fn().mockReturnValue([]), + setVisibleRecommendations: jest.fn(), + getCostPeriod: jest.fn().mockReturnValue('monthly'), + setCostPeriod: jest.fn(), + getHiddenColumns: jest.fn().mockReturnValue(new Set()), + setHiddenColumns: jest.fn(), + getCurrentUser: jest.fn(), +})); + +jest.mock('../toast', () => ({ + showToast: jest.fn().mockReturnValue({ dismiss: jest.fn() }), +})); + +import * as state from '../state'; + +const REC = { + id: 'rec-1', provider: 'aws', cloud_account_id: 'acct-1', service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', count: 1, term: 1, payment: 'all-upfront', + savings: 100, upfront_cost: 500, monthly_cost: 0, on_demand_cost: 150, +}; + +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); +const list = (): HTMLElement => document.getElementById('recommendations-list')!; +const card = (): HTMLElement | null => list().querySelector('.first-run-empty-state'); +const isHidden = (id: string): boolean => document.getElementById(id)?.classList.contains('hidden') ?? false; + +const loadWith = async (recs: unknown[]): Promise => { + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + await loadRecommendations(); +}; + +beforeEach(() => { + jest.clearAllMocks(); + resetAutoRefreshInFlight(); + (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'u', email: 'u@example.com', groups: [ADMINISTRATORS_GROUP_ID] }); + (state.getCurrentProvider as jest.Mock).mockReturnValue(''); + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); + (api.listAccountsMinimal as jest.Mock).mockResolvedValue([]); + setOpenAccountsHandler(jest.fn()); + const tab = document.createElement('div'); + tab.id = 'opportunities-tab'; + tab.className = 'tab-content active'; + const summary = document.createElement('section'); + summary.id = 'recommendations-summary'; + const toolbar = document.createElement('section'); + toolbar.id = 'recommendations-toolbar'; + const listEl = document.createElement('section'); + listEl.id = 'recommendations-list'; + tab.append(summary, toolbar, listEl); + document.body.replaceChildren(tab); +}); + +describe('Opportunities first-run empty state (issue #756)', () => { + test('no accounts: onboarding card with connect CTA, no empty table, chrome hidden', async () => { + await loadWith([]); + expect(card()).not.toBeNull(); + expect(card()!.querySelector('h3')!.textContent).toBe('No cloud accounts connected yet'); + expect([...card()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Connect a cloud account']); + expect(list().querySelector('table')).toBeNull(); + expect(list().textContent).not.toMatch(/No rows match/); + expect(isHidden('recommendations-summary')).toBe(true); + // The Purchase bar is mounted but hidden; the filter bar is never built. + expect(isHidden('recommendations-action-box')).toBe(true); + expect(document.getElementById('recommendations-filter-status')).toBeNull(); + }); + + test('accounts connected: "No opportunities yet" and Refresh triggers a collection', async () => { + (api.listAccountsMinimal as jest.Mock).mockResolvedValue([{ id: 'acct-1', name: 'Prod' }]); + await loadWith([]); + expect(card()!.querySelector('h3')!.textContent).toBe('No opportunities yet'); + const refresh = [...card()!.querySelectorAll('button')].find((b) => b.textContent === 'Refresh recommendations'); + expect(refresh).toBeDefined(); + refresh!.click(); + expect(refreshRecsAPI).toHaveBeenCalledTimes(1); + await flush(); + }); + + test('account fetch failure does not claim that no accounts are connected', async () => { + (api.listAccountsMinimal as jest.Mock).mockRejectedValue(new Error('boom')); + await loadWith([]); + expect(card()!.querySelector('h3')!.textContent).toBe('No opportunities yet'); + }); + + test('topbar provider filter: scope copy instead of onboarding', async () => { + (state.getCurrentProvider as jest.Mock).mockReturnValue('azure'); + await loadWith([]); + expect(card()!.querySelector('h3')!.textContent).toBe('No opportunities for the selected provider or account'); + }); + + test('chrome comes back once recommendations load', async () => { + await loadWith([]); + expect(isHidden('recommendations-summary')).toBe(true); + await loadWith([REC]); + expect(card()).toBeNull(); + expect(list().querySelector('table')).not.toBeNull(); + expect(isHidden('recommendations-summary')).toBe(false); + expect(isHidden('recommendations-filter-status')).toBe(false); + expect(isHidden('recommendations-action-box')).toBe(false); + }); +}); + +describe('Filtered-to-zero hint (issue #756)', () => { + test('client-side filter: hint keeps the table and Clear filters rerenders without refetch', async () => { + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({ provider: { kind: 'set', values: ['gcp'] } }); + await loadWith([REC]); + expect(card()).toBeNull(); + const cell = list().querySelector('tbody td.empty')!; + expect(cell.textContent).toMatch(/No rows match these filters\./); + const btn = cell.querySelector('button.empty-clear-filters')!; + expect(btn.textContent).toBe('Clear filters'); + (api.getRecommendations as jest.Mock).mockClear(); + btn.click(); + expect(state.clearAllRecommendationsColumnFilters).toHaveBeenCalledTimes(1); + expect(api.getRecommendations).not.toHaveBeenCalled(); + }); + + test('server-pushed-down filter with zero loaded: hint (not onboarding) and Clear filters refetches', async () => { + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({ service: { kind: 'set', values: ['rds'] } }); + await loadWith([]); + expect(card()).toBeNull(); + const btn = list().querySelector('tbody td.empty button.empty-clear-filters')!; + (api.getRecommendations as jest.Mock).mockClear(); + btn.click(); + expect(state.clearAllRecommendationsColumnFilters).toHaveBeenCalledTimes(1); + expect(api.getRecommendations).toHaveBeenCalledTimes(1); + await flush(); + }); +}); diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 88cbe12f..e6d1fdea 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -883,10 +883,11 @@ describe('Recommendations Module', () => { expect(capCell('rec-nocap')).toBe('—'); }); - test('shows empty-state message when no recommendations', async () => { - // Issue #700: zero rows now render an empty with a hint cell - // rather than replacing the entire table with a

. The stays - // so column headers remain visible. The hint text lives inside the tbody. + test('shows the first-run empty state when nothing is loaded and no filter is set', async () => { + // Issue #756: zero loaded rows with no column filter is a first run, not + // a filter miss, so the "No rows match these filters." table hint + // (issue #700, still used for filtered-to-zero) is replaced by an + // onboarding card. The filtered case is covered by the #700 test below. (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: [], @@ -896,12 +897,9 @@ describe('Recommendations Module', () => { await loadRecommendations(); const list = document.getElementById('recommendations-list'); - // The table (including ) must still be rendered. - expect(list?.querySelector('thead')).not.toBeNull(); - // The hint cell must be present inside the tbody. - const emptyCell = list?.querySelector('tbody td.empty'); - expect(emptyCell).not.toBeNull(); - expect(emptyCell?.textContent).toMatch(/No rows match/); + expect(list?.querySelector('thead')).toBeNull(); + expect(list?.querySelector('.first-run-empty-state')).not.toBeNull(); + expect(list?.textContent).not.toMatch(/No rows match/); }); test('stores recommendations in state', async () => { diff --git a/frontend/src/app.ts b/frontend/src/app.ts index cfd147f7..5795c22a 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -23,6 +23,7 @@ import { handlePurchaseDeeplink } from './purchases-deeplink'; import { handleRIExchangeDeeplink } from './riexchange-deeplink'; import { handleArcheraDeeplink, openArcheraOfferModal } from './archera'; import { closeModal } from './modal'; +import { setOpenAccountsHandler } from './first-run-empty-state'; import { setupMobileNav } from './nav/mobile-nav'; import { initPurchasingNav } from './purchasing-nav'; import { renderInitError } from './init-error'; @@ -228,6 +229,15 @@ export function setupEventListeners(): void { // Setup recommendations event handlers (provider filter, service filter, etc.) setupRecommendationsHandlers(); + // First-run empty state (issue #756) links to Admin > Accounts. Injected + // here so that module does not import navigation.ts (import cycle). Same + // two-step as the RI Exchange settings shortcut: switchSettingsSubTab owns + // the sub-tab DOM, title and history push. + setOpenAccountsHandler(() => { + switchTab('admin', { push: false, skipDefaultLoad: true }); + switchSettingsSubTab('accounts'); + }); + // Setup plan event handlers (provider-aware service dropdown) setupPlanHandlers(); diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 7e59f371..1b8acd42 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -12,7 +12,9 @@ import { showToast } from './toast'; import { confirmDialog } from './confirmDialog'; import { openModal, closeModal } from './modal'; import { canAccess } from './permissions'; -import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations'; +import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale, startRecommendationsRefresh } from './recommendations'; +import { getRecommendationsFreshness } from './api/recommendations'; +import { buildFirstRunEmptyState, isScopeFiltered } from './first-run-empty-state'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; import { formatTrendAxisTick, formatTrendTooltipTitle, formatSparseTrendMessage, MIN_TREND_POINTS } from './modules/chart-utils'; // Re-export for backward compatibility (tests and other callers import from dashboard.ts). @@ -138,10 +140,14 @@ export async function loadDashboard(): Promise { // move the range computation server-side if needed. // Promise.allSettled ensures the dashboard still renders if any individual // fetch fails -- each card falls back gracefully. - const [summaryResult, upcomingResult, recsResult] = await Promise.allSettled([ + const [summaryResult, upcomingResult, recsResult, accountsResult] = await Promise.allSettled([ api.getDashboardSummary(currentProvider, currentAccountIDs), api.getUpcomingPurchases(), api.getRecommendations({ provider: currentProvider, account_ids: currentAccountIDs }), + // Only feeds the first-run onboarding card (issue #756); wrapped so a + // missing or failing endpoint degrades to "unknown" instead of + // breaking the dashboard. + Promise.resolve().then(() => api.listAccountsMinimal()), ]); const summaryData = summaryResult.status === 'fulfilled' ? (summaryResult.value as DashboardSummary) : null; @@ -178,6 +184,13 @@ export async function loadDashboard(): Promise { const filterDesc = buildFilterDesc(currentProvider, currentAccountIDs); renderDashboardSummary(summaryData, recs); + renderHomeOnboarding( + recsResult.status === 'fulfilled' && recs.length === 0 && !summaryData.active_commitments, + accountsResult.status === 'fulfilled' && Array.isArray(accountsResult.value) + ? accountsResult.value.length > 0 + : null, + isScopeFiltered(currentProvider, currentAccountIDs), + ); // Single merged per-service chart (#908): potential range from recs + // current-savings underlay from the summary's by_service map. renderSavingsByService(recs, summaryData.by_service || {}, filterDesc); @@ -220,6 +233,28 @@ export async function loadDashboard(): Promise { } } +/** + * Issue #756: first-run onboarding card at the top of Home. Shown when no + * recommendations loaded and nothing is committed yet, so a new user gets a + * pointer to connecting an account (or refreshing) instead of a page of + * "--" tiles and empty charts. + */ +function renderHomeOnboarding(show: boolean, hasAccounts: boolean | null, scopeFiltered: boolean): void { + const host = document.getElementById('home-onboarding'); + if (!host) return; + host.classList.toggle('hidden', !show); + if (!show) { + host.replaceChildren(); + return; + } + host.replaceChildren(buildFirstRunEmptyState({ + hasAccounts, + scopeFiltered, + onRefresh: () => startRecommendationsRefresh(loadDashboard), + loadFreshness: getRecommendationsFreshness, + })); +} + function renderDashboardSummary(data: DashboardSummary, recs: readonly LocalRecommendation[]): void { const summary = document.getElementById('summary'); if (!summary) return; diff --git a/frontend/src/first-run-empty-state.ts b/frontend/src/first-run-empty-state.ts new file mode 100644 index 00000000..3e3269b1 --- /dev/null +++ b/frontend/src/first-run-empty-state.ts @@ -0,0 +1,157 @@ +/** + * First-run empty state (issue #756). + * + * Shown on Opportunities and Home when no recommendations have been loaded at + * all, which on a fresh install means no cloud account is connected or no + * collection has produced anything yet. The older copy ("No rows match these + * filters.") told new users to adjust filters they never set; this card says + * what is going on and offers the next step: connect an account (admins), ask + * an admin (everyone else), or refresh recommendations. + * + * Navigation to Admin > Accounts is injected by app.ts through + * setOpenAccountsHandler so this module does not import navigation.ts, which + * already imports the modules that render this card (an import cycle). + */ + +import type { RecommendationsFreshness } from './api/recommendations'; +import { formatDateTime } from './utils'; +import { isAdmin } from './permissions'; + +export interface FirstRunEmptyStateOptions { + /** True/false when the account list loaded; null when it could not be read. */ + hasAccounts: boolean | null; + /** True when the topbar provider/account filter narrows the scope. */ + scopeFiltered: boolean; + /** Starts a recommendations refresh. When omitted the button is not shown. */ + onRefresh?: () => Promise; + /** Loads collection freshness for the status line. When omitted the line is not shown. */ + loadFreshness?: () => Promise; +} + +let openAccountsHandler: (() => void) | null = null; + +/** Registers how to open Admin > Accounts. Called once from app.ts init. */ +export function setOpenAccountsHandler(handler: (() => void) | null): void { + openAccountsHandler = handler; +} + +/** + * True when the topbar filter narrows the scope: a specific provider or at + * least one account. Mirrors dashboard.ts buildFilterDesc's notion of "all". + */ +export function isScopeFiltered(provider: string, accountIDs: readonly string[]): boolean { + return (provider !== '' && provider.toLowerCase() !== 'all') || accountIDs.length > 0; +} + +/** One-line description of the last recommendations collection. */ +export function describeFreshness(f: RecommendationsFreshness): string { + if (f.last_collection_started_at) { + return 'A recommendations collection is running now.'; + } + const parts: string[] = []; + const when = formatDateTime(f.last_collected_at); + parts.push(when ? `Last collected ${when}.` : 'Recommendations have not been collected yet.'); + if (f.last_collection_error) { + parts.push(`The last collection reported errors: ${f.last_collection_error}`); + } + return parts.join(' '); +} + +interface Copy { + heading: string; + body: string; + /** Show the Refresh button (when onRefresh is provided). */ + refresh: boolean; +} + +function pickCopy(opts: FirstRunEmptyStateOptions): Copy { + if (opts.scopeFiltered) { + return { + heading: 'No opportunities for the selected provider or account', + body: 'Nothing has been collected for this scope. Choose All Providers and All Accounts in the filters at the top of the page to see every opportunity.', + refresh: true, + }; + } + if (opts.hasAccounts === false) { + return { + heading: 'No cloud accounts connected yet', + body: 'CUDly finds commitment savings by analyzing usage in your cloud accounts. Connect an AWS, Azure or GCP account to start collecting recommendations.', + refresh: false, + }; + } + return { + heading: 'No opportunities yet', + body: 'CUDly has not found any commitment opportunities in your connected accounts. New recommendations appear after each collection.', + refresh: true, + }; +} + +function makeButton(label: string, className: string, onClick: () => void): HTMLButtonElement { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + btn.textContent = label; + btn.addEventListener('click', onClick); + return btn; +} + +/** + * Builds the empty-state card. All text goes through textContent, so a + * collection error message from the API cannot inject markup. + */ +export function buildFirstRunEmptyState(opts: FirstRunEmptyStateOptions): HTMLElement { + const copy = pickCopy(opts); + const card = document.createElement('div'); + card.className = 'empty-state first-run-empty-state'; + card.setAttribute('role', 'status'); + + const h3 = document.createElement('h3'); + h3.textContent = copy.heading; + card.appendChild(h3); + + const body = document.createElement('p'); + body.textContent = copy.body; + card.appendChild(body); + + if (opts.loadFreshness) { + const status = document.createElement('p'); + status.className = 'first-run-freshness'; + status.hidden = true; + card.appendChild(status); + opts.loadFreshness().then((f) => { + status.textContent = describeFreshness(f); + status.hidden = false; + }).catch(() => { + // Freshness is a nice-to-have; the card is complete without it. + }); + } + + const actions = document.createElement('div'); + actions.className = 'empty-state-actions'; + const admin = isAdmin(); + const needsAccount = opts.hasAccounts === false && !opts.scopeFiltered; + + if (copy.refresh && opts.onRefresh) { + const onRefresh = opts.onRefresh; + const refreshBtn = makeButton('Refresh recommendations', 'primary', () => { + refreshBtn.disabled = true; + onRefresh().finally(() => { refreshBtn.disabled = false; }); + }); + actions.appendChild(refreshBtn); + } + + if (admin && openAccountsHandler) { + const go = openAccountsHandler; + const label = needsAccount ? 'Connect a cloud account' : 'Manage cloud accounts'; + const cls = needsAccount ? 'primary' : 'secondary'; + actions.appendChild(makeButton(label, cls, () => go())); + } else if (needsAccount) { + const hint = document.createElement('p'); + hint.className = 'first-run-admin-hint'; + hint.textContent = 'Ask an administrator to connect one under Admin > Accounts.'; + card.appendChild(hint); + } + + if (actions.childElementCount > 0) card.appendChild(actions); + return card; +} diff --git a/frontend/src/index.html b/frontend/src/index.html index 291f024b..9dba48fe 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -99,6 +99,9 @@

CUDly

+ +
diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index ec4a7508..038255e9 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -26,6 +26,7 @@ import { openModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; import { captureFocus, restoreFocus } from './lib/focus-restore'; import { canAccess } from './permissions'; +import { buildFirstRunEmptyState, isScopeFiltered } from './first-run-empty-state'; // Issue #869: true when the current session can take any action on // recommendations (purchase or plan). Readonly/viewer sessions have neither @@ -63,6 +64,10 @@ let checkedPurchaseModalInitialised = false; let purchaseModalOpenToken = 0; // Cache of account ID → name for column display let accountNamesCache: Map = new Map(); +// Whether the last account-list fetch returned any accounts; null when it +// failed, so the first-run empty state (issue #756) does not claim "no +// accounts connected" on a transient error. +let lastHasAccounts: boolean | null = null; /** * Returns the display name for a cloud account ID, falling back to the raw @@ -410,7 +415,7 @@ async function pollUntilCollectionClears(): Promise Promise = loadRecommendations, ): Promise { // Dedup: if a refresh is already in flight, don't start a second one. @@ -606,7 +611,7 @@ export async function loadRecommendations(): Promise { const [data, accounts, cfgResponse] = await Promise.all([ api.getRecommendations(filters) as unknown as RecommendationsResponse, - api.listAccountsMinimal().catch(() => []), + api.listAccountsMinimal().catch(() => null), // issue #223: fetch GlobalConfig so per-cell variant selection and // bulk-toolbar defaults reflect the operator's configured preference. // Failure is siloed — a missing/unreachable config endpoint must not @@ -629,7 +634,8 @@ export async function loadRecommendations(): Promise { // (issue #282 dropped the toolbar dropdown; no longer need to seed // defaultBulkPurchaseState — the module-level cache is the source of truth). } - accountNamesCache = new Map(accounts.map(a => [a.id, a.name])); + accountNamesCache = new Map((accounts ?? []).map(a => [a.id, a.name])); + lastHasAccounts = accounts ? accounts.length > 0 : null; // Issue #463: gate the Opportunities list by the Settings → // General → Enabled Providers preference. The backend currently @@ -2705,6 +2711,31 @@ function rebindOpenPopoverAnchor(): void { // renderRecommendationsList itself (against the post-filter visible set), // so this helper doesn't need to call renderRecommendationsSummary // separately — every entry to renderRecommendationsList covers it. +/** + * Clears every column filter. Re-fetches when a server-pushed-down column + * (service/region/account) was filtered, since the loaded set itself was + * narrowed; otherwise a client-side rerender is enough. + */ +function clearAllColumnFilters(): void { + const active = Object.keys(state.getRecommendationsColumnFilters()) as state.RecommendationsColumnId[]; + const refetch = active.some((c) => SERVER_PUSHDOWN_COLUMNS.has(c)); + state.clearAllRecommendationsColumnFilters(); + saveColumnFilters(state.getRecommendationsColumnFilters()); + if (refetch) void loadRecommendations(); + else rerenderRecommendations(); +} + +/** + * Hides (or restores) the summary cards, filter-status bar and bottom action + * box around the list. Used by the first-run empty state (issue #756), where + * zero counts and a disabled Purchase bar only add noise. + */ +function setOpportunitiesChromeHidden(hidden: boolean): void { + for (const id of ['recommendations-summary', 'recommendations-filter-status', 'recommendations-action-box']) { + document.getElementById(id)?.classList.toggle('hidden', hidden); + } +} + function rerenderRecommendations(): void { const loaded = state.getRecommendations() as unknown as LocalRecommendation[]; renderRecommendationsList(loaded); @@ -2933,11 +2964,7 @@ function renderFilterStatusBar(loadedCount: number, visibleCount: number): void badge = document.createElement('button'); badge.type = 'button'; badge.className = 'clear-filters'; - badge.addEventListener('click', () => { - state.clearAllRecommendationsColumnFilters(); - saveColumnFilters(state.getRecommendationsColumnFilters()); - rerenderRecommendations(); - }); + badge.addEventListener('click', clearAllColumnFilters); bar.insertBefore(badge, live); } badge.textContent = `Clear filters (${activeCount})`; @@ -4899,6 +4926,27 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { // Mount once; update is per-render below. mountBottomActionBox(); + // Issue #756: nothing loaded and no column filter narrowing the fetch is a + // first run (or an empty scope), not a filter miss. Show an onboarding card + // instead of an empty 15-column table, $0 summary cards and a Purchase bar + // that can never enable. The summary and action box stay rendered (disabled) + // but hidden, so state is coherent once rows arrive. + const firstRun = (loadedRecs?.length ?? 0) === 0 + && Object.keys(state.getRecommendationsColumnFilters()).length === 0; + setOpportunitiesChromeHidden(firstRun); + if (firstRun) { + lastVisibleGroupKeys = []; + updateBottomActionBox(0, 0); + teardownSkeleton(container); + container.replaceChildren(buildFirstRunEmptyState({ + hasAccounts: lastHasAccounts, + scopeFiltered: isScopeFiltered(state.getCurrentProvider(), state.getCurrentAccountIDs()), + onRefresh: () => startRecommendationsRefresh(loadRecommendations), + loadFreshness: getRecommendationsFreshness, + })); + return; + } + const emptyResult = !recommendations || recommendations.length === 0; if (emptyResult) { lastVisibleGroupKeys = []; @@ -4941,6 +4989,15 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { td.setAttribute('colspan', String(colspan)); td.className = 'empty'; td.textContent = 'No rows match these filters.'; + // Issue #756: give the hint a next step right where the user looks. + if (Object.keys(state.getRecommendationsColumnFilters()).length > 0) { + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'btn-small empty-clear-filters'; + clearBtn.textContent = 'Clear filters'; + clearBtn.addEventListener('click', clearAllColumnFilters); + td.append(' ', clearBtn); + } tr.appendChild(td); tbody.appendChild(tr); } diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 2efe48a6..5e3bf629 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -231,6 +231,20 @@ body { display: none; } +/* Follow-up lines and call-to-action row in an empty-state card (issue + * #756 first-run onboarding on Opportunities and Home). */ +.empty-state p + p { + margin-top: var(--cudly-sp-2); +} + +.empty-state-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--cudly-sp-3); + margin-top: var(--cudly-sp-4); +} + /* Help text */ .help-text { color: var(--cudly-text-muted); diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index 6c9752c2..12b943c8 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -271,6 +271,11 @@ main { margin: 0 auto; } +/* Issue #756: first-run onboarding card above the Home KPI tiles. */ +#home-onboarding { + margin-bottom: 1.5rem; +} + #summary { display: grid; /* Four KPI cards: 4 across, then 2x2, then 1 column (breakpoints in diff --git a/frontend/tests-e2e/first-run-empty-state.spec.ts b/frontend/tests-e2e/first-run-empty-state.spec.ts new file mode 100644 index 00000000..c99b8783 --- /dev/null +++ b/frontend/tests-e2e/first-run-empty-state.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +// Issue #756: with no accounts and no recommendations, Opportunities and Home +// show an onboarding card pointing at Admin > Accounts instead of "No rows +// match these filters." over an empty 15-column table. + +async function firstRun(page: Page): Promise { + await seedAuth(page); + await mockApi(page); + await page.route('**/api/accounts**', (route) => route.fulfill({ json: [] })); + await page.route('**/api/recommendations*', (route) => + route.fulfill({ json: { recommendations: [], summary: {}, regions: [] } })); + await page.route('**/api/recommendations/freshness', (route) => + route.fulfill({ json: { last_collected_at: new Date().toISOString(), last_collection_error: null } })); + await page.route('**/api/dashboard/upcoming', (route) => route.fulfill({ json: { purchases: [] } })); + await page.route('**/api/dashboard/summary?*', (route) => route.fulfill({ json: { + total_recommendations: 0, active_commitments: 0, committed_monthly: 0, ytd_savings: 0, by_service: {}, + } })); +} + +test('Opportunities first run: onboarding card links to Admin > Accounts', async ({ page }) => { + await firstRun(page); + await page.goto('/opportunities'); + const card = page.locator('#recommendations-list .first-run-empty-state'); + await expect(card).toBeVisible(); + await expect(card.locator('h3')).toHaveText('No cloud accounts connected yet'); + await expect(page.locator('#recommendations-list table')).toHaveCount(0); + await expect(page.getByText('No rows match these filters.')).toHaveCount(0); + await expect(page.locator('#recommendations-summary')).toBeHidden(); + await expect(page.locator('#recommendations-action-box')).toBeHidden(); + + await card.getByRole('button', { name: 'Connect a cloud account' }).click(); + await expect(page).toHaveURL(/\/admin\/accounts$/); + await expect(page.locator('#accounts-section')).toBeVisible(); +}); + +test('Home first run: onboarding card above the KPI tiles', async ({ page }) => { + await firstRun(page); + await page.goto('/home'); + const card = page.locator('#home-onboarding .first-run-empty-state'); + await expect(card).toBeVisible(); + await expect(card.getByRole('button', { name: 'Connect a cloud account' })).toBeVisible(); +}); + +test('filtered to zero keeps the table hint and offers Clear filters', async ({ page }) => { + await seedAuth(page); + await mockApi(page); + await page.addInitScript(() => { + localStorage.setItem('cudly.recs.columnFilters.v1', JSON.stringify({ + schemaVersion: 1, filters: { count: { kind: 'expr', expr: '>100000' } }, + })); + }); + await page.goto('/opportunities'); + const hint = page.locator('#recommendations-list tbody td.empty'); + await expect(hint).toContainText('No rows match these filters.'); + await expect(page.locator('.first-run-empty-state')).toHaveCount(0); + await hint.getByRole('button', { name: 'Clear filters' }).click(); + await expect(page.locator('#recommendations-list tbody td.empty')).toHaveCount(0); + await expect(page.locator('#recommendations-list tbody tr').first()).toBeVisible(); +}); diff --git a/frontend/tests-e2e/recommendations.spec.ts b/frontend/tests-e2e/recommendations.spec.ts index cf8e2b34..f6579beb 100644 --- a/frontend/tests-e2e/recommendations.spec.ts +++ b/frontend/tests-e2e/recommendations.spec.ts @@ -58,9 +58,12 @@ test('shows loading and zero-result filter states without losing filter controls await expression.press('Enter'); await expect(page.locator('#recommendations-list thead')).toBeVisible(); - await expect(page.locator('#recommendations-list tbody .empty')).toHaveText( + // Issue #756: the hint now carries a Clear filters button as its next step. + await expect(page.locator('#recommendations-list tbody .empty')).toContainText( 'No rows match these filters.', ); + await expect(page.locator('#recommendations-list tbody .empty') + .getByRole('button', { name: 'Clear filters' })).toBeVisible(); await expect(page.locator('#recommendations-action-summary')).toHaveText( '(0 visible — adjust filters)', );