feat(onboarding): step card with highlight, and the Import page's card #404
3 changed files with 191 additions and 0 deletions
128
src/hooks/useImportWizard.mount.test.ts
Normal file
128
src/hooks/useImportWizard.mount.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue