On the page of the step in progress, a card takes the place of the
banner. `StepCard` is driven by props: an h2 title that takes the focus
("Step n of N", then the step), the page's sentence, a status region
always rendered that receives "Done.", "Skip this step" until the step is
done, then "Next step" or the end of the path. `OnboardingStepCard` is
the only piece that reads the path: it renders on the latched card page
only, so the card survives its own success, focuses its title once per
arrival by "Next step" and consumes the navigation state.
The control to use carries `data-onboarding-highlight`, drawn in
styles.css as a 3px accent outline with a pulse bounded to three cycles
and none under prefers-reduced-motion; a test reads the stylesheet and
checks 3:1 against the background and the cards in both themes. The
target is passed by props to EmptyState's primary action, the first
SourceList card and WizardNavigation's next button, never queried.
`importCardModel` reads the wizard and the checklist in order: importing
before the counters, a report that added nothing as a failure, and the
"steps 3 and 4 checked" row at rest, where it can apply. CARD_PAGES gets
/import; the Import page builds the input, renders the card first and
highlights the model's target only while the card shows, and its empty
state waits for the folder to be read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
// 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)");
|
|
});
|
|
});
|