From 624eef7ba8a8853814a3b1900d9ab8bed3e7838e Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 3 Oct 2026 20:10:25 -0400 Subject: [PATCH] feat(onboarding): Import empty states, sample folder button and guide links (#356) 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 --- .../import/ImportEmptyState.test.tsx | 163 ++++++++++++++++++ src/components/import/ImportEmptyState.tsx | 87 ++++++++++ src/components/import/ImportFolderConfig.tsx | 11 +- src/components/shared/PageHelp.tsx | 21 ++- src/hooks/useImportWizard.test.ts | 121 +++++++++++++ src/hooks/useImportWizard.ts | 73 +++++++- src/i18n/locales/en.json | 18 +- src/i18n/locales/fr.json | 18 +- src/pages/ImportPage.tsx | 57 +++++- 9 files changed, 552 insertions(+), 17 deletions(-) create mode 100644 src/components/import/ImportEmptyState.test.tsx create mode 100644 src/components/import/ImportEmptyState.tsx create mode 100644 src/hooks/useImportWizard.test.ts diff --git a/src/components/import/ImportEmptyState.test.tsx b/src/components/import/ImportEmptyState.test.tsx new file mode 100644 index 0000000..f07e896 --- /dev/null +++ b/src/components/import/ImportEmptyState.test.tsx @@ -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) => + 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 & { 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([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) => 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 = {}) { + 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(""); + }); +}); diff --git a/src/components/import/ImportEmptyState.tsx b/src/components/import/ImportEmptyState.tsx new file mode 100644 index 0000000..ffb5847 --- /dev/null +++ b/src/components/import/ImportEmptyState.tsx @@ -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 = ( +

+ + {t("import.empty.guideLink")} + +

+ ); + + if (!hasFolder) { + return ( + } + title={t("import.empty.noFolder.title")} + description={t("import.empty.noFolder.description")} + primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse }} + > + {guideLink} + + ); + } + + return ( + } + 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} + + ); +} diff --git a/src/components/import/ImportFolderConfig.tsx b/src/components/import/ImportFolderConfig.tsx index 7b83ad9..db49a75 100644 --- a/src/components/import/ImportFolderConfig.tsx +++ b/src/components/import/ImportFolderConfig.tsx @@ -50,9 +50,18 @@ export default function ImportFolderConfig({ {t("import.folder.refresh")} )} + {/* + 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. + */} + + )} + {/* Folder config - always visible */} - + {/* + 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 ? ( + + ) : ( + + )} )} -- 2.45.2