feat(onboarding): Import empty states, sample folder button and guide links (#356)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
The import page without a source no longer shows a bare "no source found" card. `ImportEmptyState` (built on the shared `EmptyState`) takes the place of the empty `SourceList` in both situations: without an import folder it offers "Choose a folder" as the single primary action; with a folder and no bank sub-folder it offers "Create a sample folder" (primary, disabled while in flight) and "Refresh" (secondary). Both variants, and the "?" help popover through the new optional `linkTo` prop of `PageHelp`, link to the bank CSV export guide at `/settings/users#import-export`. `SourceList` stays an untouched pure list. `useImportWizard` gains `createSampleFolders`: it invokes the Rust command `create_sample_import_folders` with the folder and the webview's LOCAL date (built from the local getters, not `toISOString()`), rescans the folder so the two cards appear without another click, then raises a closable success banner from the new `sampleCreated` state, which `reset` and a folder change clear. A refusal from Rust (a sample folder already exists) lands in the existing error banner. The log line carries the folder NAMES only, never the absolute path, since the logs travel with a feedback report. "Browse" on the folder card drops to the secondary style while no folder is configured, so a single primary button is on screen. The reducer and its initial state are exported as `wizardReducer` / `initialWizardState` and unit-tested; `ImportEmptyState` is tested by direct call. Resolves #356 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
e0ee09bca7
commit
624eef7ba8
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" && (
|
||||
<>
|
||||
<SourceList
|
||||
sources={state.scannedSources}
|
||||
configuredSourceNames={state.configuredSourceNames}
|
||||
importedFileNames={state.importedFilesBySource}
|
||||
onSelectSource={selectSource}
|
||||
/>
|
||||
{/*
|
||||
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