From 1f7b63c4c1458bc2c0ad86799b3df8dcc38c2341 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 00:10:35 +0000 Subject: [PATCH 1/3] fix(frontend): show a first-run empty state instead of "no rows match these filters" With no accounts and no recommendations, Opportunities rendered an empty 15-column table with "No rows match these filters.", $0 summary cards and a Purchase bar that could never enable, and Home had no pointer anywhere to connecting an account. New users were told to adjust filters they never set. Opportunities now tells "nothing loaded" apart from "filtered to zero". When nothing loaded and no column filter narrowed the fetch, a shared .empty-state card replaces the table (summary cards, filter bar and action bar are hidden): - no accounts: "No cloud accounts connected yet" with a "Connect a cloud account" button to Admin > Accounts for admins, or a pointer to an admin for everyone else; - accounts but no recommendations: "No opportunities yet" with "Refresh recommendations" and "Manage cloud accounts"; - topbar provider/account filter active: explains the empty scope. The card shows the last collection time (or that one is running / never ran, plus any collection error) from the freshness endpoint. Filtered-to-zero keeps the table hint and adds a "Clear filters" button. The shared clear-filters helper re-fetches when a server-pushed-down column (service/region/account) was filtered, since the loaded set itself was narrowed; the existing Clear filters badge uses it too. Home shows the same card above the KPI tiles when no recommendations loaded and nothing is committed. Navigation to Admin > Accounts is injected from app.ts so the new module does not import navigation.ts (import cycle). Refs #756 Co-Authored-By: Claude Opus 5.5 --- frontend/src/app.ts | 10 ++ frontend/src/dashboard.ts | 39 ++++++- frontend/src/first-run-empty-state.ts | 157 ++++++++++++++++++++++++++ frontend/src/index.html | 3 + frontend/src/recommendations.ts | 73 ++++++++++-- frontend/src/styles/base.css | 14 +++ frontend/src/styles/layout.css | 5 + 7 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 frontend/src/first-run-empty-state.ts diff --git a/frontend/src/app.ts b/frontend/src/app.ts index c73b9658e..976eb1234 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -22,6 +22,7 @@ import { buildCommitmentSummary, commitmentTotals, executeButtonLabel, executeCo import { handlePurchaseDeeplink } from './purchases-deeplink'; import { handleArcheraDeeplink, openArcheraOfferModal } from './archera'; import { closeModal } from './modal'; +import { setOpenAccountsHandler } from './first-run-empty-state'; /** * describeDuplicateSubmit words the outcome of a submit the backend collapsed @@ -205,6 +206,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 2a8e61716..1fc267ed0 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -11,7 +11,9 @@ import type { SavingsDataPoint } from './api'; import { showToast } from './toast'; import { confirmDialog } from './confirmDialog'; 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 } from './modules/chart-utils'; // Re-export for backward compatibility (tests and other callers import from dashboard.ts). @@ -208,10 +210,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; @@ -248,6 +254,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); @@ -289,6 +302,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 000000000..3e3269b12 --- /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 ebbbdd24c..ab54f2847 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 307ff2d7a..1735b4c25 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -25,6 +25,7 @@ import type { RecommendationsResponse, LocalRecommendation, RecommendationsSumma import { openModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; 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 @@ -61,6 +62,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 @@ -406,7 +411,7 @@ async function pollUntilCollectionClears(): Promise Promise = loadRecommendations, ): Promise { // Dedup: if a refresh is already in flight, don't start a second one. @@ -602,7 +607,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 @@ -625,7 +630,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 @@ -2666,6 +2672,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); @@ -2894,11 +2925,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})`; @@ -4807,6 +4834,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 = []; @@ -4845,6 +4893,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 74c206fbd..93d3f8e59 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -212,6 +212,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 55708f7ff..c36107968 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; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); From 9d161c22aab08796775524dafcbe35c5cdab5b46 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 00:10:35 +0000 Subject: [PATCH 2/3] test(frontend): cover the first-run empty state and filtered-to-zero hint Unit tests for the shared card (copy per account/scope state, admin vs non-admin actions, freshness line, escaping) and for the Opportunities wiring: chrome hidden on a first run and restored once rows load, account fetch failure not reported as "no accounts", and Clear filters rerendering for client-side filters but re-fetching for pushed-down ones. The old "no recommendations" test now expects the onboarding card. Refs #756 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/first-run-empty-state.test.ts | 131 +++++++++++++ .../recommendations-first-run.test.ts | 177 ++++++++++++++++++ .../src/__tests__/recommendations.test.ts | 18 +- 3 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 frontend/src/__tests__/first-run-empty-state.test.ts create mode 100644 frontend/src/__tests__/recommendations-first-run.test.ts 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 000000000..8e74e76e0 --- /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 000000000..7057f026b --- /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 c62b3342c..8c4d875ad 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -860,10 +860,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: [], @@ -873,12 +874,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 () => { From b6b6afea1f15c9b6edad705286e949d94be60f87 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 00:10:35 +0000 Subject: [PATCH 3/3] test(frontend): cover the Home onboarding card and first-run e2e flow Home: card shown on a first run, hidden when recommendations or commitments exist or when the recommendations fetch failed, refresh reloads Home. Playwright: first-run card on Opportunities and Home, Connect a cloud account lands on /admin/accounts, and filtered-to-zero keeps the table hint with a working Clear filters button. The existing zero-result spec now expects the hint to carry that button. Refs #756 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/dashboard-onboarding.test.ts | 111 ++++++++++++++++++ .../tests-e2e/first-run-empty-state.spec.ts | 61 ++++++++++ frontend/tests-e2e/recommendations.spec.ts | 5 +- 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/dashboard-onboarding.test.ts create mode 100644 frontend/tests-e2e/first-run-empty-state.spec.ts diff --git a/frontend/src/__tests__/dashboard-onboarding.test.ts b/frontend/src/__tests__/dashboard-onboarding.test.ts new file mode 100644 index 000000000..6dd8269ac --- /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/tests-e2e/first-run-empty-state.spec.ts b/frontend/tests-e2e/first-run-empty-state.spec.ts new file mode 100644 index 000000000..c99b8783f --- /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 cf8e2b34a..f6579bebd 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)', );