From 84ec3481d9199320bb722b57fc048f782eff1862 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Thu, 13 Aug 2026 15:57:24 -0400 Subject: [PATCH] Let a subsection hang under another subsection A&E asked for a Food subsection holding Beer Reviews, Wine Reviews, Restaurant Reviews and Cooking -- a third level the taxonomy did not have. parent_slug had to name a section, and the rollup was a single non-recursive hop. Depth is a property of the parent chain, not a new kind: both levels stay kind='subsection', so everything that switches on kind -- the public site's slug router, the article filters, the sections screen -- keeps working. MaxTaxonomyDepth caps the chain at three, because the walks are recursive over editor-editable rows. The load-bearing part is the rollup, which is now transitive. One hop would have counted Beer Reviews for Food while dropping it from A&E, leaving a section quietly shorter than the sum of its subsections -- the same silent-omission failure the alias seeding exists to fix. Recounts walk every ancestor for the same reason, and a subsection resolves to its ROOT section, not its parent, since that is what ?section_slug= is checked against. A subsection can be a container now, so its page matches its descendants -- /food alone would render empty, as its articles are filed under the four categories beneath it -- and the delete and slug-change guards, which only asked about sections, now ask about anything with children. SeedFoodSubsection creates Food and moves the four rows, once, under its own flag: legacy_subsections_seeded is already set in production. The move is guarded on the current parent, so a row an editor pulls back out stays where they put it. Co-Authored-By: Claude Opus 5 --- frontend/src/pages/sectionsView.tsx | 180 +++++++--- server/internal/database/taxonomy.go | 315 +++++++++++++++++- .../database/taxonomy_integration_test.go | 87 +++++ server/internal/database/taxonomy_test.go | 75 +++++ server/internal/handlers/article_params.go | 69 ++-- server/internal/handlers/handlers.go | 57 +++- server/internal/handlers/taxonomy.go | 166 ++++++--- .../handlers/taxonomy_integration_test.go | 200 ++++++++++- server/internal/handlers/taxonomy_test.go | 6 +- server/internal/models/api_responses.go | 12 +- 10 files changed, 1018 insertions(+), 149 deletions(-) diff --git a/frontend/src/pages/sectionsView.tsx b/frontend/src/pages/sectionsView.tsx index ccb9691..bb8730f 100644 --- a/frontend/src/pages/sectionsView.tsx +++ b/frontend/src/pages/sectionsView.tsx @@ -21,8 +21,18 @@ type SectionRow = { childCount: number isChild: boolean isLast: boolean + // How far from a section this row sits: 0 for a section, 1 for a subsection, + // 2 for a subsection of a subsection. The tree is three levels deep now (A&E + // > Food > Beer Reviews), so indentation cannot be a boolean any more. + depth: number } +// How deep the tree may go, counting the section. Mirrors MaxTaxonomyDepth on +// the server, which is the side that enforces it -- this copy only decides which +// rows the parent picker offers, so that the editor does not present a choice +// the save would reject. +const MAX_DEPTH = 3 + type FormState = { type: TaxonomyKind canonicalTitle: string @@ -146,57 +156,126 @@ export default function SectionsView() { }) }, [items]) - const rows = useMemo(() => { - const children = items - .filter((item) => item.type === "subsection") - .sort((left, right) => left.canonical_title.localeCompare(right.canonical_title)) - - const childrenByParent = new Map() - for (const child of children) { + const childrenByParent = useMemo(() => { + const grouped = new Map() + for (const child of items.filter((item) => item.type === "subsection")) { const parentSlug = child.parent_slug ?? "" - if (!childrenByParent.has(parentSlug)) { - childrenByParent.set(parentSlug, []) + if (!grouped.has(parentSlug)) { + grouped.set(parentSlug, []) } - childrenByParent.get(parentSlug)?.push(child) + grouped.get(parentSlug)?.push(child) } + for (const group of grouped.values()) { + group.sort((left, right) => left.canonical_title.localeCompare(right.canonical_title)) + } + return grouped + }, [items]) + const rows = useMemo(() => { const nextRows: SectionRow[] = [] - const renderedChildren = new Set() - - for (const parent of parentSections) { - const parentChildren = childrenByParent.get(parent.slug) ?? [] + const rendered = new Set() + + // Recursive, because the tree is three levels: a section, its subsections, + // and theirs. The rendered set doubles as the cycle guard -- this walks + // server data, and a row that somehow parented an ancestor would otherwise + // loop forever and hang the screen rather than showing a broken tree. + const pushSubtree = (item: TaxonomyItem, parentTitle: string | null, depth: number, isLast: boolean) => { + if (rendered.has(item.id)) return + rendered.add(item.id) + const children = childrenByParent.get(item.slug) ?? [] nextRows.push({ - item: parent, - parentTitle: null, - childCount: parentChildren.length, - isChild: false, - isLast: false, + item, + parentTitle, + childCount: children.length, + isChild: depth > 0, + isLast, + depth, }) - parentChildren.forEach((child, index) => { - renderedChildren.add(child.id) - nextRows.push({ - item: child, - parentTitle: parent.canonical_title, - childCount: 0, - isChild: true, - isLast: index === parentChildren.length - 1, - }) + children.forEach((child, index) => { + pushSubtree(child, item.canonical_title, depth + 1, index === children.length - 1) }) } - for (const child of children) { - if (renderedChildren.has(child.id)) continue + for (const section of parentSections) { + pushSubtree(section, null, 0, false) + } + + // Anything the walk did not reach hangs under a parent that no longer + // exists. Still listed, and named as orphaned, because an invisible row is + // one nobody can fix. + for (const item of items) { + if (item.type !== "subsection" || rendered.has(item.id)) continue nextRows.push({ - item: child, - parentTitle: child.parent_slug ? `Unknown parent: ${child.parent_slug}` : "No parent", - childCount: 0, + item, + parentTitle: item.parent_slug ? `Unknown parent: ${item.parent_slug}` : "No parent", + childCount: (childrenByParent.get(item.slug) ?? []).length, isChild: true, isLast: true, + depth: 1, }) } return nextRows - }, [items, parentSections]) + }, [items, parentSections, childrenByParent]) + + // Depth of the chain ABOVE a slug, so the parent picker can drop anything that + // has no room left beneath it. + const depthOf = useCallback((slug: string) => { + const bySlug = new Map(items.map((item) => [item.slug, item])) + let depth = 1 + let current = bySlug.get(slug) + const seen = new Set() + while (current && current.type === "subsection" && current.parent_slug) { + if (seen.has(current.slug)) break + seen.add(current.slug) + depth += 1 + current = bySlug.get(current.parent_slug) + } + return depth + }, [items]) + + // Everything a subsection may hang under: the sections, plus the subsections + // still shallow enough to take a child. Editing a row excludes itself and its + // own descendants, since neither can be its parent. + const parentChoices = useMemo(() => { + const editingSlug = editor?.mode === "edit" ? editor.item.slug : null + + const forbidden = new Set() + if (editingSlug) { + const queue = [editingSlug] + while (queue.length > 0) { + const slug = queue.shift() as string + if (forbidden.has(slug)) continue + forbidden.add(slug) + for (const child of childrenByParent.get(slug) ?? []) { + queue.push(child.slug) + } + } + } + + // The moved row brings its own subtree along, so the room it needs is its + // own height, not one level. + const movedHeight = editingSlug + ? (() => { + const heightOf = (slug: string): number => { + const children = childrenByParent.get(slug) ?? [] + return children.length === 0 ? 0 : 1 + Math.max(...children.map((child) => heightOf(child.slug))) + } + return heightOf(editingSlug) + })() + : 0 + + return items + .filter((item) => item.type === "section" || item.type === "subsection") + .filter((item) => !forbidden.has(item.slug)) + .filter((item) => depthOf(item.slug) + 1 + movedHeight <= MAX_DEPTH) + .sort((left, right) => { + const leftDepth = depthOf(left.slug) + const rightDepth = depthOf(right.slug) + if (leftDepth !== rightDepth) return leftDepth - rightDepth + return left.canonical_title.localeCompare(right.canonical_title) + }) + }, [items, childrenByParent, depthOf, editor]) const filtered = useMemo(() => { const query = search.toLowerCase().trim() @@ -220,7 +299,9 @@ export default function SectionsView() { ) const openCreate = (type: TaxonomyKind, parentSlug = "") => { - const parentExists = parentSlug && parentSections.some((section) => section.slug === parentSlug) + // Any row that can still take a child, not just a section: "Add subsection" + // from Food has to preselect Food. + const parentExists = parentSlug && parentChoices.some((choice) => choice.slug === parentSlug) const nextParent = type === "subsection" ? (parentExists ? parentSlug : parentSections.some((section) => section.slug === "columns") ? "columns" : parentSections[0]?.slug ?? "") : "" @@ -479,14 +560,14 @@ export default function SectionsView() { No sections found. ) : ( - filtered.map(({ item, parentTitle, childCount, isChild, isLast }) => { + filtered.map(({ item, parentTitle, childCount, isChild, isLast, depth }) => { const articleCount = item.article_count ?? 0 const isVisible = item.is_visible !== false const canDelete = articleCount === 0 && childCount === 0 const deleteTitle = articleCount > 0 ? "Cannot delete while articles use this item." : childCount > 0 - ? "Cannot delete while subsections use this section." + ? "Cannot delete while subsections use this item." : `Delete ${item.canonical_title}` return ( @@ -494,6 +575,12 @@ export default function SectionsView() { {isChild ? ( + {/* One blank rail per level above the elbow, so a + grandchild reads as sitting under its subsection + rather than beside it. */} + {Array.from({ length: depth - 1 }, (_, level) => ( +