= {},
+): 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(
+
+
+ ,
+ );
+
+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>/)?.[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("
{
+ 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(/
]*>.*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)");
+ });
+});
diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx
index 98678a2..c18f69e 100644
--- a/src/pages/ImportPage.tsx
+++ b/src/pages/ImportPage.tsx
@@ -1,5 +1,9 @@
import { useTranslation } from "react-i18next";
+import { useLocation } from "react-router-dom";
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 SourceList from "../components/import/SourceList";
import ImportEmptyState, {
@@ -46,12 +50,36 @@ export default function ImportPage() {
toggleDuplicateRow,
setSkipAllDuplicates,
} = useImportWizard();
+ const onboarding = useOnboarding();
+ const { pathname } = useLocation();
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
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 (
+
+
{t("import.title")}
@@ -104,25 +132,30 @@ export default function ImportPage() {
{/*
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
- folder is configured. SourceList itself stays a pure list.
+ folder is configured. SourceList itself stays a pure list. Neither
+ shows before the folder is read (#384): the "no folder" variant, or
+ the list's own empty box, would flash.
*/}
- {showEmptyState ? (
-
- ) : (
-
- )}
+ {state.importFolderLoaded &&
+ (showEmptyState ? (
+
+ ) : (
+
+ ))}
>
)}
@@ -161,6 +194,7 @@ export default function ImportPage() {
onCancel={reset}
nextLabel={t("import.wizard.preview")}
nextDisabled={nextDisabled || state.isLoading}
+ onboardingTarget={target === "wizardNext"}
/>
)}
@@ -192,6 +226,7 @@ export default function ImportPage() {
onCancel={reset}
nextLabel={t("import.wizard.checkDuplicates")}
nextDisabled={state.isLoading}
+ onboardingTarget={target === "wizardNext"}
/>
)}
@@ -210,6 +245,7 @@ export default function ImportPage() {
onNext={() => goToStep("confirm")}
onCancel={reset}
nextLabel={t("import.wizard.confirm")}
+ onboardingTarget={target === "wizardNext"}
/>