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

Merged
maximus merged 3 commits from issue-398-onboarding-step-card-import into main 2026-10-09 01:10:40 +00:00
23 changed files with 1936 additions and 36 deletions
Showing only changes of commit ed5eeb0265 - Show all commits

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,25 +132,30 @@ 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 &&
<ImportEmptyState (showEmptyState ? (
hasFolder={state.importFolder !== null} <ImportEmptyState
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null} hasFolder={state.importFolder !== null}
onBrowse={browseFolder} folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
onCreateSample={createSampleFolders} onBrowse={browseFolder}
onRefresh={refreshFolder} onCreateSample={createSampleFolders}
isCreatingSample={state.isLoading} onRefresh={refreshFolder}
/> isCreatingSample={state.isLoading}
) : ( onboardingTarget={target === "emptyStateAction"}
<SourceList />
sources={state.scannedSources} ) : (
configuredSourceNames={state.configuredSourceNames} <SourceList
importedFileNames={state.importedFilesBySource} sources={state.scannedSources}
onSelectSource={selectSource} configuredSourceNames={state.configuredSourceNames}
/> importedFileNames={state.importedFilesBySource}
)} 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" } : {};
}