Simpl-Resultat/src/components/categories/parentOptions.ts
le king fu df3fefe934 feat: gather the category hierarchy rules in one module (#393)
src/utils/categoryHierarchy.ts holds the depth, height and descent walks
that buildParentOptions had inline, each with a set of the categories it
has seen, so a parent_id cycle ends the walk. A category caught in a cycle,
or under one, now has no depth instead of a made-up one, and the parent
list of the category form never offers it: a child placed there would be
hidden with it. The module also carries the orphan diagnostic, the parent
change refusal and the root rule that the service, the Categories page and
the budget use next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:16:14 -04:00

58 lines
2.6 KiB
TypeScript

import type { CategoryTreeNode } from "../../shared/types";
import { depthsOf, fitsUnder, subtreeHeight } from "../../utils/categoryHierarchy";
export interface ParentOptionsContext {
/** Id of the category being edited; absent or null when creating. */
editingId?: number | null;
/**
* Parent currently stored on the form. Always kept in the options when it
* exists, so a legacy or inconsistent parent never leaves the field blank
* while `parent_id` is set.
*/
currentParentId?: number | null;
}
/**
* Categories that may be chosen as the parent in the category form, and in the
* « Vérifier la hiérarchie » window (#393).
*
* - Roots and level-1 categories only: a level-2 parent would create a fourth
* level, which nothing else in the app knows how to display.
* - When editing, the edited category is left out, and the allowed parent depth
* shrinks by the height of its sub-tree: with children, only roots remain
* (the children would otherwise land at level 3); with grandchildren, no
* parent at all (they would land at level 4). This alone also leaves out its
* whole descent, which would create a cycle that hides the branch everywhere
* (#393): a descendant sits at least one level below the edited category,
* deeper than any depth still allowed.
* - A category caught in a `parent_id` cycle, or under one, has no depth and is
* never offered: a child placed under it would be hidden with it.
* - The parent currently set on the form is always kept when it exists.
*
* The depth and height rules are those of `src/utils/categoryHierarchy.ts`,
* which `updateCategory` and `reattachCategory` apply again before writing
* (#393): the list only spares a refusal at save time. It does not leave out
* a parent that holds transactions; the service refuses it with a translated
* message.
*
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
*/
export function buildParentOptions(
categories: CategoryTreeNode[],
ctx: ParentOptionsContext = {},
): CategoryTreeNode[] {
const editingId = ctx.editingId ?? null;
const currentParentId = ctx.currentParentId ?? null;
const depths = depthsOf(categories);
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
return categories.filter((c) => {
if (c.id === editingId) return false;
// Kept even when the depth rule would drop it: the field must show what is
// stored. Keeping it changes nothing on save (it is already the parent).
if (c.id === currentParentId) return true;
const depth = depths.get(c.id);
return depth !== undefined && fitsUnder(depth, height);
});
}