From a060f0e0e09eb660081fe2c16e69600856bcac7f Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 3 Oct 2026 19:32:43 -0400 Subject: [PATCH] feat(onboarding): dashboard empty state, getting-started checklist and re-show button (#352) A profile without any transaction now opens a dashboard that says what to do: a welcome card with the expanded getting-started checklist and one primary action towards the import page, instead of widgets showing 0 $. With transactions, the compact checklist sits between the title and the filter panel until it is complete or dismissed. - `EmptyState` (pure, dashed card from the import source list; a `to` action renders a react-router ``, an `onClick` action a button) - `OnboardingChecklist` (pure, expanded and compact modes; undone rows link to their route, the locked Budget row shows a lock, the "Base and up" label and links to the license card; neutral budget row and hidden counter until the entitlement is known) - `DashboardEmptyState` composes both - `DashboardPage` branches on the hook `status`: loading renders the title, help and banner only; error falls back to the v0.15.0 page without the list and logs a warning; ready with zero transactions renders the empty state outside the `isLoading` veil - `useOnboardingChecklist` exposes `counters` and `error` for the page - Users settings: "Show the getting-started checklist" button in the Guide section, visible only when the preference is set, with an inline confirmation after the click - FR + EN keys `dashboard.empty.*`, `onboarding.checklist.*`, `settings.users.onboarding.*` Tests: `EmptyState.test.tsx` and `OnboardingChecklist.test.tsx` by direct call (FilterPanel pattern). No DB migration. CHANGELOG left to #357. Resolves #352 Co-Authored-By: Claude Fable 5.1 --- .../dashboard/DashboardEmptyState.tsx | 47 +++++ .../dashboard/OnboardingChecklist.test.tsx | 183 ++++++++++++++++++ .../dashboard/OnboardingChecklist.tsx | 164 ++++++++++++++++ src/components/shared/EmptyState.test.tsx | 111 +++++++++++ src/components/shared/EmptyState.tsx | 80 ++++++++ src/hooks/useOnboardingChecklist.ts | 4 + src/i18n/locales/en.json | 26 +++ src/i18n/locales/fr.json | 26 +++ src/pages/DashboardPage.tsx | 66 ++++++- src/pages/settings/UsersSettingsPage.tsx | 60 +++++- 10 files changed, 763 insertions(+), 4 deletions(-) create mode 100644 src/components/dashboard/DashboardEmptyState.tsx create mode 100644 src/components/dashboard/OnboardingChecklist.test.tsx create mode 100644 src/components/dashboard/OnboardingChecklist.tsx create mode 100644 src/components/shared/EmptyState.test.tsx create mode 100644 src/components/shared/EmptyState.tsx diff --git a/src/components/dashboard/DashboardEmptyState.tsx b/src/components/dashboard/DashboardEmptyState.tsx new file mode 100644 index 0000000..44846db --- /dev/null +++ b/src/components/dashboard/DashboardEmptyState.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from "react-i18next"; +import { LayoutDashboard } from "lucide-react"; +import EmptyState from "../shared/EmptyState"; +import OnboardingChecklist from "./OnboardingChecklist"; +import type { Checklist } from "../../utils/onboardingChecklist"; + +interface DashboardEmptyStateProps { + checklist: Checklist; + budgetReady: boolean; + /** The user dismissed the checklist: the welcome card stays, the list goes. */ + hidden: boolean; + onHide: () => 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 $. + */ +export default function DashboardEmptyState({ + checklist, + budgetReady, + hidden, + onHide, +}: DashboardEmptyStateProps) { + const { t } = useTranslation(); + + return ( + } + title={t("dashboard.empty.title")} + description={t("dashboard.empty.description")} + primaryAction={{ label: t("dashboard.empty.cta"), to: "/import" }} + > + {!hidden && ( + + )} + + ); +} diff --git a/src/components/dashboard/OnboardingChecklist.test.tsx b/src/components/dashboard/OnboardingChecklist.test.tsx new file mode 100644 index 0000000..a6c053d --- /dev/null +++ b/src/components/dashboard/OnboardingChecklist.test.tsx @@ -0,0 +1,183 @@ +// OnboardingChecklist — unit tests (#352). +// +// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react` +// mocked, the component called as a function and its element tree walked. +// `t` returns the key, with the interpolation values appended, so the asserts +// read the i18n keys and never a hard-coded string. +import { describe, it, expect, vi } from "vitest"; +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { Link } from "react-router-dom"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, o?: Record) => + o ? `${key}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : key, + i18n: { language: "fr" }, + }), +})); + +vi.mock("lucide-react", () => ({ + CheckCircle2: () => null, + Circle: () => null, + Lock: () => null, +})); + +import { CheckCircle2, Circle, Lock } from "lucide-react"; +import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist"; +import { computeChecklist } from "../../utils/onboardingChecklist"; +import type { OnboardingCounters } from "../../shared/types"; + +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); +} + +// Leaves of the walk: react-router's Link and the mocked icons keep their type. +const LEAVES = new Set([Link, CheckCircle2, Circle, Lock]); + +/** Resolves the inner `RowIcon` component so the icon elements are visible. */ +function expand(node: ReactNode): ReactNode { + if (Array.isArray(node)) return node.map(expand); + if (!isValidElement(node)) return node; + const el = node as AnyElement; + if (typeof el.type === "function" && !LEAVES.has(el.type)) { + const render = el.type as (props: Record) => ReactNode; + return expand(render(el.props)); + } + const children = el.props.children; + return { ...el, props: { ...el.props, children: expand(children) } } as ReactNode; +} + +const ZERO: OnboardingCounters = { + sources: 0, + imports: 0, + manualCategorized: 0, + budgetEntries: 0, + transactions: 0, +}; + +function render(overrides: Partial = {}, counters = ZERO, budgetAllowed = true) { + const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed }); + const onHide = vi.fn(); + const props: OnboardingChecklistProps = { + items: checklist.items, + done: checklist.done, + total: checklist.total, + mode: "expanded", + budgetReady: true, + onHide, + ...overrides, + }; + return { element: expand(OnboardingChecklist(props)), onHide, checklist }; +} + +function rowOf(element: ReactNode, key: string): AnyElement { + const row = find(element, (e) => e.type === "li" && e.key === key)[0]; + expect(row, `row ${key}`).toBeDefined(); + return row; +} + +describe("OnboardingChecklist — expanded", () => { + it("renders the title, the counter 1 / 6 and the hide button", () => { + const { element, onHide } = render(); + const text = texts(element); + expect(text).toContain("onboarding.checklist.title"); + expect(text).toContain("onboarding.checklist.counter[done=1,total=6]"); + const [hide] = find(element, (e) => e.type === "button"); + expect(hide.props.type).toBe("button"); + expect(texts(hide.props.children)).toContain("onboarding.checklist.hide"); + (hide.props.onClick as () => void)(); + expect(onHide).toHaveBeenCalledTimes(1); + }); + + it("renders six rows with their i18n labels", () => { + const { element } = render(); + expect(find(element, (e) => e.type === "li")).toHaveLength(6); + const text = texts(element); + for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) { + expect(text).toContain(`onboarding.checklist.items.${key}`); + } + }); + + it("links every undone row to its `to`, and never a done row", () => { + const { element, checklist } = render(); + 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)); + const profile = rowOf(element, "profile"); + expect(find(profile, (e) => e.type === Link)).toHaveLength(0); + expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1); + }); + + it("marks a row done when its counter is reached", () => { + const { element } = render({}, { ...ZERO, sources: 1 }); + const source = rowOf(element, "source"); + expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1); + expect(find(source, (e) => e.type === Link)).toHaveLength(0); + }); + + it("in Free, the budget row shows a lock, the 'Base and up' label and links to the license card", () => { + const { element } = render({}, ZERO, false); + const budget = rowOf(element, "budget"); + expect(find(budget, (e) => e.type === Lock)).toHaveLength(1); + expect(texts(budget)).toContain("onboarding.checklist.lockedBudget"); + const [link] = find(budget, (e) => e.type === Link); + expect(link.props.to).toBe("/settings/users"); + expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]"); + }); + + it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => { + const { element } = render({ budgetReady: false }, ZERO, false); + const budget = rowOf(element, "budget"); + expect(find(budget, (e) => e.type === Lock)).toHaveLength(0); + expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0); + expect(find(budget, (e) => e.type === Circle)).toHaveLength(1); + expect(find(budget, (e) => e.type === Link)).toHaveLength(0); + expect(texts(budget)).not.toContain("onboarding.checklist.lockedBudget"); + expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false); + }); +}); + +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); + }); + + 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("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); + }); +}); diff --git a/src/components/dashboard/OnboardingChecklist.tsx b/src/components/dashboard/OnboardingChecklist.tsx new file mode 100644 index 0000000..3b84560 --- /dev/null +++ b/src/components/dashboard/OnboardingChecklist.tsx @@ -0,0 +1,164 @@ +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"; + +export interface OnboardingChecklistProps { + items: ChecklistItem[]; + /** Number of unlocked rows that are done. */ + 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 + * `total` depends on the lock. + */ + budgetReady: boolean; + onHide: () => void; +} + +type RowState = "done" | "todo" | "locked" | "pending"; + +function rowState(item: ChecklistItem, budgetReady: boolean): RowState { + if (item.key === "budget" && !budgetReady) return "pending"; + if (item.locked) return "locked"; + return item.done ? "done" : "todo"; +} + +const ICON_CLASS: Record = { + done: "text-[var(--positive)]", + 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 }) { + const className = `shrink-0 ${ICON_CLASS[state]}`; + if (state === "done") return