feat(onboarding): step card with highlight, and the Import page's card #404
23 changed files with 1936 additions and 36 deletions
|
|
@ -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)", () => {
|
||||
it("returns the last segment for POSIX and Windows paths", () => {
|
||||
expect(folderNameFromPath("/home/max/releves")).toBe("releves");
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ export interface ImportEmptyStateProps {
|
|||
onRefresh: () => void;
|
||||
/** The sample folders are being written: the primary button is disabled. */
|
||||
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,
|
||||
onRefresh,
|
||||
isCreatingSample,
|
||||
onboardingTarget,
|
||||
}: ImportEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -62,7 +68,7 @@ export default function ImportEmptyState({
|
|||
icon={<FolderOpen size={40} />}
|
||||
title={t("import.empty.noFolder.title")}
|
||||
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}
|
||||
</EmptyState>
|
||||
|
|
@ -78,6 +84,7 @@ export default function ImportEmptyState({
|
|||
label: t("import.empty.createSample"),
|
||||
onClick: onCreateSample,
|
||||
disabled: isCreatingSample,
|
||||
onboardingTarget,
|
||||
}}
|
||||
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FileText, CheckCircle } from "lucide-react";
|
||||
import type { ScannedSource } from "../../shared/types";
|
||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||
|
||||
interface SourceCardProps {
|
||||
source: ScannedSource;
|
||||
isConfigured: boolean;
|
||||
newFileCount: number;
|
||||
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({
|
||||
source,
|
||||
isConfigured,
|
||||
newFileCount,
|
||||
onClick,
|
||||
onboardingTarget,
|
||||
}: SourceCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -21,6 +28,7 @@ export default function SourceCard({
|
|||
<button
|
||||
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"
|
||||
{...onboardingTargetAttributes(SOURCE_CARD_TARGET, onboardingTarget)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
63
src/components/import/SourceList.test.tsx
Normal file
63
src/components/import/SourceList.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,8 @@ interface SourceListProps {
|
|||
configuredSourceNames: Set<string>;
|
||||
importedFileNames: Map<string, Set<string>>;
|
||||
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({
|
||||
|
|
@ -15,6 +17,7 @@ export default function SourceList({
|
|||
configuredSourceNames,
|
||||
importedFileNames,
|
||||
onSelectSource,
|
||||
onboardingTarget,
|
||||
}: SourceListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -38,7 +41,7 @@ export default function SourceList({
|
|||
{t("import.sources.title")}
|
||||
</h2>
|
||||
<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);
|
||||
// Count files not yet imported for this source
|
||||
const sourceHashes = importedFileNames.get(source.folder_name);
|
||||
|
|
@ -55,6 +58,7 @@ export default function SourceList({
|
|||
isConfigured={isConfigured}
|
||||
newFileCount={newFileCount}
|
||||
onClick={() => onSelectSource(source)}
|
||||
onboardingTarget={onboardingTarget && index === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
55
src/components/import/WizardNavigation.test.tsx
Normal file
55
src/components/import/WizardNavigation.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||
|
||||
interface WizardNavigationProps {
|
||||
onBack?: () => void;
|
||||
|
|
@ -11,8 +12,13 @@ interface WizardNavigationProps {
|
|||
showBack?: boolean;
|
||||
showNext?: 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({
|
||||
onBack,
|
||||
onNext,
|
||||
|
|
@ -23,6 +29,7 @@ export default function WizardNavigation({
|
|||
showBack = true,
|
||||
showNext = true,
|
||||
showCancel = true,
|
||||
onboardingTarget,
|
||||
}: WizardNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -54,6 +61,7 @@ export default function WizardNavigation({
|
|||
onClick={onNext}
|
||||
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"
|
||||
{...onboardingTargetAttributes(WIZARD_NEXT_TARGET, onboardingTarget)}
|
||||
>
|
||||
{nextLabel || t("import.wizard.next")}
|
||||
<ChevronRight size={16} />
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
@ -151,7 +151,6 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["/import", NEW_PROFILE],
|
||||
["/transactions", IMPORTED],
|
||||
["/budget", BUDGET_LEFT],
|
||||
] 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", () => {
|
||||
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
|
||||
expect(renderWith("/import", value)).not.toContain(BANNER);
|
||||
|
|
|
|||
236
src/components/onboarding/OnboardingStepCard.test.tsx
Normal file
236
src/components/onboarding/OnboardingStepCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
71
src/components/onboarding/OnboardingStepCard.tsx
Normal file
71
src/components/onboarding/OnboardingStepCard.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
212
src/components/onboarding/StepCard.render.test.tsx
Normal file
212
src/components/onboarding/StepCard.render.test.tsx
Normal 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)");
|
||||
});
|
||||
});
|
||||
136
src/components/onboarding/StepCard.tsx
Normal file
136
src/components/onboarding/StepCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@
|
|||
// component: creating the element never needs a router, only rendering would.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
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[] {
|
||||
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"));
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import type { ReactNode } from "react";
|
||||
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
|
||||
* `<Link>`, never `useNavigate`) or a callback (rendered as a `<button>`).
|
||||
* `onboardingTarget` highlights it for the page's step card (#398).
|
||||
*/
|
||||
export type EmptyStateAction =
|
||||
| { label: string; to: string }
|
||||
| { label: string; onClick: () => void; disabled?: boolean };
|
||||
| { label: string; to: string; onboardingTarget?: 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 {
|
||||
/** 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";
|
||||
|
||||
function renderAction(action: EmptyStateAction, className: string) {
|
||||
const target = onboardingTargetAttributes(EMPTY_STATE_TARGET, action.onboardingTarget);
|
||||
if ("to" in action) {
|
||||
return (
|
||||
<Link to={action.to} className={className}>
|
||||
<Link to={action.to} className={className} {...target}>
|
||||
{action.label}
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -41,6 +47,7 @@ function renderAction(action: EmptyStateAction, className: string) {
|
|||
onClick={action.onClick}
|
||||
disabled={action.disabled ?? false}
|
||||
className={className}
|
||||
{...target}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,23 @@
|
|||
"complete": "Getting started complete. The user guide takes it from here.",
|
||||
"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": {
|
||||
"done": "(done)",
|
||||
"skipped": "(skipped)",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,23 @@
|
|||
"complete": "Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite.",
|
||||
"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": {
|
||||
"done": "(faite)",
|
||||
"skipped": "(passée)",
|
||||
|
|
|
|||
283
src/pages/ImportPage.render.test.tsx
Normal file
283
src/pages/ImportPage.render.test.tsx
Normal 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)");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<OnboardingStepCard model={cardModel} />
|
||||
|
||||
<div className="relative flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold">{t("import.title")}</h1>
|
||||
<PageHelp helpKey="import" linkTo={CSV_EXPORT_GUIDE_PATH} />
|
||||
|
|
@ -104,9 +132,12 @@ export default function ImportPage() {
|
|||
{/*
|
||||
No source to list (#356): the empty state takes the place of the
|
||||
list and tells the user what to do next, which depends on whether a
|
||||
folder is configured. SourceList itself stays a pure list.
|
||||
folder is configured. SourceList itself stays a pure list. Neither
|
||||
shows before the folder is read (#384): the "no folder" variant, or
|
||||
the list's own empty box, would flash.
|
||||
*/}
|
||||
{showEmptyState ? (
|
||||
{state.importFolderLoaded &&
|
||||
(showEmptyState ? (
|
||||
<ImportEmptyState
|
||||
hasFolder={state.importFolder !== null}
|
||||
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
|
||||
|
|
@ -114,6 +145,7 @@ export default function ImportPage() {
|
|||
onCreateSample={createSampleFolders}
|
||||
onRefresh={refreshFolder}
|
||||
isCreatingSample={state.isLoading}
|
||||
onboardingTarget={target === "emptyStateAction"}
|
||||
/>
|
||||
) : (
|
||||
<SourceList
|
||||
|
|
@ -121,8 +153,9 @@ export default function ImportPage() {
|
|||
configuredSourceNames={state.configuredSourceNames}
|
||||
importedFileNames={state.importedFilesBySource}
|
||||
onSelectSource={selectSource}
|
||||
onboardingTarget={target === "firstSource"}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
<ImportHistoryPanel onChanged={refreshFolder} />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -161,6 +194,7 @@ export default function ImportPage() {
|
|||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.preview")}
|
||||
nextDisabled={nextDisabled || state.isLoading}
|
||||
onboardingTarget={target === "wizardNext"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -192,6 +226,7 @@ export default function ImportPage() {
|
|||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.checkDuplicates")}
|
||||
nextDisabled={state.isLoading}
|
||||
onboardingTarget={target === "wizardNext"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -210,6 +245,7 @@ export default function ImportPage() {
|
|||
onNext={() => goToStep("confirm")}
|
||||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.confirm")}
|
||||
onboardingTarget={target === "wizardNext"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -230,6 +266,7 @@ export default function ImportPage() {
|
|||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.import")}
|
||||
showCancel={false}
|
||||
onboardingTarget={target === "wizardNext"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1001,3 +1001,23 @@ export interface ChecklistItem {
|
|||
* successful load: a later failed re-read keeps `ready` and the last state.
|
||||
*/
|
||||
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
164
src/styles.contrast.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -148,6 +148,37 @@ body {
|
|||
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 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
|
|
|||
|
|
@ -3,17 +3,24 @@ import { computeChecklist, type Checklist } from "./onboardingChecklist";
|
|||
import {
|
||||
CARD_PAGES,
|
||||
bannerMode,
|
||||
cardNext,
|
||||
cardStep,
|
||||
importCardModel,
|
||||
latchCardPage,
|
||||
nextStep,
|
||||
onboardingTargetAttributes,
|
||||
readOnboardingNavState,
|
||||
showsStepCard,
|
||||
stepNavState,
|
||||
stepsOfPage,
|
||||
type BannerModeInput,
|
||||
type ImportCardModel,
|
||||
} from "./onboardingPath";
|
||||
import {
|
||||
CHECKLIST_ITEM_KEYS,
|
||||
type ChecklistItem,
|
||||
type ChecklistItemKey,
|
||||
type ImportCardInput,
|
||||
type OnboardingCounters,
|
||||
} from "../shared/types";
|
||||
|
||||
|
|
@ -334,3 +341,241 @@ describe("stepNavState (#397)", () => {
|
|||
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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
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 step cards of the next issues only read what they decide. Nothing here
|
||||
* touches React, the router or the database.
|
||||
* The provider (`useOnboardingChecklist`) feeds them its state; the banner,
|
||||
* the step cards and the pages that carry a card only read what they decide.
|
||||
* 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
|
||||
* adds its page, and a page without a delivered card keeps the banner, so a
|
||||
* partly merged stack never removes any guidance.
|
||||
* Routes whose page carries a step card. Each card issue adds its page (#398:
|
||||
* Import), and a page without a delivered card keeps the banner, so a partly
|
||||
* 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[]>> = {
|
||||
"/import": ["importFolder", "source", "import"],
|
||||
|
|
@ -145,3 +150,203 @@ export function readOnboardingNavState(state: unknown): OnboardingNavState {
|
|||
const flags = onboarding as { focus?: unknown; guided?: unknown };
|
||||
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" } : {};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue