From 75c1708bbe013abee976f81c04fe4d330ba57a26 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 08:02:39 -0400 Subject: [PATCH] feat(onboarding): show the getting-started banner on top of every page The getting-started path now follows the user: AppShell mounts OnboardingBanner at the top of
, in the page flow, and the provider's mode keeps it off a hidden path, the dashboard of a profile without transactions, a page that carries a step card and the first load. The banner shows "Getting started n/N" once the Budget entitlement is known, the next step linked with its navigation state, "Show the list", which unfolds the detailed list in place, and "Hide"; at the end, the closing message, "Open the guide" and "Close". Each route mounts its own banner slot, so a page always starts folded. The dashboard's compact bar and the compact mode of OnboardingChecklist are gone (D7). The detailed list reads each state out to screen readers, shows a skipped step with its badge and "Resume", and gives every link to a step the same navigation state (stepNavState), with the guided entry for the manual category. Co-Authored-By: Claude Opus 5.5 --- .../DashboardEmptyState.render.test.tsx | 64 ++++++ .../dashboard/DashboardEmptyState.tsx | 11 +- .../dashboard/OnboardingChecklist.test.tsx | 144 +++++++++--- .../dashboard/OnboardingChecklist.tsx | 175 +++++++------- .../layout/AppShell.render.test.tsx | 194 ++++++++++++++++ src/components/layout/AppShell.slot.test.tsx | 146 ++++++++++++ src/components/layout/AppShell.tsx | 39 +++- .../OnboardingBanner.render.test.tsx | 214 ++++++++++++++++++ .../onboarding/OnboardingBanner.tsx | 144 ++++++++++++ src/i18n/locales/en.json | 21 +- src/i18n/locales/fr.json | 21 +- src/pages/DashboardPage.render.test.tsx | 45 ++-- src/pages/DashboardPage.tsx | 28 +-- src/utils/onboardingPath.test.ts | 28 ++- src/utils/onboardingPath.ts | 17 ++ 15 files changed, 1127 insertions(+), 164 deletions(-) create mode 100644 src/components/dashboard/DashboardEmptyState.render.test.tsx create mode 100644 src/components/layout/AppShell.render.test.tsx create mode 100644 src/components/layout/AppShell.slot.test.tsx create mode 100644 src/components/onboarding/OnboardingBanner.render.test.tsx create mode 100644 src/components/onboarding/OnboardingBanner.tsx diff --git a/src/components/dashboard/DashboardEmptyState.render.test.tsx b/src/components/dashboard/DashboardEmptyState.render.test.tsx new file mode 100644 index 0000000..30967e3 --- /dev/null +++ b/src/components/dashboard/DashboardEmptyState.render.test.tsx @@ -0,0 +1,64 @@ +// DashboardEmptyState — the welcome card of a profile without transactions +// (#352). #397 leaves it as it was: the card keeps the detailed list, which +// now also takes the "Resume" handler. No jsdom: react-dom/server for the +// markup, a function call for the wiring. +import { describe, it, expect, vi } from "vitest"; +import type { ReactElement, ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => + o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`, + i18n: { language: "fr" }, + }), +})); + +import DashboardEmptyState from "./DashboardEmptyState"; +import OnboardingChecklist from "./OnboardingChecklist"; +import { computeChecklist } from "../../utils/onboardingChecklist"; + +const checklist = computeChecklist( + { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 }, + { importFolderSet: false, budgetAllowed: true }, +); + +function cardProps(hidden = false) { + return { checklist, budgetReady: true, hidden, onHide: vi.fn(), onResume: vi.fn() }; +} + +const markup = (hidden = false) => + renderToStaticMarkup( + + + , + ); + +describe("DashboardEmptyState (#352, #397)", () => { + it("shows the welcome card, its action towards Import and the detailed list", () => { + const html = markup(); + expect(html).toContain("t(dashboard.empty.title)"); + expect(html).toContain("t(dashboard.empty.description)"); + expect(html).toMatch(/]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/); + expect(html).toContain('aria-label="t(onboarding.checklist.title)"'); + expect(html).toContain("t(onboarding.checklist.counter[done=1,total=6])"); + }); + + it("keeps the card and drops the list once the path is hidden", () => { + const html = markup(true); + expect(html).toContain("t(dashboard.empty.title)"); + expect(html).not.toContain("onboarding.checklist.title"); + }); + + it("hands its list the path's handlers", () => { + const props = cardProps(); + const card = DashboardEmptyState(props) as ReactElement<{ children?: ReactNode }>; + const list = card.props.children as ReactElement>; + expect(list.type).toBe(OnboardingChecklist); + expect(list.props.items).toBe(checklist.items); + expect(list.props.budgetReady).toBe(true); + expect(list.props.onHide).toBe(props.onHide); + expect(list.props.onResume).toBe(props.onResume); + }); +}); diff --git a/src/components/dashboard/DashboardEmptyState.tsx b/src/components/dashboard/DashboardEmptyState.tsx index 44846db..bbef967 100644 --- a/src/components/dashboard/DashboardEmptyState.tsx +++ b/src/components/dashboard/DashboardEmptyState.tsx @@ -3,6 +3,7 @@ import { LayoutDashboard } from "lucide-react"; import EmptyState from "../shared/EmptyState"; import OnboardingChecklist from "./OnboardingChecklist"; import type { Checklist } from "../../utils/onboardingChecklist"; +import type { ChecklistItemKey } from "../../shared/types"; interface DashboardEmptyStateProps { checklist: Checklist; @@ -10,18 +11,22 @@ interface DashboardEmptyStateProps { /** The user dismissed the checklist: the welcome card stays, the list goes. */ hidden: boolean; onHide: () => void; + /** "Resume" on a skipped row of the list (#397). */ + onResume: (key: ChecklistItemKey) => void; } /** * Dashboard of a profile without any transaction (#352): a welcome card with - * the expanded getting-started checklist and one primary action towards the - * import page, in place of widgets showing 0 $. + * the detailed getting-started checklist and one primary action towards the + * import page, in place of widgets showing 0 $. The getting-started banner + * stays off this page (D11): the card already holds the list. */ export default function DashboardEmptyState({ checklist, budgetReady, hidden, onHide, + onResume, }: DashboardEmptyStateProps) { const { t } = useTranslation(); @@ -37,9 +42,9 @@ export default function DashboardEmptyState({ items={checklist.items} done={checklist.done} total={checklist.total} - mode="expanded" budgetReady={budgetReady} onHide={onHide} + onResume={onResume} /> )} diff --git a/src/components/dashboard/OnboardingChecklist.test.tsx b/src/components/dashboard/OnboardingChecklist.test.tsx index a6c053d..0dd6503 100644 --- a/src/components/dashboard/OnboardingChecklist.test.tsx +++ b/src/components/dashboard/OnboardingChecklist.test.tsx @@ -1,4 +1,4 @@ -// OnboardingChecklist — unit tests (#352). +// OnboardingChecklist — unit tests (#352, #397). // // FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react` // mocked, the component called as a function and its element tree walked. @@ -20,12 +20,13 @@ vi.mock("lucide-react", () => ({ CheckCircle2: () => null, Circle: () => null, Lock: () => null, + SkipForward: () => null, })); -import { CheckCircle2, Circle, Lock } from "lucide-react"; +import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react"; import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist"; import { computeChecklist } from "../../utils/onboardingChecklist"; -import type { OnboardingCounters } from "../../shared/types"; +import type { ChecklistItemKey, OnboardingCounters } from "../../shared/types"; type AnyElement = ReactElement & { children?: ReactNode }>; @@ -44,7 +45,7 @@ function texts(node: ReactNode): string[] { } // Leaves of the walk: react-router's Link and the mocked icons keep their type. -const LEAVES = new Set([Link, CheckCircle2, Circle, Lock]); +const LEAVES = new Set([Link, CheckCircle2, Circle, Lock, SkipForward]); /** Resolves the inner `RowIcon` component so the icon elements are visible. */ function expand(node: ReactNode): ReactNode { @@ -67,19 +68,25 @@ const ZERO: OnboardingCounters = { transactions: 0, }; -function render(overrides: Partial = {}, counters = ZERO, budgetAllowed = true) { - const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed }); +function render( + overrides: Partial = {}, + counters = ZERO, + budgetAllowed = true, + skipped: ChecklistItemKey[] = [], +) { + const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped }); const onHide = vi.fn(); + const onResume = vi.fn(); const props: OnboardingChecklistProps = { items: checklist.items, done: checklist.done, total: checklist.total, - mode: "expanded", budgetReady: true, onHide, + onResume, ...overrides, }; - return { element: expand(OnboardingChecklist(props)), onHide, checklist }; + return { element: expand(OnboardingChecklist(props)), onHide, onResume, checklist }; } function rowOf(element: ReactNode, key: string): AnyElement { @@ -88,7 +95,14 @@ function rowOf(element: ReactNode, key: string): AnyElement { return row; } -describe("OnboardingChecklist — expanded", () => { +/** The visually hidden texts of a row: what assistive technologies read after the label. */ +function stateTextsOf(row: AnyElement): string[] { + return find(row, (e) => e.type === "span" && e.props.className === "sr-only").flatMap((e) => + texts(e.props.children).map((s) => s.trim()).filter(Boolean), + ); +} + +describe("OnboardingChecklist", () => { it("renders the title, the counter 1 / 6 and the hide button", () => { const { element, onHide } = render(); const text = texts(element); @@ -120,6 +134,15 @@ describe("OnboardingChecklist — expanded", () => { expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1); }); + it("gives the link of a row to do the step's navigation state, guided on the manual category", () => { + const { element } = render(); + const [folder] = find(rowOf(element, "importFolder"), (e) => e.type === Link); + expect(folder.props.state).toEqual({ onboarding: { focus: true } }); + const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link); + expect(manual.props.to).toBe("/transactions"); + expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } }); + }); + it("marks a row done when its counter is reached", () => { const { element } = render({}, { ...ZERO, sources: 1 }); const source = rowOf(element, "source"); @@ -134,6 +157,8 @@ describe("OnboardingChecklist — expanded", () => { expect(texts(budget)).toContain("onboarding.checklist.lockedBudget"); const [link] = find(budget, (e) => e.type === Link); expect(link.props.to).toBe("/settings/users"); + // The license card carries no step card: nothing to focus there. + expect(link.props.state).toBeUndefined(); expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]"); }); @@ -149,35 +174,84 @@ describe("OnboardingChecklist — expanded", () => { }); }); -describe("OnboardingChecklist — compact", () => { - it("renders the icons on one line with the counter, without the row sentences", () => { - const { element } = render({ mode: "compact" }, { ...ZERO, sources: 1, imports: 1 }); - const text = texts(element); - expect(text).toContain("onboarding.checklist.title"); - expect(text).toContain("onboarding.checklist.counter[done=3,total=6]"); - expect(text.some((s) => s.startsWith("onboarding.checklist.items."))).toBe(false); - expect(find(element, (e) => e.type === "li")).toHaveLength(6); - expect(find(element, (e) => e.type === CheckCircle2)).toHaveLength(3); - expect(find(element, (e) => e.type === Circle)).toHaveLength(3); +describe("OnboardingChecklist — states read out (#384, #397)", () => { + it("reads done, skipped and locked after the label, and nothing for a row still to do", () => { + const { element } = render({}, ZERO, false, ["source"]); + expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["onboarding.state.done"]); + expect(stateTextsOf(rowOf(element, "source"))).toEqual(["onboarding.state.skipped"]); + expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.locked"]); + expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]); + expect(stateTextsOf(rowOf(element, "import"))).toEqual([]); }); - it("labels each icon for assistive technologies and links the undone rows", () => { - const { element, checklist } = render({ mode: "compact" }); - const links = find(element, (e) => e.type === Link); - const undone = checklist.items.filter((i) => !i.done); - expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to)); - for (const link of links) { - expect(String(link.props["aria-label"])).toMatch(/^onboarding\.checklist\.items\./); - } - const [profileIcon] = find(element, (e) => e.type === "span" && e.props.role === "img"); - expect(profileIcon.props["aria-label"]).toBe("onboarding.checklist.items.profile"); + it("reads the budget row as pending while the entitlement is unknown", () => { + const { element } = render({ budgetReady: false }, ZERO, false); + expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.pending"]); }); - it("keeps the hide button and hides the counter until the entitlement is known", () => { - const { element, onHide } = render({ mode: "compact", budgetReady: false }); - expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false); - const [hide] = find(element, (e) => e.type === "button"); - (hide.props.onClick as () => void)(); - expect(onHide).toHaveBeenCalledTimes(1); + it("puts the state text right after the label, inside the same element", () => { + const { element } = render({}, { ...ZERO, sources: 1 }); + const [labelled] = find( + rowOf(element, "source"), + (e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.items.source"), + ); + expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([ + "onboarding.checklist.items.source", + "onboarding.state.done", + ]); + }); +}); + +describe("OnboardingChecklist — skipped rows (#397)", () => { + it("shows the skip icon, the 'Skipped' badge and 'Resume', and counts the row", () => { + const { element } = render({}, ZERO, true, ["source"]); + const source = rowOf(element, "source"); + expect(find(source, (e) => e.type === SkipForward)).toHaveLength(1); + expect(find(source, (e) => e.type === Circle)).toHaveLength(0); + const [badge] = find( + source, + (e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.skippedBadge"), + ); + // The hidden "(skipped)" text already says it: the badge is not read twice. + expect(badge.props["aria-hidden"]).toBe("true"); + expect(texts(element)).toContain("onboarding.checklist.counter[done=2,total=6]"); + }); + + it("'Resume' takes the step back, then opens its page with the step's navigation state", () => { + const { element, onResume } = render({}, ZERO, true, ["source", "manualCategory"]); + const sourceLinks = find(rowOf(element, "source"), (e) => e.type === Link); + // The label is no link: only "Resume" leads to the page, after taking the step back. + expect(sourceLinks).toHaveLength(1); + const [resume] = sourceLinks; + expect(texts(resume.props.children)).toEqual(["onboarding.checklist.resume"]); + expect(resume.props.to).toBe("/import"); + expect(resume.props.state).toEqual({ onboarding: { focus: true } }); + (resume.props.onClick as () => void)(); + expect(onResume).toHaveBeenCalledTimes(1); + expect(onResume).toHaveBeenCalledWith("source"); + + const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link); + expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } }); + (manual.props.onClick as () => void)(); + expect(onResume).toHaveBeenLastCalledWith("manualCategory"); + }); + + it("a row done and skipped shows as done, without 'Resume'", () => { + const { element } = render({}, { ...ZERO, sources: 1 }, true, ["source"]); + const source = rowOf(element, "source"); + expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1); + expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0); + expect(find(source, (e) => e.type === Link)).toHaveLength(0); + expect(texts(source)).not.toContain("onboarding.checklist.resume"); + }); + + it("a locked row stays locked even when skipped", () => { + const { element } = render({}, ZERO, false, ["budget"]); + const budget = rowOf(element, "budget"); + expect(find(budget, (e) => e.type === Lock)).toHaveLength(1); + expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0); + expect(texts(budget)).not.toContain("onboarding.checklist.resume"); + const [link] = find(budget, (e) => e.type === Link); + expect(link.props.to).toBe("/settings/users"); }); }); diff --git a/src/components/dashboard/OnboardingChecklist.tsx b/src/components/dashboard/OnboardingChecklist.tsx index 3b84560..f4fac18 100644 --- a/src/components/dashboard/OnboardingChecklist.tsx +++ b/src/components/dashboard/OnboardingChecklist.tsx @@ -1,18 +1,15 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { CheckCircle2, Circle, Lock } from "lucide-react"; -import type { ChecklistItem } from "../../shared/types"; - -export type OnboardingChecklistMode = "expanded" | "compact"; +import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react"; +import { stepNavState } from "../../utils/onboardingPath"; +import type { ChecklistItem, ChecklistItemKey } from "../../shared/types"; export interface OnboardingChecklistProps { items: ChecklistItem[]; - /** Number of unlocked rows that are done. */ + /** Number of unlocked rows that are done or skipped. */ done: number; /** Number of unlocked rows (6, or 5 when Budget is locked). */ total: number; - /** `expanded` = one row per line with its label; `compact` = icons on one line. */ - mode: OnboardingChecklistMode; /** * False until the Budget entitlement is known. The budget row is then * neutral (neither done nor locked) and the counter is hidden, because @@ -20,102 +17,68 @@ export interface OnboardingChecklistProps { */ budgetReady: boolean; onHide: () => void; + /** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */ + onResume: (key: ChecklistItemKey) => void; } -type RowState = "done" | "todo" | "locked" | "pending"; +type RowState = "done" | "skipped" | "todo" | "locked" | "pending"; +/** + * A locked row stays locked whatever else it is, and a row the data says is + * done shows as done even if it was skipped too: "Resume" would bring back + * nothing to do. + */ function rowState(item: ChecklistItem, budgetReady: boolean): RowState { if (item.key === "budget" && !budgetReady) return "pending"; if (item.locked) return "locked"; - return item.done ? "done" : "todo"; + if (item.done) return "done"; + return item.skipped ? "skipped" : "todo"; } const ICON_CLASS: Record = { done: "text-[var(--positive)]", + skipped: "text-[var(--muted-foreground)]", todo: "text-[var(--muted-foreground)]", locked: "text-[var(--muted-foreground)]", pending: "text-[var(--muted-foreground)] opacity-50", }; -function RowIcon({ state, size }: { state: RowState; size: number }) { +function RowIcon({ state }: { state: RowState }) { const className = `shrink-0 ${ICON_CLASS[state]}`; - if (state === "done") return

{t("onboarding.checklist.title")}

- {counter} - {hideButton} + {budgetReady && ( + + {t("onboarding.checklist.counter", { done, total })} + + )} + + +
+ ); + } + + return ( +
+
+ + {budgetReady && ( + + {t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })} + + )} + {budgetReady && nextStep && ( +
+ {/* Always in the page, so `aria-controls` names an element even while folded. */} +
+ {listOpen && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 259d87e..45b5849 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -65,11 +65,28 @@ } }, "onboarding": { + "banner": { + "label": "Getting-started path", + "progress": "Getting started {{done}}/{{total}}", + "next": "Next step:", + "showList": "Show the list", + "hideList": "Close the list", + "complete": "Getting started complete. The user guide takes it from here.", + "openGuide": "Open the guide" + }, + "state": { + "done": "(done)", + "skipped": "(skipped)", + "locked": "(locked)", + "pending": "(pending)" + }, "checklist": { "title": "Getting started", "counter": "{{done}} / {{total}}", "hide": "Hide", "lockedBudget": "Base and up", + "skippedBadge": "Skipped", + "resume": "Resume", "items": { "profile": "Create a profile", "importFolder": "Choose an import folder", @@ -771,9 +788,9 @@ "userGuide": "User guide" }, "onboarding": { - "label": "Getting-started checklist on the dashboard", + "label": "Getting-started path", "show": "Show the getting-started checklist", - "confirmation": "The checklist will reappear on the dashboard" + "confirmation": "The getting-started path reappears at the top of the pages" } }, "data": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b7ac9da..15908d0 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -65,11 +65,28 @@ } }, "onboarding": { + "banner": { + "label": "Parcours de démarrage", + "progress": "Démarrage {{done}}/{{total}}", + "next": "Étape suivante :", + "showList": "Voir la liste", + "hideList": "Fermer la liste", + "complete": "Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite.", + "openGuide": "Ouvrir le guide" + }, + "state": { + "done": "(faite)", + "skipped": "(passée)", + "locked": "(verrouillée)", + "pending": "(en attente)" + }, "checklist": { "title": "Liste de démarrage", "counter": "{{done}} / {{total}}", "hide": "Masquer", "lockedBudget": "Base et plus", + "skippedBadge": "Passée", + "resume": "Reprendre", "items": { "profile": "Créer un profil", "importFolder": "Choisir un dossier d'import", @@ -771,9 +788,9 @@ "userGuide": "Guide d'utilisation" }, "onboarding": { - "label": "Liste de démarrage sur le tableau de bord", + "label": "Parcours de démarrage", "show": "Afficher la liste de démarrage", - "confirmation": "La liste réapparaîtra sur le tableau de bord" + "confirmation": "Le parcours de démarrage réapparaît en haut des pages" } }, "data": { diff --git a/src/pages/DashboardPage.render.test.tsx b/src/pages/DashboardPage.render.test.tsx index 9416fd0..833dcd6 100644 --- a/src/pages/DashboardPage.render.test.tsx +++ b/src/pages/DashboardPage.render.test.tsx @@ -1,8 +1,9 @@ // DashboardPage reads the shared getting-started path (#396). No jsdom: // react-dom/server renders in vitest's node environment. The path comes from a // fake `useOnboarding()`, and the dashboard's data hook and widgets are stubbed -// to markers: what is pinned is the page's branching on the path, unchanged -// since #352 except that its source is now the provider. +// to markers: what is pinned is the page's branching on the path. Since #397 +// the page has no list of its own once transactions exist: the banner of +// AppShell replaced the compact bar (D7). import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; @@ -40,14 +41,20 @@ vi.mock("../hooks/useDashboard", () => ({ })); // Hoisted with the mocks below, which run before the imports. -const { marker } = vi.hoisted(() => ({ - marker: (name: string) => ({ - default: (props: { mode?: string }) => , - }), +const { marker, emptyStateProps } = vi.hoisted(() => ({ + marker: (name: string) => ({ default: () => }), + emptyStateProps: { current: null as Record | null }, })); vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null })); vi.mock("../components/dashboard/CategoriesV1DiscoveryBanner", () => marker("discovery")); -vi.mock("../components/dashboard/DashboardEmptyState", () => marker("empty-state")); +vi.mock("../components/dashboard/DashboardEmptyState", () => ({ + default: (props: Record) => { + emptyStateProps.current = props; + return ; + }, +})); +// The page no longer imports the list; the marker would show a compact bar +// that came back. vi.mock("../components/dashboard/OnboardingChecklist", () => marker("checklist")); vi.mock("../components/dashboard/PeriodSelector", () => marker("period")); vi.mock("../components/dashboard/NetWorthTile", () => marker("net-worth")); @@ -102,9 +109,10 @@ const render = () => beforeEach(() => { onboarding = path(); + emptyStateProps.current = null; }); -describe("DashboardPage on the shared path (#352, #396)", () => { +describe("DashboardPage on the shared path (#352, #396, #397)", () => { it("renders only its header while the path first loads", () => { onboarding = path({ status: "loading", checklist: null, counters: null }); const html = render(); @@ -120,16 +128,25 @@ describe("DashboardPage on the shared path (#352, #396)", () => { expect(html).not.toContain('data-marker="filters"'); }); - it("shows the compact checklist above the widgets on a profile with transactions", () => { - const html = render(); - expect(html).toContain('data-marker="checklist" data-mode="compact"'); - expect(html).toContain('data-marker="filters"'); + it("wires the welcome card's list to the path: hidden state, 'Hide' and 'Resume'", () => { + onboarding = path({ transactions: 0, hidden: true }); + render(); + const props = emptyStateProps.current!; + expect(props.checklist).toBe(onboarding.checklist); + expect(props.hidden).toBe(true); + expect(props.budgetReady).toBe(true); + expect(props.onHide).toBe(onboarding.hide); + expect(props.onResume).toBe(onboarding.resume); }); - it("hides the compact checklist of a hidden path (a profile already active, D9)", () => { - onboarding = path({ hidden: true, mode: "none" }); + it.each([ + ["shown", false], + ["hidden", true], + ])("has no list of its own on a profile with transactions, path %s: the banner replaced the compact bar (D7)", (_, hidden) => { + onboarding = path({ hidden, mode: hidden ? "none" : "banner" }); const html = render(); expect(html).not.toContain('data-marker="checklist"'); + expect(html).not.toContain('data-marker="empty-state"'); expect(html).toContain('data-marker="filters"'); }); diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index d8dda50..c7536f9 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -10,12 +10,13 @@ // // Onboarding (#352): the page branches on the getting-started checklist // `status`, never on a bare counter. While it loads, only the title and the -// banner are rendered (no flash of 0 $ widgets nor of the empty state). A +// categories banner are rendered (no flash of 0 $ widgets nor of the empty state). A // profile without any transaction gets `DashboardEmptyState` instead of the // widgets, outside the `isLoading` veil so its link stays clickable. With -// transactions, the compact checklist sits between the title and the filter -// panel until it is complete or dismissed. A first load that fails falls back -// to the v0.15.0 page without the list; the provider logs it. +// transactions, the page shows no list of its own: the getting-started banner +// that `AppShell` puts on top of every page replaced the compact bar (#397, +// D7). A first load that fails falls back to the v0.15.0 page without the +// list; the provider logs it. // // Since #396 the checklist comes from `OnboardingProvider` (AppShell), shared // by every page and re-read live, instead of a load of its own at mount. @@ -28,7 +29,6 @@ import PeriodSelector from "../components/dashboard/PeriodSelector"; import CategoryBarChart from "../components/reports/CategoryBarChart"; import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner"; import DashboardEmptyState from "../components/dashboard/DashboardEmptyState"; -import OnboardingChecklist from "../components/dashboard/OnboardingChecklist"; import NetWorthTile from "../components/dashboard/NetWorthTile"; import FilterPanel from "../components/reports/FilterPanel"; import KpiCard from "../components/reports/cards/KpiCard"; @@ -100,32 +100,16 @@ export default function DashboardPage() { budgetReady={onboarding.budgetReady} hidden={onboarding.hidden} onHide={onboarding.hide} + onResume={onboarding.resume} /> ); } - // `complete` is computed with the fail-closed entitlement (Budget locked → 5/5) - // until the licence resolves; gate on `budgetReady` so a Base+ profile with the - // five other items done does not see the list vanish, then reappear as 5/6. - const showCompactChecklist = - checklistReady && !onboarding.hidden && !(onboarding.budgetReady && checklist.complete); - return (
{header} - {showCompactChecklist && ( - - )} - {state.error && (
{state.error} diff --git a/src/utils/onboardingPath.test.ts b/src/utils/onboardingPath.test.ts index 0728868..bbbc0af 100644 --- a/src/utils/onboardingPath.test.ts +++ b/src/utils/onboardingPath.test.ts @@ -6,10 +6,16 @@ import { latchCardPage, nextStep, readOnboardingNavState, + stepNavState, stepsOfPage, type BannerModeInput, } from "./onboardingPath"; -import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types"; +import { + CHECKLIST_ITEM_KEYS, + type ChecklistItem, + type ChecklistItemKey, + type OnboardingCounters, +} from "../shared/types"; const zero: OnboardingCounters = { sources: 0, @@ -308,3 +314,23 @@ describe("readOnboardingNavState (#396)", () => { ).toEqual({ focus: true, guided: false }); }); }); + +describe("stepNavState (#397)", () => { + it("asks for the focus on every step, and for the guided entry on the manual category only", () => { + for (const key of CHECKLIST_ITEM_KEYS) { + expect(stepNavState(key), key).toEqual( + key === "manualCategory" + ? { onboarding: { focus: true, guided: true } } + : { onboarding: { focus: true } }, + ); + } + }); + + it("reads back through readOnboardingNavState", () => { + expect(readOnboardingNavState(stepNavState("manualCategory"))).toEqual({ + focus: true, + guided: true, + }); + expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false }); + }); +}); diff --git a/src/utils/onboardingPath.ts b/src/utils/onboardingPath.ts index 0e8fef4..f65d282 100644 --- a/src/utils/onboardingPath.ts +++ b/src/utils/onboardingPath.ts @@ -114,6 +114,23 @@ export interface OnboardingNavState { guided: boolean; } +/** What a link to a step puts in `location.state`, read back by `readOnboardingNavState`. */ +export interface OnboardingLinkState { + onboarding: { focus: true; guided?: true }; +} + +/** + * The navigation state of every link to a step's page (#397): the banner's + * next step, the rows of the list and their "Resume". The step card of the + * page takes the focus, and the "categorize by hand" step also opens + * Transactions in its guided entry, wherever its link was clicked. + */ +export function stepNavState(key: ChecklistItemKey): OnboardingLinkState { + return key === "manualCategory" + ? { onboarding: { focus: true, guided: true } } + : { onboarding: { focus: true } }; +} + /** * The only way to read `location.state` (typed `any` by react-router) for the * path (CWE-476): anything that is not `{ onboarding: { focus: true } }` or