feat(onboarding): step card with highlight, and the Import page's card #404

Merged
maximus merged 3 commits from issue-398-onboarding-step-card-import into main 2026-10-09 01:10:40 +00:00
3 changed files with 191 additions and 0 deletions
Showing only changes of commit d024f5aeb9 - Show all commits

View file

@ -0,0 +1,128 @@
// useImportWizard — the folder read at mount marks itself done (#384, #398).
//
// No renderer in this project, and the reducer alone cannot reach the mount
// read: it is an effect of the hook. React's hooks used by `useImportWizard`
// are stubbed (technique of useImportWizard.executeImport.test.ts): the hook
// runs as a plain function, its effect is captured and run by hand, and the
// services it calls are mocked. What is pinned here is the `finally`: the
// folder counts as read once the read and its scan are over, success or not.
import { describe, it, expect, vi, beforeEach } from "vitest";
const harness = vi.hoisted(() => ({
state: null as unknown,
dispatch: vi.fn(),
effects: [] as Array<() => void>,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useReducer: () => [harness.state, harness.dispatch],
useCallback: (fn: unknown) => fn,
useEffect: (fn: () => void) => {
harness.effects.push(fn);
},
};
});
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../services/userPreferenceService", () => ({
getImportFolder: vi.fn(),
setImportFolder: vi.fn(),
}));
vi.mock("../services/importSourceService", () => ({
getAllSources: vi.fn(async () => []),
getSourceByName: vi.fn(async () => null),
createSource: vi.fn(),
updateSource: vi.fn(),
}));
vi.mock("../services/importedFileService", () => ({
existsByHash: vi.fn(),
createImportedFile: vi.fn(),
updateFileStatus: vi.fn(),
getFilesBySourceId: vi.fn(async () => []),
}));
vi.mock("../services/importConfigTemplateService", () => ({
getAllTemplates: vi.fn(async () => []),
createTemplate: vi.fn(),
updateTemplate: vi.fn(),
deleteTemplate: vi.fn(),
}));
import { invoke } from "@tauri-apps/api/core";
import { useImportWizard, initialWizardState } from "./useImportWizard";
import { getImportFolder } from "../services/userPreferenceService";
import type { ScannedSource } from "../shared/types";
const SOURCES: ScannedSource[] = [
{ folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] },
];
/** Lets the mount read's promises settle. */
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
/** Mounts the hook and runs its mount effect, the first one it registers. */
async function mount() {
useImportWizard();
const [readFolder] = harness.effects;
readFolder();
await flush();
}
const types = () => harness.dispatch.mock.calls.map((c) => (c[0] as { type: string }).type);
beforeEach(() => {
harness.state = initialWizardState;
harness.effects = [];
harness.dispatch.mockReset();
vi.mocked(getImportFolder).mockReset();
vi.mocked(invoke).mockReset();
});
describe("useImportWizard — the mount read marks the folder read (#384, #398)", () => {
it("with a folder: after the folder and its first scan, as the last dispatch", async () => {
vi.mocked(getImportFolder).mockResolvedValue("/releves");
vi.mocked(invoke).mockResolvedValue(SOURCES);
await mount();
expect(invoke).toHaveBeenCalledWith("scan_import_folder", { folderPath: "/releves" });
const order = types();
expect(order[0]).toBe("SET_IMPORT_FOLDER");
expect(order.indexOf("SET_SCANNED_SOURCES")).toBeGreaterThan(0);
expect(order[order.length - 1]).toBe("SET_IMPORT_FOLDER_LOADED");
expect(order.filter((t) => t === "SET_IMPORT_FOLDER_LOADED")).toHaveLength(1);
});
it("without a folder: marked read, nothing scanned", async () => {
vi.mocked(getImportFolder).mockResolvedValue(null);
await mount();
expect(invoke).not.toHaveBeenCalled();
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([
{ type: "SET_IMPORT_FOLDER", payload: null },
{ type: "SET_IMPORT_FOLDER_LOADED" },
]);
});
it("when the preference cannot be read: still marked read (the empty state then offers a folder)", async () => {
vi.mocked(getImportFolder).mockRejectedValue(new Error("database is locked"));
await mount();
expect(types()).toEqual(["SET_IMPORT_FOLDER_LOADED"]);
});
it("when the scan fails: marked read after the error", async () => {
vi.mocked(getImportFolder).mockResolvedValue("/releves");
vi.mocked(invoke).mockRejectedValue(new Error("folder not found"));
await mount();
const order = types();
expect(order).toContain("SET_ERROR");
expect(order[order.length - 1]).toBe("SET_IMPORT_FOLDER_LOADED");
});
});

View file

@ -110,6 +110,46 @@ describe("wizardReducer — sampleCreated (#356)", () => {
});
});
describe("wizardReducer — importFolderLoaded (#384, #398)", () => {
it("starts unread: the page cannot tell 'no folder' from 'not read yet'", () => {
expect(initialWizardState.importFolderLoaded).toBe(false);
expect(initialWizardState.importFolder).toBeNull();
});
it("SET_IMPORT_FOLDER_LOADED marks the folder read, and nothing else", () => {
const before = afterCreation();
const after = wizardReducer(before, { type: "SET_IMPORT_FOLDER_LOADED" });
expect(after.importFolderLoaded).toBe(true);
expect({ ...after, importFolderLoaded: false }).toEqual(before);
});
it("RESET ('Cancel', 'Done') keeps it: the mount read does not run again to restore it", () => {
const loaded = reduce([{ type: "SET_IMPORT_FOLDER_LOADED" }], afterCreation());
const inWizard = reduce(
[
{ type: "SET_STEP", payload: "report" },
{ type: "SET_LOADING", payload: true },
],
loaded,
);
const after = wizardReducer(inWizard, { type: "RESET" });
expect(after.importFolderLoaded).toBe(true);
expect(after.step).toBe("source-list");
expect(after.isLoading).toBe(false);
});
it("RESET before the read leaves it unread", () => {
expect(wizardReducer(initialWizardState, { type: "RESET" }).importFolderLoaded).toBe(false);
});
it("choosing another folder keeps it read", () => {
const loaded = reduce([{ type: "SET_IMPORT_FOLDER_LOADED" }]);
expect(
wizardReducer(loaded, { type: "SET_IMPORT_FOLDER", payload: "/autre" }).importFolderLoaded,
).toBe(true);
});
});
describe("formatLocalDate (#356)", () => {
// Pin a timezone west of Greenwich: under TZ=UTC (the CI runner) local and
// UTC dates coincide, so a `toISOString()` regression would pass unnoticed.

View file

@ -71,6 +71,14 @@ function errorMessage(e: unknown): string {
export interface WizardState {
step: ImportWizardStep;
importFolder: string | null;
/**
* The stored import folder has been read at mount, and scanned when there is
* one (#384, #398). Until then `importFolder` is null for lack of reading,
* not for lack of a folder: the empty state and the step card wait for it,
* or the "no folder" variant would flash. Set once, success or failure, and
* kept by `RESET`, which the mount read never runs again to restore.
*/
importFolderLoaded: boolean;
scannedSources: ScannedSource[];
selectedSource: ScannedSource | null;
selectedFiles: ScannedFile[];
@ -125,6 +133,7 @@ export type WizardAction =
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_STEP"; payload: ImportWizardStep }
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
| { type: "SET_IMPORT_FOLDER_LOADED" }
| { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] }
| { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
@ -163,6 +172,7 @@ const defaultConfig: SourceConfig = {
const initialState: WizardState = {
step: "source-list",
importFolder: null,
importFolderLoaded: false,
scannedSources: [],
selectedSource: null,
selectedFiles: [],
@ -201,6 +211,8 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
case "SET_IMPORT_FOLDER":
// The sample-folder banner describes the previous folder: drop it.
return { ...state, importFolder: action.payload, sampleCreated: null };
case "SET_IMPORT_FOLDER_LOADED":
return { ...state, importFolderLoaded: true };
case "SET_SCANNED_SOURCES":
return { ...state, scannedSources: action.payload, isLoading: false };
case "SET_SELECTED_SOURCE":
@ -289,9 +301,15 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
case "SET_SAMPLE_CREATED":
return { ...state, sampleCreated: action.payload };
case "RESET":
// Rebuilt from `initialState`, minus what the page read once and keeps:
// the folder, whether it was read (#384: the mount read does not run
// again, so "Cancel" or "Done" would otherwise hide the empty state and
// the step card for good), its scan, the configured sources and the
// templates.
return {
...initialState,
importFolder: state.importFolder,
importFolderLoaded: state.importFolderLoaded,
scannedSources: state.scannedSources,
configuredSourceNames: state.configuredSourceNames,
importedFilesBySource: state.importedFilesBySource,
@ -328,6 +346,11 @@ export function useImportWizard() {
}
} catch {
// No folder configured yet
} finally {
// Read, and scanned when there is a folder (#384): the empty state and
// the step card can tell "no folder" from "not read yet". After the
// scan, so the "no source" variant does not flash either.
dispatch({ type: "SET_IMPORT_FOLDER_LOADED" });
}
})();
}, []); // eslint-disable-line react-hooks/exhaustive-deps