feat(onboarding): step card with highlight, and the Import page's card

On the page of the step in progress, a card takes the place of the
banner. `StepCard` is driven by props: an h2 title that takes the focus
("Step n of N", then the step), the page's sentence, a status region
always rendered that receives "Done.", "Skip this step" until the step is
done, then "Next step" or the end of the path. `OnboardingStepCard` is
the only piece that reads the path: it renders on the latched card page
only, so the card survives its own success, focuses its title once per
arrival by "Next step" and consumes the navigation state.

The control to use carries `data-onboarding-highlight`, drawn in
styles.css as a 3px accent outline with a pulse bounded to three cycles
and none under prefers-reduced-motion; a test reads the stylesheet and
checks 3:1 against the background and the cards in both themes. The
target is passed by props to EmptyState's primary action, the first
SourceList card and WizardNavigation's next button, never queried.

`importCardModel` reads the wizard and the checklist in order: importing
before the counters, a report that added nothing as a failure, and the
"steps 3 and 4 checked" row at rest, where it can apply. CARD_PAGES gets
/import; the Import page builds the input, renders the card first and
highlights the model's target only while the card shows, and its empty
state waits for the folder to be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-08 08:34:45 -04:00
parent d024f5aeb9
commit ed5eeb0265
23 changed files with 1936 additions and 36 deletions

View file

@ -145,6 +145,32 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
}); });
}); });
describe("ImportEmptyState — getting-started target (#398)", () => {
const highlighted = (tree: ReactNode) =>
find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
it("highlights 'Choose a folder' without a folder, and only it", () => {
const { tree } = render({ hasFolder: false, onboardingTarget: true });
const targets = highlighted(tree);
expect(targets).toHaveLength(1);
expect(targets[0].type).toBe("button");
expect(texts(targets[0].props.children)).toContain("import.empty.noFolder.cta");
});
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
const targets = highlighted(tree);
expect(targets).toHaveLength(1);
expect(texts(targets[0].props.children)).toContain("import.empty.createSample");
});
it("highlights nothing without the property", () => {
expect(highlighted(render({ hasFolder: false }).tree)).toHaveLength(0);
expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0);
expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0);
});
});
describe("folderNameFromPath (#356)", () => { describe("folderNameFromPath (#356)", () => {
it("returns the last segment for POSIX and Windows paths", () => { it("returns the last segment for POSIX and Windows paths", () => {
expect(folderNameFromPath("/home/max/releves")).toBe("releves"); expect(folderNameFromPath("/home/max/releves")).toBe("releves");

View file

@ -16,6 +16,11 @@ export interface ImportEmptyStateProps {
onRefresh: () => void; onRefresh: () => void;
/** The sample folders are being written: the primary button is disabled. */ /** The sample folders are being written: the primary button is disabled. */
isCreatingSample: boolean; isCreatingSample: boolean;
/**
* Highlight the primary action for the getting-started card (#398): "Choose
* a folder" without a folder, "Create a sample folder" with one.
*/
onboardingTarget?: boolean;
} }
/** /**
@ -42,6 +47,7 @@ export default function ImportEmptyState({
onCreateSample, onCreateSample,
onRefresh, onRefresh,
isCreatingSample, isCreatingSample,
onboardingTarget,
}: ImportEmptyStateProps) { }: ImportEmptyStateProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -62,7 +68,7 @@ export default function ImportEmptyState({
icon={<FolderOpen size={40} />} icon={<FolderOpen size={40} />}
title={t("import.empty.noFolder.title")} title={t("import.empty.noFolder.title")}
description={t("import.empty.noFolder.description")} description={t("import.empty.noFolder.description")}
primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse }} primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse, onboardingTarget }}
> >
{guideLink} {guideLink}
</EmptyState> </EmptyState>
@ -78,6 +84,7 @@ export default function ImportEmptyState({
label: t("import.empty.createSample"), label: t("import.empty.createSample"),
onClick: onCreateSample, onClick: onCreateSample,
disabled: isCreatingSample, disabled: isCreatingSample,
onboardingTarget,
}} }}
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }} secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
> >

View file

@ -1,19 +1,26 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FolderOpen, FileText, CheckCircle } from "lucide-react"; import { FolderOpen, FileText, CheckCircle } from "lucide-react";
import type { ScannedSource } from "../../shared/types"; import type { ScannedSource } from "../../shared/types";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
interface SourceCardProps { interface SourceCardProps {
source: ScannedSource; source: ScannedSource;
isConfigured: boolean; isConfigured: boolean;
newFileCount: number; newFileCount: number;
onClick: () => void; onClick: () => void;
/** Highlight this card for the getting-started card (#398). */
onboardingTarget?: boolean;
} }
/** `data-onboarding-target` of a highlighted source card. */
export const SOURCE_CARD_TARGET = "source-card";
export default function SourceCard({ export default function SourceCard({
source, source,
isConfigured, isConfigured,
newFileCount, newFileCount,
onClick, onClick,
onboardingTarget,
}: SourceCardProps) { }: SourceCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -21,6 +28,7 @@ export default function SourceCard({
<button <button
onClick={onClick} onClick={onClick}
className="w-full text-left bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] hover:border-[var(--primary)] hover:shadow-sm transition-all" className="w-full text-left bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] hover:border-[var(--primary)] hover:shadow-sm transition-all"
{...onboardingTargetAttributes(SOURCE_CARD_TARGET, onboardingTarget)}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -0,0 +1,63 @@
// SourceList and SourceCard — the getting-started target (#398). No jsdom:
// react-dom/server renders in vitest's node environment, and the markup says
// which card carries the highlight.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
i18n: { language: "fr" },
}),
}));
import SourceList from "./SourceList";
import { SOURCE_CARD_TARGET } from "./SourceCard";
import type { ScannedSource } from "../../shared/types";
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const source = (name: string): ScannedSource => ({
folder_name: name,
folder_path: `/releves/${name}`,
files: [{ filename: "releve.csv", file_path: `/releves/${name}/releve.csv`, size_bytes: 10, modified_at: "" }],
});
const SOURCES = [source("Desjardins"), source("Tangerine"), source("RBC")];
const markup = (onboardingTarget?: boolean, sources: ScannedSource[] = SOURCES) =>
renderToStaticMarkup(
<SourceList
sources={sources}
configuredSourceNames={new Set()}
importedFileNames={new Map()}
onSelectSource={vi.fn()}
onboardingTarget={onboardingTarget}
/>,
);
/** The source cards, in order: each is a button. */
const cards = (html: string) => html.split("<button").slice(1);
describe("SourceList — getting-started target (#398)", () => {
it("highlights the first card of the list, and only it", () => {
const html = markup(true);
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
const [first, ...others] = cards(html);
expect(first).toContain(HIGHLIGHT);
expect(first).toContain(`data-onboarding-target="${SOURCE_CARD_TARGET}"`);
expect(first).toContain("Desjardins");
expect(others).toHaveLength(2);
for (const card of others) expect(card).not.toContain("data-onboarding");
});
it("highlights the only card of a one-source list", () => {
expect(markup(true, [source("Desjardins")]).split(HIGHLIGHT).length - 1).toBe(1);
});
it("highlights nothing without the property", () => {
expect(markup()).not.toContain("data-onboarding");
expect(markup(false)).not.toContain("data-onboarding");
});
});

View file

@ -8,6 +8,8 @@ interface SourceListProps {
configuredSourceNames: Set<string>; configuredSourceNames: Set<string>;
importedFileNames: Map<string, Set<string>>; importedFileNames: Map<string, Set<string>>;
onSelectSource: (source: ScannedSource) => void; onSelectSource: (source: ScannedSource) => void;
/** Highlight the first card for the getting-started card (#398): one target at a time. */
onboardingTarget?: boolean;
} }
export default function SourceList({ export default function SourceList({
@ -15,6 +17,7 @@ export default function SourceList({
configuredSourceNames, configuredSourceNames,
importedFileNames, importedFileNames,
onSelectSource, onSelectSource,
onboardingTarget,
}: SourceListProps) { }: SourceListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -38,7 +41,7 @@ export default function SourceList({
{t("import.sources.title")} {t("import.sources.title")}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sources.map((source) => { {sources.map((source, index) => {
const isConfigured = configuredSourceNames.has(source.folder_name); const isConfigured = configuredSourceNames.has(source.folder_name);
// Count files not yet imported for this source // Count files not yet imported for this source
const sourceHashes = importedFileNames.get(source.folder_name); const sourceHashes = importedFileNames.get(source.folder_name);
@ -55,6 +58,7 @@ export default function SourceList({
isConfigured={isConfigured} isConfigured={isConfigured}
newFileCount={newFileCount} newFileCount={newFileCount}
onClick={() => onSelectSource(source)} onClick={() => onSelectSource(source)}
onboardingTarget={onboardingTarget && index === 0}
/> />
); );
})} })}

View file

@ -0,0 +1,55 @@
// WizardNavigation — the getting-started target (#398). No jsdom:
// react-dom/server renders in vitest's node environment, and the markup says
// which button carries the highlight.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "fr" } }),
}));
import WizardNavigation, { WIZARD_NEXT_TARGET } from "./WizardNavigation";
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const markup = (props: Parameters<typeof WizardNavigation>[0]) =>
renderToStaticMarkup(
<WizardNavigation
onBack={vi.fn()}
onNext={vi.fn()}
onCancel={vi.fn()}
nextLabel="NEXT_LABEL"
{...props}
/>,
);
/** The buttons, in order: cancel, back, next. */
const buttons = (html: string) => html.split("<button").slice(1);
describe("WizardNavigation — getting-started target (#398)", () => {
it("highlights the next button, and neither back nor cancel", () => {
const html = markup({ onboardingTarget: true });
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
const all = buttons(html);
expect(all).toHaveLength(3);
const [cancel, back, next] = all;
expect(next).toContain("NEXT_LABEL");
expect(next).toContain(HIGHLIGHT);
expect(next).toContain(`data-onboarding-target="${WIZARD_NEXT_TARGET}"`);
expect(cancel).toContain("common.cancel");
expect(cancel).not.toContain("data-onboarding");
expect(back).toContain("import.wizard.back");
expect(back).not.toContain("data-onboarding");
});
it("keeps the highlight on a disabled next button", () => {
const html = markup({ onboardingTarget: true, nextDisabled: true });
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
});
it("highlights nothing without the property, or without a next button", () => {
expect(markup({})).not.toContain("data-onboarding");
expect(markup({ onboardingTarget: false })).not.toContain("data-onboarding");
expect(markup({ onboardingTarget: true, showNext: false })).not.toContain("data-onboarding");
});
});

View file

@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight, X } from "lucide-react"; import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
interface WizardNavigationProps { interface WizardNavigationProps {
onBack?: () => void; onBack?: () => void;
@ -11,8 +12,13 @@ interface WizardNavigationProps {
showBack?: boolean; showBack?: boolean;
showNext?: boolean; showNext?: boolean;
showCancel?: boolean; showCancel?: boolean;
/** Highlight the next button for the getting-started card (#398). */
onboardingTarget?: boolean;
} }
/** `data-onboarding-target` of a highlighted next button. */
export const WIZARD_NEXT_TARGET = "wizard-next";
export default function WizardNavigation({ export default function WizardNavigation({
onBack, onBack,
onNext, onNext,
@ -23,6 +29,7 @@ export default function WizardNavigation({
showBack = true, showBack = true,
showNext = true, showNext = true,
showCancel = true, showCancel = true,
onboardingTarget,
}: WizardNavigationProps) { }: WizardNavigationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -54,6 +61,7 @@ export default function WizardNavigation({
onClick={onNext} onClick={onNext}
disabled={nextDisabled} disabled={nextDisabled}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
{...onboardingTargetAttributes(WIZARD_NEXT_TARGET, onboardingTarget)}
> >
{nextLabel || t("import.wizard.next")} {nextLabel || t("import.wizard.next")}
<ChevronRight size={16} /> <ChevronRight size={16} />

View file

@ -140,7 +140,7 @@ describe("AppShell — the getting-started banner (#397)", () => {
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => { 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("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
expect(renderAt("/import", loaded(NEW_PROFILE))).toContain(BANNER); expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain(BANNER);
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER); expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
}); });
@ -151,7 +151,6 @@ describe("AppShell — the getting-started banner (#397)", () => {
}); });
it.each([ it.each([
["/import", NEW_PROFILE],
["/transactions", IMPORTED], ["/transactions", IMPORTED],
["/budget", BUDGET_LEFT], ["/budget", BUDGET_LEFT],
] as const)( ] as const)(
@ -166,6 +165,17 @@ describe("AppShell — the getting-started banner (#397)", () => {
}, },
); );
it("gives /import, the page of the next step, its step card instead of the banner (#398)", () => {
expect(CARD_PAGES.has("/import")).toBe(true);
const value = pathAt("/import", loaded(NEW_PROFILE));
expect(value.nextStep?.to).toBe("/import");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/import");
expect(renderWith("/import", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /import.
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"');
});
it("shows nothing on a page that carries a step card", () => { it("shows nothing on a page that carries a step card", () => {
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" }; const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
expect(renderWith("/import", value)).not.toContain(BANNER); expect(renderWith("/import", value)).not.toContain(BANNER);

View file

@ -0,0 +1,236 @@
// OnboardingStepCard — the connected card's wiring (#398).
//
// renderToStaticMarkup runs no effect, so the focus move would stay unguarded.
// React's `useRef` and `useEffect` are stubbed (technique of
// useOnboardingChecklist.wiring.test.ts and AppShell.slot.test.tsx): the card
// runs as a plain function, its refs survive from one call to the next as they
// would between two renders of a mounted component, and its effects are
// captured and run by hand. The router and the path are faked.
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactElement } from "react";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
const harness = vi.hoisted(() => ({
refs: [] as Array<{ current: unknown }>,
cursor: 0,
effects: [] as Effect[],
location: { pathname: "/import", state: null as unknown, key: "arrival-1" },
navigate: vi.fn(),
onboarding: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useRef: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.refs)) harness.refs[index] = { current: init };
return harness.refs[index];
},
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => harness.location,
useNavigate: () => harness.navigate,
}));
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
import OnboardingStepCard from "./OnboardingStepCard";
import StepCard, { type StepCardProps } from "./StepCard";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep, type StepCardModel } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
import type { OnboardingCounters } from "../../shared/types";
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
/** Folder chosen, no source yet: the next step is the source step, on /import. */
const FOLDER_SET = ZERO;
/** Source and import done: the next step is the manual category, on /transactions. */
const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 41 };
/** The path latched on /import, as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/import",
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),
...overrides,
};
}
const SOURCE_LIST: StepCardModel = { text: { key: "onboarding.card.import.sourceList" }, done: false };
const DONE: StepCardModel = { text: null, done: true };
/** One render of the mounted card: its hooks are called in the same order each time. */
function render(model: StepCardModel | null = SOURCE_LIST): ReactElement<StepCardProps> | null {
harness.cursor = 0;
harness.effects = [];
return OnboardingStepCard({ model }) as ReactElement<StepCardProps> | null;
}
/** The title the card's ref points at, once React would have attached it. */
function attachTitle(): { focus: ReturnType<typeof vi.fn> } {
const title = { focus: vi.fn() };
harness.refs[0].current = title;
return title;
}
const runEffects = () => {
for (const effect of harness.effects) effect.fn();
};
beforeEach(() => {
harness.refs = [];
harness.effects = [];
harness.location = { pathname: "/import", state: null, key: "arrival-1" };
harness.navigate.mockReset();
harness.onboarding = path(FOLDER_SET);
});
describe("OnboardingStepCard — when it shows (#398)", () => {
it("renders the card in card mode, latched on the current route", () => {
const card = render();
expect(card).not.toBeNull();
expect(card!.type).toBe(StepCard);
});
it("renders nothing in another mode, on a route that is not the latched one, or without a model", () => {
for (const mode of ["none", "banner", "complete"] as const) {
harness.onboarding = path(FOLDER_SET, { mode });
expect(render(), mode).toBeNull();
}
harness.onboarding = path(FOLDER_SET, { cardPage: "/transactions" });
expect(render()).toBeNull();
harness.onboarding = path(FOLDER_SET);
expect(render(null)).toBeNull();
});
it("stays once the page's steps are done: the latch keeps it, not the next step", () => {
harness.onboarding = path(IMPORTED);
const value = harness.onboarding as OnboardingValue;
expect(value.nextStep?.to).toBe("/transactions");
const card = render(DONE);
expect(card).not.toBeNull();
expect(card!.props.done).toBe(true);
});
});
describe("OnboardingStepCard — what it hands the card (#398)", () => {
it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => {
const props = render()!.props;
expect(props.step.key).toBe("source");
expect(props.position).toEqual({ n: 3, total: 6 });
expect(props.text).toBe(SOURCE_LIST.text);
expect(props.done).toBe(false);
expect(props.titleRef).toBe(harness.refs[0]);
});
it("the next step once done, and a wait while the next step is still on this page", () => {
harness.onboarding = path(IMPORTED);
expect(render(DONE)!.props.next).toEqual({
kind: "step",
step: (harness.onboarding as OnboardingValue).nextStep,
});
// Before the re-read, the next step is still the source step, on /import.
harness.onboarding = path(FOLDER_SET);
expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
});
it("no 'Step n of N' until the Budget entitlement is known", () => {
harness.onboarding = path(FOLDER_SET, { budgetReady: false });
expect(render()!.props.position).toBeNull();
});
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
const value = harness.onboarding as OnboardingValue;
const props = render()!.props;
props.onSkip();
expect(value.skip).toHaveBeenCalledWith("source");
expect(value.hide).not.toHaveBeenCalled();
props.onHide();
expect(value.hide).toHaveBeenCalledTimes(1);
});
});
describe("OnboardingStepCard — the focus after 'Next step' (#398)", () => {
const ARRIVAL = { onboarding: { focus: true } };
it("moves the focus to the title, then consumes the navigation state", () => {
harness.location = { ...harness.location, state: ARRIVAL };
render();
const title = attachTitle();
runEffects();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null });
expect(vi.mocked(harness.navigate).mock.invocationCallOrder[0]).toBeGreaterThan(
title.focus.mock.invocationCallOrder[0],
);
});
it("does it once per arrival: StrictMode's second run and later renders do nothing", () => {
harness.location = { ...harness.location, state: ARRIVAL };
render();
const title = attachTitle();
runEffects();
runEffects();
render();
runEffects();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1);
// A new arrival on the same mounted page asks again.
harness.location = { ...harness.location, key: "arrival-2" };
render();
runEffects();
expect(title.focus).toHaveBeenCalledTimes(2);
expect(harness.navigate).toHaveBeenCalledTimes(2);
});
it("waits for the card to show before focusing and consuming", () => {
harness.location = { ...harness.location, state: ARRIVAL };
expect(render(null)).toBeNull();
runEffects();
expect(harness.navigate).not.toHaveBeenCalled();
render();
const title = attachTitle();
runEffects();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1);
});
it("an ordinary visit, without navigation state or with a malformed one, does nothing and does not fail", () => {
for (const state of [null, undefined, "focus", { onboarding: "focus" }, { onboarding: { focus: "yes" } }]) {
harness.location = { ...harness.location, state };
render();
const title = attachTitle();
expect(() => runEffects()).not.toThrow();
expect(title.focus).not.toHaveBeenCalled();
}
expect(harness.navigate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,71 @@
import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import StepCard from "./StepCard";
import { useOnboarding } from "../../contexts/OnboardingContext";
import {
cardNext,
cardStep,
readOnboardingNavState,
showsStepCard,
type StepCardModel,
} from "../../utils/onboardingPath";
export interface OnboardingStepCardProps {
/** The page's card model; null for no card yet (the page's own data is not read). */
model: StepCardModel | null;
}
/**
* The step card of a page (#398), connected to the getting-started path. Each
* page that carries a card renders it as its first child, before its header,
* and passes only its model; this component reads `useOnboarding()`, the only
* one of the card's pieces to do so.
*
* - It renders only in card mode, latched on the current route: the latch,
* not the next step, keeps it on screen once the page's steps are done.
* - After an arrival by "Next step" (`readOnboardingNavState(location.state)
* .focus`), it moves the focus to its title once the card is on screen,
* then consumes the navigation state, once per arrival: StrictMode runs the
* effect twice on mount, a guard on the location key keeps it to one focus
* and one replace. An ordinary visit carries no state and does nothing.
* - It wires "Skip this step" to the step it shows, "Close" to `hide`, and
* the next step's link to the path's next step.
*/
export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
const onboarding = useOnboarding();
const location = useLocation();
const navigate = useNavigate();
const titleRef = useRef<HTMLHeadingElement>(null);
const focusedFor = useRef<string | null>(null);
const { pathname } = location;
const current = onboarding.checklist ? cardStep(onboarding.checklist, pathname) : null;
const shown =
model !== null &&
current !== null &&
showsStepCard(onboarding.mode, onboarding.cardPage, pathname);
const focusRequested = readOnboardingNavState(location.state).focus;
useEffect(() => {
if (!shown || !focusRequested || focusedFor.current === location.key) return;
focusedFor.current = location.key;
titleRef.current?.focus();
navigate(pathname, { replace: true, state: null });
}, [shown, focusRequested, location.key, pathname, navigate]);
if (!shown) return null;
const { skip, hide } = onboarding;
return (
<StepCard
step={current.step}
position={onboarding.budgetReady ? { n: current.n, total: current.total } : null}
text={model.text}
done={model.done}
next={cardNext(onboarding.nextStep, pathname, onboarding.budgetReady)}
onSkip={() => void skip(current.step.key)}
onHide={() => void hide()}
titleRef={titleRef}
/>
);
}

View file

@ -0,0 +1,212 @@
// StepCard — render tests (#398). No jsdom: react-dom/server renders in
// vitest's node environment for the markup (status region, heading, links),
// and the component is called as a function for what the markup does not
// carry (a link's navigation state, the handlers, the title's ref).
import { describe, it, expect, vi } from "vitest";
import { createRef, 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 StepCard, { type StepCardProps } from "./StepCard";
import { GETTING_STARTED_GUIDE } from "./OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist";
import type { ChecklistItem, ChecklistItemKey, 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,
};
const rows = (counters: OnboardingCounters = ZERO) =>
computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }).items;
const row = (key: ChecklistItemKey, counters?: OnboardingCounters): ChecklistItem =>
rows(counters).find((item) => item.key === key)!;
/** The source step on /import, step 3 of 6, not done yet. */
function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
return {
step: row("source"),
position: { n: 3, total: 6 },
text: { key: "onboarding.card.import.sourceList" },
done: false,
next: { kind: "wait" },
onSkip: vi.fn(),
onHide: vi.fn(),
...overrides,
};
}
/** After a successful import: the page's steps are done, the next step is elsewhere. */
const doneProps = (overrides: Partial<StepCardProps> = {}) =>
cardProps({
step: row("import", { ...ZERO, sources: 1, imports: 1 }),
position: { n: 4, total: 6 },
text: null,
done: true,
next: { kind: "step", step: row("manualCategory") },
...overrides,
});
const markup = (props: StepCardProps) =>
renderToStaticMarkup(
<MemoryRouter>
<StepCard {...props} />
</MemoryRouter>,
);
const tree = (props: StepCardProps) => StepCard(props) as ReactNode;
const STATUS = /<p role="status"[^>]*>(.*?)<\/p>/;
describe("StepCard — the card (#398)", () => {
it("is a region of the getting-started path", () => {
const html = markup(cardProps());
expect(html).toMatch(/^<div role="region" aria-label="t\(onboarding\.banner\.label\)"/);
});
it("titles itself with an h2 out of the tab order: 'Step n of N', then the step", () => {
const html = markup(cardProps());
const heading = html.match(/<h2 [^>]*>(.*?)<\/h2>/);
expect(heading).not.toBeNull();
expect(heading![0]).toContain('tabindex="-1"');
const title = heading![1];
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=6])");
expect(title.indexOf("onboarding.card.stepOf")).toBeLessThan(
title.indexOf("t(onboarding.checklist.items.source)"),
);
});
it("keeps the title without its counter until the Budget entitlement is known", () => {
const html = markup(cardProps({ position: null }));
expect(html).not.toContain("onboarding.card.stepOf");
expect(html).toMatch(/<h2 [^>]*tabindex="-1"[^>]*>.*t\(onboarding\.checklist\.items\.source\)/);
});
it("hands its ref to the title, which takes the focus", () => {
const titleRef = createRef<HTMLHeadingElement>();
const [heading] = find(tree(cardProps({ titleRef })), (e) => e.type === "h2");
expect(heading.props.ref).toBe(titleRef);
expect(heading.props.tabIndex).toBe(-1);
});
it("says the page's sentence, with the labels of the buttons it names translated from their keys", () => {
const html = markup(
cardProps({
text: {
key: "onboarding.card.import.noSources",
labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" },
values: { folder: "Relevés" },
},
}),
);
expect(html).toContain(
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
);
});
});
describe("StepCard — before and after the success (#398)", () => {
it("renders the status region before the success, empty", () => {
const html = markup(cardProps());
const status = html.match(STATUS);
expect(status).not.toBeNull();
expect(status![1]).toBe("");
});
it("puts 'Done.' in the same status region after the success", () => {
const status = markup(doneProps()).match(STATUS);
expect(status).not.toBeNull();
expect(status![1]).toContain("t(onboarding.card.done)");
});
it("offers 'Skip this step' before the success only, wired to onSkip", () => {
const props = cardProps();
expect(markup(props)).toContain("t(onboarding.card.skip)");
const [skip] = find(tree(props), (e) => e.type === "button");
expect(texts(skip.props.children)).toEqual(["t(onboarding.card.skip)"]);
(skip.props.onClick as () => void)();
expect(props.onSkip).toHaveBeenCalledTimes(1);
expect(markup(doneProps())).not.toContain("onboarding.card.skip");
});
it("does not offer to skip a step the data already marks done (a new pass through the wizard)", () => {
const html = markup(cardProps({ step: row("import", { ...ZERO, sources: 1, imports: 1 }) }));
expect(html).not.toContain("onboarding.card.skip");
expect(html).not.toContain("onboarding.card.done");
});
it("offers 'Next step' after the success only, linked to its page with the step's navigation state", () => {
expect(markup(cardProps({ next: { kind: "step", step: row("manualCategory") } }))).not.toContain(
"onboarding.card.next",
);
const html = markup(doneProps());
expect(html).toMatch(
/<a [^>]*href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=t\(onboarding\.checklist\.items\.manualCategory\)\]\)/,
);
const [link] = find(tree(doneProps()), (e) => e.type === Link);
expect(link.props.to).toBe("/transactions");
expect(link.props.state).toEqual({ onboarding: { focus: true, guided: true } });
});
it("shows nothing after 'Done.' while the next step waits", () => {
const html = markup(doneProps({ next: { kind: "wait" } }));
expect(html).toContain("t(onboarding.card.done)");
expect(html).not.toContain("onboarding.card.next");
expect(html).not.toContain("onboarding.banner.complete");
expect(html).not.toContain("<a ");
expect(html).not.toContain("<button");
});
});
describe("StepCard — end of the path (#398)", () => {
it("without a next step: 'Done.', the end message, 'Open the guide' and 'Close'", () => {
const props = doneProps({ next: { kind: "end" } });
const html = markup(props);
expect(html.match(STATUS)![1]).toContain("t(onboarding.card.done)");
expect(html).toContain("t(onboarding.banner.complete)");
expect(html).toContain(`href="${GETTING_STARTED_GUIDE}"`);
expect(html).toMatch(/<a [^>]*>t\(onboarding\.banner\.openGuide\)<\/a>/);
expect(html).not.toContain("onboarding.card.next");
const [close] = find(tree(props), (e) => e.type === "button");
expect(texts(close.props.children)).toEqual(["t(common.close)"]);
(close.props.onClick as () => void)();
expect(props.onHide).toHaveBeenCalledTimes(1);
});
it("is not the end before the success", () => {
const html = markup(cardProps({ next: { kind: "end" } }));
expect(html).not.toContain("onboarding.banner.complete");
expect(html).toContain("t(onboarding.card.skip)");
});
});

View file

@ -0,0 +1,136 @@
import type { Ref } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CheckCircle2, Rocket } from "lucide-react";
import { GETTING_STARTED_GUIDE } from "./OnboardingBanner";
import {
stepNavState,
type StepCardNext,
type StepCardText,
} from "../../utils/onboardingPath";
import type { ChecklistItem } from "../../shared/types";
export interface StepCardProps {
/** The step the card shows (`cardStep`): its label is the title. */
step: ChecklistItem;
/** "Step n of N", null until the Budget entitlement is known: N depends on its lock. */
position: { n: number; total: number } | null;
/** The page's sentence, null when "Done." says it all. */
text: StepCardText | null;
/** The page's steps are done: "Done." in the status, then `next`. */
done: boolean;
/** What follows "Done." (`cardNext`), read only once `done`. */
next: StepCardNext;
/** "Skip this step": the provider's `skip`, for `step`. */
onSkip: () => void;
/** "Close" at the end of the path: the provider's `hide`. */
onHide: () => void;
/** The title, which takes the focus after an arrival by "Next step". */
titleRef?: Ref<HTMLHeadingElement>;
}
const REGION_CLASS =
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
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";
/**
* Step card (#398): on the page of the step in progress, it takes the place
* of the banner, says what to do, follows the success live and leads to the
* next step. Driven by props, so it is tested without the provider; the
* connected `OnboardingStepCard` feeds it.
*
* - The title is an `h2` with `tabIndex={-1}`: "Step n of N", then the step.
* - The sentence comes from the page's model; the labels of the buttons it
* names are translated here, from their own keys.
* - The `role="status"` region is always in the page, empty at first, so the
* "Done." it receives once the page's steps are done is announced.
* - "Skip this step" shows until the page's steps are done, and only while
* the step itself is not: skipping a done step would only leave a stale
* "skipped" mark behind.
* - Once done: "Next step: <step> →", linked to its page with the step's
* navigation state; the end message, "Open the guide" and "Close" when no
* step is left; nothing yet while `next` waits.
*/
export default function StepCard({
step,
position,
text,
done,
next,
onSkip,
onHide,
titleRef,
}: StepCardProps) {
const { t } = useTranslation();
const sentence =
text === null
? null
: t(text.key, {
...text.values,
...Object.fromEntries(
Object.entries(text.labels ?? {}).map(([name, key]) => [name, t(key)]),
),
});
return (
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
<div className="flex items-start gap-3">
<Rocket size={18} className="mt-0.5 shrink-0 text-[var(--primary)]" aria-hidden="true" />
<div className="min-w-0 flex-1">
<h2
ref={titleRef}
tabIndex={-1}
className="flex flex-wrap items-center gap-x-2 text-sm font-semibold text-[var(--foreground)]"
>
{position && (
<>
<span className="text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.card.stepOf", { n: position.n, total: position.total })}
</span>
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
</>
)}
<span>{t(`onboarding.checklist.items.${step.key}`)}</span>
</h2>
{sentence !== null && <p className="mt-1 text-sm text-[var(--foreground)]">{sentence}</p>}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<p role="status" className="inline-flex items-center gap-1 font-medium text-[var(--foreground)]">
{done && (
<>
<CheckCircle2 size={16} className="text-[var(--positive)]" aria-hidden="true" />
{t("onboarding.card.done")}
</>
)}
</p>
{!done && !step.done && (
<button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}>
{t("onboarding.card.skip")}
</button>
)}
{done && next.kind === "step" && (
<Link to={next.step.to} state={stepNavState(next.step.key)} className={LINK_CLASS}>
{t("onboarding.card.next", {
step: t(`onboarding.checklist.items.${next.step.key}`),
})}
</Link>
)}
{done && next.kind === "end" && (
<>
<span className="text-[var(--foreground)]">{t("onboarding.banner.complete")}</span>
<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>
</div>
</div>
);
}

View file

@ -6,9 +6,10 @@
// component: creating the element never needs a router, only rendering would. // component: creating the element never needs a router, only rendering would.
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Link } from "react-router-dom"; import { renderToStaticMarkup } from "react-dom/server";
import { Link, MemoryRouter } from "react-router-dom";
import EmptyState from "./EmptyState"; import EmptyState, { EMPTY_STATE_TARGET, type EmptyStateAction } from "./EmptyState";
function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] { function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred)); if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
@ -109,3 +110,40 @@ describe("EmptyState", () => {
expect(text.indexOf("CHILD_MARKER")).toBeLessThan(text.indexOf("Go")); expect(text.indexOf("CHILD_MARKER")).toBeLessThan(text.indexOf("Go"));
}); });
}); });
describe("EmptyState — getting-started target (#398)", () => {
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string) => html.split(HIGHLIGHT).length - 1;
const markup = (primaryAction: EmptyStateAction, secondaryAction?: EmptyStateAction) =>
renderToStaticMarkup(
<MemoryRouter>
<EmptyState icon={ICON} title="Title" primaryAction={primaryAction} secondaryAction={secondaryAction} />
</MemoryRouter>,
);
const highlighted = (el: ReactNode) =>
find(el, (e) => (e.props as Record<string, unknown>)["data-onboarding-highlight"] === "true");
it("highlights the action that carries onboardingTarget, and nothing else, link or button", () => {
const secondary: EmptyStateAction = { label: "Other", onClick: vi.fn() };
for (const primary of [
{ label: "Go", to: "/import", onboardingTarget: true },
{ label: "Do", onClick: vi.fn(), onboardingTarget: true },
] satisfies EmptyStateAction[]) {
const html = markup(primary, secondary);
expect(count(html), primary.label).toBe(1);
expect(html).toContain(`data-onboarding-target="${EMPTY_STATE_TARGET}"`);
const [target, ...others] = highlighted(
EmptyState({ icon: ICON, title: "Title", primaryAction: primary, secondaryAction: secondary }),
);
expect(others).toHaveLength(0);
expect(texts((target.props as { children?: ReactNode }).children)).toEqual([primary.label]);
}
});
it("highlights nothing without it, or with it false", () => {
expect(count(markup({ label: "Go", to: "/import" }, { label: "Other", onClick: vi.fn() }))).toBe(0);
expect(count(markup({ label: "Do", onClick: vi.fn(), onboardingTarget: false }))).toBe(0);
expect(markup({ label: "Do", onClick: vi.fn() })).not.toContain("data-onboarding-target");
});
});

View file

@ -1,13 +1,18 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
/** /**
* An action of the empty state: either a route (rendered as a react-router * An action of the empty state: either a route (rendered as a react-router
* `<Link>`, never `useNavigate`) or a callback (rendered as a `<button>`). * `<Link>`, never `useNavigate`) or a callback (rendered as a `<button>`).
* `onboardingTarget` highlights it for the page's step card (#398).
*/ */
export type EmptyStateAction = export type EmptyStateAction =
| { label: string; to: string } | { label: string; to: string; onboardingTarget?: boolean }
| { label: string; onClick: () => void; disabled?: boolean }; | { label: string; onClick: () => void; disabled?: boolean; onboardingTarget?: boolean };
/** `data-onboarding-target` of a highlighted action. */
export const EMPTY_STATE_TARGET = "empty-state-action";
export interface EmptyStateProps { export interface EmptyStateProps {
/** Centered icon, already sized by the caller. */ /** Centered icon, already sized by the caller. */
@ -28,9 +33,10 @@ const SECONDARY_CLASS =
"inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"; "inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
function renderAction(action: EmptyStateAction, className: string) { function renderAction(action: EmptyStateAction, className: string) {
const target = onboardingTargetAttributes(EMPTY_STATE_TARGET, action.onboardingTarget);
if ("to" in action) { if ("to" in action) {
return ( return (
<Link to={action.to} className={className}> <Link to={action.to} className={className} {...target}>
{action.label} {action.label}
</Link> </Link>
); );
@ -41,6 +47,7 @@ function renderAction(action: EmptyStateAction, className: string) {
onClick={action.onClick} onClick={action.onClick}
disabled={action.disabled ?? false} disabled={action.disabled ?? false}
className={className} className={className}
{...target}
> >
{action.label} {action.label}
</button> </button>

View file

@ -74,6 +74,23 @@
"complete": "Getting started complete. The user guide takes it from here.", "complete": "Getting started complete. The user guide takes it from here.",
"openGuide": "Open the guide" "openGuide": "Open the guide"
}, },
"card": {
"stepOf": "Step {{n}} of {{total}}",
"skip": "Skip this step",
"done": "Done.",
"next": "Next step: {{step}} →",
"import": {
"noFolder": "Choose the folder where you will drop your bank statements, with the {{button}} button. Simpl'Résultat will look for one sub-folder per bank in it.",
"noSources": "In the {{folder}} folder, create one sub-folder per bank and drop your CSV statements in it, then click {{refresh}}. To try first, {{sample}} adds two fictitious banks.",
"sourceList": "Click your bank's card. Simpl'Résultat recognises the format of its statements and shows you what it understood.",
"sourceConfig": "Check the source name and the selected files, then click {{button}}.",
"filePreview": "Check that dates and amounts are read correctly, then click {{button}}.",
"duplicateCheck": "Choose what to do with transactions already imported, then click {{button}}.",
"confirm": "Click {{button}}: the source is saved and your transactions come in.",
"importing": "Importing…",
"failed": "The import added no transaction; the report below says why."
}
},
"state": { "state": {
"done": "(done)", "done": "(done)",
"skipped": "(skipped)", "skipped": "(skipped)",

View file

@ -74,6 +74,23 @@
"complete": "Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite.", "complete": "Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite.",
"openGuide": "Ouvrir le guide" "openGuide": "Ouvrir le guide"
}, },
"card": {
"stepOf": "Étape {{n}} sur {{total}}",
"skip": "Passer cette étape",
"done": "Fait.",
"next": "Étape suivante : {{step}} →",
"import": {
"noFolder": "Choisissez le dossier où vous déposerez vos relevés bancaires, avec le bouton {{button}}. Simpl'Résultat y cherchera un sous-dossier par banque.",
"noSources": "Dans le dossier {{folder}}, créez un sous-dossier par banque et déposez-y vos relevés CSV, puis cliquez {{refresh}}. Pour essayer d'abord, {{sample}} dépose deux banques fictives.",
"sourceList": "Cliquez sur la carte de votre banque. Simpl'Résultat reconnaît le format de ses relevés et vous montre ce qu'il a compris.",
"sourceConfig": "Vérifiez le nom de la source et les fichiers cochés, puis cliquez {{button}}.",
"filePreview": "Vérifiez que les dates et les montants sont bien lus, puis cliquez {{button}}.",
"duplicateCheck": "Choisissez quoi faire des transactions déjà importées, puis cliquez {{button}}.",
"confirm": "Cliquez {{button}} : la source est enregistrée et vos transactions arrivent.",
"importing": "Import en cours…",
"failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi."
}
},
"state": { "state": {
"done": "(faite)", "done": "(faite)",
"skipped": "(passée)", "skipped": "(passée)",

View file

@ -0,0 +1,283 @@
// ImportPage and its getting-started card (#398). No jsdom: react-dom/server
// renders in vitest's node environment. The wizard is a fake
// `useImportWizard()` over its real initial state, the path a fake
// `useOnboarding()` built from the real checklist, and the wizard's heavy
// panels are stubbed. What is pinned is the page's wiring: where the card
// sits, what it says at each step, which control carries the one highlight,
// and what waits for the folder to be read (#384).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
// Importing the real wizard module (for its initial state) pulls the import
// services, then the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: 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" },
}),
}));
const harness = vi.hoisted(() => ({ wizard: null as unknown, onboarding: null as unknown }));
vi.mock("../hooks/useImportWizard", async (importOriginal) => ({
...(await importOriginal<typeof import("../hooks/useImportWizard")>()),
useImportWizard: () => harness.wizard,
}));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
const { stub } = vi.hoisted(() => ({
stub: (name: string) => ({ default: () => <i data-stub={name} /> }),
}));
vi.mock("../components/import/ImportHistoryPanel", () => stub("history"));
vi.mock("../components/import/SourceConfigPanel", () => stub("source-config"));
vi.mock("../components/import/FilePreviewTable", () => stub("file-preview"));
vi.mock("../components/import/DuplicateCheckPanel", () => stub("duplicate-check"));
vi.mock("../components/import/ImportConfirmation", () => stub("confirmation"));
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
import ImportPage from "./ImportPage";
import { initialWizardState, type WizardState } from "../hooks/useImportWizard";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep } from "../utils/onboardingPath";
import type { ImportReport, OnboardingCounters, ScannedSource } from "../shared/types";
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
/** Source and import checked (the re-read after a successful import). */
const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 41 };
const SOURCES: ScannedSource[] = ["Desjardins", "Tangerine"].map((name) => ({
folder_name: name,
folder_path: `/home/max/Relevés/${name}`,
files: [],
}));
const report = (importedCount: number): ImportReport => ({
totalRows: 41,
importedCount,
skippedDuplicates: 41 - importedCount,
errorCount: 0,
categorizedCount: importedCount,
uncategorizedCount: 0,
errors: [],
});
const DUPLICATES = { fileAlreadyImported: false, duplicateRows: [], newRows: [] };
/** The wizard after its mount read, with `overrides`. */
function wizard(overrides: Partial<WizardState> = {}) {
const state: WizardState = { ...initialWizardState, importFolderLoaded: true, ...overrides };
const noop = vi.fn();
return new Proxy({ state } as Record<string, unknown>, {
get: (target, key) => (key in target ? target[key as string] : noop),
});
}
/** The path latched on /import (card mode), as the provider shares it. */
function path(
counters: OnboardingCounters,
importFolderSet: boolean,
overrides: Partial<OnboardingValue> = {},
): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet, budgetAllowed: true });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/import",
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),
...overrides,
};
}
const render = () =>
renderToStaticMarkup(
<MemoryRouter initialEntries={["/import"]}>
<ImportPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
/** The one highlighted control: its tag and its inner markup. */
function highlighted(html: string): { tag: string; inner: string } {
expect(count(html, HIGHLIGHT)).toBe(1);
const match = html.match(/<(button|a) [^>]*data-onboarding-highlight="true"[^>]*>(.*?)<\/\1>/);
expect(match).not.toBeNull();
return { tag: match![1], inner: match![2] };
}
/**
* The card's sentence: the paragraph that follows its title. The card always
* hands `t` its interpolations, so a sentence without any reads `t(key[])`.
*/
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
beforeEach(() => {
harness.wizard = wizard();
harness.onboarding = path(ZERO, false);
});
describe("ImportPage — the step card (#398)", () => {
it("renders the card first, before the page's heading", () => {
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=2,total=6])");
});
it("a new profile: 'Choose a folder' is the one target, and the sentence names it", () => {
const html = render();
expect(sentence(html)).toBe(
"t(onboarding.card.import.noFolder[button=t(import.empty.noFolder.cta)])",
);
const target = highlighted(html);
expect(target.tag).toBe("button");
expect(target.inner).toBe("t(import.empty.noFolder.cta)");
});
it("a folder without bank sub-folder: 'Create a sample folder', the folder named by its last segment", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
harness.onboarding = path(ZERO, true);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=6])");
expect(sentence(html)).toBe(
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
);
expect(highlighted(html).inner).toBe("t(import.empty.createSample)");
});
it("sources found: the first source card", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path(ZERO, true);
const html = render();
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
const target = highlighted(html);
expect(target.inner).toContain("Desjardins");
expect(target.inner).not.toContain("Tangerine");
});
it.each([
["source-config", "sourceConfig", "import.wizard.preview"],
["file-preview", "filePreview", "import.wizard.checkDuplicates"],
["duplicate-check", "duplicateCheck", "import.wizard.confirm"],
["confirm", "confirm", "import.wizard.import"],
] as const)("%s: the next button, the one the sentence names", (step, text, label) => {
harness.wizard = wizard({
step,
importFolder: "/home/max/Relevés",
scannedSources: SOURCES,
selectedSource: SOURCES[0],
duplicateResult: DUPLICATES,
});
harness.onboarding = path({ ...ZERO, sources: 0 }, true);
const html = render();
expect(html).toContain(`data-stub="${step === "confirm" ? "confirmation" : step}"`);
expect(sentence(html)).toBe(`t(onboarding.card.import.${text}[button=t(${label})])`);
const target = highlighted(html);
expect(target.tag).toBe("button");
expect(target.inner).toContain(`t(${label})`);
});
it("importing: the card says so and points at nothing, though the counters already tick", () => {
harness.wizard = wizard({ step: "importing", importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path(IMPORTED, true);
const html = render();
expect(html).toContain("t(import.progress.title)");
expect(sentence(html)).toBe("t(onboarding.card.import.importing[])");
expect(html).not.toContain("t(onboarding.card.done)");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("the report of an import that added transactions: 'Done.' and the next step", () => {
harness.wizard = wizard({
step: "report",
importFolder: "/home/max/Relevés",
scannedSources: SOURCES,
importReport: report(41),
});
harness.onboarding = path(IMPORTED, true);
const html = render();
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/);
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("the report of an import that added nothing: the failure sentence, never 'Done.'", () => {
harness.wizard = wizard({
step: "report",
importFolder: "/home/max/Relevés",
scannedSources: SOURCES,
importReport: report(0),
});
// Duplicates only: the file row is `completed`, the checklist counts it.
harness.onboarding = path(IMPORTED, true);
const html = render();
expect(sentence(html)).toBe("t(onboarding.card.import.failed[])");
expect(html).not.toContain("t(onboarding.card.done)");
expect(html).not.toContain("onboarding.card.next");
});
it("after 'Done' or 'Cancel', the wizard at rest: the card still says 'Done.' and points at nothing", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path(IMPORTED, true);
const html = render();
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("Desjardins");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("without the card on screen, no control is highlighted", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
for (const overrides of [
{ mode: "banner" as const, cardPage: null },
{ mode: "none" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/transactions" },
]) {
harness.onboarding = path(ZERO, true, overrides);
const html = render();
expect(html).not.toContain(CARD);
expect(count(html, HIGHLIGHT)).toBe(0);
expect(html).toContain("Desjardins");
}
});
});
describe("ImportPage — before the folder is read (#384)", () => {
it("shows neither variant of the empty state, nor the list's empty box, nor the card", () => {
harness.wizard = wizard({ importFolderLoaded: false });
const html = render();
expect(html).not.toContain("import.empty.noFolder.title");
expect(html).not.toContain("import.empty.noSources.title");
expect(html).not.toContain("import.sources.empty");
expect(html).not.toContain(CARD);
expect(count(html, HIGHLIGHT)).toBe(0);
// The page itself is there: its heading, the folder card and the history.
expect(html).toContain("t(import.title)");
expect(html).toContain('data-stub="history"');
});
it("shows the empty state once read", () => {
expect(render()).toContain("t(import.empty.noFolder.title)");
});
});

View file

@ -1,5 +1,9 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { useImportWizard } from "../hooks/useImportWizard"; import { useImportWizard } from "../hooks/useImportWizard";
import { useOnboarding } from "../contexts/OnboardingContext";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { importCardModel, showsStepCard } from "../utils/onboardingPath";
import ImportFolderConfig from "../components/import/ImportFolderConfig"; import ImportFolderConfig from "../components/import/ImportFolderConfig";
import SourceList from "../components/import/SourceList"; import SourceList from "../components/import/SourceList";
import ImportEmptyState, { import ImportEmptyState, {
@ -46,12 +50,36 @@ export default function ImportPage() {
toggleDuplicateRow, toggleDuplicateRow,
setSkipAllDuplicates, setSkipAllDuplicates,
} = useImportWizard(); } = useImportWizard();
const onboarding = useOnboarding();
const { pathname } = useLocation();
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name; const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0; const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0;
// The getting-started card follows the wizard (#398). Its target is
// highlighted only while the card is on screen: a ring needs the card that
// explains it, and the Import page of a finished path shows none.
const cardModel = importCardModel(
{
wizardStep: state.step,
folderLoaded: state.importFolderLoaded,
hasFolder: state.importFolder !== null,
folderName: state.importFolder ? folderNameFromPath(state.importFolder) : null,
sourceCount: state.scannedSources.length,
importing: state.step === "importing",
importedCount: state.step === "report" ? (state.importReport?.importedCount ?? null) : null,
},
onboarding.checklist,
);
const target =
cardModel !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname)
? cardModel.target
: null;
return ( return (
<div> <div>
<OnboardingStepCard model={cardModel} />
<div className="relative flex items-center gap-3 mb-6"> <div className="relative flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t("import.title")}</h1> <h1 className="text-2xl font-bold">{t("import.title")}</h1>
<PageHelp helpKey="import" linkTo={CSV_EXPORT_GUIDE_PATH} /> <PageHelp helpKey="import" linkTo={CSV_EXPORT_GUIDE_PATH} />
@ -104,9 +132,12 @@ export default function ImportPage() {
{/* {/*
No source to list (#356): the empty state takes the place of the No source to list (#356): the empty state takes the place of the
list and tells the user what to do next, which depends on whether a list and tells the user what to do next, which depends on whether a
folder is configured. SourceList itself stays a pure list. folder is configured. SourceList itself stays a pure list. Neither
shows before the folder is read (#384): the "no folder" variant, or
the list's own empty box, would flash.
*/} */}
{showEmptyState ? ( {state.importFolderLoaded &&
(showEmptyState ? (
<ImportEmptyState <ImportEmptyState
hasFolder={state.importFolder !== null} hasFolder={state.importFolder !== null}
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null} folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
@ -114,6 +145,7 @@ export default function ImportPage() {
onCreateSample={createSampleFolders} onCreateSample={createSampleFolders}
onRefresh={refreshFolder} onRefresh={refreshFolder}
isCreatingSample={state.isLoading} isCreatingSample={state.isLoading}
onboardingTarget={target === "emptyStateAction"}
/> />
) : ( ) : (
<SourceList <SourceList
@ -121,8 +153,9 @@ export default function ImportPage() {
configuredSourceNames={state.configuredSourceNames} configuredSourceNames={state.configuredSourceNames}
importedFileNames={state.importedFilesBySource} importedFileNames={state.importedFilesBySource}
onSelectSource={selectSource} onSelectSource={selectSource}
onboardingTarget={target === "firstSource"}
/> />
)} ))}
<ImportHistoryPanel onChanged={refreshFolder} /> <ImportHistoryPanel onChanged={refreshFolder} />
</> </>
)} )}
@ -161,6 +194,7 @@ export default function ImportPage() {
onCancel={reset} onCancel={reset}
nextLabel={t("import.wizard.preview")} nextLabel={t("import.wizard.preview")}
nextDisabled={nextDisabled || state.isLoading} nextDisabled={nextDisabled || state.isLoading}
onboardingTarget={target === "wizardNext"}
/> />
</div> </div>
)} )}
@ -192,6 +226,7 @@ export default function ImportPage() {
onCancel={reset} onCancel={reset}
nextLabel={t("import.wizard.checkDuplicates")} nextLabel={t("import.wizard.checkDuplicates")}
nextDisabled={state.isLoading} nextDisabled={state.isLoading}
onboardingTarget={target === "wizardNext"}
/> />
</div> </div>
)} )}
@ -210,6 +245,7 @@ export default function ImportPage() {
onNext={() => goToStep("confirm")} onNext={() => goToStep("confirm")}
onCancel={reset} onCancel={reset}
nextLabel={t("import.wizard.confirm")} nextLabel={t("import.wizard.confirm")}
onboardingTarget={target === "wizardNext"}
/> />
</div> </div>
)} )}
@ -230,6 +266,7 @@ export default function ImportPage() {
onCancel={reset} onCancel={reset}
nextLabel={t("import.wizard.import")} nextLabel={t("import.wizard.import")}
showCancel={false} showCancel={false}
onboardingTarget={target === "wizardNext"}
/> />
</div> </div>
)} )}

View file

@ -1001,3 +1001,23 @@ export interface ChecklistItem {
* successful load: a later failed re-read keeps `ready` and the last state. * successful load: a later failed re-read keeps `ready` and the last state.
*/ */
export type OnboardingStatus = "loading" | "ready" | "error"; export type OnboardingStatus = "loading" | "ready" | "error";
/**
* What the Import page's step card reads of the import wizard (#398), built by
* the page from its wizard state; `importCardModel` turns it into the card.
*/
export interface ImportCardInput {
wizardStep: ImportWizardStep;
/** The stored import folder has been read at mount (`importFolderLoaded`): no card before. */
folderLoaded: boolean;
/** An import folder is configured. */
hasFolder: boolean;
/** Last segment of the import folder (`folderNameFromPath`), null without a folder. */
folderName: string | null;
/** Bank sub-folders found by the last scan of the folder. */
sourceCount: number;
/** An import is writing its rows. */
importing: boolean;
/** Transactions the import added (`ImportReport.importedCount`), null outside the `report` step. */
importedCount: number | null;
}

164
src/styles.contrast.test.ts Normal file
View file

@ -0,0 +1,164 @@
// The getting-started highlight in styles.css (#398): the ring a step card
// draws around the control to use. Read from the stylesheet itself, so a
// change to a theme color or to the rule is checked where it is made:
// - WCAG 1.4.11: the ring reaches 3:1 against the page background and the
// cards, in the light theme (`:root`) and the dark one (`.dark`);
// - WCAG 2.2.2: the pulse stops by itself after three cycles, within 5 s;
// - the pulse does not run under `prefers-reduced-motion: reduce`.
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
const CSS = readFileSync(resolve(import.meta.dirname, "styles.css"), "utf-8");
const HIGHLIGHT = '[data-onboarding-highlight="true"]';
interface Block {
selector: string;
body: string;
}
/** The top-level blocks of a stylesheet, comments removed; nested braces stay in the body. */
function blocks(css: string): Block[] {
const text = css.replace(/\/\*[\s\S]*?\*\//g, "");
const found: Block[] = [];
let depth = 0;
let start = 0;
let open = -1;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c === "{") {
if (depth === 0) open = i;
depth++;
} else if (c === "}") {
depth--;
if (depth === 0) {
found.push({ selector: text.slice(start, open).trim(), body: text.slice(open + 1, i) });
start = i + 1;
}
} else if (c === ";" && depth === 0) {
start = i + 1; // a statement such as `@import "tailwindcss";`
}
}
return found;
}
/** The declarations of a block body, `name -> value`, the last one winning. */
function declarations(body: string): Map<string, string> {
const result = new Map<string, string>();
for (const [, name, value] of body.matchAll(/([\w-]+)\s*:\s*([^;{}]+);/g)) {
result.set(name, value.trim());
}
return result;
}
const TOP = blocks(CSS);
const merged = (selector: string, into = new Map<string, string>()) => {
for (const block of TOP.filter((b) => b.selector === selector)) {
for (const [name, value] of declarations(block.body)) into.set(name, value);
}
return into;
};
const THEME_TOKENS = merged("@theme");
const LIGHT = merged(":root");
/** `.dark` overrides `:root`, which still holds what it does not redefine. */
const DARK = merged(".dark", new Map(LIGHT));
/** A color variable resolved through `var()` down to its hex value. */
function color(name: string, theme: Map<string, string>): string {
let value = theme.get(name) ?? THEME_TOKENS.get(name);
for (let hops = 0; value !== undefined && hops < 10; hops++) {
const ref = value.match(/^var\((--[\w-]+)\)$/);
if (!ref) break;
value = theme.get(ref[1]) ?? THEME_TOKENS.get(ref[1]);
}
if (value === undefined || !/^#[0-9a-f]{6}$/i.test(value)) {
throw new Error(`${name} does not resolve to a #rrggbb color: ${String(value)}`);
}
return value;
}
/** WCAG relative luminance of a #rrggbb color. */
function luminance(hex: string): number {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const highlightRule = () => {
const rule = TOP.find((b) => b.selector === HIGHLIGHT);
expect(rule, "the highlight rule").toBeDefined();
return declarations(rule!.body);
};
/** The variable the ring is drawn with: `--accent`, or `--onboarding-ring` if the accent failed. */
function ringVariable(): string {
const outline = highlightRule().get("outline") ?? "";
const variable = outline.match(/var\((--[\w-]+)\)/);
expect(variable, `outline: ${outline}`).not.toBeNull();
return variable![1];
}
describe("styles.css — the contrast helpers", () => {
it("compute the WCAG ratios of known pairs", () => {
expect(contrast("#000000", "#ffffff")).toBeCloseTo(21, 5);
expect(contrast("#ffffff", "#ffffff")).toBeCloseTo(1, 5);
// #767676 is the classic 4.54:1 grey on white.
expect(contrast("#767676", "#ffffff")).toBeCloseTo(4.54, 2);
});
it("read both themes from the stylesheet", () => {
expect(color("--background", LIGHT)).toBe("#FFF8F0");
expect(color("--card", LIGHT)).toBe("#FFFFFF");
expect(color("--background", DARK)).toBe("#111827");
expect(color("--card", DARK)).toBe("#1f2937");
});
});
describe("styles.css — the getting-started highlight (#398)", () => {
it("draws a 3px outline set off by 2px, in the accent color", () => {
const rule = highlightRule();
expect(rule.get("outline")).toBe("3px solid var(--accent)");
expect(rule.get("outline-offset")).toBe("2px");
expect(ringVariable()).toBe("--accent");
});
it.each([
["light", LIGHT],
["dark", DARK],
] as const)("reaches 3:1 against the background and the cards, %s theme", (_name, theme) => {
const ring = color(ringVariable(), theme);
for (const surface of ["--background", "--card"]) {
expect(contrast(ring, color(surface, theme)), surface).toBeGreaterThanOrEqual(3);
}
});
it("pulses three times at most, within the 5 s of WCAG 2.2.2", () => {
const rule = highlightRule();
expect(rule.get("animation-name")).toBe("onboarding-pulse");
expect(rule.get("animation-iteration-count")).toBe("3");
expect(rule.has("animation")).toBe(false);
const duration = rule.get("animation-duration") ?? "";
expect(duration).toMatch(/^\d+(\.\d+)?s$/);
expect(parseFloat(duration) * 3).toBeLessThanOrEqual(5);
const keyframes = TOP.find((b) => b.selector === "@keyframes onboarding-pulse");
expect(keyframes?.body).toContain("box-shadow");
});
it("does not pulse when the system asks for less motion", () => {
const media = TOP.find((b) => b.selector === "@media (prefers-reduced-motion: reduce)");
expect(media, "the reduced-motion block").toBeDefined();
const inner = blocks(media!.body).find((b) => b.selector === HIGHLIGHT);
expect(inner, "the highlight rule under reduced motion").toBeDefined();
expect(declarations(inner!.body).get("animation")).toBe("none");
});
});

View file

@ -148,6 +148,37 @@ body {
display: none; display: none;
} }
/*
* Getting-started path (#398): the control a step card points at. An outline
* in the accent color, distinct from the primary focus ring and at least 3:1
* against the page and the cards in both themes (styles.contrast.test.ts),
* plus a pulse that stops after three cycles (3.6 s, under the 5 s of WCAG
* 2.2.2) and does not run at all when the system asks for less motion.
*/
[data-onboarding-highlight="true"] {
outline: 3px solid var(--accent);
outline-offset: 2px;
animation-name: onboarding-pulse;
animation-duration: 1.2s;
animation-timing-function: ease-out;
animation-iteration-count: 3;
}
@keyframes onboarding-pulse {
from {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
to {
box-shadow: 0 0 0 10px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
[data-onboarding-highlight="true"] {
animation: none;
}
}
/* Scrollbar styling */ /* Scrollbar styling */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;

View file

@ -3,17 +3,24 @@ import { computeChecklist, type Checklist } from "./onboardingChecklist";
import { import {
CARD_PAGES, CARD_PAGES,
bannerMode, bannerMode,
cardNext,
cardStep,
importCardModel,
latchCardPage, latchCardPage,
nextStep, nextStep,
onboardingTargetAttributes,
readOnboardingNavState, readOnboardingNavState,
showsStepCard,
stepNavState, stepNavState,
stepsOfPage, stepsOfPage,
type BannerModeInput, type BannerModeInput,
type ImportCardModel,
} from "./onboardingPath"; } from "./onboardingPath";
import { import {
CHECKLIST_ITEM_KEYS, CHECKLIST_ITEM_KEYS,
type ChecklistItem, type ChecklistItem,
type ChecklistItemKey, type ChecklistItemKey,
type ImportCardInput,
type OnboardingCounters, type OnboardingCounters,
} from "../shared/types"; } from "../shared/types";
@ -334,3 +341,241 @@ describe("stepNavState (#397)", () => {
expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false }); expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false });
}); });
}); });
describe("CARD_PAGES (#398)", () => {
it("carries the Import page", () => {
expect(CARD_PAGES.has("/import")).toBe(true);
});
});
describe("showsStepCard (#398)", () => {
it("is true in card mode on the latched route only", () => {
expect(showsStepCard("card", "/import", "/import")).toBe(true);
expect(showsStepCard("card", "/import", "/transactions")).toBe(false);
expect(showsStepCard("card", null, "/import")).toBe(false);
for (const mode of ["none", "banner", "complete"] as const) {
expect(showsStepCard(mode, "/import", "/import"), mode).toBe(false);
}
});
});
describe("cardStep (#398)", () => {
const at = (checklist: Checklist, pathname = "/import") => {
const found = cardStep(checklist, pathname);
return found && { key: found.step.key, n: found.n, total: found.total };
};
it("on /import, shows the first of the page's steps not done yet, ranked among the unlocked rows", () => {
expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 6 });
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 6 });
expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({
key: "import",
n: 4,
total: 6,
});
});
it("shows the import step once the three are done", () => {
expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({
key: "import",
n: 4,
total: 6,
});
});
it("passes over a skipped step, as the next step does", () => {
// After "Skip" on the folder step, the card latches again on /import for
// the source step: it must show that step, not the skipped one.
expect(at(checklistOf(zero, { skipped: ["importFolder"] }))?.key).toBe("source");
expect(at(checklistOf(zero, { skipped: ["importFolder", "source"] }))?.key).toBe("import");
expect(at(checklistOf(zero, { skipped: ["importFolder", "source", "import"] }))?.key).toBe(
"import",
);
});
it("counts the unlocked rows only: five in the Free edition", () => {
expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({
key: "importFolder",
n: 2,
total: 5,
});
expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({
key: "manualCategory",
n: 5,
total: 6,
});
});
it("is null on a page that guides no unlocked step", () => {
expect(cardStep(checklistOf(zero), "/reports")).toBeNull();
expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull();
expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget");
});
});
describe("cardNext (#398)", () => {
it("links the next step when it lives on another page", () => {
expect(cardNext(step("manualCategory"), "/import", true)).toEqual({
kind: "step",
step: step("manualCategory"),
});
// The link does not wait for the entitlement: only the end does.
expect(cardNext(step("manualCategory"), "/import", false).kind).toBe("step");
});
it("is the end without a next step, once the Budget entitlement is known", () => {
expect(cardNext(null, "/import", true)).toEqual({ kind: "end" });
expect(cardNext(null, "/import", false)).toEqual({ kind: "wait" });
});
it("waits while the next step is still one of this page's own (re-read not landed)", () => {
for (const key of ["importFolder", "source", "import"] as const) {
expect(cardNext(step(key), "/import", true), key).toEqual({ kind: "wait" });
}
expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" });
expect(cardNext(step("budget"), "/import", true).kind).toBe("step");
});
});
describe("importCardModel (#398)", () => {
/** A new profile: folder read, none chosen, nothing scanned. */
const NEW: ImportCardInput = {
wizardStep: "source-list",
folderLoaded: true,
hasFolder: false,
folderName: null,
sourceCount: 0,
importing: false,
importedCount: null,
};
/** A folder with two bank sub-folders, the wizard at rest. */
const AT_REST: ImportCardInput = { ...NEW, hasFolder: true, folderName: "Relevés", sourceCount: 2 };
const fresh = checklistOf(zero, { importFolderSet: true });
/** Steps 3 and 4 checked: a source and a completed `imported_files` row. */
const imported = checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true });
const model = (input: Partial<ImportCardInput>, checklist: Checklist | null = fresh) =>
importCardModel({ ...AT_REST, ...input }, checklist);
const wizardNext = (text: string, button: string): ImportCardModel => ({
text: { key: `onboarding.card.import.${text}`, labels: { button } },
done: false,
target: "wizardNext",
});
const DONE: ImportCardModel = { text: null, done: true, target: null };
const IMPORTING: ImportCardModel = {
text: { key: "onboarding.card.import.importing" },
done: false,
target: null,
};
const FAILED: ImportCardModel = {
text: { key: "onboarding.card.import.failed" },
done: false,
target: null,
};
it("1. no card before the folder is read, whatever else is true", () => {
expect(importCardModel({ ...NEW, folderLoaded: false }, fresh)).toBeNull();
expect(model({ folderLoaded: false, importing: true, wizardStep: "importing" })).toBeNull();
});
it("2. an import running says so, before the counters that already tick", () => {
expect(model({ wizardStep: "importing", importing: true })).toEqual(IMPORTING);
// The `imported_files` row is written `completed` before the inserts:
// steps 3 and 4 read as checked while the import runs.
expect(model({ wizardStep: "importing", importing: true }, imported)).toEqual(IMPORTING);
// The wizard's own step says it too.
expect(model({ wizardStep: "importing", importing: false }, imported)).toEqual(IMPORTING);
});
it("3. a report of an import that added nothing is a failure, never 'Done.'", () => {
expect(model({ wizardStep: "report", importedCount: 0 })).toEqual(FAILED);
// Duplicates only: the file row is `completed`, so the checklist counts
// the import step as done; the card still does not say "Done.".
expect(model({ wizardStep: "report", importedCount: 0 }, imported)).toEqual(FAILED);
// No count at all is no success either.
expect(model({ wizardStep: "report", importedCount: null }, imported)).toEqual(FAILED);
});
it("4. a report of an import that added transactions is 'Done.', even before the re-read", () => {
expect(model({ wizardStep: "report", importedCount: 41 }, imported)).toEqual(DONE);
expect(model({ wizardStep: "report", importedCount: 1 }, fresh)).toEqual(DONE);
});
it("5. without a folder: choose one, with the empty state's button", () => {
const noFolder: ImportCardModel = {
text: {
key: "onboarding.card.import.noFolder",
labels: { button: "import.empty.noFolder.cta" },
},
done: false,
target: "emptyStateAction",
};
expect(importCardModel(NEW, checklistOf(zero))).toEqual(noFolder);
// Before "steps checked": the folder step is the one left.
expect(importCardModel(NEW, imported)).toEqual(noFolder);
});
it("6. a folder without bank sub-folder: create them, or the sample folder", () => {
expect(model({ sourceCount: 0 })).toEqual({
text: {
key: "onboarding.card.import.noSources",
labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" },
values: { folder: "Relevés" },
},
done: false,
target: "emptyStateAction",
});
});
it("7. the wizard: the first source card, then its next button with the label it shows", () => {
expect(model({ wizardStep: "source-list" })).toEqual({
text: { key: "onboarding.card.import.sourceList" },
done: false,
target: "firstSource",
});
expect(model({ wizardStep: "source-config" })).toEqual(
wizardNext("sourceConfig", "import.wizard.preview"),
);
expect(model({ wizardStep: "file-preview" })).toEqual(
wizardNext("filePreview", "import.wizard.checkDuplicates"),
);
expect(model({ wizardStep: "duplicate-check" })).toEqual(
wizardNext("duplicateCheck", "import.wizard.confirm"),
);
expect(model({ wizardStep: "confirm" })).toEqual(wizardNext("confirm", "import.wizard.import"));
});
it("8. steps 3 and 4 checked, the wizard at rest: 'Done.' (after 'Done' or 'Cancel')", () => {
expect(model({ wizardStep: "source-list" }, imported)).toEqual(DONE);
// Ahead of the rows of the wizard at rest: an emptied folder too.
expect(model({ wizardStep: "source-list", sourceCount: 0 }, imported)).toEqual(DONE);
});
it("8. ...but only at rest: a new pass through the wizard is guided again", () => {
expect(model({ wizardStep: "source-config" }, imported)).toEqual(
wizardNext("sourceConfig", "import.wizard.preview"),
);
expect(model({ wizardStep: "confirm" }, imported)).toEqual(
wizardNext("confirm", "import.wizard.import"),
);
});
it("8. ...and only when both are checked, by the data: skipped is not checked", () => {
const sourceOnly = checklistOf({ ...zero, sources: 1 }, { importFolderSet: true });
expect(model({}, sourceOnly)?.target).toBe("firstSource");
const skipped = checklistOf(zero, { importFolderSet: true, skipped: ["source", "import"] });
expect(model({}, skipped)?.target).toBe("firstSource");
expect(model({}, null)?.target).toBe("firstSource");
});
});
describe("onboardingTargetAttributes (#398)", () => {
it("marks the target and its highlight when on, nothing otherwise", () => {
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({
"data-onboarding-target": "wizard-next",
"data-onboarding-highlight": "true",
});
expect(onboardingTargetAttributes("wizard-next", false)).toEqual({});
expect(onboardingTargetAttributes("wizard-next", undefined)).toEqual({});
});
});

View file

@ -1,12 +1,17 @@
import type { Checklist } from "./onboardingChecklist"; import type { Checklist } from "./onboardingChecklist";
import type { ChecklistItem, ChecklistItemKey, OnboardingStatus } from "../shared/types"; import type {
ChecklistItem,
ChecklistItemKey,
ImportCardInput,
OnboardingStatus,
} from "../shared/types";
/** /**
* Pure rules of the guided getting-started path (#396). * Pure rules of the guided getting-started path (#396, #398).
* *
* The provider (`useOnboardingChecklist`) feeds them its state; the banner and * The provider (`useOnboardingChecklist`) feeds them its state; the banner,
* the step cards of the next issues only read what they decide. Nothing here * the step cards and the pages that carry a card only read what they decide.
* touches React, the router or the database. * Nothing here touches React, the router or the database.
*/ */
/** /**
@ -18,11 +23,11 @@ export function nextStep(checklist: Checklist): ChecklistItem | null {
} }
/** /**
* Routes whose page carries a step card. Empty in this issue: each card issue * Routes whose page carries a step card. Each card issue adds its page (#398:
* adds its page, and a page without a delivered card keeps the banner, so a * Import), and a page without a delivered card keeps the banner, so a partly
* partly merged stack never removes any guidance. * merged stack never removes any guidance.
*/ */
export const CARD_PAGES: ReadonlySet<string> = new Set<string>(); export const CARD_PAGES: ReadonlySet<string> = new Set<string>(["/import"]);
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = { const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
"/import": ["importFolder", "source", "import"], "/import": ["importFolder", "source", "import"],
@ -145,3 +150,203 @@ export function readOnboardingNavState(state: unknown): OnboardingNavState {
const flags = onboarding as { focus?: unknown; guided?: unknown }; const flags = onboarding as { focus?: unknown; guided?: unknown };
return { focus: flags.focus === true, guided: flags.guided === true }; return { focus: flags.focus === true, guided: flags.guided === true };
} }
// ---------------------------------------------------------------------------
// Step cards (#398)
// ---------------------------------------------------------------------------
/**
* The step card shows on this page: the path is in card mode, latched on this
* very route. The connected card renders only then, and the page highlights
* its model's target only then: no ring without the card that explains it.
*/
export function showsStepCard(mode: BannerMode, cardPage: string | null, pathname: string): boolean {
return mode === "card" && cardPage === pathname;
}
/** The step a page's card shows, and its place in the path ("Step n of N"). */
export interface CardStep {
step: ChecklistItem;
/** Rank of the step among the unlocked rows, from 1, in checklist order. */
n: number;
/** Number of unlocked rows. */
total: number;
}
/**
* The step a page's card shows: the first of the page's steps neither done
* nor skipped, or the page's last step once none is left (on /import:
* `importFolder`, `source`, `import`, then `import`). A skipped step is passed
* over as `nextStep` does: after "Skip" the card latches again when the new
* next step is on the same page, and must then show that step, not the
* skipped one with its "Skip" button again. Null for a page that guides no
* unlocked step.
*/
export function cardStep(checklist: Checklist, pathname: string): CardStep | null {
const pageSteps = stepsOfPage(pathname);
const unlocked = checklist.items.filter((item) => !item.locked);
const ofPage = unlocked.filter((item) => pageSteps.includes(item.key));
if (ofPage.length === 0) return null;
const step = ofPage.find((item) => !item.done && !item.skipped) ?? ofPage[ofPage.length - 1];
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
}
/** What follows "Done." on a step card. */
export type StepCardNext =
| { kind: "step"; step: ChecklistItem }
| { kind: "end" }
| { kind: "wait" };
/**
* What follows "Done." on a page's card:
* - `step`: the next step, which lives on another page;
* - `end`: no step left (end mode), once the Budget entitlement is known:
* until then the checklist counts Budget as locked, and the end could be a
* sixth step away (the rule of `bannerMode`);
* - `wait`: nothing yet, while the entitlement is unknown at the end, or
* while the next step is still one of this page's own: the re-read that
* follows the write that completed them (ADR 0022) has not landed, and a
* link would lead back to this very page.
*/
export function cardNext(
nextStep: ChecklistItem | null,
pathname: string,
budgetReady: boolean,
): StepCardNext {
if (nextStep === null) return budgetReady ? { kind: "end" } : { kind: "wait" };
if (stepsOfPage(pathname).includes(nextStep.key)) return { kind: "wait" };
return { kind: "step", step: nextStep };
}
/** A sentence of a step card: an i18n key and its interpolations. */
export interface StepCardText {
key: string;
/**
* Interpolations that name a control: the i18n key of its label, translated
* when the card renders, so a button's label is never copied into a sentence.
*/
labels?: Readonly<Record<string, string>>;
/** Interpolations shown as they are (a folder name). */
values?: Readonly<Record<string, string>>;
}
/** What a page tells its step card. */
export interface StepCardModel {
/** The sentence; null once the page's steps are done, "Done." then says it. */
text: StepCardText | null;
/** The page's steps are done: "Done." and what follows it (`cardNext`). */
done: boolean;
}
/** The control of the Import page that the card points at. */
export type ImportCardTarget = "emptyStateAction" | "firstSource" | "wizardNext";
export interface ImportCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: ImportCardTarget | null;
}
const IMPORT_DONE: ImportCardModel = { text: null, done: true, target: null };
function importText(name: string, rest: Omit<StepCardText, "key"> = {}): StepCardText {
return { key: `onboarding.card.import.${name}`, ...rest };
}
/**
* The wizard steps that end on its next button: their sentence and the label
* of that button, the one `ImportPage` gives the button at that step.
*/
const WIZARD_ROWS = {
"source-config": { text: "sourceConfig", button: "import.wizard.preview" },
"file-preview": { text: "filePreview", button: "import.wizard.checkDuplicates" },
"duplicate-check": { text: "duplicateCheck", button: "import.wizard.confirm" },
confirm: { text: "confirm", button: "import.wizard.import" },
} as const;
/** The checklist marks the step done (a skipped step is not done). */
function checked(checklist: Checklist | null, key: ChecklistItemKey): boolean {
return checklist?.items.some((item) => item.key === key && item.done) ?? false;
}
/**
* The Import page's card, read from the wizard and the checklist. The rows
* are tried in order, and the first that applies wins:
* 1. the folder is not read yet: no card (null);
* 2. an import is running: "Importing…". It comes before anything the
* counters say, because `executeImport` writes the `imported_files` row
* `completed` before inserting a single transaction;
* 3. the report: "Done." when the import added transactions, the failure
* sentence when it added none (insertion failed, duplicates only), or when
* the count is missing: never "Done." without one;
* 4. no folder: choose one, with the empty state's button;
* 5. the wizard at rest (`source-list`) with the source and import steps
* checked: "Done.". This is the table's last row ("steps 3 and 4 already
* checked, outside the wizard"): placed after the rows that cover every
* wizard step, it could never apply, and "Done" or "Cancel" at the end of
* an import would send the card back to "click your bank's card";
* 6. a folder without bank sub-folder: create them, or the sample folder;
* 7. the wizard: the first source card, then its next button at each step.
*/
export function importCardModel(
input: ImportCardInput,
checklist: Checklist | null,
): ImportCardModel | null {
const step = input.wizardStep;
if (!input.folderLoaded) return null;
if (input.importing || step === "importing") {
return { text: importText("importing"), done: false, target: null };
}
if (step === "report") {
return input.importedCount !== null && input.importedCount > 0
? IMPORT_DONE
: { text: importText("failed"), done: false, target: null };
}
if (!input.hasFolder) {
return {
text: importText("noFolder", { labels: { button: "import.empty.noFolder.cta" } }),
done: false,
target: "emptyStateAction",
};
}
if (step === "source-list" && checked(checklist, "source") && checked(checklist, "import")) {
return IMPORT_DONE;
}
if (input.sourceCount === 0) {
return {
text: importText("noSources", {
labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" },
values: { folder: input.folderName ?? "" },
}),
done: false,
target: "emptyStateAction",
};
}
if (step === "source-list") {
return { text: importText("sourceList"), done: false, target: "firstSource" };
}
const row = WIZARD_ROWS[step];
return {
text: importText(row.text, { labels: { button: row.button } }),
done: false,
target: "wizardNext",
};
}
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
export interface OnboardingTargetAttributes {
"data-onboarding-target"?: string;
"data-onboarding-highlight"?: "true";
}
/**
* What a target control renders: its key and the highlight when the page
* passes `onboardingTarget`, nothing otherwise. The page picks the target
* from its card model and hands it down through props, never by a DOM query:
* a target that is not on screen leaves the card without a ring.
*/
export function onboardingTargetAttributes(
key: string,
active: boolean | undefined,
): OnboardingTargetAttributes {
return active ? { "data-onboarding-target": key, "data-onboarding-highlight": "true" } : {};
}