Skip to content

fix(frontend): show a first-run empty state instead of "no rows match" - #1389

Merged
IAmJSD merged 6 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-756
Oct 24, 2026
Merged

IAmJSD merged 6 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-756

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

On a fresh install (no accounts, no recommendations), Opportunities showed an empty 15-column table with "No rows match these filters.", $0 summary cards and a Purchase bar, and Home showed -- tiles with nothing pointing at connecting an account. This PR tells "nothing loaded" apart from "filtered to zero": the first case gets an onboarding card on Opportunities and Home, and the second keeps the table hint and adds a "Clear filters" button.

Refs #756
Partial: the card shows the last collection time (or a running collection, or the last error) but not the next collection time the issue asks for, because the API has no field for it yet.

What changed

  • New frontend/src/first-run-empty-state.ts builds one .empty-state card used on both pages. Copy depends on state:
    • no accounts: "No cloud accounts connected yet" with "Connect a cloud account" for admins (others are told to ask an admin);
    • accounts but no recommendations: "No opportunities yet" with "Refresh recommendations" and "Manage cloud accounts";
    • topbar provider/account filter active: explains the empty scope.
      It also shows the last collection time, a running collection, or the last collection error from GET /api/recommendations/freshness.
  • frontend/src/recommendations.ts: renderRecommendationsList shows the card when nothing loaded and no column filter is set, and hides the summary cards, filter bar and action box (they are still rendered, so state is correct once rows arrive). When filters leave zero rows, the old hint stays and gets a "Clear filters" button. A shared clearAllColumnFilters re-fetches when a server-pushed-down column (service, region, account) was filtered; the existing Clear filters badge uses it too.
  • frontend/src/dashboard.ts and index.html: loadDashboard also fetches the minimal account list (a failure is treated as unknown) and fills a new #home-onboarding section above the KPI tiles when no recommendations loaded and nothing is committed.
  • frontend/src/app.ts injects the Admin > Accounts navigation (same switchTab + switchSettingsSubTab pattern as the RI Exchange settings shortcut).
  • Small CSS in styles/base.css and styles/layout.css for the action row and spacing.
  • Tests: __tests__/first-run-empty-state.test.ts, __tests__/recommendations-first-run.test.ts, __tests__/dashboard-onboarding.test.ts, tests-e2e/first-run-empty-state.spec.ts; recommendations.test.ts and tests-e2e/recommendations.spec.ts updated.

Why this approach

"Nothing loaded" is distinguished from "filtered to zero" by the loaded count plus whether any column filter is set. A pushed-down service or region filter can make the loaded count 0, so it must not trigger onboarding. The Purchase bar and $0 cards are hidden rather than removed, which keeps the existing permission-gating code and tests valid. Account presence comes from the existing /accounts/list, so no new endpoints are needed, and a failed account fetch is treated as unknown so nobody is told "no accounts" because of a transient error. Navigation is injected from app.ts because navigation.ts already imports dashboard and recommendations; importing it would create a cycle and pull the whole navigation graph into jest suites that mock those modules.

Screenshots

Before is main (a14dc8f2), after is this branch. Both are headless Chromium at 1440x900 against the repo's mocked e2e API, with accounts [] and recommendations [] for the first-run views.

Before After
firstrun-opps before firstrun-opps after
firstrun-home before firstrun-home after
filtered-zero before filtered-zero after
  • Opportunities, first run: on main, an empty 15-column table, "No rows match these filters.", $0 cards and the Purchase bar. On this branch, an onboarding card with the last collection time and "Connect a cloud account"; the table, cards and Purchase bar are hidden.
  • Home, first run: on main, only -- tiles and empty charts. On this branch, the same onboarding card above the KPI tiles.
  • Filtered to zero (Count > 100000): on main, a hint with no action. On this branch, the same hint with a "Clear filters" button.

Verification

Run on this branch's head b6b6afea on top of main a14dc8f2:

  • npm run lint: 0 errors (142 pre-existing warnings). Typecheck and build pass.
  • Jest (--maxWorkers=2): 112 suites, 3514 passed, 1 skipped, 0 failed. The 3 new suites (28 tests: 15 + 7 + 6) also pass with --randomize.
  • Playwright via e2e-local.sh: 159 passed, 1 failed (the recommendations.spec zero-result test expected the exact old hint text). That spec was updated to expect the new Clear filters button, and recommendations.spec was re-run: 4/4 passed. The 3 new e2e tests cover the Opportunities card with "Connect a cloud account" landing on /admin/accounts, the Home card, and filtered-to-zero Clear filters.
  • Secrets scan clean (planted key flagged); scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

Notes for review

  • New copy to approve: headings "No cloud accounts connected yet", "No opportunities yet", "No opportunities for the selected provider or account"; buttons "Connect a cloud account", "Manage cloud accounts", "Refresh recommendations".
  • Backend follow-up: the issue asks for the next collection time, but the freshness endpoint only has last_collected_at, last_collection_started_at and last_collection_error, and collection_schedule is just a word such as "daily". A next-run time needs a backend field.
  • On a first run Opportunities hides the summary cards, filter bar and Purchase/Plan bar; the AWS lookback selector stays visible.
  • Behavior change: the Clear filters badge now re-fetches when a pushed-down service/region/account filter was active. It used to only re-render, which left the narrowed data loaded.
  • Home shows the card only when the recommendations fetch succeeded with zero rows and active_commitments is 0, so a failed fetch never shows onboarding. If the enabled_providers setting hides every row, the card says "No opportunities yet".
  • "Connect a cloud account" follows the RI Exchange shortcut pattern: if the Accounts sub-tab was already open earlier in the session, the URL may not update (same edge case as that shortcut).
  • recommendations.ts and app.ts were already far over the 500-line rule, so the new logic is in its own module.

Overlaps

Note for the UI owner

This changes UI code that @cristim owns. IAmJSD reviewed the before/after screenshots and asked for it to be merged; any design point you disagree with can be a follow-up.

🤖 Generated with Claude Code

IAmJSD and others added 3 commits October 24, 2026 00:10
… 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 LeanerCloud#756

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 LeanerCloud#756

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 LeanerCloud#756

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/many Affects most users effort/m Days type/bug Defect labels Oct 24, 2026
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 13 billable files and costs up to $3.25.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 19534fc and 378a072.

Files selected for processing: 13
  • frontend/src/__tests__/dashboard-onboarding.test.ts
  • frontend/src/__tests__/first-run-empty-state.test.ts
  • frontend/src/__tests__/recommendations-first-run.test.ts
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/app.ts
  • frontend/src/dashboard.ts
  • frontend/src/first-run-empty-state.ts
  • frontend/src/index.html
  • frontend/src/recommendations.ts
  • frontend/src/styles/base.css
  • frontend/src/styles/layout.css
  • frontend/tests-e2e/first-run-empty-state.spec.ts
  • frontend/tests-e2e/recommendations.spec.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

IAmJSD and others added 3 commits October 24, 2026 17:36
Conflicts:
- frontend/src/app.ts (imports): kept both, LeanerCloud#756's setOpenAccountsHandler
  from first-run-empty-state and main's renderInitError from init-error
  (LeanerCloud#1338). The setOpenAccountsHandler call in setupEventListeners and
  main's renderInitError in init()'s catch merged cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- frontend/src/app.ts: import block only. This branch imports
  setOpenAccountsHandler (first-run card's Admin > Accounts navigation);
  main's LeanerCloud#1336 imports initPurchasingNav. Kept both imports; both call
  sites merged cleanly and are unchanged.

Also checked: main's LeanerCloud#1397 envelope typing for getUpcomingPurchases sits
next to this branch's 4th allSettled entry (listAccountsMinimal) in
loadDashboard and merged cleanly. LeanerCloud#1368's "Select none" rename is the
per-column popover button, separate from this branch's "Clear filters"
(clearAllColumnFilters) action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- frontend/src/app.ts: import block. Kept this branch's
  setOpenAccountsHandler import (first-run empty state, LeanerCloud#756) and
  main's setupMobileNav import from nav/mobile-nav (LeanerCloud#1340). The rest of
  app.ts merged cleanly; main's removal of the inline drawer code
  stands and the setOpenAccountsHandler wiring is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): APPROVE 378a072bc02e391c33724ec9c9792c3f1c2ac4ac

Blocking:

  • none
    Non-blocking:
  • frontend/src/dashboard.ts:187 and :242: the error path in loadDashboard (summary rejected or empty) never touches #home-onboarding. Scenario: a first-run load shows the card, then a refresh fails on /dashboard/summary. The old onboarding card stays visible above "Failed to load dashboard: ...". Hiding it in the catch would fix this.
  • frontend/src/first-run-empty-state.ts:106: role="status" sits on the whole card, and the card is rebuilt on every renderRecommendationsList (each rerender also refetches /freshness, :121). Every client-side rerender while empty can re-announce the whole card. Putting the live role on the freshness line only would be quieter.
  • frontend/src/styles/base.css:236: .empty-state p + p also applies to main's init-error card (fix(frontend): show an error state when init() fails, not a silent Home #1338, init-error.ts). There is no visual change today because it is the same 0.5rem as its .mt-2, but the rule is not scoped to the first-run card.
    Attacked: The two-dot and three-dot diffs vs origin/main are identical (tree = main + PR). All 3 merges conflicted only in the app.ts import block, and main's setupMobileNav (refactor(frontend): move mobile nav and header projection to a module #1340), initPurchasingNav (feat(frontend): add section links and collapsed grids to Purchasing #1336) and renderInitError (fix(frontend): show an error state when init() fails, not a silent Home #1338) are intact. Checked against current main: the chrome ids (recommendations-summary, filter-status, action-box) still exist, and switchSettingsSubTab('accounts') still admin-gates. renderRecommendationsList puts the firstRun branch after main's column-filter seeding and summary render. The pushed-down column filter keeps the filtered hint. The modified main tests (recommendations.test.ts empty-state, recommendations.spec zero-result) are narrowed to the first-run case, not loosened, and the fix(frontend): per-service savings chart colors change with rank and legend mismatches #700 hint is still asserted for filtered-to-zero. XSS: the freshness error goes through textContent only. Jest dashboard-onboarding, first-run-empty-state, recommendations-first-run, recommendations, dashboard and init-error: 591 passed, 1 skipped.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 378a072b merged onto current main (bb564dd5; merge-tree 529247b8; Linux, Node, headless Chromium):

  • This branch's own checks passed at this head: lint, typecheck, build, the full jest suite, the full Playwright e2e suite, and the git-secrets checks (see Verification in the PR body).
  • The combined tree of every UI PR in this merge wave (tree ab77aa6e, main plus 5 PRs) passes npm ci, lint, typecheck, build, jest (1 skipped, 4248 passed, 4249 total), and the full Playwright suite (247 passed).
  • CI is green on 378a072b (CI Success, Run pre-commit hooks).
  • The independent review above approves this exact head.
  • Nothing was applied to any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 24, 2026 22:06
@IAmJSD
IAmJSD merged commit da896f4 into LeanerCloud:main Oct 24, 2026
29 checks passed
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Marked ready and merged by IAmJSD's agent, at IAmJSD's request (IAmJSD reviewed the before/after screenshots; cristim is traveling), after the independent review and local verification above, all at 378a072b.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 24, 2026
Brings in LeanerCloud#1365, LeanerCloud#1381, LeanerCloud#1378 and LeanerCloud#1389. No conflicts; index.html
auto-merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 24, 2026
Conflicts:
- frontend/src/app.ts: adjacent imports; kept both this branch's
  wireTablistKeyboard import and main's setOpenAccountsHandler import
  (LeanerCloud#1389 first-run empty state).
- frontend/src/index.html: #home-tab keeps this branch's markup (no
  role=tabpanel/aria-labelledby, hidden page heading as the first child)
  and main's #home-onboarding section (LeanerCloud#1389) right after the heading,
  before #summary.

The new "open Accounts" handler from main switches pages programmatically
(no focusContent), the same as the existing RI Exchange settings shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 24, 2026
Conflicts:
- frontend/src/dashboard.ts:
  - imports: kept this branch's filterByEnabledProviders import and all of
    main's new imports (upcoming-scope LeanerCloud#1365, sparse-trend helpers LeanerCloud#1334,
    chart-a11y LeanerCloud#1381, first-run empty state LeanerCloud#1389).
  - loadDashboard Promise.allSettled: combined both sides into five
    fetches; this branch's loadEnabledProviders() (LeanerCloud#180) plus main's
    listAccountsMinimal() for the first-run onboarding card (LeanerCloud#1389).
  - call site: kept renderDashboardSummary(summaryData, recs, recsCount)
    and main's renderHomeOnboarding(...). The onboarding check uses the
    Enabled-Providers-filtered recs, matching the Opportunities first-run
    check, which also runs on the preference-filtered list.
  - helpers: kept both loadEnabledProviders() and main's
    renderHomeOnboarding(); renderDashboardSummary keeps this branch's
    recsCount parameter.
  - savings tile detail: this branch's filtered count with main's
    NO_VALUE placeholder (LeanerCloud#1376): recsCount ?? total_recommendations ??
    NO_VALUE; Active Commitments takes main's NO_VALUE.
- frontend/src/recommendations.ts: import block; main's NO_VALUE added to
  the utils import, kept this branch's enabled-providers import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 24, 2026
Conflicts resolved:
- frontend/src/app.ts: import block only. Kept this branch's
  fanout-retry import and main's wireTablistKeyboard (LeanerCloud#730/LeanerCloud#1371) and
  setOpenAccountsHandler (LeanerCloud#1389) imports; the code using each merged
  cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant