From 75c1708bbe013abee976f81c04fe4d330ba57a26 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 08:02:39 -0400 Subject: [PATCH 1/3] 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 -- 2.45.2 From 2388ad62faa379db435addc5ed4123c9899f7334 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 08:02:43 -0400 Subject: [PATCH 2/3] feat(settings): read the getting-started path from its provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Users no longer reads the hidden preference at mount: it takes hidden and show from useOnboarding(), the provider being the only writer of the path's preferences. Hiding the path from the banner of this very page now offers "Show the getting-started checklist" at once, and a local flag keeps the confirmation in place of the button until the path is hidden again. Co-Authored-By: Claude Opus 5.5 --- src/pages/settings/UsersSettingsPage.test.tsx | 153 ++++++++++++++++++ src/pages/settings/UsersSettingsPage.tsx | 57 ++----- 2 files changed, 169 insertions(+), 41 deletions(-) create mode 100644 src/pages/settings/UsersSettingsPage.test.tsx diff --git a/src/pages/settings/UsersSettingsPage.test.tsx b/src/pages/settings/UsersSettingsPage.test.tsx new file mode 100644 index 0000000..947cb7d --- /dev/null +++ b/src/pages/settings/UsersSettingsPage.test.tsx @@ -0,0 +1,153 @@ +// UsersSettingsPage — the re-show button reads the shared path (#397). +// +// React's `useState` is stubbed (same technique as +// useOnboardingChecklist.wiring.test.ts): the page runs as a plain function +// and its state survives from one call to the next, as between two renders of +// the mounted page. So "the path got hidden by the banner of this very page" +// is a new `useOnboarding()` value on the next render, without remounting. +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isValidElement, type ReactElement, type ReactNode } from "react"; + +const harness = vi.hoisted(() => ({ + slots: [] as unknown[], + cursor: 0, + onboarding: null as unknown, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useState: (init: unknown) => { + const index = harness.cursor++; + if (!(index in harness.slots)) { + harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init; + } + const set = (next: unknown) => { + harness.slots[index] = + typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next; + }; + return [harness.slots[index], set]; + }, + }; +}); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), +})); +vi.mock("../../contexts/OnboardingContext", () => ({ + useOnboarding: () => harness.onboarding, +})); +// The page's cards are not under test, and they reach tauri. +vi.mock("../../components/settings/AccountCard", () => ({ default: () => null })); +vi.mock("../../components/settings/LicenseCard", () => ({ default: () => null })); +vi.mock("../../components/settings/DocsContent", () => ({ default: () => null })); +// The provider is the only writer of the path's preferences: the page must +// neither read the preference nor write it itself. +vi.mock("../../services/userPreferenceService", () => ({ getPreference: vi.fn() })); +vi.mock("../../services/onboardingService", () => ({ showChecklist: vi.fn() })); + +import UsersSettingsPage from "./UsersSettingsPage"; +import { getPreference } from "../../services/userPreferenceService"; +import { showChecklist } from "../../services/onboardingService"; + +type AnyElement = ReactElement & { children?: ReactNode }>; + +function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { + if (Array.isArray(node)) return node.flatMap((n) => find(n, pred)); + if (!isValidElement(node)) return []; + const el = node as AnyElement; + return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)]; +} + +function texts(node: ReactNode): string[] { + if (Array.isArray(node)) return node.flatMap(texts); + if (typeof node === "string") return [node]; + if (!isValidElement(node)) return []; + return texts((node as AnyElement).props.children); +} + +/** What the page reads from the provider: only `hidden` and `show` matter here. */ +function path(hidden: boolean) { + return { hidden, show: vi.fn(async () => undefined) }; +} + +/** One render of the mounted page. */ +function renderPage(): ReactNode { + harness.cursor = 0; + return UsersSettingsPage() as ReactNode; +} + +const showButton = (page: ReactNode) => + find(page, (e) => e.type === "button" && texts(e.props.children).includes("t(settings.users.onboarding.show)")); +const confirmation = (page: ReactNode) => + find(page, (e) => e.props.role === "status" && texts(e.props.children).includes("t(settings.users.onboarding.confirmation)")); + +beforeEach(() => { + harness.slots = []; + harness.onboarding = path(false); + vi.mocked(getPreference).mockReset(); + vi.mocked(showChecklist).mockReset(); +}); + +describe("UsersSettingsPage — showing the path again (#397)", () => { + it("offers nothing while the path is shown", () => { + const page = renderPage(); + expect(showButton(page)).toHaveLength(0); + expect(confirmation(page)).toHaveLength(0); + expect(texts(page)).not.toContain("t(settings.users.onboarding.label)"); + }); + + it("offers the button as soon as the path is hidden from the banner of this page, without remounting", () => { + expect(showButton(renderPage())).toHaveLength(0); + harness.onboarding = path(true); + const page = renderPage(); + expect(texts(page)).toContain("t(settings.users.onboarding.label)"); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("the button shows the path through the provider, then confirms in place", async () => { + const hiddenPath = path(true); + harness.onboarding = hiddenPath; + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + expect(hiddenPath.show).toHaveBeenCalledTimes(1); + expect(showChecklist).not.toHaveBeenCalled(); + + // The provider applied SHOW: the next render reads the path shown. + harness.onboarding = path(false); + const page = renderPage(); + expect(showButton(page)).toHaveLength(0); + expect(confirmation(page)).toHaveLength(1); + expect(texts(page)).toContain("t(settings.users.onboarding.label)"); + }); + + it("hidden again after a restore, it offers the button again", async () => { + harness.onboarding = path(true); + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + harness.onboarding = path(false); + expect(confirmation(renderPage())).toHaveLength(1); + + harness.onboarding = path(true); + const page = renderPage(); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("keeps the button when showing failed: the provider left the path hidden", async () => { + const hiddenPath = path(true); + harness.onboarding = hiddenPath; + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + const page = renderPage(); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("never reads the preference itself", () => { + harness.onboarding = path(true); + renderPage(); + expect(getPreference).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/settings/UsersSettingsPage.tsx b/src/pages/settings/UsersSettingsPage.tsx index de75c2e..0942fcf 100644 --- a/src/pages/settings/UsersSettingsPage.tsx +++ b/src/pages/settings/UsersSettingsPage.tsx @@ -1,51 +1,26 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { ArrowLeft, ListChecks } from "lucide-react"; import AccountCard from "../../components/settings/AccountCard"; import LicenseCard from "../../components/settings/LicenseCard"; import DocsContent from "../../components/settings/DocsContent"; -import { getPreference } from "../../services/userPreferenceService"; -import { - ONBOARDING_CHECKLIST_HIDDEN_KEY, - showChecklist, -} from "../../services/onboardingService"; -import { logError } from "../../services/logService"; - -// Getting-started checklist preference, for its re-show button (#352). -// `dismissed` = the preference was "1" at mount, the button is offered; -// `restored` = the button was clicked and the preference removed, so a -// confirmation line replaces it; `shown` = nothing to offer. -type ChecklistPreference = "unknown" | "shown" | "dismissed" | "restored"; +import { useOnboarding } from "../../contexts/OnboardingContext"; export default function UsersSettingsPage() { const { t } = useTranslation(); - const [checklistPref, setChecklistPref] = useState("unknown"); + // The getting-started path comes from its provider, the only writer of its + // preferences (#397): hiding it from the banner of this very page offers + // the re-show button at once, without reading the preference here. + const { hidden, show } = useOnboarding(); + // Set once the path was shown again from here: the confirmation then + // replaces the button, until the path is hidden again. + const [restored, setRestored] = useState(false); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const value = await getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY); - if (!cancelled) setChecklistPref(value === "1" ? "dismissed" : "shown"); - } catch (e) { - // Informational control: without the preference, keep it out of the way. - if (!cancelled) setChecklistPref("shown"); - logError("Settings: failed to read the checklist preference", e); - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const handleShowChecklist = async () => { - try { - await showChecklist(); - setChecklistPref("restored"); - } catch (e) { - logError("Settings: failed to show the checklist", e); - } + const handleShowPath = async () => { + // `show` logs its own failure and leaves the path hidden, which keeps the button. + await show(); + setRestored(true); }; return ( @@ -78,14 +53,14 @@ export default function UsersSettingsPage() {

{t("settings.users.sections.userGuide")}

- {(checklistPref === "dismissed" || checklistPref === "restored") && ( + {(hidden || restored) && (