feat(categories): allow unlimited depth of categories

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-01 22:15:23 +02:00
1 parent 172270fce8
commit 04f3106ce8
13 files changed
+501 -434

No files matched your search

+20 -2
View File
@@ -207,10 +207,28 @@ router.delete(
const category = await prisma.category.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!category) throw new HttpError(404, "Not found");
await prisma.category.updateMany({
// Subcategories move up one level, after the deleted category's former siblings.
const children = await prisma.category.findMany({
where: { parentId: category.id, userId: req.userId },
data: { parentId: null },
orderBy: [{ position: "asc" }, { name: "asc" }],
select: { id: true },
});
if (children.length) {
const last = await prisma.category.findFirst({
where: { userId: req.userId, parentId: category.parentId, id: { not: category.id } },
orderBy: { position: "desc" },
select: { position: true },
});
const start = (last?.position ?? -1) + 1;
await prisma.$transaction(
children.map((child, idx) =>
prisma.category.update({
where: { id: child.id },
data: { parentId: category.parentId, position: start + idx },
}),
),
);
}
const prints = await prisma.print.findMany({ where: { categoryId: category.id, userId: req.userId } });
for (const print of prints) {
+1 -1
View File
@@ -1,5 +1,5 @@
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse and Printables ids so
* imports are auto-categorized from the start. At most two levels deep. */
* imports are auto-categorized from the start. */
export type DefaultCategoryNode = {
name: string;
tags?: string[];
+14 -7
View File
@@ -3,7 +3,7 @@ import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { DEFAULT_CATEGORIES, type DefaultCategoryNode } from "../seedData/defaultCategories";
/** Keeps the tree at most two levels deep, which also makes cycles impossible. */
/** Any depth is allowed; only cycles are rejected (a category under itself or its own descendant). */
export async function validateParentCategory(
userId: string,
parentId: string | null | undefined,
@@ -14,13 +14,20 @@ export async function validateParentCategory(
if (!parent) throw new HttpError(400, "Parent category not found");
if (categoryId && parentId === categoryId) throw new HttpError(400, "Category cannot be its own parent");
if (parent.parentId) {
throw new HttpError(400, "Categories can only be nested two levels deep");
}
if (categoryId) {
const childCount = await prisma.category.count({ where: { parentId: categoryId, userId } });
if (childCount > 0) {
throw new HttpError(400, "A category with subcategories cannot be moved under another category");
// `visited` guards against a cycle already in the data.
const visited = new Set<string>();
let ancestorId = parent.parentId;
while (ancestorId && !visited.has(ancestorId)) {
if (ancestorId === categoryId) {
throw new HttpError(400, "A category cannot be moved under one of its own subcategories");
}
visited.add(ancestorId);
const ancestor = await prisma.category.findFirst({
where: { id: ancestorId, userId },
select: { parentId: true },
});
ancestorId = ancestor?.parentId ?? null;
}
}
+24 -5
View File
@@ -89,12 +89,31 @@ describe("POST /api/category/:id/move", () => {
expect(await childNames(empty)).toEqual(["a1"]);
});
it("keeps the tree two levels deep", async () => {
// Under a subcategory: would be a third level.
expect((await move(subs.a1, subs.b1, 0)).status).toBe(400);
// A category with subcategories can't become one.
expect((await move(rootA, rootB, 0)).status).toBe(400);
it("nests to any depth, whole subtrees included", async () => {
expect((await move(subs.a1, subs.b1, 0)).status).toBe(200);
expect((await move(subs.b1, subs.a2, 0)).status).toBe(200);
expect(await childNames(subs.a2)).toEqual(["b1"]);
expect(await childNames(subs.b1)).toEqual(["a1"]);
// A category with subcategories can itself become one.
expect((await move(rootB, rootA, 0)).status).toBe(200);
expect((await childNames(rootA))[0]).toMatch(/^Root B/);
});
it("rejects cycles", async () => {
expect((await move(subs.a1, subs.a1, 0)).status).toBe(400);
// Under its own child, and under its own grandchild.
expect((await move(rootA, subs.a1, 0)).status).toBe(400);
const grandchild = await create("a1-child", subs.a1);
expect((await move(rootA, grandchild, 0)).status).toBe(400);
});
it("moves subcategories up one level when their category is deleted", async () => {
await create("a2-child-1", subs.a2);
await create("a2-child-2", subs.a2);
expect((await request(app).delete(`/api/category/${subs.a2}`).set(auth(token))).status).toBe(200);
const names = await childNames(rootA);
expect(names.slice(0, 2)).toEqual(["a1", "a3"]);
expect(names.slice(2).toSorted()).toEqual(["a2-child-1", "a2-child-2"]);
});
it("only touches the user's own categories", async () => {
-6
View File
@@ -25,12 +25,6 @@ describe("DEFAULT_CATEGORIES", () => {
expect(DEFAULT_CATEGORIES.length).toBeGreaterThan(0);
});
it("never nests more than two levels deep, matching validateParentCategory's limit", () => {
for (const { depth } of all) {
expect(depth).toBeLessThanOrEqual(1);
}
});
it("gives every node a non-empty name", () => {
for (const { node } of all) {
expect(node.name.trim()).not.toBe("");
+2 -3
View File
@@ -1441,7 +1441,7 @@
"addSubcategory": "Add subcategory",
"noCategories": "No categories yet. Add one to get started.",
"confirmDeleteCategory": "Delete \"{{name}}\"? Any models in it move to Unassigned.",
"confirmDeleteCategoryWithSub": "Delete \"{{name}}\"? Its subcategories become top-level categories, and any models in them move to Unassigned.",
"confirmDeleteCategoryWithSub": "Delete \"{{name}}\"? Its subcategories move up one level, and any models directly in it move to Unassigned.",
"confirmDeleteSubcategory": "Delete \"{{name}}\"? Any models in it move to Unassigned.",
"addDetailsTooltip": "Add details",
"editDetailsTooltip": "Edit details",
@@ -1453,8 +1453,7 @@
"printablesCatIdLabel": "Printables category IDs",
"catIdsPlaceholder": "e.g. 800;71;1001",
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
"dragToMove": "Drag to move",
"dropHere": "Drop here"
"dragToMove": "Drag to move"
}
}
}
+2 -3
View File
@@ -1446,7 +1446,7 @@
"addSubcategory": "Pridėti subkategoriją",
"noCategories": "Kol kas nėra kategorijų. Pridėkite pirmąją.",
"confirmDeleteCategory": "Ištrinti „{{name}}“? Joje esantys modeliai taps nepriskirti.",
"confirmDeleteCategoryWithSub": "Ištrinti „{{name}}“? Jos subkategorijos taps pagrindinėmis kategorijomis, o jose esantys modeliai taps nepriskirti.",
"confirmDeleteCategoryWithSub": "Ištrinti „{{name}}“? Jos subkategorijos pakils vienu lygiu aukščiau, o modeliai, esantys tiesiai joje, bus perkelti į „Nepriskirta“.",
"confirmDeleteSubcategory": "Ištrinti „{{name}}“? Joje esantys modeliai taps nepriskirti.",
"addDetailsTooltip": "Pridėti informaciją",
"editDetailsTooltip": "Redaguoti informaciją",
@@ -1458,8 +1458,7 @@
"printablesCatIdLabel": "Printables kategorijų ID",
"catIdsPlaceholder": "pvz. 800;71;1001",
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
"dragToMove": "Vilkite, kad perkeltumėte",
"dropHere": "Numeskite čia"
"dragToMove": "Vilkite, kad perkeltumėte"
}
}
}
@@ -36,6 +36,7 @@ import { useConfirm } from "../../components/ConfirmProvider";
import { useToast } from "../../components/ToastProvider";
import TagInput from "../../components/TagInput";
import { translateCategoryDisplay } from "../../utils/translateCategoryDisplay";
import { buildCategoryTree, flattenCategoryTree } from "../../utils/categoryTree";
type ImageItem =
{ kind: "existing"; id: string; url: string } | { kind: "new"; localId: string; file: File; previewUrl: string };
@@ -107,24 +108,7 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
const markDirty = () => setDirty(true);
const { roots, childrenByParent } = useMemo(() => {
const list = categories ?? [];
const childrenMap: Record<string, Category[]> = {};
const rootList: Category[] = [];
for (const c of list) {
if (c.parent_id) {
if (!childrenMap[c.parent_id]) childrenMap[c.parent_id] = [];
childrenMap[c.parent_id].push(c);
} else {
rootList.push(c);
}
}
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
Object.keys(childrenMap).forEach((key) => {
childrenMap[key] = childrenMap[key].toSorted(byPosition);
});
return { roots: rootList.toSorted(byPosition), childrenByParent: childrenMap };
}, [categories]);
const flatCategories = useMemo(() => flattenCategoryTree(buildCategoryTree(categories ?? [])), [categories]);
const categoryName = (c: Category) => translateCategoryDisplay(c, i18n).name;
const onAddImages = (fileList: FileList | null) => {
@@ -323,19 +307,17 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
}}
>
<MenuItem value="">{t("models:edit.noCategory")}</MenuItem>
{roots.flatMap((root) =>
[
// A disabled MenuItem, not ListSubheader: MUI's Select still handles clicks on the latter and
// gets stuck open.
<MenuItem key={`h-${root.id}`} disabled divider sx={{ fontWeight: 700, opacity: "1 !important" }}>
{categoryName(root)}
</MenuItem>,
].concat(
(childrenByParent[root.id] ?? []).map((child) => (
<MenuItem key={child.id} value={child.id} sx={{ pl: 3 }}>
{categoryName(child)}
</MenuItem>
)),
{flatCategories.map(({ category, depth }) =>
depth === 0 ? (
// Top-level categories are headings. A disabled MenuItem, not ListSubheader: MUI's Select
// still handles clicks on the latter and gets stuck open.
<MenuItem key={category.id} disabled divider sx={{ fontWeight: 700, opacity: "1 !important" }}>
{categoryName(category)}
</MenuItem>
) : (
<MenuItem key={category.id} value={category.id} sx={{ pl: 1 + depth * 2 }}>
{categoryName(category)}
</MenuItem>
),
)}
</Select>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Theme } from "@mui/material/styles";
import Paper from "@mui/material/Paper";
@@ -16,6 +16,7 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import type { Category, CategoryMetaInput } from "../../api/categories";
import { translateCategoryDisplay } from "../../utils/translateCategoryDisplay";
import { dividerBorderColor } from "../../theme";
import { ancestorPath, buildCategoryTree } from "../../utils/categoryTree";
import CategoryManagerModal from "./CategoryManagerModal";
type Props = {
@@ -58,7 +59,7 @@ function rowTextSx(active: boolean, extra?: object) {
};
}
/** A two-level tree: a root selects all its subcategories' models; one root is expanded at a time. */
/** Any depth; a category selects the models at every level beneath it. Only the selected path is open. */
export default function CategoriesPanel({
categories,
loading,
@@ -72,45 +73,58 @@ export default function CategoriesPanel({
onUpdateMeta,
}: Props) {
const { t, i18n } = useTranslation(["models", "common"]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [managerOpen, setManagerOpen] = useState(false);
const displayName = (category: Category) => translateCategoryDisplay(category, i18n).name;
const untitledLabel = t("models:categories.untitled");
// Expand the selected child's root when the selection comes from outside (URL, back/forward), or
// the child never renders.
useEffect(() => {
if (!selectedId) return;
const selected = categories.find((c) => c.id === selectedId);
if (!selected) return;
setExpandedId(selected.parent_id || selected.id);
}, [selectedId, categories]);
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
// The selected category and its ancestors are open, so a selection from the URL is always visible.
const openIds = useMemo(
() => new Set(selectedId && tree.byId.has(selectedId) ? ancestorPath(tree, selectedId) : []),
[tree, selectedId],
);
const { roots, childrenByParent } = useMemo(() => {
const childrenMap: Record<string, Category[]> = {};
const rootList: Category[] = [];
categories.forEach((f) => {
if (f.parent_id) {
if (!childrenMap[f.parent_id]) childrenMap[f.parent_id] = [];
childrenMap[f.parent_id].push(f);
} else {
rootList.push(f);
}
});
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
Object.keys(childrenMap).forEach((key) => {
childrenMap[key] = childrenMap[key].toSorted(byPosition);
});
return {
roots: rootList.toSorted(byPosition),
childrenByParent: childrenMap,
};
}, [categories]);
const handleRootClick = (id: string) => {
setExpandedId(id);
onSelect(id);
const renderCategory = (category: Category, depth: number) => {
const children = tree.childrenByParent[category.id] ?? [];
const isRoot = depth === 0;
const isOpen = openIds.has(category.id);
const isSelected = selectedId === category.id;
return (
<Stack key={category.id}>
<ListItemButton onClick={() => onSelect(category.id)} sx={{ pl: 1 + depth * 2, ...rowSx(isSelected) }}>
<ListItemText
primary={displayName(category) || untitledLabel}
primaryTypographyProps={rowTextSx(
isSelected,
isRoot ? { fontWeight: 600 } : isSelected ? { fontWeight: 700 } : undefined,
)}
/>
{(isRoot || children.length > 0) && (
<ChevronRightIcon
fontSize="small"
sx={{
ml: 0.5,
flexShrink: 0,
transform: isOpen ? "rotate(90deg)" : "none",
transition: "transform 0.15s",
color: (theme) => (isSelected ? theme.thingport.selectedNavText : theme.thingport.navInactiveText),
}}
/>
)}
</ListItemButton>
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{children.map((child) => renderCategory(child, depth + 1))}
{isRoot && !children.length && (
<Typography variant="caption" color="text.secondary" sx={{ pl: 4, display: "block", py: 0.5 }}>
{t("models:categories.noSubcategories")}
</Typography>
)}
</List>
</Collapse>
</Stack>
);
};
return (
@@ -156,62 +170,9 @@ export default function CategoriesPanel({
</Stack>
)}
{!loading &&
roots.map((root) => {
const children = childrenByParent[root.id] || [];
const isOpen = expandedId === root.id;
const isRootActive = isOpen && selectedId === root.id;
return (
<Stack key={root.id}>
<ListItemButton onClick={() => handleRootClick(root.id)} sx={rowSx(isRootActive)}>
<ListItemText
primary={displayName(root) || untitledLabel}
primaryTypographyProps={rowTextSx(isRootActive, { fontWeight: 600 })}
/>
<ChevronRightIcon
fontSize="small"
sx={{
ml: 0.5,
flexShrink: 0,
transform: isOpen ? "rotate(90deg)" : "none",
transition: "transform 0.15s",
color: (theme) =>
isRootActive ? theme.thingport.selectedNavText : theme.thingport.navInactiveText,
}}
/>
</ListItemButton>
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{children.map((child) => {
const isChildSelected = selectedId === child.id;
return (
<ListItemButton
key={child.id}
onClick={() => onSelect(child.id)}
sx={{ pl: 4, ...rowSx(isChildSelected) }}
>
<ListItemText
primary={displayName(child) || untitledLabel}
primaryTypographyProps={rowTextSx(
isChildSelected,
isChildSelected ? { fontWeight: 700 } : undefined,
)}
/>
</ListItemButton>
);
})}
{!children.length && (
<Typography variant="caption" color="text.secondary" sx={{ pl: 4, display: "block", py: 0.5 }}>
{t("models:categories.noSubcategories")}
</Typography>
)}
</List>
</Collapse>
</Stack>
);
})}
{!loading && tree.roots.map((root) => renderCategory(root, 0))}
{!loading && !roots.length && (
{!loading && !tree.roots.length && (
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
{t("models:categories.empty")}
</Typography>
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Fragment, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
@@ -14,6 +14,7 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import CircularProgress from "@mui/material/CircularProgress";
import Divider from "@mui/material/Divider";
import Paper from "@mui/material/Paper";
import Tooltip from "@mui/material/Tooltip";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
@@ -24,20 +25,19 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import Paper from "@mui/material/Paper";
import {
DndContext,
DragOverlay,
KeyboardSensor,
MeasuringStrategy,
PointerSensor,
closestCorners,
useDroppable,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
type UniqueIdentifier,
} from "@dnd-kit/core";
import {
SortableContext,
@@ -49,8 +49,13 @@ import {
import { CSS } from "@dnd-kit/utilities";
import type { Category, CategoryMetaInput } from "../../api/categories";
import { useConfirm } from "../../components/ConfirmProvider";
import { buildCategoryTree, flattenCategoryTree } from "../../utils/categoryTree";
import CategoryMetaDialog from "./CategoryMetaDialog";
// One nesting level. Also how far a row must be dragged sideways to change its level.
const INDENT_PX = 24;
const ACTION_BUTTON_PX = 30;
type Props = {
categories: Category[];
onClose: () => void;
@@ -72,40 +77,67 @@ function hasMeta(category: Category): boolean {
);
}
/** Subcategory ids per top-level category id, in display order. */
type Containers = Record<string, string[]>;
type FlatItem = { id: string; parentId: string | null; depth: number };
type Projection = { depth: number; parentId: string | null };
/**
* Where the dragged row would land: its vertical position comes from the row it's over, its level
* from how far it was dragged sideways, kept within what the neighbours allow. Null when there's
* no valid level, e.g. above the first top-level category, since dragging never makes a top-level one.
*/
function getProjection(items: FlatItem[], activeId: string, overId: string, offsetX: number): Projection | null {
const activeIndex = items.findIndex((i) => i.id === activeId);
const overIndex = items.findIndex((i) => i.id === overId);
if (activeIndex === -1 || overIndex === -1) return null;
const newItems = arrayMove(items, activeIndex, overIndex);
const previous = newItems[overIndex - 1];
const next = newItems[overIndex + 1];
const maxDepth = previous ? previous.depth + 1 : 0;
// Can't slip in above `next` at a shallower level than it: that would steal it as a child.
const minDepth = Math.max(next ? next.depth : 0, 1);
if (!previous || maxDepth < minDepth) return null;
const wanted = items[activeIndex].depth + Math.round(offsetX / INDENT_PX);
const depth = Math.min(Math.max(wanted, minDepth), maxDepth);
let parentId: string | null;
if (depth === previous.depth) parentId = previous.parentId;
else if (depth > previous.depth) parentId = previous.id;
else parentId = newItems.slice(0, overIndex).findLast((i) => i.depth === depth)?.parentId ?? null;
return { depth, parentId };
}
type MoveButtons = { canMoveUp: boolean; canMoveDown: boolean; onMoveUp: () => void; onMoveDown: () => void };
/** What a sortable row needs from useSortable. */
type SortableBinding = { setNodeRef: (node: HTMLElement | null) => void; style: CSSProperties; handle: ReactNode };
function CategoryRow({
name,
indent,
bold,
depth,
busy,
move,
sortable,
dragHandle,
nodeRef,
style,
metaTitle,
metaDescription,
hasDetails,
onOpenMeta,
onAddChild,
onRename,
onDelete,
}: {
name: string;
indent: number;
bold?: boolean;
depth: number;
busy: boolean;
/** Up/down arrows; top-level categories only. */
move?: MoveButtons;
/** Subcategories are dragged instead. */
sortable?: SortableBinding;
dragHandle?: ReactNode;
nodeRef: (node: HTMLElement | null) => void;
style: CSSProperties;
metaTitle: string | null;
metaDescription: string | null;
hasDetails: boolean;
onOpenMeta: () => void;
onAddChild: () => void;
onRename: (name: string) => Promise<void>;
onDelete: () => void;
}) {
@@ -113,6 +145,7 @@ function CategoryRow({
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(name);
const [saving, setSaving] = useState(false);
const indent = `${depth * INDENT_PX}px`;
const startEdit = () => {
setValue(name);
@@ -141,7 +174,7 @@ function CategoryRow({
if (editing) {
return (
<ListItem ref={sortable?.setNodeRef} style={sortable?.style} disableGutters sx={{ pl: indent, py: 0.5 }}>
<ListItem ref={nodeRef} style={style} disableGutters sx={{ pl: indent, py: 0.5 }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ width: "100%" }}>
<TextField
size="small"
@@ -166,10 +199,11 @@ function CategoryRow({
);
}
const actionCount = move ? 6 : 4;
return (
<ListItem
ref={sortable?.setNodeRef}
style={sortable?.style}
ref={nodeRef}
style={style}
disableGutters
sx={{ pl: indent, py: 0.5 }}
secondaryAction={
@@ -194,6 +228,18 @@ function CategoryRow({
</IconButton>
</>
)}
<Tooltip title={t("models:categories.manager.addSubcategory")}>
<span>
<IconButton
size="small"
onClick={onAddChild}
disabled={busy}
aria-label={t("models:categories.manager.addSubcategory") ?? undefined}
>
<AddIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip
title={
hasDetails ? (
@@ -244,8 +290,13 @@ function CategoryRow({
</Stack>
}
>
{sortable?.handle}
<Typography variant="body2" fontWeight={bold ? 600 : 400} noWrap sx={{ pr: move ? 19 : 12 }}>
{dragHandle}
<Typography
variant="body2"
fontWeight={depth === 0 ? 600 : 400}
noWrap
sx={{ pr: `${actionCount * ACTION_BUTTON_PX + 8}px` }}
>
{name}
</Typography>
</ListItem>
@@ -280,72 +331,81 @@ function DragHandle({
);
}
function SortableSubcategoryRow({
/** Every row is in the sortable list so the others can be placed around it; only subcategories drag. */
function TreeRow({
id,
depth,
...rowProps
}: { id: string } & Omit<Parameters<typeof CategoryRow>[0], "sortable" | "move">) {
}: { id: string; depth: number } & Omit<Parameters<typeof CategoryRow>[0], "nodeRef" | "style" | "dragHandle">) {
const isRoot = depth === 0;
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
id,
disabled: isRoot,
});
return (
<CategoryRow
{...rowProps}
sortable={{
setNodeRef,
// The DragOverlay shows the dragged row; this one stays as a faint placeholder.
style: { transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.35 : 1 },
handle: <DragHandle setActivatorNodeRef={setActivatorNodeRef} attributes={attributes} listeners={listeners} />,
}}
depth={depth}
nodeRef={setNodeRef}
// The DragOverlay shows the dragged row; this one stays as a faint placeholder at the projected level.
style={{ transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.35 : 1 }}
dragHandle={
isRoot ? undefined : (
<DragHandle setActivatorNodeRef={setActivatorNodeRef} attributes={attributes} listeners={listeners} />
)
}
/>
);
}
/** A drop target even when empty, so subcategories can be dragged into a category without any. */
function SubcategoryList({
parentId,
ids,
dragging,
children,
/** Opened from a row's + button; sits after that category's existing subcategories. */
function InlineAddRow({
depth,
busy,
onAdd,
onCancel,
}: {
parentId: string;
ids: string[];
dragging: boolean;
children: ReactNode;
depth: number;
busy: boolean;
onAdd: (name: string) => Promise<void>;
onCancel: () => void;
}) {
const { t } = useTranslation("models");
const { setNodeRef } = useDroppable({ id: parentId });
const [value, setValue] = useState("");
const submit = async () => {
const trimmed = value.trim();
if (trimmed) await onAdd(trimmed);
};
return (
<SortableContext id={parentId} items={ids} strategy={verticalListSortingStrategy}>
<List ref={setNodeRef} disablePadding>
{children}
{dragging && ids.length === 0 && (
<Box
sx={{
ml: 3,
py: 0.75,
border: "1px dashed",
borderColor: "divider",
borderRadius: 1,
textAlign: "center",
}}
>
<Typography variant="caption" color="text.secondary">
{t("categories.manager.dropHere")}
</Typography>
</Box>
)}
</List>
</SortableContext>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ pl: `${depth * INDENT_PX}px`, py: 0.5 }}>
<TextField
size="small"
fullWidth
placeholder={t("categories.manager.addSubcategory")}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
if (e.key === "Escape") onCancel();
}}
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<IconButton size="small" onClick={submit} disabled={busy || !value.trim()}>
{busy ? <CircularProgress size={16} /> : <CheckIcon fontSize="small" />}
</IconButton>
<IconButton size="small" onClick={onCancel} disabled={busy}>
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
);
}
function AddRow({
indent,
placeholder,
busy,
onAdd,
}: {
indent: number;
placeholder: string;
busy: boolean;
onAdd: (name: string) => Promise<void>;
@@ -371,7 +431,6 @@ function AddRow({
setAdding(true);
setTimeout(() => inputRef.current?.focus(), 0);
}}
sx={{ ml: `${indent * 8}px` }}
>
{placeholder}
</Button>
@@ -379,7 +438,7 @@ function AddRow({
}
return (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ pl: `${indent * 8}px`, py: 0.5 }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ py: 0.5 }}>
<TextField
inputRef={inputRef}
size="small"
@@ -426,86 +485,29 @@ export default function CategoryManagerModal({
const confirmDialog = useConfirm();
const [busyId, setBusyId] = useState<string | null>(null);
const [metaCategory, setMetaCategory] = useState<Category | null>(null);
const [addingUnderId, setAddingUnderId] = useState<string | null>(null);
const { roots, childrenByParent } = useMemo(() => {
const childrenMap: Record<string, Category[]> = {};
const rootList: Category[] = [];
categories.forEach((f) => {
if (f.parent_id) {
if (!childrenMap[f.parent_id]) childrenMap[f.parent_id] = [];
childrenMap[f.parent_id].push(f);
} else {
rootList.push(f);
}
});
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
Object.keys(childrenMap).forEach((key) => {
childrenMap[key] = childrenMap[key].toSorted(byPosition);
});
return {
roots: rootList.toSorted(byPosition),
childrenByParent: childrenMap,
};
}, [categories]);
// A drop shows right away and stays until `categories` is refetched (which happens on failure too).
const [optimistic, setOptimistic] = useState<{ base: Category[]; list: Category[] } | null>(null);
const shown = optimistic?.base === categories ? optimistic.list : categories;
const tree = useMemo(() => buildCategoryTree(shown), [shown]);
const untitledLabel = t("models:categories.untitled");
const handleDeleteCategory = async (category: Category) => {
const hasChildren = (childrenByParent[category.id] || []).length > 0;
const message = hasChildren
? t("models:categories.manager.confirmDeleteCategoryWithSub", { name: category.name || untitledLabel })
: t("models:categories.manager.confirmDeleteCategory", { name: category.name || untitledLabel });
if (!(await confirmDialog({ message, destructive: true }))) return;
setBusyId(category.id);
try {
await onDelete(category.id);
} finally {
setBusyId(null);
}
};
const handleDeleteSubcategory = async (category: Category) => {
const message = t("models:categories.manager.confirmDeleteSubcategory", { name: category.name || untitledLabel });
if (!(await confirmDialog({ message, destructive: true }))) return;
setBusyId(category.id);
try {
await onDelete(category.id);
} finally {
setBusyId(null);
}
};
const swapAndReorder = async (siblingIds: string[], index: number, direction: -1 | 1, busyKey: string) => {
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= siblingIds.length) return;
const reordered = siblingIds.slice();
[reordered[index], reordered[swapIndex]] = [reordered[swapIndex], reordered[index]];
setBusyId(busyKey);
try {
await onReorder(reordered);
} finally {
setBusyId(null);
}
};
const moveRoot = (index: number, direction: -1 | 1) =>
swapAndReorder(
roots.map((r) => r.id),
index,
direction,
roots[index].id,
);
const categoryById = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
const propContainers = useMemo<Containers>(
() => Object.fromEntries(roots.map((root) => [root.id, (childrenByParent[root.id] || []).map((c) => c.id)])),
[roots, childrenByParent],
);
// The order mid-drag and after a drop, until `categories` is refetched (which happens on failure too).
const [optimistic, setOptimistic] = useState<{ base: Category[]; containers: Containers } | null>(null);
const containers = optimistic?.base === categories ? optimistic.containers : propContainers;
const [activeId, setActiveId] = useState<string | null>(null);
const dragOrigin = useRef<{ parentId: string; ids: string[] } | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [offsetX, setOffsetX] = useState(0);
// While dragging, the dragged category's subtree is folded into it.
const flat = useMemo(() => flattenCategoryTree(tree, activeId ? new Set([activeId]) : undefined), [tree, activeId]);
const items = useMemo<FlatItem[]>(
() =>
flat.map(({ category, depth }) => ({
id: category.id,
parentId: category.parent_id && tree.byId.has(category.parent_id) ? category.parent_id : null,
depth,
})),
[flat, tree],
);
const projection = activeId && overId ? getProjection(items, activeId, overId, offsetX) : null;
const sensors = useSensors(
// A few pixels of travel before dragging, so clicks on the handle don't start one.
@@ -513,80 +515,103 @@ export default function CategoryManagerModal({
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
/** Over ids are either a subcategory or a (possibly empty) top-level category's list. */
const containerOf = (id: UniqueIdentifier): string | null => {
const key = String(id);
if (key in containers) return key;
return Object.keys(containers).find((parentId) => containers[parentId].includes(key)) ?? null;
};
const untitledLabel = t("models:categories.untitled");
const handleDragStart = ({ active }: DragStartEvent) => {
const parentId = containerOf(active.id);
if (!parentId) return;
setActiveId(String(active.id));
dragOrigin.current = { parentId, ids: containers[parentId] };
setOptimistic({ base: categories, containers });
};
// Crossing into another category moves the row there right away, so that list opens a gap for it.
const handleDragOver = ({ active, over }: DragOverEvent) => {
if (!over) return;
const from = containerOf(active.id);
const to = containerOf(over.id);
if (!from || !to || from === to) return;
const id = String(active.id);
const target = containers[to];
const overIndex = target.indexOf(String(over.id));
const insertAt = overIndex === -1 ? target.length : overIndex;
setOptimistic({
base: categories,
containers: {
...containers,
[from]: containers[from].filter((c) => c !== id),
[to]: [...target.slice(0, insertAt), id, ...target.slice(insertAt)],
},
});
};
const resetDrag = () => {
setActiveId(null);
dragOrigin.current = null;
};
const handleDragCancel = () => {
setOptimistic(null);
resetDrag();
};
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
const origin = dragOrigin.current;
const to = over ? containerOf(over.id) : null;
resetDrag();
if (!origin || !to) {
setOptimistic(null);
return;
}
const id = String(active.id);
const fromIndex = containers[to].indexOf(id);
const overIndex = containers[to].indexOf(String(over!.id));
const ids = overIndex === -1 ? containers[to] : arrayMove(containers[to], fromIndex, overIndex);
setOptimistic({ base: categories, containers: { ...containers, [to]: ids } });
setBusyId(id);
const handleDelete = async (category: Category) => {
const name = category.name || untitledLabel;
const message = tree.childrenByParent[category.id]?.length
? t("models:categories.manager.confirmDeleteCategoryWithSub", { name })
: t("models:categories.manager.confirmDeleteCategory", { name });
if (!(await confirmDialog({ message, destructive: true }))) return;
setBusyId(category.id);
try {
if (to !== origin.parentId) {
await onMove(id, to, ids.indexOf(id));
} else if (ids.some((c, i) => c !== origin.ids[i])) {
await onReorder(ids);
} else {
setOptimistic(null);
}
await onDelete(category.id);
} finally {
setBusyId(null);
}
};
const activeCategory = activeId ? categoryById.get(activeId) : undefined;
const moveRoot = async (index: number, direction: -1 | 1) => {
const ids = tree.roots.map((r) => r.id);
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= ids.length) return;
[ids[index], ids[swapIndex]] = [ids[swapIndex], ids[index]];
setBusyId(ids[swapIndex]);
try {
await onReorder(ids);
} finally {
setBusyId(null);
}
};
const resetDrag = () => {
setActiveId(null);
setOverId(null);
setOffsetX(0);
};
const handleDragStart = ({ active }: DragStartEvent) => {
setActiveId(String(active.id));
setOverId(String(active.id));
setOffsetX(0);
setAddingUnderId(null);
};
const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetX(delta.x);
const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null);
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
const target = projection;
resetDrag();
if (!target || !over) return;
const id = String(active.id);
const moved = arrayMove(
items,
items.findIndex((i) => i.id === id),
items.findIndex((i) => i.id === over.id),
);
const siblingIds = moved
.filter((i) => (i.id === id ? target.parentId : i.parentId) === target.parentId)
.map((i) => i.id);
const position = siblingIds.indexOf(id);
const original = tree.byId.get(id);
const originalParentId = original?.parent_id ?? null;
const sameParent = originalParentId === target.parentId;
const originalSiblingIds = (originalParentId ? tree.childrenByParent[originalParentId] : tree.roots)?.map(
(c) => c.id,
);
if (sameParent && siblingIds.every((c, i) => c === originalSiblingIds?.[i])) return;
const list: Category[] = [];
for (const c of shown) {
const idx = siblingIds.indexOf(c.id);
if (c.id === id) list.push({ ...c, parent_id: target.parentId, position });
else list.push(idx === -1 ? c : { ...c, position: idx });
}
setOptimistic({ base: categories, list });
setBusyId(id);
try {
if (sameParent) await onReorder(siblingIds);
else await onMove(id, target.parentId, position);
} finally {
setBusyId(null);
}
};
// The add-subcategory input goes after the category's last descendant.
const addAfterId = useMemo(() => {
if (!addingUnderId) return null;
const start = flat.findIndex((f) => f.category.id === addingUnderId);
if (start === -1) return null;
let end = start;
while (flat[end + 1] && flat[end + 1].depth > flat[start].depth) end += 1;
return flat[end].category.id;
}, [flat, addingUnderId]);
const addingDepth = addingUnderId ? (flat.find((f) => f.category.id === addingUnderId)?.depth ?? 0) + 1 : 0;
const activeCategory = activeId ? tree.byId.get(activeId) : undefined;
return (
<>
@@ -600,81 +625,72 @@ export default function CategoryManagerModal({
<DialogContent dividers>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
collisionDetection={closestCenter}
// Folding the dragged subtree shifts the rows below, so keep re-measuring.
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
onDragCancel={resetDrag}
>
<List disablePadding>
{roots.map((root, rootIndex) => {
const childIds = containers[root.id] || [];
return (
<Box key={root.id} sx={{ mb: 1.5 }}>
<CategoryRow
name={root.name || untitledLabel}
indent={0}
bold
busy={busyId === root.id}
move={{
canMoveUp: rootIndex > 0,
canMoveDown: rootIndex < roots.length - 1,
onMoveUp: () => moveRoot(rootIndex, -1),
onMoveDown: () => moveRoot(rootIndex, 1),
}}
metaTitle={root.meta_title}
metaDescription={root.meta_description}
hasDetails={hasMeta(root)}
onOpenMeta={() => setMetaCategory(root)}
onRename={(name) => onRename(root.id, name)}
onDelete={() => handleDeleteCategory(root)}
/>
<SubcategoryList parentId={root.id} ids={childIds} dragging={activeId !== null}>
{childIds.map((childId) => {
const child = categoryById.get(childId);
if (!child) return null;
return (
<SortableSubcategoryRow
key={child.id}
id={child.id}
name={child.name || untitledLabel}
indent={3}
busy={busyId === child.id}
metaTitle={child.meta_title}
metaDescription={child.meta_description}
hasDetails={hasMeta(child)}
onOpenMeta={() => setMetaCategory(child)}
onRename={(name) => onRename(child.id, name)}
onDelete={() => handleDeleteSubcategory(child)}
/>
);
})}
</SubcategoryList>
<Box sx={{ pl: 0.5 }}>
<AddRow
indent={3}
placeholder={t("models:categories.manager.addSubcategory")}
busy={busyId === `new-sub-${root.id}`}
onAdd={async (name) => {
setBusyId(`new-sub-${root.id}`);
try {
await onCreate(name, root.id);
} finally {
setBusyId(null);
}
}}
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
<List disablePadding>
{flat.map(({ category, depth }) => {
const rootIndex = depth === 0 ? tree.roots.findIndex((r) => r.id === category.id) : -1;
return (
<Fragment key={category.id}>
{rootIndex > 0 && <Box sx={{ height: 12 }} />}
<TreeRow
id={category.id}
depth={category.id === activeId && projection ? projection.depth : depth}
name={category.name || untitledLabel}
busy={busyId === category.id}
move={
depth === 0
? {
canMoveUp: rootIndex > 0,
canMoveDown: rootIndex < tree.roots.length - 1,
onMoveUp: () => moveRoot(rootIndex, -1),
onMoveDown: () => moveRoot(rootIndex, 1),
}
: undefined
}
metaTitle={category.meta_title}
metaDescription={category.meta_description}
hasDetails={hasMeta(category)}
onOpenMeta={() => setMetaCategory(category)}
onAddChild={() => setAddingUnderId(category.id)}
onRename={(name) => onRename(category.id, name)}
onDelete={() => handleDelete(category)}
/>
</Box>
</Box>
);
})}
{addAfterId === category.id && addingUnderId && (
<InlineAddRow
depth={addingDepth}
busy={busyId === `new-sub-${addingUnderId}`}
onCancel={() => setAddingUnderId(null)}
onAdd={async (name) => {
setBusyId(`new-sub-${addingUnderId}`);
try {
await onCreate(name, addingUnderId);
setAddingUnderId(null);
} finally {
setBusyId(null);
}
}}
/>
)}
</Fragment>
);
})}
{!roots.length && (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{t("models:categories.manager.noCategories")}
</Typography>
)}
</List>
{!tree.roots.length && (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{t("models:categories.manager.noCategories")}
</Typography>
)}
</List>
</SortableContext>
<DragOverlay>
{activeCategory && (
<Paper
@@ -693,7 +709,6 @@ export default function CategoryManagerModal({
<Divider sx={{ my: 1.5 }} />
<AddRow
indent={0}
placeholder={t("models:categories.manager.addCategory")}
busy={busyId === "new-root"}
onAdd={async (name) => {
+4 -5
View File
@@ -12,6 +12,7 @@ import { type PreviewMode } from "../../api/settings";
import type { AuthUser } from "../../api/auth";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { buildCategoryTree, subtreeIds } from "../../utils/categoryTree";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
import CategoriesPanel from "./CategoriesPanel";
import CategoryBanner from "./CategoryBanner";
@@ -95,13 +96,11 @@ export default function ModelsPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryId]);
// A root category includes the models in all its subcategories.
// A category includes the models at every level beneath it.
const categoryIdFilter = useMemo(() => {
if (!categoryId) return undefined;
const isRoot = categories.some((f) => f.id === categoryId && !f.parent_id);
if (!isRoot) return categoryId;
const childIds = categories.filter((f) => f.parent_id === categoryId).map((f) => f.id);
return [categoryId, ...childIds];
const ids = subtreeIds(buildCategoryTree(categories), categoryId);
return ids.length === 1 ? categoryId : ids;
}, [categoryId, categories]);
const selectedCategory = categoryId ? (categories.find((f) => f.id === categoryId) ?? null) : null;
+65
View File
@@ -0,0 +1,65 @@
import type { Category } from "../api/categories";
export type CategoryTree = {
roots: Category[];
/** Sorted children per parent id; leaves have no entry. */
childrenByParent: Record<string, Category[]>;
byId: Map<string, Category>;
};
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
/** A category whose parent is missing is treated as a root, so nothing disappears. */
export function buildCategoryTree(categories: Category[]): CategoryTree {
const byId = new Map(categories.map((c) => [c.id, c]));
const childrenByParent: Record<string, Category[]> = {};
const roots: Category[] = [];
for (const category of categories) {
if (category.parent_id && byId.has(category.parent_id)) {
(childrenByParent[category.parent_id] ??= []).push(category);
} else {
roots.push(category);
}
}
for (const key of Object.keys(childrenByParent)) childrenByParent[key].sort(byPosition);
return { roots: roots.toSorted(byPosition), childrenByParent, byId };
}
/** The category and everything under it. */
export function subtreeIds(tree: CategoryTree, id: string): string[] {
const ids: string[] = [];
const stack = [id];
while (stack.length) {
const current = stack.pop()!;
ids.push(current);
for (const child of tree.childrenByParent[current] ?? []) stack.push(child.id);
}
return ids;
}
/** Root first, ending with the category itself. */
export function ancestorPath(tree: CategoryTree, id: string): string[] {
const path: string[] = [];
const seen = new Set<string>();
let current = tree.byId.get(id);
while (current && !seen.has(current.id)) {
seen.add(current.id);
path.unshift(current.id);
current = current.parent_id ? tree.byId.get(current.parent_id) : undefined;
}
return path;
}
export type FlatCategory = { category: Category; depth: number };
/** Depth-first display order. Children of ids in `skipChildrenOf` are left out. */
export function flattenCategoryTree(tree: CategoryTree, skipChildrenOf?: ReadonlySet<string>): FlatCategory[] {
const out: FlatCategory[] = [];
const visit = (category: Category, depth: number) => {
out.push({ category, depth });
if (skipChildrenOf?.has(category.id)) return;
for (const child of tree.childrenByParent[category.id] ?? []) visit(child, depth + 1);
};
for (const root of tree.roots) visit(root, 0);
return out;
}
+11 -2
View File
@@ -98,10 +98,19 @@ export async function uploadEntriesToCategory(
const categoryCache = new Map<string, string>();
const baseKey = parentCategoryId || "root";
// Folders reuse a same-named category in the same place, so uploading a tree twice doesn't duplicate it.
let existingCategories: Awaited<ReturnType<typeof categoriesApi.list>> | null = null;
const getOrCreateCategory = async (parentId: string | null, parentKey: string, name: string) => {
const key = `${parentKey}/${name}`;
const existing = categoryCache.get(key);
if (existing) return existing;
const cached = categoryCache.get(key);
if (cached) return cached;
existingCategories ??= await categoriesApi.list();
const match = existingCategories.find((c) => (c.parent_id ?? null) === parentId && c.name === name);
if (match) {
categoryCache.set(key, match.id);
return match.id;
}
const created = await categoriesApi.create(name, [], parentId || undefined);
const categoryId = (created as { id: string }).id;
categoryCache.set(key, categoryId);