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 <main>, 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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-08 08:02:39 -04:00
parent 21c46b6380
commit 75c1708bbe
15 changed files with 1127 additions and 164 deletions

View file

@ -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<string, unknown>) =>
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(
<MemoryRouter>
<DashboardEmptyState {...cardProps(hidden)} />
</MemoryRouter>,
);
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(/<a [^>]*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<Record<string, unknown>>;
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);
});
});

View file

@ -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}
/>
)}
</EmptyState>

View file

@ -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<Record<string, unknown> & { 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<unknown>([Link, CheckCircle2, Circle, Lock]);
const LEAVES = new Set<unknown>([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<OnboardingChecklistProps> = {}, counters = ZERO, budgetAllowed = true) {
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed });
function render(
overrides: Partial<OnboardingChecklistProps> = {},
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");
});
});

View file

@ -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<RowState, string> = {
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 <CheckCircle2 size={size} className={className} aria-hidden="true" />;
if (state === "locked") return <Lock size={size} className={className} aria-hidden="true" />;
return <Circle size={size} className={className} aria-hidden="true" />;
if (state === "done") return <CheckCircle2 size={20} className={className} aria-hidden="true" />;
if (state === "skipped") return <SkipForward size={20} className={className} aria-hidden="true" />;
if (state === "locked") return <Lock size={20} className={className} aria-hidden="true" />;
return <Circle size={20} className={className} aria-hidden="true" />;
}
const BADGE_CLASS =
"rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]";
/**
* Getting-started checklist of the dashboard (#352). Pure: the only hook is
* Getting-started checklist (#352, #397): the welcome card of the empty
* dashboard and the list that the banner unfolds. Pure: the only hook is
* `useTranslation`, so the component is tested by calling it as a function.
*
* - A row that is not done links to its `to` route; a done row has no link.
* - A row still to do links to its `to` route with the step's navigation
* state (`stepNavState`); a done row has no link.
* - A skipped row shows a skip icon, the "Skipped" badge and "Resume", which
* takes the step back, then opens its page like the link of a row to do.
* - A locked row (Budget outside the edition) shows a lock, the "Base and up"
* label and links to the license card (`/settings/users`).
* - Compact mode keeps the title, the icons (one per row, labelled for
* assistive technologies), the counter and the hide button, no sentences.
* label and links to the license card (`/settings/users`), which carries no
* step card: no navigation state.
* - The icons are hidden from assistive technologies, so each state but "to
* do" is read out by a visually hidden text after the label (#384); the
* "Skipped" badge is hidden from them, that text already says it.
*/
export default function OnboardingChecklist({
items,
done,
total,
mode,
budgetReady,
onHide,
onResume,
}: OnboardingChecklistProps) {
const { t } = useTranslation();
const counter = budgetReady ? (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })}
</span>
) : null;
const hideButton = (
<button
type="button"
onClick={onHide}
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
>
{t("onboarding.checklist.hide")}
</button>
);
if (mode === "compact") {
return (
<div
role="region"
aria-label={t("onboarding.checklist.title")}
className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-2 mb-6"
>
<span className="text-sm font-semibold">{t("onboarding.checklist.title")}</span>
<ul className="flex items-center gap-2">
{items.map((item) => {
const state = rowState(item, budgetReady);
const label = t(`onboarding.checklist.items.${item.key}`);
const icon = <RowIcon state={state} size={18} />;
return (
<li key={item.key} className="flex">
{state === "todo" || state === "locked" ? (
<Link
to={item.to}
title={label}
aria-label={label}
className="rounded-md p-0.5 hover:bg-[var(--muted)] transition-colors"
>
{icon}
</Link>
) : (
<span title={label} aria-label={label} role="img" className="p-0.5">
{icon}
</span>
)}
</li>
);
})}
</ul>
{counter}
<span className="ml-auto">{hideButton}</span>
</div>
);
}
return (
<div
@ -125,36 +88,84 @@ export default function OnboardingChecklist({
>
<div className="flex items-center gap-3 mb-3">
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
{counter}
<span className="ml-auto">{hideButton}</span>
{budgetReady && (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })}
</span>
)}
<span className="ml-auto">
<button
type="button"
onClick={onHide}
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
>
{t("onboarding.checklist.hide")}
</button>
</span>
</div>
<ul className="space-y-2">
{items.map((item) => {
const state = rowState(item, budgetReady);
const label = t(`onboarding.checklist.items.${item.key}`);
const stateText =
state === "todo" ? null : (
<span className="sr-only"> {t(`onboarding.state.${state}`)}</span>
);
return (
<li key={item.key} className="flex items-center gap-3 text-sm">
<RowIcon state={state} size={20} />
<li key={item.key} className="flex flex-wrap items-center gap-3 text-sm">
<RowIcon state={state} />
{state === "todo" && (
<Link to={item.to} className="hover:underline text-[var(--foreground)]">
<Link
to={item.to}
state={stepNavState(item.key)}
className="hover:underline text-[var(--foreground)]"
>
{label}
</Link>
)}
{state === "skipped" && (
<>
<span className="text-[var(--muted-foreground)]">
{label}
{stateText}
</span>
<span aria-hidden="true" className={BADGE_CLASS}>
{t("onboarding.checklist.skippedBadge")}
</span>
<Link
to={item.to}
state={stepNavState(item.key)}
onClick={() => onResume(item.key)}
className="font-medium text-[var(--primary)] hover:underline"
>
{t("onboarding.checklist.resume")}
</Link>
</>
)}
{state === "locked" && (
<Link
to={item.to}
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
>
<span>{label}</span>
<span className="rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]">
{t("onboarding.checklist.lockedBudget")}
<span>
{label}
{stateText}
</span>
<span className={BADGE_CLASS}>{t("onboarding.checklist.lockedBudget")}</span>
</Link>
)}
{state === "done" && (
<span className="text-[var(--muted-foreground)] line-through">{label}</span>
<span className="text-[var(--muted-foreground)] line-through">
{label}
{stateText}
</span>
)}
{state === "pending" && (
<span className="text-[var(--muted-foreground)]">
{label}
{stateText}
</span>
)}
{state === "pending" && <span className="text-[var(--muted-foreground)]">{label}</span>}
</li>
);
})}

View file

@ -0,0 +1,194 @@
// AppShell and the getting-started banner (#397). No jsdom: react-dom/server
// renders in vitest's node environment. The path comes from a fake
// `useOnboarding()` built like the provider builds it: a loaded state, the
// real `computeChecklist`, and the provider's own pure derivation
// (`deriveOnboardingView`, with the real `CARD_PAGES`) at the rendered route.
// What is pinned is where the banner shows, and where it sits.
import { describe, it, expect, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter, Route, Routes } from "react-router-dom";
// Importing the hook module pulls the onboarding service (→ db → tauri
// plugins) and useEntitlement (→ LicenseContext): stub both.
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
let onboarding: OnboardingValue;
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
useOnboarding: () => onboarding,
}));
import AppShell from "./AppShell";
import {
deriveOnboardingView,
initialOnboardingChecklistState,
onboardingChecklistReducer,
type OnboardingChecklistState,
type OnboardingValue,
} from "../../hooks/useOnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { CARD_PAGES } from "../../utils/onboardingPath";
import type { OnboardingSnapshot } from "../../services/onboardingService";
const NEW_PROFILE: OnboardingSnapshot = {
counters: { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
importFolderSet: false,
hidden: false,
skipped: [],
};
/** Folder, source and import done: the next step is the manual category, on /transactions. */
const IMPORTED: OnboardingSnapshot = {
...NEW_PROFILE,
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
importFolderSet: true,
};
/** Everything but the budget: the next step is on /budget. */
const BUDGET_LEFT: OnboardingSnapshot = {
...IMPORTED,
counters: { ...IMPORTED.counters, manualCategorized: 1 },
};
const ALL_DONE: OnboardingSnapshot = {
...IMPORTED,
counters: { ...IMPORTED.counters, manualCategorized: 1, budgetEntries: 1 },
};
/** The provider's state after a first successful load of `snapshot`. */
const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState =>
[
{ type: "LOAD_START", seq: 1 } as const,
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
/** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */
function pathAt(pathname: string, state: OnboardingChecklistState, budgetReady = true): OnboardingValue {
const checklist = state.counters
? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet,
budgetAllowed: true,
skipped: state.skipped,
})
: null;
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
return {
status: state.status,
checklist,
counters: state.counters,
hidden: state.hidden,
budgetReady,
nextStep,
mode,
cardPage,
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
function renderWith(pathname: string, value: OnboardingValue): string {
onboarding = value;
return renderToStaticMarkup(
<MemoryRouter initialEntries={[pathname]}>
<Routes>
<Route element={<AppShell />}>
<Route path="*" element={<p data-page="">page</p>} />
</Route>
</Routes>
</MemoryRouter>,
);
}
const renderAt = (pathname: string, state: OnboardingChecklistState, budgetReady = true) =>
renderWith(pathname, pathAt(pathname, state, budgetReady));
const BANNER = 'aria-label="t(onboarding.banner.label)"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
describe("AppShell — the getting-started banner (#397)", () => {
it("puts the banner on top of the routed page, inside main", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(count(html, BANNER)).toBe(1);
const main = html.indexOf("<main");
const banner = html.indexOf(BANNER);
expect(main).toBeGreaterThan(-1);
expect(banner).toBeGreaterThan(main);
expect(html.indexOf("data-page")).toBeGreaterThan(banner);
});
it("shows nothing before the first load", () => {
const html = renderAt("/import", initialOnboardingChecklistState);
expect(html).not.toContain(BANNER);
expect(html).toContain("data-page");
});
it("shows nothing when the path is hidden", () => {
const html = renderAt("/import", loaded({ ...IMPORTED, hidden: true }));
expect(html).not.toContain(BANNER);
expect(html).toContain("data-page");
});
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => {
expect(renderAt("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
expect(renderAt("/import", loaded(NEW_PROFILE))).toContain(BANNER);
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
});
it("shows the banner on a dashboard with transactions, and on any other page", () => {
for (const pathname of ["/", "/reports", "/categories", "/settings/users"]) {
expect(renderAt(pathname, loaded(IMPORTED)), pathname).toContain(BANNER);
}
});
it.each([
["/import", NEW_PROFILE],
["/transactions", IMPORTED],
["/budget", BUDGET_LEFT],
] as const)(
"keeps the banner on %s, the page of the next step, while no step card is delivered",
(pathname, snapshot) => {
expect(CARD_PAGES.has(pathname)).toBe(false);
const value = pathAt(pathname, loaded(snapshot));
expect(value.nextStep?.to).toBe(pathname);
const html = renderWith(pathname, value);
expect(html).toContain(BANNER);
expect(html).toContain(`href="${pathname}"`);
},
);
it("shows nothing on a page that carries a step card", () => {
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
expect(renderWith("/import", value)).not.toContain(BANNER);
});
it("shows the end message once every step is done (D4)", () => {
const html = renderAt("/reports", loaded(ALL_DONE));
expect(html).toContain(BANNER);
expect(html).toContain("t(onboarding.banner.complete)");
});
it("waits for the Budget entitlement before showing the counter", () => {
const waiting = renderAt("/reports", loaded(IMPORTED), false);
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
"t(onboarding.banner.progress[done=4,total=6])",
);
});
it("starts with the list folded", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain("onboarding.checklist.title");
});
});

View file

@ -0,0 +1,146 @@
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397).
//
// renderToStaticMarkup runs no click, so React's `useState` is stubbed (same
// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a
// plain function, and its state survives from one call to the next as it
// would between two renders of a mounted component.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Outlet } from "react-router-dom";
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
cursor: 0,
pathname: "/reports",
onboarding: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
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-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }),
}));
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
useOnboarding: () => harness.onboarding,
}));
vi.mock("./Sidebar", () => ({ default: () => null }));
import AppShell, { OnboardingBannerSlot } from "./AppShell";
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
type AnyElement = ReactElement<Record<string, unknown> & { 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 onboardingValue(): OnboardingValue {
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "banner",
cardPage: null,
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
/** One render of the mounted slot: its hooks are read in the same order each time. */
function renderSlot(): OnboardingBannerProps {
harness.cursor = 0;
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
expect(element.type).toBe(OnboardingBanner);
return element.props;
}
beforeEach(() => {
harness.slots = [];
harness.pathname = "/reports";
harness.onboarding = onboardingValue();
});
describe("OnboardingBannerSlot (#397)", () => {
it("hands the shared path to the banner, the list folded", () => {
const value = harness.onboarding as OnboardingValue;
const props = renderSlot();
expect(props.mode).toBe("banner");
expect(props.checklist).toBe(value.checklist);
expect(props.nextStep).toBe(value.nextStep);
expect(props.budgetReady).toBe(true);
expect(props.listOpen).toBe(false);
expect(props.onHide).toBe(value.hide);
expect(props.onResume).toBe(value.resume);
});
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
const props = renderSlot();
expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false);
});
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
renderSlot().onToggleList();
expect(renderSlot().listOpen).toBe(true);
// Another render without a click keeps it unfolded.
expect(renderSlot().listOpen).toBe(true);
renderSlot().onToggleList();
expect(renderSlot().listOpen).toBe(false);
});
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
harness.pathname = "/import";
const shell = AppShell() as ReactNode;
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
expect(slot).toBeDefined();
expect(slot.key).toBe("/import");
harness.pathname = "/transactions";
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot);
expect(next.key).toBe("/transactions");
});
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
const shell = AppShell() as ReactNode;
const [main] = find(shell, (e) => e.type === "main");
expect(main).toBeDefined();
const [provider] = find(main.props.children, (e) => typeof e.type === "function");
const children = provider.props.children as ReactNode[];
expect(Array.isArray(children)).toBe(true);
const [first, second] = children as AnyElement[];
expect(first.type).toBe(OnboardingBannerSlot);
expect(second.type).toBe(Outlet);
});
});

View file

@ -1,14 +1,47 @@
import { Outlet } from "react-router-dom";
import { useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Sidebar from "./Sidebar";
import { OnboardingProvider } from "../../contexts/OnboardingContext";
import OnboardingBanner from "../onboarding/OnboardingBanner";
import { OnboardingProvider, useOnboarding } from "../../contexts/OnboardingContext";
/**
* The getting-started banner (#397) on top of the routed page. It reads the
* shared path, so it sits inside the provider; the path's `mode` already keeps
* it off a hidden path, the empty dashboard (D11), a page that carries a step
* card and the first load, and the banner renders nothing then.
*
* "Show the list" unfolds the list in place. `AppShell` mounts one slot per
* route, so each page starts folded and the banner stays one line on top of
* it (D7).
*/
export function OnboardingBannerSlot() {
const onboarding = useOnboarding();
const [listOpen, setListOpen] = useState(false);
return (
<OnboardingBanner
mode={onboarding.mode}
checklist={onboarding.checklist}
nextStep={onboarding.nextStep}
budgetReady={onboarding.budgetReady}
listOpen={listOpen}
onToggleList={() => setListOpen((open) => !open)}
onHide={onboarding.hide}
onResume={onboarding.resume}
/>
);
}
export default function AppShell() {
const { pathname } = useLocation();
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto p-6">
{/* The getting-started path, shared by every page (#396). */}
{/* The getting-started path, shared by every page (#396), and its banner in the page flow (#397). */}
<OnboardingProvider>
<OnboardingBannerSlot key={pathname} />
<Outlet />
</OnboardingProvider>
</main>

View file

@ -0,0 +1,214 @@
// OnboardingBanner — render tests (#397). No jsdom: react-dom/server renders
// in vitest's node environment for the markup (region, aria-expanded, hrefs),
// and the component is called as a function for what the markup does not
// carry (a link's navigation state, the handlers).
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Link, MemoryRouter } from "react-router-dom";
// The fake `t` marks what it translates, `t(key[options])`: a string that
// skipped `t` would show unmarked.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
import OnboardingBanner, {
GETTING_STARTED_GUIDE,
ONBOARDING_BANNER_LIST_ID,
type OnboardingBannerProps,
} from "./OnboardingBanner";
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingCounters } from "../../shared/types";
type AnyElement = ReactElement<Record<string, unknown> & { 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);
}
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
/** Profile, folder and source done: 3/6, the next step is "import" on /import. */
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
/** Up to the import: the next step is "manualCategory" on /transactions. */
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
const ALL_DONE: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 1,
budgetEntries: 1,
transactions: 40,
};
function bannerProps(
overrides: Partial<OnboardingBannerProps> = {},
counters: OnboardingCounters = THREE_DONE,
): OnboardingBannerProps {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
mode: checklist.complete ? "complete" : "banner",
checklist,
nextStep: nextStep(checklist),
budgetReady: true,
listOpen: false,
onToggleList: vi.fn(),
onHide: vi.fn(),
onResume: vi.fn(),
...overrides,
};
}
const markup = (props: OnboardingBannerProps) =>
renderToStaticMarkup(
<MemoryRouter>
<OnboardingBanner {...props} />
</MemoryRouter>,
);
const tree = (props: OnboardingBannerProps) => OnboardingBanner(props) as ReactNode;
const buttonNamed = (node: ReactNode, key: string) => {
const [button] = find(node, (e) => e.type === "button" && texts(e.props.children).includes(`t(${key})`));
expect(button, `button ${key}`).toBeDefined();
return button;
};
describe("OnboardingBanner — banner mode (#397)", () => {
it("is a region named for the path, with the counter, the next step and its link", () => {
const html = markup(bannerProps());
expect(html).toContain('role="region"');
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])");
expect(html).toContain("t(onboarding.banner.next)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
expect(html).toContain("t(onboarding.checklist.hide)");
});
it("links the next step to its page with the step's navigation state", () => {
const [toImport] = find(tree(bannerProps()), (e) => e.type === Link);
expect(toImport.props.to).toBe("/import");
expect(toImport.props.state).toEqual({ onboarding: { focus: true } });
// The manual category opens Transactions in its guided entry too.
const [toTransactions] = find(tree(bannerProps({}, FOUR_DONE)), (e) => e.type === Link);
expect(toTransactions.props.to).toBe("/transactions");
expect(toTransactions.props.state).toEqual({ onboarding: { focus: true, guided: true } });
});
it("the counter waits for the Budget entitlement, the next step does not", () => {
const html = markup(bannerProps({ budgetReady: false }));
expect(html).not.toContain("onboarding.banner.progress");
expect(html).toMatch(/href="\/import"/);
});
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
const html = markup(bannerProps());
expect(html).toMatch(
new RegExp(`<button [^>]*aria-expanded="false"[^>]*aria-controls="${ONBOARDING_BANNER_LIST_ID}"[^>]*>t\\(onboarding\\.banner\\.showList\\)`),
);
expect(html).toContain(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(html).not.toContain("onboarding.checklist.title");
expect(html).not.toContain("onboarding.banner.hideList");
});
it("unfolded: 'Close the list' with aria-expanded true, and the detailed list in place", () => {
const html = markup(bannerProps({ listOpen: true }));
expect(html).toMatch(/<button [^>]*aria-expanded="true"[^>]*>t\(onboarding\.banner\.hideList\)/);
expect(html).not.toContain("onboarding.banner.showList");
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(container).toBeGreaterThan(-1);
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
}
});
it("the unfolded list is the detailed list, wired to the path", () => {
const props = bannerProps({ listOpen: true });
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
expect(list.props.items).toBe(props.checklist!.items);
expect(list.props.done).toBe(3);
expect(list.props.total).toBe(6);
expect(list.props.budgetReady).toBe(true);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);
});
it("'Show the list' toggles the list and 'Hide' hides the path", () => {
const props = bannerProps();
const node = tree(props);
(buttonNamed(node, "onboarding.banner.showList").props.onClick as () => void)();
expect(props.onToggleList).toHaveBeenCalledTimes(1);
expect(props.onHide).not.toHaveBeenCalled();
(buttonNamed(node, "onboarding.checklist.hide").props.onClick as () => void)();
expect(props.onHide).toHaveBeenCalledTimes(1);
});
it("sits in the page flow, never fixed over the page (D1)", () => {
const root = tree(bannerProps()) as AnyElement;
expect(root.props.role).toBe("region");
expect(String(root.props.className)).not.toMatch(/\b(fixed|sticky|absolute)\b/);
});
});
describe("OnboardingBanner — end of the path (D4)", () => {
it("says the path is complete, opens the guide's getting-started section and offers 'Close'", () => {
const props = bannerProps({}, ALL_DONE);
expect(props.mode).toBe("complete");
const html = markup(props);
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
expect(html).toContain("t(onboarding.banner.complete)");
expect(GETTING_STARTED_GUIDE).toBe("/settings/users#gettingStarted");
expect(html).toMatch(/<a [^>]*href="\/settings\/users#gettingStarted"[^>]*>t\(onboarding\.banner\.openGuide\)/);
expect(html).toContain("t(common.close)");
expect(html).not.toContain("onboarding.banner.progress");
expect(html).not.toContain("onboarding.banner.next");
expect(html).not.toContain("aria-expanded");
expect(html).not.toContain("onboarding.checklist.hide");
});
it("'Close' hides the path", () => {
const props = bannerProps({}, ALL_DONE);
(buttonNamed(tree(props), "common.close").props.onClick as () => void)();
expect(props.onHide).toHaveBeenCalledTimes(1);
});
it("shows no list at the end, even if it was unfolded", () => {
const html = markup(bannerProps({ listOpen: true }, ALL_DONE));
expect(html).not.toContain("onboarding.checklist.title");
});
});
describe("OnboardingBanner — nothing to show", () => {
it("renders nothing in mode none, nor on a page that carries a step card", () => {
expect(markup(bannerProps({ mode: "none" }))).toBe("");
expect(markup(bannerProps({ mode: "card" }))).toBe("");
});
it("renders nothing without a checklist", () => {
expect(markup(bannerProps({ checklist: null, nextStep: null }))).toBe("");
});
});

View file

@ -0,0 +1,144 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowRight, Rocket } from "lucide-react";
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
import { stepNavState, type BannerMode } from "../../utils/onboardingPath";
import type { Checklist } from "../../utils/onboardingChecklist";
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
/** Id of the unfolded list, named by the toggle's `aria-controls`: one banner per window. */
export const ONBOARDING_BANNER_LIST_ID = "onboarding-banner-list";
/**
* "Open the guide": the getting-started section of the user guide, on the
* Users settings page. `DocsContent` gives each section `id={key}` and
* scrolls to the section named by the hash.
*/
export const GETTING_STARTED_GUIDE = "/settings/users#gettingStarted";
export interface OnboardingBannerProps {
/** What the path shows on this page (`useOnboarding().mode`): the banner renders `banner` and `complete` only. */
mode: BannerMode;
checklist: Checklist | null;
/** The next step, linked to its page with the step's navigation state. */
nextStep: ChecklistItem | null;
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
budgetReady: boolean;
/** The detailed list is unfolded under the banner. */
listOpen: boolean;
onToggleList: () => void;
/** "Hide", and "Close" at the end: the provider's `hide`. */
onHide: () => void;
/** "Resume" on a skipped row of the unfolded list. */
onResume: (key: ChecklistItemKey) => void;
}
const REGION_CLASS =
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
const ROW_CLASS = "flex flex-wrap items-center gap-x-3 gap-y-2 text-sm";
const TEXT_BUTTON_CLASS =
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
function BannerIcon() {
return <Rocket size={18} className="shrink-0 text-[var(--primary)]" aria-hidden="true" />;
}
/**
* Getting-started banner (#397), mounted by `AppShell` on top of every page,
* in the page flow (D1: a fixed bar could cover the focused control). Driven
* by props, so it is tested without the provider.
*
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
* which unfolds the detailed list in place, and "Hide". The counter waits
* for `budgetReady`, like the list's own counter.
* - `complete` (D4): the end message, "Open the guide" and "Close", which
* hides the path until it is shown again from Settings → Users.
* - Anything else (`none`, `card`) renders nothing.
*/
export default function OnboardingBanner({
mode,
checklist,
nextStep,
budgetReady,
listOpen,
onToggleList,
onHide,
onResume,
}: OnboardingBannerProps) {
const { t } = useTranslation();
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
if (mode === "complete") {
return (
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
<div className={ROW_CLASS}>
<BannerIcon />
<p className="flex-1 min-w-[12rem] text-[var(--foreground)]">
{t("onboarding.banner.complete")}
</p>
<Link to={GETTING_STARTED_GUIDE} className={LINK_CLASS}>
{t("onboarding.banner.openGuide")}
</Link>
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
{t("common.close")}
</button>
</div>
</div>
);
}
return (
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
<div className={ROW_CLASS}>
<BannerIcon />
{budgetReady && (
<span className="font-semibold text-[var(--foreground)] tabular-nums">
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
</span>
)}
{budgetReady && nextStep && (
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
)}
{nextStep && (
<span className="text-[var(--muted-foreground)]">
{t("onboarding.banner.next")}{" "}
<Link to={nextStep.to} state={stepNavState(nextStep.key)} className={LINK_CLASS}>
{t(`onboarding.checklist.items.${nextStep.key}`)}
<ArrowRight size={14} aria-hidden="true" />
</Link>
</span>
)}
<span className="ml-auto flex items-center gap-4">
<button
type="button"
aria-expanded={listOpen}
aria-controls={ONBOARDING_BANNER_LIST_ID}
onClick={onToggleList}
className={TEXT_BUTTON_CLASS}
>
{t(listOpen ? "onboarding.banner.hideList" : "onboarding.banner.showList")}
</button>
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
{t("onboarding.checklist.hide")}
</button>
</span>
</div>
{/* Always in the page, so `aria-controls` names an element even while folded. */}
<div id={ONBOARDING_BANNER_LIST_ID}>
{listOpen && (
<div className="mt-3">
<OnboardingChecklist
items={checklist.items}
done={checklist.done}
total={checklist.total}
budgetReady={budgetReady}
onHide={onHide}
onResume={onResume}
/>
</div>
)}
</div>
</div>
);
}

View file

@ -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": {

View file

@ -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": {

View file

@ -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 }) => <i data-marker={name} data-mode={props.mode} />,
}),
const { marker, emptyStateProps } = vi.hoisted(() => ({
marker: (name: string) => ({ default: () => <i data-marker={name} /> }),
emptyStateProps: { current: null as Record<string, unknown> | 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<string, unknown>) => {
emptyStateProps.current = props;
return <i data-marker="empty-state" />;
},
}));
// 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"');
});

View file

@ -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}
/>
</div>
);
}
// `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 (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
{header}
{showCompactChecklist && (
<OnboardingChecklist
items={checklist.items}
done={checklist.done}
total={checklist.total}
mode="compact"
budgetReady={onboarding.budgetReady}
onHide={onboarding.hide}
/>
)}
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}

View file

@ -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 });
});
});

View file

@ -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