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 ? ( + + ) : ( + + )} )}