diff --git a/src/hooks/useCategories.test.ts b/src/hooks/useCategories.test.ts index aaf2fe7..7e47133 100644 --- a/src/hooks/useCategories.test.ts +++ b/src/hooks/useCategories.test.ts @@ -281,6 +281,38 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { expect(types).not.toContain("SET_LOADING"); }); + it("lets a load started during the check's read win over it", async () => { + let answer: (rows: unknown) => void = () => undefined; + const NEWER = [...ROWS, { ...ROWS[0], id: 2, name: "Épargne", sort_order: 2 }]; + vi.mocked(findOrphanCategories).mockResolvedValue([]); + let api = await mount(); + vi.mocked(getAllCategoriesWithCounts) + .mockReturnValueOnce(new Promise((r) => (answer = r)) as never) + .mockResolvedValueOnce(NEWER as never); + const checked = api.checkHierarchy(); + await flush(); // the check's read is out, still unanswered + api = harness.render(useCategories); + await api.reinitializeCategories(); // a newer load: NEWER lands + answer(ROWS); // the check's older answer comes last + await checked; + const ids = harness.render(useCategories).state.categories.map((c) => c.id).sort((a, b) => a - b); + expect(ids).toEqual([1, 2, 10]); + }); + + it("a reattachment reads the categories again without the loading state: a form behind the window stays", async () => { + vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]); + vi.mocked(reattachCategory).mockResolvedValue(3); + let api = await mount(); + await api.checkHierarchy(); + api = harness.render(useCategories); + harness.dispatch.mockClear(); + await api.reattachOrphan(CYCLE, null); + const types = harness.dispatch.mock.calls.map(([action]) => (action as { type: string }).type); + expect(types).toContain("SET_CATEGORIES"); + expect(types).not.toContain("SET_LOADING"); + expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(3); + }); + it("reads the categories once the drag-and-drop's writes are over, never halfway", async () => { let written: () => void = () => undefined; vi.mocked(updateCategorySortOrders).mockReturnValue(new Promise((r) => (written = r))); diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts index e6689bf..6a4aa0e 100644 --- a/src/hooks/useCategories.ts +++ b/src/hooks/useCategories.ts @@ -407,38 +407,49 @@ export function useCategories() { [state.tree, loadCategories] ); + /** + * Reads the categories again for the « Vérifier la hiérarchie » window + * (#393), without the page's loading state: `loadCategories` would show + * « Chargement » in place of the tree and the detail panel, and a form open + * behind the window would come back from its stored values, its unsaved + * changes lost. Waits for a drag-and-drop's writes first (one `UPDATE` per + * category: read halfway, the tree would show half the move); a load + * started meanwhile wins. Throws when the read fails. + */ + const readCategoriesAgain = useCallback(async () => { + await pendingMoveRef.current; + const fetchId = fetchIdRef.current; + const rows = await getAllCategoriesWithCounts(); + if (fetchId !== fetchIdRef.current) return; + const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] })); + dispatch({ type: "SET_CATEGORIES", payload: { flat, tree: buildTree(flat) } }); + }, []); + /** * Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). * The window names the orphans and offers their parents from * `state.categories`, which a drag-and-drop leaves with what the tree shows * only (`SET_TREE`), without the categories no root reaches: the categories - * are read again first, once the drag-and-drop's writes are over (one - * `UPDATE` per category: read halfway, the tree would show half the move). - * Without the page's loading state, so a form open behind the window stays - * as it is; a load started meanwhile wins. + * are read again first (`readCategoriesAgain`). */ const checkHierarchy = useCallback(async () => { dispatch({ type: "OPEN_HIERARCHY_CHECK" }); try { - await pendingMoveRef.current; - const fetchId = fetchIdRef.current; - const rows = await getAllCategoriesWithCounts(); - if (fetchId === fetchIdRef.current) { - const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] })); - dispatch({ type: "SET_CATEGORIES", payload: { flat, tree: buildTree(flat) } }); - } + await readCategoriesAgain(); const orphans = await findOrphanCategories(); dispatch({ type: "SET_ORPHANS", payload: orphans }); } catch (e) { dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) }); } - }, []); + }, [readCategoriesAgain]); /** * Reattaches an orphan under `parentId`, or to the root with `null` (#393). * On success: one Journal line (counts, no name), then the orphan list and - * the tree reload; the tree reloads even if the new check fails, since the - * write is committed. A refusal shows in the window, by its translated code. + * the categories are read again; the categories are read even if the new + * check fails, since the write is committed. Read like at the opening + * (`readCategoriesAgain`), so a form open behind the window keeps its + * unsaved changes. A refusal, or a failed read, shows in the window. */ const reattachOrphan = useCallback( async (orphan: OrphanCategory, parentId: number | null) => { @@ -450,14 +461,14 @@ export function useCategories() { const orphans = await findOrphanCategories(); dispatch({ type: "SET_ORPHANS", payload: orphans }); } finally { - await loadCategories(); + await readCategoriesAgain(); } dispatch({ type: "SET_SAVING", payload: false }); } catch (e) { dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) }); } }, - [loadCategories] + [readCategoriesAgain] ); const closeHierarchyCheck = useCallback(() => {