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")}
|
||||
</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
|
||||
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} />
|
||||
{t("import.folder.browse")}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
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 [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -52,6 +63,14 @@ export function PageHelp({ helpKey }: { helpKey: string }) {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{linkTo && (
|
||||
<Link
|
||||
to={linkTo}
|
||||
className="inline-block mt-3 text-sm text-[var(--primary)] hover:underline"
|
||||
>
|
||||
{t(`${helpKey}.help.linkLabel`)}
|
||||
</Link>
|
||||
)}
|
||||
</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,
|
||||
ROW_ERROR_KEYS,
|
||||
} from "../utils/importFormat";
|
||||
import { logInfo } from "../services/logService";
|
||||
|
||||
/** Error text for the banner: an i18n key when we have one, the raw message otherwise. */
|
||||
function errorMessage(e: unknown): string {
|
||||
|
|
@ -66,7 +67,7 @@ function errorMessage(e: unknown): string {
|
|||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
interface WizardState {
|
||||
export interface WizardState {
|
||||
step: ImportWizardStep;
|
||||
importFolder: string | null;
|
||||
scannedSources: ScannedSource[];
|
||||
|
|
@ -109,9 +110,16 @@ interface WizardState {
|
|||
* is still worth reporting, there is just nothing to adopt.
|
||||
*/
|
||||
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_ERROR"; payload: string | null }
|
||||
| { type: "SET_STEP"; payload: ImportWizardStep }
|
||||
|
|
@ -136,6 +144,7 @@ type WizardAction =
|
|||
type: "SET_FORMAT_DRIFT";
|
||||
payload: { entries: HeaderDriftEntry[]; config: SourceConfig | null } | null;
|
||||
}
|
||||
| { type: "SET_SAMPLE_CREATED"; payload: string[] | null }
|
||||
| { type: "RESET" };
|
||||
|
||||
const defaultConfig: SourceConfig = {
|
||||
|
|
@ -174,9 +183,13 @@ const initialState: WizardState = {
|
|||
detectedBank: null,
|
||||
formatDrift: 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) {
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
|
|
@ -185,7 +198,8 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
|||
case "SET_STEP":
|
||||
return { ...state, step: action.payload };
|
||||
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":
|
||||
return { ...state, scannedSources: action.payload, isLoading: false };
|
||||
case "SET_SELECTED_SOURCE":
|
||||
|
|
@ -271,6 +285,8 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
|||
formatDrift: action.payload?.entries ?? null,
|
||||
driftConfig: action.payload?.config ?? null,
|
||||
};
|
||||
case "SET_SAMPLE_CREATED":
|
||||
return { ...state, sampleCreated: action.payload };
|
||||
case "RESET":
|
||||
return {
|
||||
...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() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [state, dispatch] = useReducer(wizardReducer, initialState);
|
||||
|
||||
// Load import folder on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -363,6 +391,39 @@ export function useImportWizard() {
|
|||
}
|
||||
}, [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
|
||||
* format merged onto `base`, or null when detection produced no usable
|
||||
|
|
@ -1264,6 +1325,8 @@ export function useImportWizard() {
|
|||
state,
|
||||
browseFolder,
|
||||
refreshFolder,
|
||||
createSampleFolders,
|
||||
dismissSampleCreated,
|
||||
selectSource,
|
||||
updateConfig,
|
||||
toggleFile,
|
||||
|
|
|
|||
|
|
@ -253,7 +253,23 @@
|
|||
"Click a source to configure column mapping, delimiter, and date format",
|
||||
"Preview your data before importing to catch formatting issues",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -253,7 +253,23 @@
|
|||
"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",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useTranslation } from "react-i18next";
|
|||
import { useImportWizard } from "../hooks/useImportWizard";
|
||||
import ImportFolderConfig from "../components/import/ImportFolderConfig";
|
||||
import SourceList from "../components/import/SourceList";
|
||||
import ImportEmptyState, {
|
||||
CSV_EXPORT_GUIDE_PATH,
|
||||
folderNameFromPath,
|
||||
} from "../components/import/ImportEmptyState";
|
||||
import SourceConfigPanel from "../components/import/SourceConfigPanel";
|
||||
import FilePreviewTable from "../components/import/FilePreviewTable";
|
||||
import FormatDriftPanel from "../components/import/FormatDriftPanel";
|
||||
|
|
@ -11,7 +15,7 @@ import ImportProgress from "../components/import/ImportProgress";
|
|||
import ImportReportPanel from "../components/import/ImportReportPanel";
|
||||
import WizardNavigation from "../components/import/WizardNavigation";
|
||||
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle, X } from "lucide-react";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
|
||||
export default function ImportPage() {
|
||||
|
|
@ -20,6 +24,8 @@ export default function ImportPage() {
|
|||
state,
|
||||
browseFolder,
|
||||
refreshFolder,
|
||||
createSampleFolders,
|
||||
dismissSampleCreated,
|
||||
selectSource,
|
||||
updateConfig,
|
||||
toggleFile,
|
||||
|
|
@ -42,12 +48,13 @@ export default function ImportPage() {
|
|||
} = useImportWizard();
|
||||
|
||||
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
|
||||
const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold">{t("import.title")}</h1>
|
||||
<PageHelp helpKey="import" />
|
||||
<PageHelp helpKey="import" linkTo={CSV_EXPORT_GUIDE_PATH} />
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
|
|
@ -65,6 +72,24 @@ export default function ImportPage() {
|
|||
</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 */}
|
||||
<ImportFolderConfig
|
||||
folderPath={state.importFolder}
|
||||
|
|
@ -76,12 +101,28 @@ export default function ImportPage() {
|
|||
{/* Wizard steps */}
|
||||
{state.step === "source-list" && (
|
||||
<>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
{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} />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue