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)", () => {
|
describe("formatLocalDate (#356)", () => {
|
||||||
// Pin a timezone west of Greenwich: under TZ=UTC (the CI runner) local and
|
// Pin a timezone west of Greenwich: under TZ=UTC (the CI runner) local and
|
||||||
// UTC dates coincide, so a `toISOString()` regression would pass unnoticed.
|
// UTC dates coincide, so a `toISOString()` regression would pass unnoticed.
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,14 @@ function errorMessage(e: unknown): string {
|
||||||
export interface WizardState {
|
export interface WizardState {
|
||||||
step: ImportWizardStep;
|
step: ImportWizardStep;
|
||||||
importFolder: string | null;
|
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[];
|
scannedSources: ScannedSource[];
|
||||||
selectedSource: ScannedSource | null;
|
selectedSource: ScannedSource | null;
|
||||||
selectedFiles: ScannedFile[];
|
selectedFiles: ScannedFile[];
|
||||||
|
|
@ -125,6 +133,7 @@ export type WizardAction =
|
||||||
| { type: "SET_ERROR"; payload: string | null }
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
| { type: "SET_STEP"; payload: ImportWizardStep }
|
| { type: "SET_STEP"; payload: ImportWizardStep }
|
||||||
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
|
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
|
||||||
|
| { type: "SET_IMPORT_FOLDER_LOADED" }
|
||||||
| { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] }
|
| { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] }
|
||||||
| { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
|
| { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
|
||||||
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
|
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
|
||||||
|
|
@ -163,6 +172,7 @@ const defaultConfig: SourceConfig = {
|
||||||
const initialState: WizardState = {
|
const initialState: WizardState = {
|
||||||
step: "source-list",
|
step: "source-list",
|
||||||
importFolder: null,
|
importFolder: null,
|
||||||
|
importFolderLoaded: false,
|
||||||
scannedSources: [],
|
scannedSources: [],
|
||||||
selectedSource: null,
|
selectedSource: null,
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
|
|
@ -201,6 +211,8 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
|
||||||
case "SET_IMPORT_FOLDER":
|
case "SET_IMPORT_FOLDER":
|
||||||
// The sample-folder banner describes the previous folder: drop it.
|
// The sample-folder banner describes the previous folder: drop it.
|
||||||
return { ...state, importFolder: action.payload, sampleCreated: null };
|
return { ...state, importFolder: action.payload, sampleCreated: null };
|
||||||
|
case "SET_IMPORT_FOLDER_LOADED":
|
||||||
|
return { ...state, importFolderLoaded: true };
|
||||||
case "SET_SCANNED_SOURCES":
|
case "SET_SCANNED_SOURCES":
|
||||||
return { ...state, scannedSources: action.payload, isLoading: false };
|
return { ...state, scannedSources: action.payload, isLoading: false };
|
||||||
case "SET_SELECTED_SOURCE":
|
case "SET_SELECTED_SOURCE":
|
||||||
|
|
@ -289,9 +301,15 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
|
||||||
case "SET_SAMPLE_CREATED":
|
case "SET_SAMPLE_CREATED":
|
||||||
return { ...state, sampleCreated: action.payload };
|
return { ...state, sampleCreated: action.payload };
|
||||||
case "RESET":
|
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 {
|
return {
|
||||||
...initialState,
|
...initialState,
|
||||||
importFolder: state.importFolder,
|
importFolder: state.importFolder,
|
||||||
|
importFolderLoaded: state.importFolderLoaded,
|
||||||
scannedSources: state.scannedSources,
|
scannedSources: state.scannedSources,
|
||||||
configuredSourceNames: state.configuredSourceNames,
|
configuredSourceNames: state.configuredSourceNames,
|
||||||
importedFilesBySource: state.importedFilesBySource,
|
importedFilesBySource: state.importedFilesBySource,
|
||||||
|
|
@ -328,6 +346,11 @@ export function useImportWizard() {
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No folder configured yet
|
// 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
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue