feat(onboarding): Import empty states, sample folder button and guide links (#356) #382
9 changed files with 552 additions and 17 deletions
163
src/components/import/ImportEmptyState.test.tsx
Normal file
163
src/components/import/ImportEmptyState.test.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// ImportEmptyState — unit tests (#356).
|
||||||
|
//
|
||||||
|
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
|
||||||
|
// mocked, the component called as a function and its element tree walked.
|
||||||
|
// `t` returns the key, with the interpolation values appended, so the asserts
|
||||||
|
// read the i18n keys and never a hard-coded string. The inner `EmptyState` is
|
||||||
|
// expanded (called as a function too) so its buttons and links are visible.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `${key}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : key,
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
FolderOpen: () => null,
|
||||||
|
Inbox: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { FolderOpen, Inbox } from "lucide-react";
|
||||||
|
import ImportEmptyState, {
|
||||||
|
CSV_EXPORT_GUIDE_PATH,
|
||||||
|
folderNameFromPath,
|
||||||
|
type ImportEmptyStateProps,
|
||||||
|
} from "./ImportEmptyState";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
|
||||||
|
const LEAVES = new Set<unknown>([Link, FolderOpen, Inbox]);
|
||||||
|
|
||||||
|
/** Resolves the inner `EmptyState` component so its buttons and links are visible. */
|
||||||
|
function expand(node: ReactNode): ReactNode {
|
||||||
|
if (Array.isArray(node)) return node.map(expand);
|
||||||
|
if (!isValidElement(node)) return node;
|
||||||
|
const el = node as AnyElement;
|
||||||
|
if (typeof el.type === "function" && !LEAVES.has(el.type)) {
|
||||||
|
const render = el.type as (props: Record<string, unknown>) => ReactNode;
|
||||||
|
return expand(render(el.props));
|
||||||
|
}
|
||||||
|
return { ...el, props: { ...el.props, children: expand(el.props.children) } } as ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonEl = ReactElement<{ type: string; disabled: boolean; onClick: () => void; children?: ReactNode }>;
|
||||||
|
type LinkEl = ReactElement<{ to: string; children?: ReactNode }>;
|
||||||
|
|
||||||
|
function render(overrides: Partial<ImportEmptyStateProps> = {}) {
|
||||||
|
const props: ImportEmptyStateProps = {
|
||||||
|
hasFolder: false,
|
||||||
|
folderName: null,
|
||||||
|
onBrowse: vi.fn(),
|
||||||
|
onCreateSample: vi.fn(),
|
||||||
|
onRefresh: vi.fn(),
|
||||||
|
isCreatingSample: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const tree = expand(ImportEmptyState(props));
|
||||||
|
const buttons = find(tree, (e) => e.type === "button") as ButtonEl[];
|
||||||
|
const links = find(tree, (e) => e.type === Link) as LinkEl[];
|
||||||
|
return { props, tree, buttons, links };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ImportEmptyState — no folder (#356)", () => {
|
||||||
|
it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => {
|
||||||
|
const { props, tree, buttons, links } = render({ hasFolder: false });
|
||||||
|
|
||||||
|
expect(texts(tree)).toContain("import.empty.noFolder.title");
|
||||||
|
expect(texts(tree)).toContain("import.empty.noFolder.description");
|
||||||
|
|
||||||
|
expect(buttons).toHaveLength(1);
|
||||||
|
expect(texts(buttons[0].props.children)).toContain("import.empty.noFolder.cta");
|
||||||
|
expect(buttons[0].props.disabled).toBe(false);
|
||||||
|
buttons[0].props.onClick();
|
||||||
|
expect(props.onBrowse).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onCreateSample).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
expect(links[0].props.to).toBe(CSV_EXPORT_GUIDE_PATH);
|
||||||
|
expect(links[0].props.to).toBe("/settings/users#import-export");
|
||||||
|
expect(texts(links[0].props.children)).toContain("import.empty.guideLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never shows the sample or refresh actions without a folder", () => {
|
||||||
|
const { tree } = render({ hasFolder: false });
|
||||||
|
expect(texts(tree)).not.toContain("import.empty.createSample");
|
||||||
|
expect(texts(tree)).not.toContain("import.empty.refresh");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ImportEmptyState — folder without sources (#356)", () => {
|
||||||
|
it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => {
|
||||||
|
const { props, tree, buttons, links } = render({ hasFolder: true, folderName: "Relevés" });
|
||||||
|
|
||||||
|
expect(texts(tree)).toContain("import.empty.noSources.title");
|
||||||
|
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
expect(texts(buttons[0].props.children)).toContain("import.empty.createSample");
|
||||||
|
expect(buttons[0].props.disabled).toBe(false);
|
||||||
|
buttons[0].props.onClick();
|
||||||
|
expect(props.onCreateSample).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(texts(buttons[1].props.children)).toContain("import.empty.refresh");
|
||||||
|
buttons[1].props.onClick();
|
||||||
|
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onBrowse).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
expect(links[0].props.to).toBe("/settings/users#import-export");
|
||||||
|
expect(texts(links[0].props.children)).toContain("import.empty.guideLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the create button while the sample folders are being written", () => {
|
||||||
|
const { buttons } = render({ hasFolder: true, folderName: "Relevés", isCreatingSample: true });
|
||||||
|
expect(buttons[0].props.disabled).toBe(true);
|
||||||
|
// Refresh stays available: it is not the action in flight.
|
||||||
|
expect(buttons[1].props.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the folder in the description, never the full path", () => {
|
||||||
|
const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés");
|
||||||
|
const { tree } = render({ hasFolder: true, folderName });
|
||||||
|
const description = texts(tree).find((s) => s.startsWith("import.empty.noSources.description"));
|
||||||
|
|
||||||
|
expect(description).toBe("import.empty.noSources.description[folderName=Relevés]");
|
||||||
|
expect(description).not.toContain("Users");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("folderNameFromPath (#356)", () => {
|
||||||
|
it("returns the last segment for POSIX and Windows paths", () => {
|
||||||
|
expect(folderNameFromPath("/home/max/releves")).toBe("releves");
|
||||||
|
expect(folderNameFromPath("C:\\Users\\max\\Relevés")).toBe("Relevés");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores trailing separators", () => {
|
||||||
|
expect(folderNameFromPath("/home/max/releves/")).toBe("releves");
|
||||||
|
expect(folderNameFromPath("C:\\Users\\max\\Relevés\\")).toBe("Relevés");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the path itself when it has no segment", () => {
|
||||||
|
expect(folderNameFromPath("/")).toBe("/");
|
||||||
|
expect(folderNameFromPath("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
87
src/components/import/ImportEmptyState.tsx
Normal file
87
src/components/import/ImportEmptyState.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { FolderOpen, Inbox } from "lucide-react";
|
||||||
|
import EmptyState from "../shared/EmptyState";
|
||||||
|
|
||||||
|
/** Where the user guide explains how to export a CSV from each bank (#355). */
|
||||||
|
export const CSV_EXPORT_GUIDE_PATH = "/settings/users#import-export";
|
||||||
|
|
||||||
|
export interface ImportEmptyStateProps {
|
||||||
|
/** An import folder is configured; the empty state then speaks of its content. */
|
||||||
|
hasFolder: boolean;
|
||||||
|
/** Last segment of the import folder path, never the full path. */
|
||||||
|
folderName: string | null;
|
||||||
|
onBrowse: () => void;
|
||||||
|
onCreateSample: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
/** The sample folders are being written: the primary button is disabled. */
|
||||||
|
isCreatingSample: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last segment of a filesystem path, whichever separator the OS used, with
|
||||||
|
* trailing separators ignored (`C:\Users\max\Relevés\` → `Relevés`). The full
|
||||||
|
* path is already shown by the folder card; the empty state only names it.
|
||||||
|
*/
|
||||||
|
export function folderNameFromPath(path: string): string {
|
||||||
|
const segments = path.split(/[\\/]+/).filter((s) => s.length > 0);
|
||||||
|
return segments.length > 0 ? segments[segments.length - 1] : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state of the import page (#356), replacing the empty `SourceList` in
|
||||||
|
* both of its situations: no import folder yet (choose one), or a folder with
|
||||||
|
* no bank sub-folder (create the sample folders, or refresh after dropping
|
||||||
|
* files). Both variants link to the bank CSV export guide. Pure: no hook
|
||||||
|
* besides `useTranslation`, so it is tested by direct call.
|
||||||
|
*/
|
||||||
|
export default function ImportEmptyState({
|
||||||
|
hasFolder,
|
||||||
|
folderName,
|
||||||
|
onBrowse,
|
||||||
|
onCreateSample,
|
||||||
|
onRefresh,
|
||||||
|
isCreatingSample,
|
||||||
|
}: ImportEmptyStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const guideLink = (
|
||||||
|
<p className="text-center">
|
||||||
|
<Link
|
||||||
|
to={CSV_EXPORT_GUIDE_PATH}
|
||||||
|
className="text-sm text-[var(--primary)] hover:underline"
|
||||||
|
>
|
||||||
|
{t("import.empty.guideLink")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasFolder) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
{guideLink}
|
||||||
|
</EmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Inbox size={40} />}
|
||||||
|
title={t("import.empty.noSources.title")}
|
||||||
|
description={t("import.empty.noSources.description", { folderName: folderName ?? "" })}
|
||||||
|
primaryAction={{
|
||||||
|
label: t("import.empty.createSample"),
|
||||||
|
onClick: onCreateSample,
|
||||||
|
disabled: isCreatingSample,
|
||||||
|
}}
|
||||||
|
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
|
||||||
|
>
|
||||||
|
{guideLink}
|
||||||
|
</EmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -50,9 +50,18 @@ export default function ImportFolderConfig({
|
||||||
{t("import.folder.refresh")}
|
{t("import.folder.refresh")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/*
|
||||||
|
Without a folder the empty state below carries the primary "choose
|
||||||
|
a folder" button (#356); this one steps back to the secondary style
|
||||||
|
so a single primary button is on screen.
|
||||||
|
*/}
|
||||||
<button
|
<button
|
||||||
onClick={onBrowse}
|
onClick={onBrowse}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
className={
|
||||||
|
folderPath
|
||||||
|
? "flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
||||||
|
: "flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FolderOpen size={14} />
|
<FolderOpen size={14} />
|
||||||
{t("import.folder.browse")}
|
{t("import.folder.browse")}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { CircleHelp, X } from "lucide-react";
|
import { CircleHelp, X } from "lucide-react";
|
||||||
|
|
||||||
export function PageHelp({ helpKey }: { helpKey: string }) {
|
interface PageHelpProps {
|
||||||
|
helpKey: string;
|
||||||
|
/**
|
||||||
|
* Optional route rendered as a link under the tips (#356), labelled by
|
||||||
|
* `${helpKey}.help.linkLabel`. No close handler on click: navigating away
|
||||||
|
* unmounts the page and the popover with it.
|
||||||
|
*/
|
||||||
|
linkTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHelp({ helpKey, linkTo }: PageHelpProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -52,6 +63,14 @@ export function PageHelp({ helpKey }: { helpKey: string }) {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{linkTo && (
|
||||||
|
<Link
|
||||||
|
to={linkTo}
|
||||||
|
className="inline-block mt-3 text-sm text-[var(--primary)] hover:underline"
|
||||||
|
>
|
||||||
|
{t(`${helpKey}.help.linkLabel`)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
121
src/hooks/useImportWizard.test.ts
Normal file
121
src/hooks/useImportWizard.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// useImportWizard — reducer unit tests (#356).
|
||||||
|
//
|
||||||
|
// No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts):
|
||||||
|
// the EXPORTED reducer and initial state are driven directly. What is pinned
|
||||||
|
// here is the lifecycle of `sampleCreated`, the names behind the "sample
|
||||||
|
// folders created" banner: set by its own action, dropped by `RESET` (the
|
||||||
|
// wizard's `reset`) and by `SET_IMPORT_FOLDER` (what `browseFolder`
|
||||||
|
// dispatches), and never touched by an error — which also leaves the scanned
|
||||||
|
// sources exactly as they were. Plus the local-date helper handed to Rust.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
// The hook module imports the import services (→ db → tauri plugins). Stub the
|
||||||
|
// connection so importing the module never touches a real database.
|
||||||
|
vi.mock("../services/db", () => {
|
||||||
|
const getDb = vi.fn();
|
||||||
|
return {
|
||||||
|
getDb,
|
||||||
|
connectToProfile: vi.fn(),
|
||||||
|
initializeNewProfileDb: vi.fn(),
|
||||||
|
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
wizardReducer,
|
||||||
|
initialWizardState,
|
||||||
|
formatLocalDate,
|
||||||
|
type WizardAction,
|
||||||
|
type WizardState,
|
||||||
|
} from "./useImportWizard";
|
||||||
|
import type { ScannedSource } from "../shared/types";
|
||||||
|
|
||||||
|
const NAMES = ["Exemple Desjardins", "Exemple Tangerine"];
|
||||||
|
|
||||||
|
const sources: ScannedSource[] = [
|
||||||
|
{ folder_name: "Exemple Desjardins", folder_path: "/releves/Exemple Desjardins", files: [] },
|
||||||
|
{ folder_name: "Exemple Tangerine", folder_path: "/releves/Exemple Tangerine", files: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const reduce = (actions: WizardAction[], from: WizardState = initialWizardState): WizardState =>
|
||||||
|
actions.reduce(wizardReducer, from);
|
||||||
|
|
||||||
|
/** The state right after a successful creation: folder set, sources rescanned, banner up. */
|
||||||
|
const afterCreation = (): WizardState =>
|
||||||
|
reduce([
|
||||||
|
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
|
||||||
|
{ type: "SET_SCANNED_SOURCES", payload: sources },
|
||||||
|
{ type: "SET_SAMPLE_CREATED", payload: NAMES },
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("wizardReducer — sampleCreated (#356)", () => {
|
||||||
|
it("starts with no sample folders to report", () => {
|
||||||
|
expect(initialWizardState.sampleCreated).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SET_SAMPLE_CREATED stores the folder names and nothing else", () => {
|
||||||
|
const before = reduce([
|
||||||
|
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
|
||||||
|
{ type: "SET_SCANNED_SOURCES", payload: sources },
|
||||||
|
]);
|
||||||
|
const after = wizardReducer(before, { type: "SET_SAMPLE_CREATED", payload: NAMES });
|
||||||
|
|
||||||
|
expect(after.sampleCreated).toEqual(NAMES);
|
||||||
|
expect(after.scannedSources).toBe(before.scannedSources);
|
||||||
|
expect(after.importFolder).toBe("/releves");
|
||||||
|
expect(after.step).toBe("source-list");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SET_SAMPLE_CREATED null dismisses the banner", () => {
|
||||||
|
const after = wizardReducer(afterCreation(), { type: "SET_SAMPLE_CREATED", payload: null });
|
||||||
|
expect(after.sampleCreated).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RESET clears it while keeping the folder and the scanned sources", () => {
|
||||||
|
const before = afterCreation();
|
||||||
|
const after = wizardReducer(before, { type: "RESET" });
|
||||||
|
|
||||||
|
expect(after.sampleCreated).toBeNull();
|
||||||
|
expect(after.importFolder).toBe("/releves");
|
||||||
|
expect(after.scannedSources).toBe(before.scannedSources);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SET_IMPORT_FOLDER (what browseFolder dispatches) clears it", () => {
|
||||||
|
const after = wizardReducer(afterCreation(), {
|
||||||
|
type: "SET_IMPORT_FOLDER",
|
||||||
|
payload: "/autre-dossier",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(after.sampleCreated).toBeNull();
|
||||||
|
expect(after.importFolder).toBe("/autre-dossier");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a creation error leaves the scanned sources untouched and raises no banner", () => {
|
||||||
|
const before = reduce([
|
||||||
|
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
|
||||||
|
{ type: "SET_SCANNED_SOURCES", payload: [] },
|
||||||
|
{ type: "SET_LOADING", payload: true },
|
||||||
|
]);
|
||||||
|
const after = wizardReducer(before, {
|
||||||
|
type: "SET_ERROR",
|
||||||
|
payload: "Folder already exists: Exemple Desjardins",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(after.error).toBe("Folder already exists: Exemple Desjardins");
|
||||||
|
expect(after.scannedSources).toBe(before.scannedSources);
|
||||||
|
expect(after.scannedSources).toEqual([]);
|
||||||
|
expect(after.sampleCreated).toBeNull();
|
||||||
|
expect(after.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatLocalDate (#356)", () => {
|
||||||
|
it("formats the LOCAL calendar date, zero-padded", () => {
|
||||||
|
// Built from local components, so the expectation holds in every timezone —
|
||||||
|
// unlike `toISOString()`, which would shift this evening date to the 6th
|
||||||
|
// east of Greenwich and to the 4th west of it.
|
||||||
|
expect(formatLocalDate(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
|
||||||
|
expect(formatLocalDate(new Date(2026, 11, 31, 0, 0))).toBe("2026-12-31");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -59,6 +59,7 @@ import {
|
||||||
mapRow,
|
mapRow,
|
||||||
ROW_ERROR_KEYS,
|
ROW_ERROR_KEYS,
|
||||||
} from "../utils/importFormat";
|
} from "../utils/importFormat";
|
||||||
|
import { logInfo } from "../services/logService";
|
||||||
|
|
||||||
/** Error text for the banner: an i18n key when we have one, the raw message otherwise. */
|
/** Error text for the banner: an i18n key when we have one, the raw message otherwise. */
|
||||||
function errorMessage(e: unknown): string {
|
function errorMessage(e: unknown): string {
|
||||||
|
|
@ -66,7 +67,7 @@ function errorMessage(e: unknown): string {
|
||||||
return e instanceof Error ? e.message : String(e);
|
return e instanceof Error ? e.message : String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WizardState {
|
export interface WizardState {
|
||||||
step: ImportWizardStep;
|
step: ImportWizardStep;
|
||||||
importFolder: string | null;
|
importFolder: string | null;
|
||||||
scannedSources: ScannedSource[];
|
scannedSources: ScannedSource[];
|
||||||
|
|
@ -109,9 +110,16 @@ interface WizardState {
|
||||||
* is still worth reporting, there is just nothing to adopt.
|
* is still worth reporting, there is just nothing to adopt.
|
||||||
*/
|
*/
|
||||||
driftConfig: SourceConfig | null;
|
driftConfig: SourceConfig | null;
|
||||||
|
/**
|
||||||
|
* Names of the sample folders the last "create a sample folder" action wrote
|
||||||
|
* (#356), shown in the success banner. Null when there is nothing to report:
|
||||||
|
* initially, after the banner is dismissed, and whenever the import folder
|
||||||
|
* changes — a banner naming folders of another folder would be a lie.
|
||||||
|
*/
|
||||||
|
sampleCreated: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WizardAction =
|
export type WizardAction =
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_ERROR"; payload: string | null }
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
| { type: "SET_STEP"; payload: ImportWizardStep }
|
| { type: "SET_STEP"; payload: ImportWizardStep }
|
||||||
|
|
@ -136,6 +144,7 @@ type WizardAction =
|
||||||
type: "SET_FORMAT_DRIFT";
|
type: "SET_FORMAT_DRIFT";
|
||||||
payload: { entries: HeaderDriftEntry[]; config: SourceConfig | null } | null;
|
payload: { entries: HeaderDriftEntry[]; config: SourceConfig | null } | null;
|
||||||
}
|
}
|
||||||
|
| { type: "SET_SAMPLE_CREATED"; payload: string[] | null }
|
||||||
| { type: "RESET" };
|
| { type: "RESET" };
|
||||||
|
|
||||||
const defaultConfig: SourceConfig = {
|
const defaultConfig: SourceConfig = {
|
||||||
|
|
@ -174,9 +183,13 @@ const initialState: WizardState = {
|
||||||
detectedBank: null,
|
detectedBank: null,
|
||||||
formatDrift: null,
|
formatDrift: null,
|
||||||
driftConfig: null,
|
driftConfig: null,
|
||||||
|
sampleCreated: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function reducer(state: WizardState, action: WizardAction): WizardState {
|
/** Exported for the unit tests (#356): the hook is driven through this reducer. */
|
||||||
|
export const initialWizardState: WizardState = initialState;
|
||||||
|
|
||||||
|
export function wizardReducer(state: WizardState, action: WizardAction): WizardState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "SET_LOADING":
|
case "SET_LOADING":
|
||||||
return { ...state, isLoading: action.payload };
|
return { ...state, isLoading: action.payload };
|
||||||
|
|
@ -185,7 +198,8 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
||||||
case "SET_STEP":
|
case "SET_STEP":
|
||||||
return { ...state, step: action.payload };
|
return { ...state, step: action.payload };
|
||||||
case "SET_IMPORT_FOLDER":
|
case "SET_IMPORT_FOLDER":
|
||||||
return { ...state, importFolder: action.payload };
|
// The sample-folder banner describes the previous folder: drop it.
|
||||||
|
return { ...state, importFolder: action.payload, sampleCreated: null };
|
||||||
case "SET_SCANNED_SOURCES":
|
case "SET_SCANNED_SOURCES":
|
||||||
return { ...state, scannedSources: action.payload, isLoading: false };
|
return { ...state, scannedSources: action.payload, isLoading: false };
|
||||||
case "SET_SELECTED_SOURCE":
|
case "SET_SELECTED_SOURCE":
|
||||||
|
|
@ -271,6 +285,8 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
||||||
formatDrift: action.payload?.entries ?? null,
|
formatDrift: action.payload?.entries ?? null,
|
||||||
driftConfig: action.payload?.config ?? null,
|
driftConfig: action.payload?.config ?? null,
|
||||||
};
|
};
|
||||||
|
case "SET_SAMPLE_CREATED":
|
||||||
|
return { ...state, sampleCreated: action.payload };
|
||||||
case "RESET":
|
case "RESET":
|
||||||
return {
|
return {
|
||||||
...initialState,
|
...initialState,
|
||||||
|
|
@ -285,8 +301,20 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The webview's LOCAL calendar date as `YYYY-MM-DD` (#356). Built from the
|
||||||
|
* local getters, never `toISOString()`, which is UTC and would hand the Rust
|
||||||
|
* side yesterday's date to anyone west of Greenwich in the evening.
|
||||||
|
*/
|
||||||
|
export function formatLocalDate(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function useImportWizard() {
|
export function useImportWizard() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(wizardReducer, initialState);
|
||||||
|
|
||||||
// Load import folder on mount
|
// Load import folder on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -363,6 +391,39 @@ export function useImportWizard() {
|
||||||
}
|
}
|
||||||
}, [state.importFolder, scanFolderInternal]);
|
}, [state.importFolder, scanFolderInternal]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the two sample bank folders into the import folder (#356), rescan
|
||||||
|
* it so their cards show up without another click, then raise the success
|
||||||
|
* banner. The Rust command refuses when either folder already exists and
|
||||||
|
* writes nothing; its message lands in the regular error banner. Only the
|
||||||
|
* folder NAMES reach the logs: the logs travel with a feedback report, the
|
||||||
|
* absolute path of the import folder must not.
|
||||||
|
*/
|
||||||
|
const createSampleFolders = useCallback(async () => {
|
||||||
|
const folder = state.importFolder;
|
||||||
|
if (!folder) return;
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
try {
|
||||||
|
const names = await invoke<string[]>("create_sample_import_folders", {
|
||||||
|
importFolder: folder,
|
||||||
|
today: formatLocalDate(new Date()),
|
||||||
|
});
|
||||||
|
logInfo(`[import] Sample folders created: ${names.join(", ")}`);
|
||||||
|
await scanFolderInternal(folder);
|
||||||
|
dispatch({ type: "SET_SAMPLE_CREATED", payload: names });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_ERROR",
|
||||||
|
payload: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [state.importFolder, scanFolderInternal]);
|
||||||
|
|
||||||
|
const dismissSampleCreated = useCallback(() => {
|
||||||
|
dispatch({ type: "SET_SAMPLE_CREATED", payload: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run detection on one file and report the outcome. Returns the detected
|
* Run detection on one file and report the outcome. Returns the detected
|
||||||
* format merged onto `base`, or null when detection produced no usable
|
* format merged onto `base`, or null when detection produced no usable
|
||||||
|
|
@ -1264,6 +1325,8 @@ export function useImportWizard() {
|
||||||
state,
|
state,
|
||||||
browseFolder,
|
browseFolder,
|
||||||
refreshFolder,
|
refreshFolder,
|
||||||
|
createSampleFolders,
|
||||||
|
dismissSampleCreated,
|
||||||
selectSource,
|
selectSource,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
toggleFile,
|
toggleFile,
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,23 @@
|
||||||
"Click a source to configure column mapping, delimiter, and date format",
|
"Click a source to configure column mapping, delimiter, and date format",
|
||||||
"Preview your data before importing to catch formatting issues",
|
"Preview your data before importing to catch formatting issues",
|
||||||
"Duplicate detection prevents the same transactions from being imported twice"
|
"Duplicate detection prevents the same transactions from being imported twice"
|
||||||
]
|
],
|
||||||
|
"linkLabel": "How do I export a CSV from my bank?"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noFolder": {
|
||||||
|
"title": "No import folder",
|
||||||
|
"description": "Start by choosing the folder where you will drop your statements",
|
||||||
|
"cta": "Choose a folder"
|
||||||
|
},
|
||||||
|
"noSources": {
|
||||||
|
"title": "No source found",
|
||||||
|
"description": "Create one subfolder per bank in {{folderName}} and drop your CSV files there"
|
||||||
|
},
|
||||||
|
"createSample": "Create a sample folder",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"guideLink": "How do I export a CSV from my bank?",
|
||||||
|
"sampleCreated": "Two sample folders were created: {{names}}. Click one of them to start the import. Delete them once your real statements are in place."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,23 @@
|
||||||
"Cliquez sur une source pour configurer le mapping des colonnes, le délimiteur et le format de date",
|
"Cliquez sur une source pour configurer le mapping des colonnes, le délimiteur et le format de date",
|
||||||
"Prévisualisez vos données avant l'import pour détecter les problèmes de formatage",
|
"Prévisualisez vos données avant l'import pour détecter les problèmes de formatage",
|
||||||
"La détection des doublons empêche d'importer les mêmes transactions deux fois"
|
"La détection des doublons empêche d'importer les mêmes transactions deux fois"
|
||||||
]
|
],
|
||||||
|
"linkLabel": "Comment exporter un CSV depuis ma banque ?"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noFolder": {
|
||||||
|
"title": "Aucun dossier d'import",
|
||||||
|
"description": "Commencez par choisir le dossier où vous déposerez vos relevés",
|
||||||
|
"cta": "Choisir un dossier"
|
||||||
|
},
|
||||||
|
"noSources": {
|
||||||
|
"title": "Aucune source trouvée",
|
||||||
|
"description": "Créez un sous-dossier par banque dans {{folderName}} et déposez-y vos fichiers CSV"
|
||||||
|
},
|
||||||
|
"createSample": "Créer un dossier d'exemple",
|
||||||
|
"refresh": "Actualiser",
|
||||||
|
"guideLink": "Comment exporter un CSV depuis ma banque ?",
|
||||||
|
"sampleCreated": "Deux dossiers d'exemple ont été créés : {{names}}. Cliquez sur l'un d'eux pour lancer l'import. Supprimez-les quand vos vrais relevés sont en place."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useImportWizard } from "../hooks/useImportWizard";
|
import { useImportWizard } from "../hooks/useImportWizard";
|
||||||
import ImportFolderConfig from "../components/import/ImportFolderConfig";
|
import ImportFolderConfig from "../components/import/ImportFolderConfig";
|
||||||
import SourceList from "../components/import/SourceList";
|
import SourceList from "../components/import/SourceList";
|
||||||
|
import ImportEmptyState, {
|
||||||
|
CSV_EXPORT_GUIDE_PATH,
|
||||||
|
folderNameFromPath,
|
||||||
|
} from "../components/import/ImportEmptyState";
|
||||||
import SourceConfigPanel from "../components/import/SourceConfigPanel";
|
import SourceConfigPanel from "../components/import/SourceConfigPanel";
|
||||||
import FilePreviewTable from "../components/import/FilePreviewTable";
|
import FilePreviewTable from "../components/import/FilePreviewTable";
|
||||||
import FormatDriftPanel from "../components/import/FormatDriftPanel";
|
import FormatDriftPanel from "../components/import/FormatDriftPanel";
|
||||||
|
|
@ -11,7 +15,7 @@ import ImportProgress from "../components/import/ImportProgress";
|
||||||
import ImportReportPanel from "../components/import/ImportReportPanel";
|
import ImportReportPanel from "../components/import/ImportReportPanel";
|
||||||
import WizardNavigation from "../components/import/WizardNavigation";
|
import WizardNavigation from "../components/import/WizardNavigation";
|
||||||
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
|
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle, CheckCircle, X } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
|
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
|
|
@ -20,6 +24,8 @@ export default function ImportPage() {
|
||||||
state,
|
state,
|
||||||
browseFolder,
|
browseFolder,
|
||||||
refreshFolder,
|
refreshFolder,
|
||||||
|
createSampleFolders,
|
||||||
|
dismissSampleCreated,
|
||||||
selectSource,
|
selectSource,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
toggleFile,
|
toggleFile,
|
||||||
|
|
@ -42,12 +48,13 @@ export default function ImportPage() {
|
||||||
} = useImportWizard();
|
} = useImportWizard();
|
||||||
|
|
||||||
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
|
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
|
||||||
|
const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative flex items-center gap-3 mb-6">
|
<div className="relative flex items-center gap-3 mb-6">
|
||||||
<h1 className="text-2xl font-bold">{t("import.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("import.title")}</h1>
|
||||||
<PageHelp helpKey="import" />
|
<PageHelp helpKey="import" linkTo={CSV_EXPORT_GUIDE_PATH} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
|
|
@ -65,6 +72,24 @@ export default function ImportPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sample folders written (#356): same banner as the error, closable */}
|
||||||
|
{state.sampleCreated && (
|
||||||
|
<div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--positive)] flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-[var(--positive)] shrink-0" />
|
||||||
|
<p className="text-sm text-[var(--foreground)] flex-1">
|
||||||
|
{t("import.empty.sampleCreated", { names: state.sampleCreated.join(", ") })}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismissSampleCreated}
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Folder config - always visible */}
|
{/* Folder config - always visible */}
|
||||||
<ImportFolderConfig
|
<ImportFolderConfig
|
||||||
folderPath={state.importFolder}
|
folderPath={state.importFolder}
|
||||||
|
|
@ -76,12 +101,28 @@ export default function ImportPage() {
|
||||||
{/* Wizard steps */}
|
{/* Wizard steps */}
|
||||||
{state.step === "source-list" && (
|
{state.step === "source-list" && (
|
||||||
<>
|
<>
|
||||||
<SourceList
|
{/*
|
||||||
sources={state.scannedSources}
|
No source to list (#356): the empty state takes the place of the
|
||||||
configuredSourceNames={state.configuredSourceNames}
|
list and tells the user what to do next, which depends on whether a
|
||||||
importedFileNames={state.importedFilesBySource}
|
folder is configured. SourceList itself stays a pure list.
|
||||||
onSelectSource={selectSource}
|
*/}
|
||||||
/>
|
{showEmptyState ? (
|
||||||
|
<ImportEmptyState
|
||||||
|
hasFolder={state.importFolder !== null}
|
||||||
|
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
|
||||||
|
onBrowse={browseFolder}
|
||||||
|
onCreateSample={createSampleFolders}
|
||||||
|
onRefresh={refreshFolder}
|
||||||
|
isCreatingSample={state.isLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SourceList
|
||||||
|
sources={state.scannedSources}
|
||||||
|
configuredSourceNames={state.configuredSourceNames}
|
||||||
|
importedFileNames={state.importedFilesBySource}
|
||||||
|
onSelectSource={selectSource}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ImportHistoryPanel onChanged={refreshFolder} />
|
<ImportHistoryPanel onChanged={refreshFolder} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue