diff --git a/src/components/categories/parentOptions.test.ts b/src/components/categories/parentOptions.test.ts index c694dec..dd2cc64 100644 --- a/src/components/categories/parentOptions.test.ts +++ b/src/components/categories/parentOptions.test.ts @@ -47,17 +47,24 @@ describe("buildParentOptions — editing a category", () => { expect(ids(buildParentOptions(TREE, { editingId: 11 }))).toEqual([1, 2, 10, 20]); }); - it("leaves out the whole descent of the edited category", () => { - // Editing root 1, which has children: only roots remain, so none of its - // descent (10, 11, 100) can be offered, whatever its depth. - expect(ids(buildParentOptions(TREE, { editingId: 1 }))).toEqual([2]); - // Editing 10 (child 100): a level-1 category with a child; 100 stays out. - expect(ids(buildParentOptions(TREE, { editingId: 10 }))).not.toContain(100); + it("offers nothing when the edited category has grandchildren", () => { + // Root 1 carries 10 -> 100: under any parent, 100 would sit at level 3. + // Probe of the review of PR #428: `[1, 10->1, 100->10, 2]`, editing 1. + expect(ids(buildParentOptions([node(1, null), node(10, 1), node(100, 10), node(2, null)], { editingId: 1 }))).toEqual([]); + expect(ids(buildParentOptions(TREE, { editingId: 1 }))).toEqual([]); }); - it("keeps only roots when the edited category has children", () => { + it("leaves out the whole descent of the edited category", () => { + // Editing 10 (child 100): a level-1 category with a child; 100 stays out. + expect(ids(buildParentOptions(TREE, { editingId: 10 }))).not.toContain(100); + // Editing root 2 (child 20): only roots remain, so 20 stays out. + expect(ids(buildParentOptions(TREE, { editingId: 2 }))).not.toContain(20); + }); + + it("keeps only roots when the edited category has children but no grandchildren", () => { // 10 has a child (100): under a level-1 parent its child would sit at level 3. expect(ids(buildParentOptions(TREE, { editingId: 10 }))).toEqual([1, 2]); + expect(ids(buildParentOptions(TREE, { editingId: 2 }))).toEqual([1]); }); it("keeps level-1 categories when the edited category is a leaf", () => { diff --git a/src/components/categories/parentOptions.ts b/src/components/categories/parentOptions.ts index e4490d1..52f19f9 100644 --- a/src/components/categories/parentOptions.ts +++ b/src/components/categories/parentOptions.ts @@ -34,16 +34,49 @@ function depthsOf(categories: CategoryTreeNode[]): Map { return depths; } +/** + * Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with + * grandchildren. Walks `parent_id` links; a cycle is not walked twice. + */ +function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number { + const childrenOf = new Map(); + for (const c of categories) { + if (c.parent_id === null) continue; + const bucket = childrenOf.get(c.parent_id) ?? []; + bucket.push(c.id); + childrenOf.set(c.parent_id, bucket); + } + let height = 0; + const seen = new Set([rootId]); + let frontier = [rootId]; + while (frontier.length > 0) { + const next: number[] = []; + for (const id of frontier) { + for (const child of childrenOf.get(id) ?? []) { + if (seen.has(child)) continue; + seen.add(child); + next.push(child); + } + } + if (next.length === 0) break; + height += 1; + frontier = next; + } + return height; +} + /** * Categories that may be chosen as the parent in the category form. * * - 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 when it has children of - * its own, only roots remain (its children would otherwise land at level 3). - * This alone also leaves out its whole descent, which would create a cycle - * that hides the branch everywhere (#393): a category with descendants has - * children, so only roots are offered, and a descendant is never a root. + * - 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. * - The parent currently set on the form is always kept when it exists. * * Ordering is left to the caller (`CategoryCombobox` sorts hierarchically). @@ -56,9 +89,9 @@ export function buildParentOptions( const currentParentId = ctx.currentParentId ?? null; const depths = depthsOf(categories); - const editedHasChildren = - editingId !== null && categories.some((c) => c.parent_id === editingId); - const maxDepth = editedHasChildren ? 0 : MAX_PARENT_DEPTH; + const height = editingId !== null ? subtreeHeight(categories, editingId) : 0; + // Negative when the edited category has grandchildren: only the current parent survives. + const maxDepth = MAX_PARENT_DEPTH - height; return categories.filter((c) => { if (c.id === editingId) return false;