fix: refuse a looping or too deep parent, and reattach orphan categories (#393)

updateCategory checks a new parent before writing: the category itself,
one of its descendants or a category caught in a cycle (cycle), a fourth
level counting its sub-tree (too_deep), a parent holding transactions of
its own (parent_has_transactions). The refusal is a CategoryHierarchyError
that the Categories page shows by the translation of its code. The reads
and the writes, the new parent's is_inputable included, run in one
transaction through its handle; an unchanged parent is not checked, so a
category stored before these rules can still be renamed.

getCategoryDepth keeps the ids it has seen and ends on a cycle.

A "Check hierarchy" button, between "All keywords" and "Re-initialize",
opens a window that lists the orphan categories (caught in a parent loop,
parent deleted, parent gone), one per group, with a new parent for each:
the root by default, or a parent of the category form's list.
reattachCategory applies the same rules; after it, the list and the tree
reload and the Journal gets one line with the reason and the number of
categories put back, no name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-18 19:16:21 -04:00
parent df3fefe934
commit db5ee65720
11 changed files with 1458 additions and 27 deletions

View file

@ -0,0 +1,123 @@
// HierarchyCheckModal — what the « Vérifier la hiérarchie » window shows (#393).
// No jsdom: react-dom/server renders in vitest's node environment. A closed
// CategoryCombobox renders its input only, without its portal (#429).
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import HierarchyCheckModal from "./HierarchyCheckModal";
import type { CategoryTreeNode } from "../../shared/types";
import type { OrphanCategory } from "../../utils/categoryHierarchy";
const node = (id: number, parent_id: number | null, name: string, i18n_key: string | null = null): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key,
children: [],
});
// 1 is a root; 3 <-> 5 is a cycle; 20's parent is gone.
const CATEGORIES = [
node(1, null, "Logement", "categoriesSeed.housing"),
node(3, 5, "Épicerie"),
node(5, 3, "Alimentation"),
node(20, 99, "Cadeaux"),
];
const ORPHANS: OrphanCategory[] = [
{ id: 3, reason: "cycle" },
{ id: 20, reason: "missing_parent" },
];
const render = (props: { orphans: OrphanCategory[] | null; error?: string | null; isSaving?: boolean }) =>
renderToStaticMarkup(
<HierarchyCheckModal
orphans={props.orphans}
error={props.error ?? null}
categories={CATEGORIES}
isSaving={props.isSaving ?? false}
onReattach={vi.fn()}
onClose={vi.fn()}
/>,
);
const comboboxes = (html: string) => html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
describe("HierarchyCheckModal (#393)", () => {
it("is a modal dialog named after the button, with its explanation and a close button", () => {
const html = render({ orphans: [] });
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-modal="true"');
expect(html).toContain('aria-label="t(categories.hierarchy.check)"');
expect(html).toContain("t(categories.hierarchy.intro)");
expect(html).toContain("t(common.close)</button>");
});
it("says the check runs while the orphans are not known", () => {
const html = render({ orphans: null });
expect(html).toContain("t(categories.hierarchy.checking)");
expect(html).not.toContain("t(categories.hierarchy.none)");
expect(comboboxes(html)).toHaveLength(0);
});
it("says « aucune » for a sound hierarchy, with no row", () => {
const html = render({ orphans: [] });
expect(html).toContain("t(categories.hierarchy.none)");
expect(html).not.toContain("categories.hierarchy.count");
expect(html).not.toContain("t(categories.hierarchy.reattach)");
});
it("counts the orphans, and gives each row its name, its reason, a new parent and « Rattacher »", () => {
const html = render({ orphans: ORPHANS });
expect(html).toContain("t(categories.hierarchy.count[count=2])");
expect(html).toContain(">Épicerie</p>");
expect(html).toContain(">Cadeaux</p>");
expect(html).toContain("t(categories.hierarchy.reasons.cycle)");
expect(html).toContain("t(categories.hierarchy.reasons.missing_parent)");
expect(html.split("t(categories.hierarchy.reattach)</button>")).toHaveLength(3);
const inputs = comboboxes(html);
expect(inputs).toHaveLength(2);
expect(inputs[0]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Épicerie])"');
expect(inputs[1]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Cadeaux])"');
});
it("preselects the root on every row", () => {
for (const input of comboboxes(render({ orphans: ORPHANS }))) {
expect(input).toContain('value="t(categories.hierarchy.root)"');
}
});
it("names a seed category in the app's language", () => {
const html = render({ orphans: [{ id: 1, reason: "inactive_parent" }] });
expect(html).toContain(">t(categoriesSeed.housing[defaultValue=Logement])</p>");
expect(html).toContain("t(categories.hierarchy.reasons.inactive_parent)");
});
it("shows the translated error above the list, as an alert", () => {
const html = render({ orphans: ORPHANS, error: "Ce parent a des transactions" });
expect(html).toContain('role="alert"');
expect(html.indexOf("Ce parent a des transactions")).toBeLessThan(html.indexOf("categories.hierarchy.count"));
});
it("shows a failed check alone, without the « checking » line", () => {
const html = render({ orphans: null, error: "database is locked" });
expect(html).toContain("database is locked");
expect(html).not.toContain("t(categories.hierarchy.checking)");
});
it("disables « Rattacher » while a reattachment runs", () => {
const html = render({ orphans: ORPHANS, isSaving: true });
const buttons = html.match(/<button[^>]*>t\(categories\.hierarchy\.reattach\)<\/button>/g) ?? [];
expect(buttons).toHaveLength(2);
for (const button of buttons) expect(button).toContain('disabled=""');
expect(render({ orphans: ORPHANS })).not.toContain('disabled=""');
});
});

View file

@ -0,0 +1,161 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ListTree } from "lucide-react";
import type { CategoryTreeNode } from "../../shared/types";
import type { OrphanCategory } from "../../utils/categoryHierarchy";
import CategoryCombobox from "../shared/CategoryCombobox";
import { buildParentOptions } from "./parentOptions";
interface Props {
/** The orphan categories, one per group to reattach; `null` while the check runs. */
orphans: OrphanCategory[] | null;
/** The translated text of a refused reattachment, or of a check that failed. */
error: string | null;
/** The active categories, as the tree has them: names and parent choices. */
categories: CategoryTreeNode[];
isSaving: boolean;
onReattach: (orphan: OrphanCategory, parentId: number | null) => void;
onClose: () => void;
}
/** The value of the « Racine » option of each row's combobox. */
const ROOT = "";
/**
* « Vérifier la hiérarchie » (#393): the orphan categories of the profile, one
* row per group to reattach, each with its reason and a new parent. The root
* is preselected; the other choices are those of the category form
* (`buildParentOptions`, the row's category treated as the one being edited).
* The service applies the rules again and may refuse (a parent that holds
* transactions, for one): the translated refusal shows above the list.
*
* The comboboxes open their list in a portal (#429), so the scrolling list of
* rows never cuts it.
*/
export default function HierarchyCheckModal({
orphans,
error,
categories,
isSaving,
onReattach,
onClose,
}: Props) {
const { t } = useTranslation();
/** The parent picked on each row, by orphan id; no entry means the root. */
const [choices, setChoices] = useState<Record<number, number>>({});
const optionsByOrphan = useMemo(() => {
const byOrphan = new Map<number, CategoryTreeNode[]>();
for (const orphan of orphans ?? []) {
byOrphan.set(orphan.id, buildParentOptions(categories, { editingId: orphan.id }));
}
return byOrphan;
}, [orphans, categories]);
const rootOption = useMemo(() => [{ value: ROOT, label: t("categories.hierarchy.root") }], [t]);
/** The row's parent: the one picked if it is still offered, else the root. */
const choiceOf = (orphanId: number): number | null => {
const picked = choices[orphanId];
if (picked === undefined) return null;
return optionsByOrphan.get(orphanId)?.some((c) => c.id === picked) ? picked : null;
};
const choose = (orphanId: number, parentId: number | null) => {
setChoices((previous) => {
const next = { ...previous };
if (parentId === null) delete next[orphanId];
else next[orphanId] = parentId;
return next;
});
};
const nameOf = (id: number): string => {
const category = categories.find((c) => c.id === id);
if (!category) return "—";
return category.i18n_key ? t(category.i18n_key, { defaultValue: category.name }) : category.name;
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-label={t("categories.hierarchy.check")}
className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col"
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-[var(--primary)]/10">
<ListTree size={20} className="text-[var(--primary)]" />
</div>
<h2 className="text-lg font-semibold">{t("categories.hierarchy.check")}</h2>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-4">{t("categories.hierarchy.intro")}</p>
{error && (
<div role="alert" className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
{error}
</div>
)}
{orphans === null ? (
error ? null : (
<p className="text-sm text-[var(--muted-foreground)]">{t("categories.hierarchy.checking")}</p>
)
) : orphans.length === 0 ? (
<p className="text-sm font-medium">{t("categories.hierarchy.none")}</p>
) : (
<>
<p className="text-sm font-medium mb-2">{t("categories.hierarchy.count", { count: orphans.length })}</p>
<ul className="flex-1 min-h-0 overflow-y-auto divide-y divide-[var(--border)]">
{orphans.map((orphan) => {
const name = nameOf(orphan.id);
const choice = choiceOf(orphan.id);
return (
<li key={orphan.id} className="py-3 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[10rem]">
<p className="text-sm font-medium">{name}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{t(`categories.hierarchy.reasons.${orphan.reason}`)}
</p>
</div>
<div className="w-56">
<CategoryCombobox
categories={optionsByOrphan.get(orphan.id) ?? []}
value={choice}
onChange={(id) => choose(orphan.id, id)}
extraOptions={rootOption}
activeExtra={choice === null ? ROOT : null}
onExtraSelect={() => choose(orphan.id, null)}
ariaLabel={t("categories.hierarchy.newParentFor", { name })}
compact
/>
</div>
<button
type="button"
onClick={() => onReattach(orphan, choice)}
disabled={isSaving}
className="px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("categories.hierarchy.reattach")}
</button>
</li>
);
})}
</ul>
</>
)}
<div className="flex justify-end mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("common.close")}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,137 @@
// HierarchyCheckModal — what a row hands to « Rattacher » (#393).
//
// A static render clicks nothing. React's hooks come from the shared harness
// (src/test/hookHarness.ts, #409): the window runs as a plain function, its
// state survives from one call to the next, the test calls the props it hands
// to each CategoryCombobox and to its buttons, then renders again.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
// Pass-through spy on the pure module, to pin what each row offers.
vi.mock("./parentOptions", async (importOriginal) => {
const actual = await importOriginal<typeof import("./parentOptions")>();
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
});
import { hookHarness } from "../../test/hookHarness";
import HierarchyCheckModal from "./HierarchyCheckModal";
import CategoryCombobox from "../shared/CategoryCombobox";
import { buildParentOptions } from "./parentOptions";
import type { CategoryTreeNode } from "../../shared/types";
import type { OrphanCategory } from "../../utils/categoryHierarchy";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, match: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, match));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(match(el) ? [el] : []), ...find(el.props.children, match)];
}
const node = (id: number, parent_id: number | null): CategoryTreeNode => ({
id,
name: `c${id}`,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key: null,
children: [],
});
// Roots 1 and 2 (2 has a child, 21); 3 <-> 5 is a cycle; 20's parent is gone.
const CATEGORIES = [node(1, null), node(2, null), node(21, 2), node(3, 5), node(5, 3), node(20, 99)];
const CYCLE: OrphanCategory = { id: 3, reason: "cycle" };
const GONE: OrphanCategory = { id: 20, reason: "missing_parent" };
const onReattach = vi.fn();
const onClose = vi.fn();
const view = (orphans: OrphanCategory[] | null = [CYCLE, GONE], categories = CATEGORIES) => {
const tree = harness.render(() =>
HierarchyCheckModal({ orphans, error: null, categories, isSaving: false, onReattach, onClose }),
);
return {
comboboxes: find(tree, (el) => el.type === CategoryCombobox).map((el) => el.props),
reattach: find(tree, (el) => el.type === "button" && el.props.children === "t(categories.hierarchy.reattach)"),
close: find(tree, (el) => el.type === "button" && el.props.children === "t(common.close)"),
};
};
const ids = (list: unknown) => (list as CategoryTreeNode[]).map((c) => c.id).sort((a, b) => a - b);
/** Calls the `onClick` the window hands to a button. */
const click = (button: ReactElement<Props>) => (button.props.onClick as () => void)();
beforeEach(() => {
harness.clear();
onReattach.mockReset();
onClose.mockReset();
vi.mocked(buildParentOptions).mockClear();
});
describe("HierarchyCheckModal — a row's new parent (#393)", () => {
it("offers the category form's parents, the row's category treated as the one edited, the root first", () => {
const { comboboxes } = view();
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 3 });
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 20 });
// The cycle member carries the other member (5): roots only, its own cycle left out.
// 20, whose parent is gone, shows as a root in the tree, and is offered as one.
expect(ids(comboboxes[0].categories)).toEqual([1, 2, 20]);
// A leaf: roots and level-1 categories, never a cycle member nor itself.
expect(ids(comboboxes[1].categories)).toEqual([1, 2, 21]);
for (const props of comboboxes) {
expect(props.extraOptions).toEqual([{ value: "", label: "t(categories.hierarchy.root)" }]);
expect(props.value).toBeNull();
expect(props.activeExtra).toBe("");
}
});
it("« Rattacher » hands the root by default", () => {
click(view().reattach[1]);
expect(onReattach).toHaveBeenCalledWith(GONE, null);
});
it("hands the parent picked on the row, and the root again once it is picked back", () => {
(view().comboboxes[1].onChange as (id: number) => void)(21);
let current = view();
expect(current.comboboxes[1].value).toBe(21);
expect(current.comboboxes[1].activeExtra).toBeNull();
// The other row keeps the root.
expect(current.comboboxes[0].value).toBeNull();
click(current.reattach[1]);
expect(onReattach).toHaveBeenLastCalledWith(GONE, 21);
(current.comboboxes[1].onExtraSelect as (value: string) => void)("");
current = view();
expect(current.comboboxes[1].value).toBeNull();
expect(current.comboboxes[1].activeExtra).toBe("");
click(current.reattach[1]);
expect(onReattach).toHaveBeenLastCalledWith(GONE, null);
});
it("falls back to the root when the parent picked is no longer offered", () => {
(view().comboboxes[1].onChange as (id: number) => void)(21);
// After a reload, 21 is gone from the categories.
const current = view([CYCLE, GONE], CATEGORIES.filter((c) => c.id !== 21));
expect(current.comboboxes[1].value).toBeNull();
click(current.reattach[1]);
expect(onReattach).toHaveBeenCalledWith(GONE, null);
});
it("« Fermer » closes the window", () => {
const { close } = view();
expect(close).toHaveLength(1);
click(close[0]);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,214 @@
// useCategories — the hierarchy check and its refusals (#393).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, `commit()` runs
// its mount effect. The service is mocked, except the refusal class and its
// i18n key, which the hook must recognize by `instanceof`.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
vi.mock("../services/categoryService", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/categoryService")>();
return {
CategoryHierarchyError: actual.CategoryHierarchyError,
categoryHierarchyErrorKey: actual.categoryHierarchyErrorKey,
getAllCategoriesWithCounts: vi.fn(),
createCategory: vi.fn(),
updateCategory: vi.fn(),
deactivateCategory: vi.fn(),
getCategoryUsageCount: vi.fn(),
getChildrenUsageCount: vi.fn(),
getKeywordsByCategoryId: vi.fn(async () => []),
createKeyword: vi.fn(),
updateKeyword: vi.fn(),
deactivateKeyword: vi.fn(),
reinitializeCategories: vi.fn(),
hasDuplicateSortOrders: vi.fn(async () => false),
fixDuplicateSortOrders: vi.fn(),
getNextSortOrder: vi.fn(),
updateCategorySortOrders: vi.fn(),
findOrphanCategories: vi.fn(),
reattachCategory: vi.fn(),
};
});
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
import { hookHarness } from "../test/hookHarness";
import {
useCategories,
categoriesErrorOf,
categoriesErrorText,
reattachLogLine,
} from "./useCategories";
import {
CategoryHierarchyError,
findOrphanCategories,
getAllCategoriesWithCounts,
reattachCategory,
updateCategory,
} from "../services/categoryService";
import { logInfo } from "../services/logService";
import { markedT } from "../test/i18nMock";
import type { OrphanCategory } from "../utils/categoryHierarchy";
const harness = hookHarness();
const ROWS = [
{ id: 1, name: "Logement", parent_id: null, color: null, icon: null, type: "expense", is_active: true, is_inputable: false, sort_order: 1, keyword_count: 0, i18n_key: null },
{ id: 10, name: "Loyer", parent_id: 1, color: null, icon: null, type: "expense", is_active: true, is_inputable: true, sort_order: 1, keyword_count: 0, i18n_key: null },
];
const CYCLE: OrphanCategory = { id: 3, reason: "cycle" };
/** Lets the hook's promises settle. */
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
/** Mounts the hook and lets its first load settle. */
async function mount() {
harness.render(useCategories);
harness.commit();
await flush();
return harness.render(useCategories);
}
const FORM = { name: "Logement", type: "expense" as const, color: "#4A90A4", parent_id: 10, is_inputable: false, sort_order: 1 };
beforeEach(() => {
harness.clear();
vi.mocked(getAllCategoriesWithCounts).mockReset();
vi.mocked(getAllCategoriesWithCounts).mockResolvedValue(ROWS as never);
vi.mocked(updateCategory).mockReset();
vi.mocked(findOrphanCategories).mockReset();
vi.mocked(reattachCategory).mockReset();
vi.mocked(logInfo).mockReset();
});
describe("categoriesErrorOf / categoriesErrorText (#393)", () => {
it("names a hierarchy refusal by the i18n key of its code, never by its message", () => {
const error = categoriesErrorOf(new CategoryHierarchyError("cycle", "raw text"));
expect(error).toEqual({ key: "categories.hierarchy.errors.cycle" });
expect(categoriesErrorText(error, markedT)).toBe("t(categories.hierarchy.errors.cycle)");
});
it("keeps the message of any other failure", () => {
const error = categoriesErrorOf(new Error("database is locked"));
expect(error).toEqual({ message: "database is locked" });
expect(categoriesErrorText(error, markedT)).toBe("database is locked");
expect(categoriesErrorOf("boom")).toEqual({ message: "boom" });
});
});
describe("useCategories — saving the form (#393)", () => {
it("shows a refused parent by its translated code, and keeps the form open", async () => {
vi.mocked(updateCategory).mockRejectedValue(new CategoryHierarchyError("cycle"));
let api = await mount();
await api.selectCategory(1);
api = harness.render(useCategories);
api.startEditing();
api = harness.render(useCategories);
await api.saveCategory(FORM);
const { state } = harness.render(useCategories);
expect(updateCategory).toHaveBeenCalledWith(1, FORM);
expect(state.error).toEqual({ key: "categories.hierarchy.errors.cycle" });
expect(state.isSaving).toBe(false);
expect(state.editingCategory).not.toBeNull();
});
it("shows any other failure by its message", async () => {
vi.mocked(updateCategory).mockRejectedValue(new Error("database is locked"));
let api = await mount();
await api.selectCategory(1);
api = harness.render(useCategories);
await api.saveCategory(FORM);
expect(harness.render(useCategories).state.error).toEqual({ message: "database is locked" });
});
});
describe("useCategories — « Vérifier la hiérarchie » (#393)", () => {
it("opens the window while the check runs, then lists the orphans", async () => {
let resolve: (orphans: OrphanCategory[]) => void = () => undefined;
vi.mocked(findOrphanCategories).mockReturnValue(new Promise((r) => (resolve = r)));
const api = await mount();
expect(api.state.hierarchyCheck).toBeNull();
const done = api.checkHierarchy();
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: null, error: null });
resolve([CYCLE]);
await done;
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [CYCLE], error: null });
});
it("shows a check that failed in the window", async () => {
vi.mocked(findOrphanCategories).mockRejectedValue(new Error("database is locked"));
const api = await mount();
await api.checkHierarchy();
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({
orphans: null,
error: { message: "database is locked" },
});
});
it("reattaches, writes one Journal line without any name, then reloads the list and the tree", async () => {
vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]);
vi.mocked(reattachCategory).mockResolvedValue(3);
let api = await mount();
await api.checkHierarchy();
api = harness.render(useCategories);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(1);
await api.reattachOrphan(CYCLE, 1);
expect(reattachCategory).toHaveBeenCalledWith(3, 1);
expect(logInfo).toHaveBeenCalledTimes(1);
const line = vi.mocked(logInfo).mock.calls[0][0] as string;
expect(line).toBe(reattachLogLine("cycle", 3));
expect(line).toContain("3 categories");
for (const row of ROWS) expect(line).not.toContain(row.name);
expect(findOrphanCategories).toHaveBeenCalledTimes(2);
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
const { state } = harness.render(useCategories);
expect(state.hierarchyCheck).toEqual({ orphans: [], error: null });
expect(state.isSaving).toBe(false);
});
it("shows a refused reattachment in the window by its translated code, with no Journal line", async () => {
vi.mocked(findOrphanCategories).mockResolvedValue([CYCLE]);
vi.mocked(reattachCategory).mockRejectedValue(new CategoryHierarchyError("parent_has_transactions"));
let api = await mount();
await api.checkHierarchy();
api = harness.render(useCategories);
await api.reattachOrphan(CYCLE, 1);
const { state } = harness.render(useCategories);
expect(state.hierarchyCheck).toEqual({
orphans: [CYCLE],
error: { key: "categories.hierarchy.errors.parent_has_transactions" },
});
expect(state.error).toBeNull();
expect(state.isSaving).toBe(false);
expect(logInfo).not.toHaveBeenCalled();
});
it("closes the window; a check that ends after it closed changes nothing", async () => {
let resolve: (orphans: OrphanCategory[]) => void = () => undefined;
vi.mocked(findOrphanCategories).mockReturnValue(new Promise((r) => (resolve = r)));
let api = await mount();
const done = api.checkHierarchy();
api = harness.render(useCategories);
api.closeHierarchyCheck();
resolve([CYCLE]);
await done;
expect(harness.render(useCategories).state.hierarchyCheck).toBeNull();
});
});
describe("reattachLogLine (#393)", () => {
it("gives the reason code and the count, singular or plural", () => {
expect(reattachLogLine("inactive_parent", 1)).toBe(
"Categories: orphan category reattached (inactive_parent), 1 category back in the hierarchy",
);
expect(reattachLogLine("cycle", 4)).toBe(
"Categories: orphan category reattached (cycle), 4 categories back in the hierarchy",
);
});
});

View file

@ -20,7 +20,37 @@ import {
fixDuplicateSortOrders, fixDuplicateSortOrders,
getNextSortOrder, getNextSortOrder,
updateCategorySortOrders, updateCategorySortOrders,
findOrphanCategories,
reattachCategory,
CategoryHierarchyError,
categoryHierarchyErrorKey,
} from "../services/categoryService"; } from "../services/categoryService";
import { logInfo } from "../services/logService";
import type { OrphanCategory } from "../utils/categoryHierarchy";
/**
* What a failed action shows: a refusal of the hierarchy rules by the i18n key
* of its code (#393), any other failure by its message.
*/
export type CategoriesError = { key: string } | { message: string };
export function categoriesErrorOf(e: unknown): CategoriesError {
if (e instanceof CategoryHierarchyError) return { key: categoryHierarchyErrorKey(e.code) };
return { message: e instanceof Error ? e.message : String(e) };
}
/** The text of a categories error: the translation of its key, or its message. */
export function categoriesErrorText(error: CategoriesError, t: (key: string) => string): string {
return "key" in error ? t(error.key) : error.message;
}
/** The « Vérifier la hiérarchie » window (#393), while it is open. */
export interface HierarchyCheckState {
/** The orphan categories, one per group to reattach; `null` while the check runs. */
orphans: OrphanCategory[] | null;
/** A refused reattachment, or a check that failed. */
error: CategoriesError | null;
}
interface CategoriesState { interface CategoriesState {
categories: CategoryTreeNode[]; categories: CategoryTreeNode[];
@ -29,22 +59,29 @@ interface CategoriesState {
keywords: Keyword[]; keywords: Keyword[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
error: string | null; error: CategoriesError | null;
editingCategory: CategoryFormData | null; editingCategory: CategoryFormData | null;
isCreating: boolean; isCreating: boolean;
/** `null` while the window is closed. */
hierarchyCheck: HierarchyCheckState | null;
} }
type CategoriesAction = type CategoriesAction =
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SAVING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null } | { type: "SET_ERROR"; payload: CategoriesError | null }
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } } | { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
| { type: "SET_TREE"; payload: CategoryTreeNode[] } | { type: "SET_TREE"; payload: CategoryTreeNode[] }
| { type: "SELECT_CATEGORY"; payload: number | null } | { type: "SELECT_CATEGORY"; payload: number | null }
| { type: "SET_KEYWORDS"; payload: Keyword[] } | { type: "SET_KEYWORDS"; payload: Keyword[] }
| { type: "START_CREATING" } | { type: "START_CREATING" }
| { type: "START_EDITING"; payload: CategoryFormData } | { type: "START_EDITING"; payload: CategoryFormData }
| { type: "CANCEL_EDITING" }; | { type: "CANCEL_EDITING" }
| { type: "OPEN_HIERARCHY_CHECK" }
| { type: "SET_ORPHANS"; payload: OrphanCategory[] }
| { type: "START_REATTACH" }
| { type: "SET_HIERARCHY_ERROR"; payload: CategoriesError }
| { type: "CLOSE_HIERARCHY_CHECK" };
const initialState: CategoriesState = { const initialState: CategoriesState = {
categories: [], categories: [],
@ -56,9 +93,16 @@ const initialState: CategoriesState = {
error: null, error: null,
editingCategory: null, editingCategory: null,
isCreating: false, isCreating: false,
hierarchyCheck: null,
}; };
function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] { /**
* The tree of the Categories page: each category under its parent, a category
* whose parent is not in the list at the root. A category caught in a
* `parent_id` cycle is under a parent and never at the root, so no root leads
* to it: « Vérifier la hiérarchie » finds it (#393).
*/
export function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
const map = new Map<number, CategoryTreeNode>(); const map = new Map<number, CategoryTreeNode>();
const roots: CategoryTreeNode[] = []; const roots: CategoryTreeNode[] = [];
@ -119,11 +163,43 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
return { ...state, isCreating: false, editingCategory: action.payload }; return { ...state, isCreating: false, editingCategory: action.payload };
case "CANCEL_EDITING": case "CANCEL_EDITING":
return { ...state, editingCategory: null, isCreating: false }; return { ...state, editingCategory: null, isCreating: false };
case "OPEN_HIERARCHY_CHECK":
return { ...state, hierarchyCheck: { orphans: null, error: null } };
case "SET_ORPHANS":
// A check that ends after the window closed changes nothing.
return state.hierarchyCheck
? { ...state, hierarchyCheck: { ...state.hierarchyCheck, orphans: action.payload } }
: state;
case "START_REATTACH":
return {
...state,
isSaving: true,
hierarchyCheck: state.hierarchyCheck ? { ...state.hierarchyCheck, error: null } : null,
};
case "SET_HIERARCHY_ERROR":
return {
...state,
isSaving: false,
hierarchyCheck: state.hierarchyCheck ? { ...state.hierarchyCheck, error: action.payload } : null,
};
case "CLOSE_HIERARCHY_CHECK":
return { ...state, hierarchyCheck: null };
default: default:
return state; return state;
} }
} }
/**
* The Journal line of a reattachment (#393): the reason code and the number of
* categories put back in place, never a category name.
*/
export function reattachLogLine(reason: OrphanCategory["reason"], restored: number): string {
return (
`Categories: orphan category reattached (${reason}), ` +
`${restored} ${restored === 1 ? "category" : "categories"} back in the hierarchy`
);
}
export function useCategories() { export function useCategories() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
@ -150,7 +226,7 @@ export function useCategories() {
dispatch({ type: "SET_CATEGORIES", payload: { flat, tree } }); dispatch({ type: "SET_CATEGORIES", payload: { flat, tree } });
} catch (e) { } catch (e) {
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
} }
}, []); }, []);
@ -165,7 +241,7 @@ export function useCategories() {
const kws = await getKeywordsByCategoryId(id); const kws = await getKeywordsByCategoryId(id);
dispatch({ type: "SET_KEYWORDS", payload: kws }); dispatch({ type: "SET_KEYWORDS", payload: kws });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
} }
} }
}, []); }, []);
@ -212,7 +288,7 @@ export function useCategories() {
} }
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
} }
}, },
[state.isCreating, state.selectedCategoryId, loadCategories, selectCategory] [state.isCreating, state.selectedCategoryId, loadCategories, selectCategory]
@ -237,7 +313,7 @@ export function useCategories() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
return { blocked: false, count: 0 }; return { blocked: false, count: 0 };
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
return { blocked: false, count: 0 }; return { blocked: false, count: 0 };
} }
}, },
@ -253,7 +329,7 @@ export function useCategories() {
await loadCategories(); await loadCategories();
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
} }
}, [loadCategories]); }, [loadCategories]);
@ -327,12 +403,49 @@ export function useCategories() {
[state.tree, loadCategories] [state.tree, loadCategories]
); );
/** Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). */
const checkHierarchy = useCallback(async () => {
dispatch({ type: "OPEN_HIERARCHY_CHECK" });
try {
const orphans = await findOrphanCategories();
dispatch({ type: "SET_ORPHANS", payload: orphans });
} catch (e) {
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
}
}, []);
/**
* 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. A refusal shows in the window, by its translated code.
*/
const reattachOrphan = useCallback(
async (orphan: OrphanCategory, parentId: number | null) => {
dispatch({ type: "START_REATTACH" });
try {
const restored = await reattachCategory(orphan.id, parentId);
logInfo(reattachLogLine(orphan.reason, restored));
const orphans = await findOrphanCategories();
dispatch({ type: "SET_ORPHANS", payload: orphans });
await loadCategories();
dispatch({ type: "SET_SAVING", payload: false });
} catch (e) {
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
}
},
[loadCategories]
);
const closeHierarchyCheck = useCallback(() => {
dispatch({ type: "CLOSE_HIERARCHY_CHECK" });
}, []);
const loadKeywords = useCallback(async (categoryId: number) => { const loadKeywords = useCallback(async (categoryId: number) => {
try { try {
const kws = await getKeywordsByCategoryId(categoryId); const kws = await getKeywordsByCategoryId(categoryId);
dispatch({ type: "SET_KEYWORDS", payload: kws }); dispatch({ type: "SET_KEYWORDS", payload: kws });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
} }
}, []); }, []);
@ -344,7 +457,7 @@ export function useCategories() {
await loadKeywords(state.selectedCategoryId); await loadKeywords(state.selectedCategoryId);
await loadCategories(); await loadCategories();
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
} }
}, },
[state.selectedCategoryId, loadKeywords, loadCategories] [state.selectedCategoryId, loadKeywords, loadCategories]
@ -358,7 +471,7 @@ export function useCategories() {
await loadKeywords(state.selectedCategoryId); await loadKeywords(state.selectedCategoryId);
} }
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
} }
}, },
[state.selectedCategoryId, loadKeywords] [state.selectedCategoryId, loadKeywords]
@ -373,7 +486,7 @@ export function useCategories() {
await loadCategories(); await loadCategories();
} }
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
} }
}, },
[state.selectedCategoryId, loadKeywords, loadCategories] [state.selectedCategoryId, loadKeywords, loadCategories]
@ -392,5 +505,8 @@ export function useCategories() {
removeKeyword, removeKeyword,
reinitializeCategories, reinitializeCategories,
moveCategory, moveCategory,
checkHierarchy,
reattachOrphan,
closeHierarchyCheck,
}; };
} }

View file

@ -442,6 +442,28 @@
"customColor": "Custom color", "customColor": "Custom color",
"allKeywords": "All Keywords", "allKeywords": "All Keywords",
"allKeywordsEmpty": "No keywords yet", "allKeywordsEmpty": "No keywords yet",
"hierarchy": {
"check": "Check hierarchy",
"intro": "An orphan category is no longer in its place in the hierarchy: it is caught in a parent loop (a category placed under its own subcategory), which hides it along with its subcategories, or its parent was deleted or cannot be found. Reattach it to the root or to another parent: it comes back in the tree, the category menus, the budget and the reports, with its transactions, keywords and budget amounts.",
"checking": "Checking…",
"none": "No orphan categories: every category is in its place in the hierarchy.",
"count_one": "{{count}} orphan category",
"count_other": "{{count}} orphan categories",
"reasons": {
"cycle": "Caught in a parent loop",
"inactive_parent": "Its parent was deleted",
"missing_parent": "Its parent cannot be found"
},
"newParentFor": "New parent for {{name}}",
"root": "Root (top level)",
"reattach": "Reattach",
"errors": {
"self_parent": "A category cannot be its own parent.",
"cycle": "This parent would create a parent loop: it sits under this category, or inside a loop. Choose another parent.",
"too_deep": "With this parent, the category or its subcategories would go beyond three levels. Choose a parent higher in the hierarchy.",
"parent_has_transactions": "This parent has transactions of its own: once a parent, it would no longer take input. Choose another parent, or recategorize its transactions first."
}
},
"help": { "help": {
"title": "How to manage Categories", "title": "How to manage Categories",
"tips": [ "tips": [

View file

@ -442,6 +442,28 @@
"customColor": "Couleur personnalisée", "customColor": "Couleur personnalisée",
"allKeywords": "Tous les mots-clés", "allKeywords": "Tous les mots-clés",
"allKeywordsEmpty": "Aucun mot-clé", "allKeywordsEmpty": "Aucun mot-clé",
"hierarchy": {
"check": "Vérifier la hiérarchie",
"intro": "Une catégorie orpheline n'est plus à sa place dans la hiérarchie : elle est prise dans une boucle de parenté (une catégorie placée sous sa propre sous-catégorie), qui la cache avec ses sous-catégories, ou son parent a été supprimé ou est introuvable. Rattachez-la à la racine ou à un autre parent : elle revient dans l'arbre, les menus de catégories, le budget et les rapports, avec ses transactions, ses mots-clés et ses montants de budget.",
"checking": "Vérification en cours…",
"none": "Aucune catégorie orpheline : chaque catégorie est à sa place dans la hiérarchie.",
"count_one": "{{count}} catégorie orpheline",
"count_other": "{{count}} catégories orphelines",
"reasons": {
"cycle": "Prise dans une boucle de parenté",
"inactive_parent": "Son parent a été supprimé",
"missing_parent": "Son parent est introuvable"
},
"newParentFor": "Nouveau parent de {{name}}",
"root": "Racine (niveau supérieur)",
"reattach": "Rattacher",
"errors": {
"self_parent": "Une catégorie ne peut pas être son propre parent.",
"cycle": "Ce parent créerait une boucle de parenté : il se trouve sous cette catégorie, ou dans une boucle. Choisissez un autre parent.",
"too_deep": "Avec ce parent, la catégorie ou ses sous-catégories dépasseraient trois niveaux. Choisissez un parent plus haut dans la hiérarchie.",
"parent_has_transactions": "Ce parent a des transactions à son nom : devenu parent, il ne serait plus saisissable. Choisissez un autre parent, ou reclassez d'abord ses transactions."
}
},
"help": { "help": {
"title": "Comment gérer les Catégories", "title": "Comment gérer les Catégories",
"tips": [ "tips": [

View file

@ -12,10 +12,16 @@ vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock()); vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null })); vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
const harness = vi.hoisted(() => ({ onboarding: null as unknown })); const harness = vi.hoisted(() => ({
onboarding: null as unknown,
/** Overrides of the categories state, for the tests of #393. */
state: {} as Record<string, unknown>,
}));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useCategories", () => ({ vi.mock("../hooks/useCategories", async (importOriginal) => ({
// The real error text helper: the page must translate a refusal's key.
categoriesErrorText: (await importOriginal<typeof import("../hooks/useCategories")>()).categoriesErrorText,
useCategories: () => ({ useCategories: () => ({
state: { state: {
categories: [], categories: [],
@ -27,6 +33,8 @@ vi.mock("../hooks/useCategories", () => ({
isLoading: true, isLoading: true,
isSaving: false, isSaving: false,
error: null, error: null,
hierarchyCheck: null,
...harness.state,
}, },
}), }),
})); }));
@ -88,6 +96,7 @@ const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1]
beforeEach(() => { beforeEach(() => {
harness.onboarding = path([]); harness.onboarding = path([]);
harness.state = {};
}); });
describe("CategoriesPage — the step card (#432)", () => { describe("CategoriesPage — the step card (#432)", () => {
@ -150,3 +159,53 @@ describe("CategoriesPage — the step card (#432)", () => {
expect(value.skip).not.toHaveBeenCalled(); expect(value.skip).not.toHaveBeenCalled();
}); });
}); });
describe("CategoriesPage — « Vérifier la hiérarchie » (#393)", () => {
/** The opening tag of the header button whose label closes right after it. */
const buttonTag = (html: string, label: string) => {
const end = html.indexOf(`${label}</button>`);
expect(end).toBeGreaterThan(-1);
const start = html.lastIndexOf("<button", end);
return html.slice(start, html.indexOf(">", start) + 1);
};
it("sits between « All keywords » and « Reset », styled as « Reset »", () => {
const html = render();
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
const check = html.indexOf("t(categories.hierarchy.check)</button>");
const reset = html.indexOf("t(categories.reinitialize)</button>");
expect(allKeywords).toBeLessThan(check);
expect(check).toBeLessThan(reset);
const classOf = (tag: string) => tag.match(/class="([^"]*)"/)?.[1];
expect(classOf(buttonTag(html, "t(categories.hierarchy.check)"))).toBe(
classOf(buttonTag(html, "t(categories.reinitialize)")),
);
});
it("is disabled while a save runs, as « Reset »", () => {
harness.state = { isSaving: true };
expect(buttonTag(render(), "t(categories.hierarchy.check)")).toContain('disabled=""');
});
it("opens the window only while the check is open", () => {
expect(render()).not.toContain('role="dialog"');
harness.state = { hierarchyCheck: { orphans: [], error: null } };
const html = render();
expect(html).toContain('role="dialog"');
expect(html).toContain("t(categories.hierarchy.none)");
});
it("translates a refusal in the window by its key", () => {
harness.state = {
hierarchyCheck: { orphans: [], error: { key: "categories.hierarchy.errors.too_deep" } },
};
expect(render()).toContain("t(categories.hierarchy.errors.too_deep)");
});
it("translates a refused save of the form by its key, and shows any other failure as is", () => {
harness.state = { error: { key: "categories.hierarchy.errors.parent_has_transactions" } };
expect(render()).toContain(">t(categories.hierarchy.errors.parent_has_transactions)</div>");
harness.state = { error: { message: "database is locked" } };
expect(render()).toContain(">database is locked</div>");
});
});

View file

@ -1,12 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react"; import { Plus, RotateCcw, List, AlertTriangle, ListTree } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import { useCategories } from "../hooks/useCategories"; import { useCategories, categoriesErrorText } from "../hooks/useCategories";
import CategoryTree from "../components/categories/CategoryTree"; import CategoryTree from "../components/categories/CategoryTree";
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel"; import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel"; import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
import HierarchyCheckModal from "../components/categories/HierarchyCheckModal";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard"; import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext"; import { useOnboarding } from "../contexts/OnboardingContext";
import { import {
@ -35,6 +36,9 @@ export default function CategoriesPage() {
removeKeyword, removeKeyword,
reinitializeCategories, reinitializeCategories,
moveCategory, moveCategory,
checkHierarchy,
reattachOrphan,
closeHierarchyCheck,
} = useCategories(); } = useCategories();
// The getting-started card (#432) points at "All keywords" while it is on // The getting-started card (#432) points at "All keywords" while it is on
@ -79,6 +83,14 @@ export default function CategoriesPage() {
<List size={16} /> <List size={16} />
{t("categories.allKeywords")} {t("categories.allKeywords")}
</button> </button>
<button
onClick={checkHierarchy}
disabled={state.isSaving}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
>
<ListTree size={16} />
{t("categories.hierarchy.check")}
</button>
<button <button
onClick={() => setShowReinitConfirm(true)} onClick={() => setShowReinitConfirm(true)}
disabled={state.isSaving} disabled={state.isSaving}
@ -99,7 +111,7 @@ export default function CategoriesPage() {
{state.error && ( {state.error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm"> <div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
{state.error} {categoriesErrorText(state.error, t)}
</div> </div>
)} )}
@ -141,6 +153,17 @@ export default function CategoriesPage() {
</div> </div>
)} )}
{state.hierarchyCheck && (
<HierarchyCheckModal
orphans={state.hierarchyCheck.orphans}
error={state.hierarchyCheck.error ? categoriesErrorText(state.hierarchyCheck.error, t) : null}
categories={state.categories}
isSaving={state.isSaving}
onReattach={reattachOrphan}
onClose={closeHierarchyCheck}
/>
)}
{/* Reinitialize confirmation modal */} {/* Reinitialize confirmation modal */}
{showReinitConfirm && ( {showReinitConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">

View file

@ -0,0 +1,391 @@
// categoryService — the hierarchy rules at the write (#393): a new parent is
// checked by `updateCategory` and `reattachCategory`, the reads and the writes
// run in one transaction through its handle, the orphan diagnostic reads every
// category, and `getCategoryDepth` ends on a cycle.
//
// The base is a fake: a `categories` table in memory, the direct transaction
// count of each category, BEGIN/COMMIT/ROLLBACK. `withTransaction` hands the
// callback its own handle; the global connection of `getDb()` throws while a
// transaction runs, as the real lock would deadlock (docs/architecture.md,
// « Accès DB et transactions »).
import { describe, it, expect, vi, beforeEach } from "vitest";
const fake = vi.hoisted(() => ({
inTransaction: false,
global: null as unknown,
handle: null as unknown,
}));
vi.mock("./db", () => ({
getDb: vi.fn(async () => fake.global),
withTransaction: vi.fn(async (fn: (db: unknown) => Promise<unknown>) => {
fake.inTransaction = true;
try {
return await fn(fake.handle);
} finally {
fake.inTransaction = false;
}
}),
}));
import { withTransaction } from "./db";
import {
CategoryHierarchyError,
categoryHierarchyErrorKey,
createCategory,
findOrphanCategories,
getCategoryDepth,
reattachCategory,
updateCategory,
} from "./categoryService";
import { buildTree } from "../hooks/useCategories";
import type { CategoryTreeNode } from "../shared/types";
interface Row {
id: number;
name: string;
parent_id: number | null;
is_active: number;
is_inputable: number;
}
const row = (id: number, parent_id: number | null, extra: Partial<Row> = {}): Row => ({
id,
name: `c${id}`,
parent_id,
is_active: 1,
is_inputable: 1,
...extra,
});
const HIERARCHY_SQL = "SELECT id, parent_id, is_active FROM categories";
/**
* The categories table and the direct transaction counts. Every statement is
* logged with the connection it went through ("handle" or "global").
*/
function useFakeDb(initial: Row[], transactions: Record<number, number> = {}) {
let rows = initial.map((r) => ({ ...r }));
let beforeTransaction: Row[] | null = null;
const log: Array<{ via: "handle" | "global"; sql: string; params: unknown[] }> = [];
const select = async (sql: string, params: unknown[] = []) => {
if (sql === HIERARCHY_SQL) {
return rows.map(({ id, parent_id, is_active }) => ({ id, parent_id, is_active }));
}
if (sql.includes("COUNT(*) AS cnt FROM transactions WHERE category_id = $1")) {
return [{ cnt: transactions[params[0] as number] ?? 0 }];
}
if (sql.includes("SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1")) {
const found = rows.find((r) => r.id === params[0] && r.is_active === 1);
return found ? [{ parent_id: found.parent_id }] : [];
}
throw new Error(`unexpected SELECT: ${sql}`);
};
const execute = async (sql: string, params: unknown[] = []) => {
if (sql === "BEGIN") {
beforeTransaction = rows.map((r) => ({ ...r }));
} else if (sql === "COMMIT") {
beforeTransaction = null;
} else if (sql === "ROLLBACK") {
if (beforeTransaction) rows = beforeTransaction;
beforeTransaction = null;
} else if (sql.startsWith("UPDATE categories SET name = $1")) {
const [name, , , parent_id, is_inputable, , id] = params as [string, string, string, number | null, number, number, number];
rows = rows.map((r) => (r.id === id ? { ...r, name, parent_id, is_inputable } : r));
} else if (sql.startsWith("UPDATE categories SET parent_id = $1 WHERE id = $2")) {
const [parent_id, id] = params as [number | null, number];
rows = rows.map((r) => (r.id === id ? { ...r, parent_id } : r));
} else if (sql.startsWith("UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1")) {
rows = rows.map((r) => (r.id === params[0] && r.is_inputable === 1 ? { ...r, is_inputable: 0 } : r));
} else if (sql.startsWith("INSERT INTO categories")) {
const [name, , , parent_id, is_inputable] = params as [string, string, string, number | null, number];
const id = Math.max(0, ...rows.map((r) => r.id)) + 1;
rows = [...rows, { id, name, parent_id, is_active: 1, is_inputable }];
return { rowsAffected: 1, lastInsertId: id };
} else {
throw new Error(`unexpected execute: ${sql}`);
}
return { rowsAffected: 1 };
};
const connection = (via: "handle" | "global") => ({
select: vi.fn(async (sql: string, params?: unknown[]) => {
if (via === "global" && fake.inTransaction) throw new Error("getDb() inside a transaction");
log.push({ via, sql, params: params ?? [] });
return select(sql, params);
}),
execute: vi.fn(async (sql: string, params?: unknown[]) => {
if (via === "global" && fake.inTransaction) throw new Error("getDb() inside a transaction");
log.push({ via, sql, params: params ?? [] });
return execute(sql, params);
}),
});
fake.global = connection("global");
fake.handle = connection("handle");
return {
rows: () => rows,
log,
/** The statements that wrote, in order (BEGIN/COMMIT/ROLLBACK included). */
writes: () => log.filter((l) => !l.sql.startsWith("SELECT")).map((l) => l.sql.split(" WHERE")[0]),
};
}
const form = (parent_id: number | null, extra: { name?: string; is_inputable?: boolean } = {}) => ({
name: extra.name ?? "Renamed",
type: "expense",
color: "#4A90A4",
parent_id,
is_inputable: extra.is_inputable ?? true,
sort_order: 1,
});
/** What `updateCategory` or `reattachCategory` rejected with. */
async function refusal(promise: Promise<unknown>): Promise<CategoryHierarchyError> {
const error = await promise.then(
() => null,
(e: unknown) => e,
);
expect(error).toBeInstanceOf(CategoryHierarchyError);
return error as CategoryHierarchyError;
}
/** The tree of the Categories page, built from the active rows as the page loads them. */
const visibleIds = (rows: Row[]): number[] => {
const flat: CategoryTreeNode[] = rows
.filter((r) => r.is_active === 1)
.map((r) => ({
id: r.id,
name: r.name,
parent_id: r.parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: r.is_inputable === 1,
sort_order: r.id,
keyword_count: 0,
i18n_key: null,
children: [],
}));
const ids: number[] = [];
const walk = (nodes: CategoryTreeNode[]) => {
for (const node of nodes) {
ids.push(node.id);
walk(node.children);
}
};
walk(buildTree(flat));
return ids.sort((a, b) => a - b);
};
// 1 -> 10 -> 100, 1 -> 11; 2 -> 20.
const TREE = [row(1, null), row(10, 1), row(100, 10), row(11, 1), row(2, null), row(20, 2)];
beforeEach(() => {
fake.inTransaction = false;
vi.mocked(withTransaction).mockClear();
});
describe("updateCategory — regression of #393: A -> B -> A", () => {
it("refuses to put a category under its own child, writes nothing, rolls back", async () => {
// A (1) is a root, B (2) its child. Editing A with parent B closed the
// loop in one save and hid both everywhere.
const db = useFakeDb([row(1, null), row(2, 1)]);
const error = await refusal(updateCategory(1, form(2)));
expect(error.code).toBe("cycle");
expect(db.rows().find((r) => r.id === 1)?.parent_id).toBeNull();
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
expect(visibleIds(db.rows())).toEqual([1, 2]);
});
it("refuses the category itself as its parent", async () => {
useFakeDb(TREE);
expect((await refusal(updateCategory(11, form(11)))).code).toBe("self_parent");
});
it("refuses a grandchild as the parent", async () => {
useFakeDb(TREE);
expect((await refusal(updateCategory(1, form(100)))).code).toBe("cycle");
});
it("refuses a fourth level, counting the sub-tree of the category", async () => {
const db = useFakeDb(TREE);
// Root 2 carries 20: under 10 (level 1), 20 would land at level 3.
expect((await refusal(updateCategory(2, form(10)))).code).toBe("too_deep");
// A leaf under 100 (level 2) would be level 3.
expect((await refusal(updateCategory(20, form(100)))).code).toBe("too_deep");
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK", "BEGIN", "ROLLBACK"]);
});
it("refuses a new parent that holds transactions of its own", async () => {
const db = useFakeDb(TREE, { 11: 3 });
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_transactions");
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(2);
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(1);
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
});
it("moves a category under a valid parent, which stops taking input, in one transaction through its handle", async () => {
const db = useFakeDb(TREE);
await updateCategory(20, form(11, { is_inputable: false }));
expect(db.writes()).toEqual([
"BEGIN",
"UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6",
"UPDATE categories SET is_inputable = 0",
"COMMIT",
]);
// Every statement, reads included, went through the transaction's handle.
expect(db.log.every((l) => l.via === "handle")).toBe(true);
expect(withTransaction).toHaveBeenCalledTimes(1);
const moved = db.rows().find((r) => r.id === 20);
expect(moved?.parent_id).toBe(11);
expect(moved?.name).toBe("Renamed");
// The flag is bound as the integer 0, never the boolean false.
const update = db.log.find((l) => l.sql.startsWith("UPDATE categories SET name"));
expect(update?.params[4]).toBe(0);
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
});
it("accepts the root, without touching any other category", async () => {
const db = useFakeDb(TREE);
await updateCategory(10, form(null));
expect(db.rows().find((r) => r.id === 10)?.parent_id).toBeNull();
expect(db.writes()).toEqual([
"BEGIN",
"UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6",
"COMMIT",
]);
});
it("does not check an unchanged parent: a rename under a parent with transactions or at a fourth level is saved", async () => {
// Stored before the rules: 2 holds transactions and has a child; 1000 sits at level 3.
const db = useFakeDb([...TREE, row(1000, 100)], { 2: 5 });
await updateCategory(20, form(2, { name: "Rent" }));
await updateCategory(1000, form(100, { name: "Deep" }));
expect(db.rows().find((r) => r.id === 20)?.name).toBe("Rent");
expect(db.rows().find((r) => r.id === 1000)?.name).toBe("Deep");
expect(db.log.some((l) => l.sql.includes("FROM transactions"))).toBe(false);
expect(db.writes().filter((w) => w === "UPDATE categories SET is_inputable = 0")).toEqual([]);
});
it("refuses a deactivated parent with a plain error, out of reach of the pickers", async () => {
const db = useFakeDb([...TREE, row(3, null, { is_active: 0 })]);
const error = await updateCategory(20, form(3)).then(
() => null,
(e: unknown) => e,
);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(CategoryHierarchyError);
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
});
});
describe("CategoryHierarchyError", () => {
it("carries its code and names the i18n key of its message", () => {
const error = new CategoryHierarchyError("too_deep");
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe("CategoryHierarchyError");
expect(error.code).toBe("too_deep");
expect(categoryHierarchyErrorKey("parent_has_transactions")).toBe(
"categories.hierarchy.errors.parent_has_transactions",
);
});
});
describe("findOrphanCategories (#393)", () => {
it("reads every category with the dedicated query, inactive ones included", async () => {
const db = useFakeDb([row(1, null), row(2, null, { is_active: 0 }), row(20, 2), row(30, 99)]);
expect(await findOrphanCategories()).toEqual([
{ id: 20, reason: "inactive_parent" },
{ id: 30, reason: "missing_parent" },
]);
expect(db.log.map((l) => l.sql)).toEqual([HIERARCHY_SQL]);
});
});
describe("reattachCategory on rows already in a cycle (#393)", () => {
it("a two-member cycle: found by the diagnostic, broken at the root, back in the tree", async () => {
// 5 <-> 3, 8 under 5: none of them is reachable from a root.
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5), row(8, 5)]);
expect(visibleIds(db.rows())).toEqual([1]);
expect(await findOrphanCategories()).toEqual([{ id: 3, reason: "cycle" }]);
expect(await reattachCategory(3, null)).toBe(3);
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBeNull();
expect(await findOrphanCategories()).toEqual([]);
expect(visibleIds(db.rows())).toEqual([1, 3, 5, 8]);
expect(db.log.filter((l) => l.via === "global").map((l) => l.sql)).toEqual([HIERARCHY_SQL, HIERARCHY_SQL]);
});
it("a three-member cycle: too deep under a root, broken at the root, back in the tree", async () => {
// 6 -> 4 -> 9 -> 6, nothing under them. Broken at 4, the branch is 4 -> 6 -> 9:
// three levels, so it fits at the root and not under root 1.
const db = useFakeDb([row(1, null), row(6, 4), row(4, 9), row(9, 6)]);
expect(await findOrphanCategories()).toEqual([{ id: 4, reason: "cycle" }]);
// Under root 1 the branch (4, 6, 9) would reach level 3: refused, nothing written.
expect((await refusal(reattachCategory(4, 1))).code).toBe("too_deep");
expect(await findOrphanCategories()).toEqual([{ id: 4, reason: "cycle" }]);
expect(await reattachCategory(4, null)).toBe(3);
expect(await findOrphanCategories()).toEqual([]);
expect(visibleIds(db.rows())).toEqual([1, 4, 6, 9]);
});
it("moves a cycle member under a sound parent, which stops taking input", async () => {
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5)]);
expect(await reattachCategory(3, 1)).toBe(2);
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBe(1);
expect(db.rows().find((r) => r.id === 1)?.is_inputable).toBe(0);
expect(db.writes()).toEqual([
"BEGIN",
"UPDATE categories SET parent_id = $1",
"UPDATE categories SET is_inputable = 0",
"COMMIT",
]);
expect(db.log.every((l) => l.via === "handle")).toBe(true);
expect(visibleIds(db.rows())).toEqual([1, 3, 5]);
});
it("refuses the other member of the cycle and a parent with transactions, writing nothing", async () => {
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5)], { 1: 2 });
expect((await refusal(reattachCategory(3, 5))).code).toBe("cycle");
expect((await refusal(reattachCategory(3, 1))).code).toBe("parent_has_transactions");
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBe(5);
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK", "BEGIN", "ROLLBACK"]);
});
it("reattaches a category whose parent was deleted", async () => {
const db = useFakeDb([row(1, null), row(2, null, { is_active: 0 }), row(20, 2), row(21, 20)]);
expect(await reattachCategory(20, 1)).toBe(2);
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(1);
expect(await findOrphanCategories()).toEqual([]);
});
});
describe("getCategoryDepth (#393)", () => {
it("counts the active ancestors", async () => {
useFakeDb(TREE);
expect(await getCategoryDepth(1)).toBe(0);
expect(await getCategoryDepth(10)).toBe(1);
expect(await getCategoryDepth(100)).toBe(2);
});
it("ends on a cycle and on a category that is its own parent: no depth", async () => {
useFakeDb([row(5, 3), row(3, 5), row(8, 5), row(7, 7)]);
expect(await getCategoryDepth(5)).toBe(Number.POSITIVE_INFINITY);
expect(await getCategoryDepth(8)).toBe(Number.POSITIVE_INFINITY);
expect(await getCategoryDepth(7)).toBe(Number.POSITIVE_INFINITY);
});
it("so createCategory refuses a cycle member as the parent", async () => {
const db = useFakeDb([row(5, 3), row(3, 5)]);
await expect(
createCategory({ name: "New", type: "expense", color: "#000", parent_id: 5, is_inputable: true, sort_order: 1 }),
).rejects.toThrow("maximum depth");
expect(db.rows()).toHaveLength(2);
});
});

View file

@ -1,5 +1,112 @@
import { getDb } from "./db"; import type Database from "@tauri-apps/plugin-sql";
import { getDb, withTransaction } from "./db";
import type { Keyword } from "../shared/types"; import type { Keyword } from "../shared/types";
import { toSqlFlag } from "../utils/sqlFlag";
import {
descendantsOf,
findOrphans,
isActiveRow,
parentChangeRefusal,
type HierarchyRow,
type OrphanCategory,
} from "../utils/categoryHierarchy";
/** Why a new parent is refused (#393); each code has its message under `categories.hierarchy.errors`. */
export type CategoryHierarchyErrorCode =
| "self_parent"
| "cycle"
| "too_deep"
| "parent_has_transactions";
/**
* Thrown when `updateCategory` or `reattachCategory` refuses a new parent
* (#393), before any write. The UI shows the translation of its code
* (`categoryHierarchyErrorKey`), never `message`, which is for the tests and
* the developer console.
*/
export class CategoryHierarchyError extends Error {
readonly code: CategoryHierarchyErrorCode;
constructor(code: CategoryHierarchyErrorCode, message?: string) {
super(message ?? `New parent refused by the category hierarchy rules: ${code}`);
this.name = "CategoryHierarchyError";
this.code = code;
}
}
/** The i18n key of a refusal's message. */
export function categoryHierarchyErrorKey(code: CategoryHierarchyErrorCode): string {
return `categories.hierarchy.errors.${code}`;
}
/**
* Every category, active or not, with what the hierarchy rules read (#393).
* The other category queries keep active rows only, which would turn a
* deactivated parent into a missing one.
*/
const HIERARCHY_ROWS_SQL = "SELECT id, parent_id, is_active FROM categories";
async function readHierarchyRows(db: Database): Promise<HierarchyRow[]> {
return db.select<HierarchyRow[]>(HIERARCHY_ROWS_SQL);
}
/** The orphan categories of the profile, one per group to reattach (`findOrphans`, #393). */
export async function findOrphanCategories(): Promise<OrphanCategory[]> {
const db = await getDb();
return findOrphans(await readHierarchyRows(db));
}
/** A category that receives a child stops taking input, as in `createCategory`. */
async function markAsParent(db: Database, parentId: number): Promise<void> {
await db.execute(
`UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
[parentId]
);
}
/**
* Checks moving category `id` under `parentId` (#393), on rows read in the
* caller's transaction and through its handle: the hierarchy rules of
* `parentChangeRefusal`, then a parent that holds transactions of its own is
* refused (once a parent, it would stop taking input). The root is always
* accepted. Throws before any write.
*/
async function checkNewParent(
db: Database,
rows: HierarchyRow[],
id: number,
parentId: number | null
): Promise<void> {
if (parentId === null) return;
const active = rows.filter(isActiveRow);
// Unreachable from the UI: both pickers list active categories only.
if (parentId !== id && !active.some((r) => r.id === parentId)) {
throw new Error("Parent category not found or inactive");
}
const refusal = parentChangeRefusal(active, id, parentId);
if (refusal !== null) throw new CategoryHierarchyError(refusal);
const used = await db.select<Array<{ cnt: number }>>(
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
[parentId]
);
if ((used[0]?.cnt ?? 0) > 0) throw new CategoryHierarchyError("parent_has_transactions");
}
/** Runs `body` between BEGIN and COMMIT on the transaction handle, ROLLBACK on failure. */
async function inTransaction<T>(db: Database, body: () => Promise<T>): Promise<T> {
await db.execute("BEGIN");
try {
const result = await body();
await db.execute("COMMIT");
return result;
} catch (e) {
try {
await db.execute("ROLLBACK");
} catch {
// Keep the original error: it is the one that explains the failure.
}
throw e;
}
}
interface CategoryRow { interface CategoryRow {
id: number; id: number;
@ -27,11 +134,20 @@ export async function getAllCategoriesWithCounts(): Promise<CategoryRow[]> {
); );
} }
/**
* Number of active ancestors of a category: 0 for a root. A category caught in
* a `parent_id` cycle, or under one, has no depth: the walk keeps the ids it
* has seen and returns `Infinity` on the first one seen twice (#393), which
* `createCategory` refuses as a parent.
*/
export async function getCategoryDepth(categoryId: number): Promise<number> { export async function getCategoryDepth(categoryId: number): Promise<number> {
const db = await getDb(); const db = await getDb();
let depth = 0; let depth = 0;
let currentId: number | null = categoryId; let currentId: number | null = categoryId;
const visited = new Set<number>();
while (currentId !== null) { while (currentId !== null) {
if (visited.has(currentId)) return Number.POSITIVE_INFINITY;
visited.add(currentId);
const parentRows: Array<{ parent_id: number | null }> = await db.select<Array<{ parent_id: number | null }>>( const parentRows: Array<{ parent_id: number | null }> = await db.select<Array<{ parent_id: number | null }>>(
`SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`, `SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`,
[currentId] [currentId]
@ -68,15 +184,22 @@ export async function createCategory(data: {
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0 // Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
if (data.parent_id !== null) { if (data.parent_id !== null) {
await db.execute( await markAsParent(db, data.parent_id);
`UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
[data.parent_id]
);
} }
return result.lastInsertId as number; return result.lastInsertId as number;
} }
/**
* Saves the category form. A new parent goes through the hierarchy rules
* (#393, `checkNewParent`): `CategoryHierarchyError` on a refusal, nothing
* written. The reads and the writes (the row, then the new parent's
* `is_inputable`) run in one transaction, through its handle.
*
* An unchanged parent is not checked: a name or a colour saved on a category
* stored before these rules (a fourth level, a parent with transactions) must
* not be refused.
*/
export async function updateCategory( export async function updateCategory(
id: number, id: number,
data: { data: {
@ -88,10 +211,43 @@ export async function updateCategory(
sort_order: number; sort_order: number;
} }
): Promise<void> { ): Promise<void> {
const db = await getDb(); const parentId = data.parent_id ?? null;
await withTransaction((db) =>
inTransaction(db, async () => {
const rows = await readHierarchyRows(db);
const current = rows.find((r) => r.id === id);
if (current === undefined) throw new Error("Category not found");
const parentChanged = (current.parent_id ?? null) !== parentId;
if (parentChanged) await checkNewParent(db, rows, id, parentId);
await db.execute( await db.execute(
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`, `UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order, id] [data.name, data.type, data.color, parentId, toSqlFlag(data.is_inputable), data.sort_order, id]
);
if (parentChanged && parentId !== null) await markAsParent(db, parentId);
})
);
}
/**
* Moves a category under `parentId`, or to the root with `null`: the repair
* of the « Vérifier la hiérarchie » window (#393). Same rules as a parent
* change in `updateCategory`, always checked, in one transaction through its
* handle; only `parent_id` changes, and the new parent's `is_inputable`.
*
* Returns the number of categories put back in place: the category and every
* active category under it (for a member of a cycle, the whole cycle and what
* hangs under it).
*/
export async function reattachCategory(id: number, parentId: number | null): Promise<number> {
return withTransaction((db) =>
inTransaction(db, async () => {
const rows = await readHierarchyRows(db);
if (!rows.some((r) => r.id === id)) throw new Error("Category not found");
await checkNewParent(db, rows, id, parentId);
await db.execute(`UPDATE categories SET parent_id = $1 WHERE id = $2`, [parentId, id]);
if (parentId !== null) await markAsParent(db, parentId);
return 1 + descendantsOf(rows.filter(isActiveRow), id).size;
})
); );
} }
@ -140,6 +296,13 @@ export async function fixDuplicateSortOrders(): Promise<void> {
} }
} }
/**
* Writes the order and the parent of every category after a drag-and-drop in
* the tree. Unlike `updateCategory`, no hierarchy check here (#393, out of its
* scope): its only caller, `moveCategory` of `useCategories`, cannot drop a
* category inside its own sub-tree (the drop target is not found, nothing is
* written).
*/
export async function updateCategorySortOrders( export async function updateCategorySortOrders(
updates: Array<{ id: number; sort_order: number; parent_id: number | null }> updates: Array<{ id: number; sort_order: number; parent_id: number | null }>
): Promise<void> { ): Promise<void> {