feat(categories): allow moving subcategories between parent categories

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-01 21:57:23 +02:00
1 parent 3a05fae555
commit 172270fce8
11 files changed
+746 -195

No files matched your search

+52
View File
@@ -60,6 +60,58 @@ router.post(
}),
);
const moveSchema = z.object({
parent_id: z.string().nullable(),
// Index among the new siblings; past the end appends.
position: z.number().int().min(0),
});
// Reparents and positions in one step, so dragging a subcategory into another category is one request.
router.post(
"/category/:id/move",
asyncHandler(async (req, res) => {
const body = parseBody(moveSchema, req.body);
const category = await prisma.category.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!category) throw new HttpError(404, "Not found");
const parentId = await validateParentCategory(req.userId!, body.parent_id, category.id);
const siblingOrder: Prisma.CategoryOrderByWithRelationInput[] = [{ position: "asc" }, { name: "asc" }];
const moved = await prisma.$transaction(async (tx) => {
const renumber = async (ids: string[]) => {
for (const [idx, id] of ids.entries()) {
if (id !== category.id) await tx.category.update({ where: { id }, data: { position: idx } });
}
};
if (category.parentId !== parentId) {
// Close the gap left behind.
const oldSiblings = await tx.category.findMany({
where: { userId: req.userId, parentId: category.parentId, id: { not: category.id } },
orderBy: siblingOrder,
select: { id: true },
});
await renumber(oldSiblings.map((c) => c.id));
}
const siblings = await tx.category.findMany({
where: { userId: req.userId, parentId, id: { not: category.id } },
orderBy: siblingOrder,
select: { id: true },
});
const ids = siblings.map((c) => c.id);
ids.splice(Math.min(body.position, ids.length), 0, category.id);
await renumber(ids);
return tx.category.update({
where: { id: category.id },
data: { parentId, position: ids.indexOf(category.id) },
});
});
// Managed storage paths include the category path.
if (category.parentId !== parentId) await reorganizeManagedPrints(undefined, req.userId);
res.json(toCategoryOut(moved));
}),
);
router.post(
"/categories",
asyncHandler(async (req, res) => {
+113
View File
@@ -0,0 +1,113 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
const app = createApp();
const stamp = Date.now();
let token: string;
let otherToken: string;
type CategoryOut = { id: string; name: string; parent_id: string | null; position: number };
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
async function create(name: string, parentId: string | null = null): Promise<string> {
const res = await request(app).post("/api/categories").set(auth(token)).send({ name, parent_id: parentId });
expect(res.status).toBe(200);
return res.body.id;
}
const move = (id: string, parentId: string | null, position: number, as = token) =>
request(app).post(`/api/category/${id}/move`).set(auth(as)).send({ parent_id: parentId, position });
/** Child names under `parentId`, in display order. */
async function childNames(parentId: string): Promise<string[]> {
const res = await request(app).get("/api/categories").set(auth(token));
return (res.body as CategoryOut[])
.filter((c) => c.parent_id === parentId)
.toSorted((a, b) => a.position - b.position)
.map((c) => c.name);
}
beforeAll(async () => {
token = (
await request(app)
.post("/api/register")
.send({ displayName: "Mover", email: `mover-${stamp}@example.com`, password: "password123" })
).body.token;
otherToken = (
await request(app)
.post("/api/register")
.send({ displayName: "Other", email: `mover-other-${stamp}@example.com`, password: "password123" })
).body.token;
});
describe("POST /api/category/:id/move", () => {
let rootA: string;
let rootB: string;
const subs: Record<string, string> = {};
beforeEach(async () => {
rootA = await create(`Root A ${Math.random()}`);
rootB = await create(`Root B ${Math.random()}`);
for (const name of ["a1", "a2", "a3"]) subs[name] = await create(name, rootA);
for (const name of ["b1", "b2"]) subs[name] = await create(name, rootB);
// Fresh categories all start at position 0; give them a definite order first.
await request(app)
.post("/api/categories/reorder")
.set(auth(token))
.send({ category_ids: [subs.a1, subs.a2, subs.a3] });
await request(app)
.post("/api/categories/reorder")
.set(auth(token))
.send({ category_ids: [subs.b1, subs.b2] });
});
it("reorders within the same category", async () => {
expect((await move(subs.a3, rootA, 0)).status).toBe(200);
expect(await childNames(rootA)).toEqual(["a3", "a1", "a2"]);
expect((await move(subs.a3, rootA, 2)).status).toBe(200);
expect(await childNames(rootA)).toEqual(["a1", "a2", "a3"]);
});
it("moves into another category at the given position, closing the gap behind", async () => {
const res = await move(subs.a2, rootB, 1);
expect(res.status).toBe(200);
expect(res.body.parent_id).toBe(rootB);
expect(await childNames(rootB)).toEqual(["b1", "a2", "b2"]);
expect(await childNames(rootA)).toEqual(["a1", "a3"]);
});
it("appends when the position is past the end", async () => {
expect((await move(subs.a1, rootB, 99)).status).toBe(200);
expect(await childNames(rootB)).toEqual(["b1", "b2", "a1"]);
});
it("moves into an empty category", async () => {
const empty = await create(`Empty ${Math.random()}`);
expect((await move(subs.a1, empty, 0)).status).toBe(200);
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);
expect((await move(subs.a1, subs.a1, 0)).status).toBe(400);
});
it("only touches the user's own categories", async () => {
expect((await move(subs.a1, rootB, 0, otherToken)).status).toBe(404);
const otherRoot = (await request(app).post("/api/categories").set(auth(otherToken)).send({ name: "Other root" }))
.body.id;
expect((await move(subs.a1, otherRoot, 0)).status).toBe(400);
});
it("validates the body", async () => {
expect((await move(subs.a1, rootB, -1)).status).toBe(400);
expect(
(await request(app).post(`/api/category/${subs.a1}/move`).set(auth(token)).send({ position: 0 })).status,
).toBe(400);
});
});
+62
View File
@@ -9,6 +9,9 @@
"version": "0.0.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.0",
@@ -172,6 +175,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -3004,6 +3060,12 @@
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+3
View File
@@ -15,6 +15,9 @@
"verify": "npm run typecheck && npm run lint && npm run test:run && npm run build"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.0",
+12
View File
@@ -87,6 +87,18 @@ export const categoriesApi = {
return res.json();
},
/** Puts a category under `parentId` (null for top level) at `position` among its new siblings. */
move: async (id: string, parentId: string | null, position: number) => {
const res = await fetch(`${apiBase()}/category/${id}/move`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ parent_id: parentId, position }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Move category failed"));
return res.json();
},
downloadZip: async (category_id: string) => {
const res = await fetch(`${apiBase()}/category/${category_id}/download`, { headers: authHeaders() });
assertOk(res, "Category download failed");
+149 -95
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import type { Theme } from "@mui/material/styles";
@@ -14,6 +14,7 @@ import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Divider from "@mui/material/Divider";
import Tooltip from "@mui/material/Tooltip";
import Paper from "@mui/material/Paper";
import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import CollectionsIcon from "@mui/icons-material/Collections";
@@ -24,6 +25,24 @@ import DownloadIcon from "@mui/icons-material/Download";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Wordmark from "../../Wordmark";
import { type BookmarkEntry, bookmarksApi } from "../../../api/bookmarks";
@@ -90,6 +109,49 @@ function bookmarkTarget(entry: BookmarkEntry): { href: string; label: string } {
: { href: `/models/collections/${entry.collection_id}`, label: entry.name };
}
// A click that ends a drag (pointer or Space key) must not also navigate.
const POST_DRAG_CLICK_GUARD_MS = 250;
/** The whole row drags; a few pixels of travel tell a drag from a click. */
function SortableBookmarkRow({
id,
label,
selected,
onNavigate,
}: {
id: string;
label: string;
selected: boolean;
onNavigate: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<ListItemButton
ref={setNodeRef}
{...attributes}
{...listeners}
selected={selected}
onClick={onNavigate}
style={{ transform: CSS.Translate.toString(transform), transition }}
sx={{
borderRadius: 1,
mb: 0.5,
cursor: "grab",
touchAction: "none",
// The DragOverlay shows the dragged row; this one stays as a faint placeholder.
opacity: isDragging ? 0.4 : 1,
...navRowSx(selected),
}}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<BookmarkIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={label} primaryTypographyProps={{ variant: "body2", noWrap: true }} />
<DragIndicatorIcon fontSize="small" sx={{ ml: "auto", flexShrink: 0, color: "text.disabled" }} />
</ListItemButton>
);
}
export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }: Props) {
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
@@ -100,8 +162,22 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
});
const [bookmarks, setBookmarks] = useState<BookmarkEntry[]>([]);
const [draggingId, setDraggingId] = useState<string | null>(null);
// Tracked on dragover: dragenter/dragleave fire unreliably across child elements.
const [dragOverId, setDragOverId] = useState<string | null>(null);
const suppressClickRef = useRef(false);
const endDrag = () => {
setDraggingId(null);
suppressClickRef.current = true;
window.setTimeout(() => {
suppressClickRef.current = false;
}, POST_DRAG_CLICK_GUARD_MS);
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
// Space only: Enter keeps opening the bookmark.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
keyboardCodes: { start: ["Space"], cancel: ["Escape"], end: ["Space", "Enter"] },
}),
);
useEffect(() => {
let cancelled = false;
@@ -120,52 +196,31 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
}, [bookmarksVersion]);
// Optimistic reorder; a failed save refetches the server's order.
const handleDrop = (draggedId: string, targetId: string) => {
if (draggedId === targetId) return;
setBookmarks((prev) => {
const from = prev.findIndex((b) => b.id === draggedId);
const to = prev.findIndex((b) => b.id === targetId);
if (from === -1 || to === -1) return prev;
const next = prev.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
bookmarksApi.reorder(next.map((b) => b.id)).catch(() => {
bookmarksApi
.list()
.then(setBookmarks)
.catch(() => {
/* leave the optimistic order as-is */
});
});
return next;
const handleDragEnd = ({ active, over }: DragEndEvent) => {
endDrag();
if (!over || active.id === over.id) return;
const from = bookmarks.findIndex((b) => b.id === active.id);
const to = bookmarks.findIndex((b) => b.id === over.id);
if (from === -1 || to === -1) return;
const next = arrayMove(bookmarks, from, to);
setBookmarks(next);
bookmarksApi.reorder(next.map((b) => b.id)).catch(() => {
bookmarksApi
.list()
.then(setBookmarks)
.catch(() => {
/* leave the optimistic order as-is */
});
});
};
// The default drag image is the whole full-width row, which looks like neighbours are moving
// too, so use a small off-screen label instead. setDragImage snapshots it synchronously.
const handleDragStart = (e: React.DragEvent<HTMLElement>, id: string, label: string) => {
setDraggingId(id);
const preview = document.createElement("div");
preview.textContent = label;
Object.assign(preview.style, {
position: "fixed",
top: "-1000px",
left: "-1000px",
padding: "4px 10px",
borderRadius: "6px",
background: "#1f2430",
color: "#fff",
fontSize: "13px",
fontFamily: "inherit",
whiteSpace: "nowrap",
boxShadow: "0 2px 8px rgba(0,0,0,.25)",
});
document.body.appendChild(preview);
e.dataTransfer.setDragImage(preview, 12, 14);
e.dataTransfer.effectAllowed = "move";
requestAnimationFrame(() => preview.remove());
const navigateUnlessDragged = (href: string) => {
if (draggingId || suppressClickRef.current) return;
navigate(href);
};
const draggingBookmark = draggingId ? bookmarks.find((b) => b.id === draggingId) : undefined;
const onDashboard = location.pathname === "/";
const onCollections = location.pathname.startsWith("/models/collections");
const onTags = location.pathname.startsWith("/models/tags");
@@ -354,57 +409,56 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
{t("sidebar.bookmarks")}
</Typography>
)}
{bookmarks.map((entry) => {
const { href, label } = bookmarkTarget(entry);
const selected = location.pathname === href;
return collapsed ? (
<CollapsedNavIcon
key={entry.id}
icon={<BookmarkIcon fontSize="small" />}
label={label}
selected={selected}
onClick={() => navigate(href)}
/>
) : (
<ListItemButton
key={entry.id}
draggable
onDragStart={(e) => handleDragStart(e, entry.id, label)}
onDragOver={(e) => {
e.preventDefault();
if (draggingId && draggingId !== entry.id) setDragOverId(entry.id);
}}
onDrop={(e) => {
e.preventDefault();
if (draggingId) handleDrop(draggingId, entry.id);
setDraggingId(null);
setDragOverId(null);
}}
onDragEnd={() => {
setDraggingId(null);
setDragOverId(null);
}}
selected={selected}
onClick={() => navigate(href)}
sx={{
borderRadius: 1,
mb: 0.5,
cursor: "grab",
opacity: draggingId === entry.id ? 0.4 : 1,
...(dragOverId === entry.id && draggingId !== entry.id
? { outline: "2px solid", outlineColor: "primary.main", outlineOffset: "-2px" }
: {}),
...navRowSx(selected),
}}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<BookmarkIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={label} primaryTypographyProps={{ variant: "body2", noWrap: true }} />
<DragIndicatorIcon fontSize="small" sx={{ ml: "auto", flexShrink: 0, color: "text.disabled" }} />
</ListItemButton>
);
})}
{collapsed ? (
bookmarks.map((entry) => {
const { href, label } = bookmarkTarget(entry);
return (
<CollapsedNavIcon
key={entry.id}
icon={<BookmarkIcon fontSize="small" />}
label={label}
selected={location.pathname === href}
onClick={() => navigate(href)}
/>
);
})
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={({ active }) => setDraggingId(String(active.id))}
onDragEnd={handleDragEnd}
onDragCancel={endDrag}
>
<SortableContext items={bookmarks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
{bookmarks.map((entry) => {
const { href, label } = bookmarkTarget(entry);
return (
<SortableBookmarkRow
key={entry.id}
id={entry.id}
label={label}
selected={location.pathname === href}
onNavigate={() => navigateUnlessDragged(href)}
/>
);
})}
</SortableContext>
<DragOverlay>
{draggingBookmark && (
<Paper
elevation={6}
sx={{ display: "flex", alignItems: "center", gap: 1, px: 1.5, py: 0.75, cursor: "grabbing" }}
>
<BookmarkIcon fontSize="small" />
<Typography variant="body2" noWrap>
{bookmarkTarget(draggingBookmark).label}
</Typography>
</Paper>
)}
</DragOverlay>
</DndContext>
)}
</>
)}
+4 -1
View File
@@ -1423,6 +1423,7 @@
"renameCategoryFailed": "Failed to rename the category. Please try again.",
"deleteCategoryFailed": "Failed to delete the category. Please try again.",
"reorderCategoryFailed": "Failed to reorder categories. Please try again.",
"moveCategoryFailed": "Failed to move the subcategory. Please try again.",
"updateCategoryMetaFailed": "Failed to save category details. Please try again.",
"notFound": "Model not found."
},
@@ -1451,7 +1452,9 @@
"thingiverseCatIdLabel": "Thingiverse category IDs",
"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"
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
"dragToMove": "Drag to move",
"dropHere": "Drop here"
}
}
}
+4 -1
View File
@@ -1428,6 +1428,7 @@
"renameCategoryFailed": "Nepavyko pervadinti kategorijos. Bandykite dar kartą.",
"deleteCategoryFailed": "Nepavyko ištrinti kategorijos. Bandykite dar kartą.",
"reorderCategoryFailed": "Nepavyko pakeisti kategorijų tvarkos. Bandykite dar kartą.",
"moveCategoryFailed": "Nepavyko perkelti subkategorijos. Bandykite dar kartą.",
"updateCategoryMetaFailed": "Nepavyko išsaugoti kategorijos informacijos. Bandykite dar kartą.",
"notFound": "Modelis nerastas."
},
@@ -1456,7 +1457,9 @@
"thingiverseCatIdLabel": "Thingiverse kategorijų ID",
"printablesCatIdLabel": "Printables kategorijų ID",
"catIdsPlaceholder": "pvz. 800;71;1001",
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų"
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
"dragToMove": "Vilkite, kad perkeltumėte",
"dropHere": "Numeskite čia"
}
}
}
@@ -27,6 +27,7 @@ type Props = {
onRename: (id: string, name: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onReorder: (categoryIds: string[]) => Promise<void>;
onMove: (id: string, parentId: string | null, position: number) => Promise<void>;
onUpdateMeta: (id: string, meta: CategoryMetaInput) => Promise<void>;
};
@@ -67,6 +68,7 @@ export default function CategoriesPanel({
onRename,
onDelete,
onReorder,
onMove,
onUpdateMeta,
}: Props) {
const { t, i18n } = useTranslation(["models", "common"]);
@@ -225,6 +227,7 @@ export default function CategoriesPanel({
onRename={onRename}
onDelete={onDelete}
onReorder={onReorder}
onMove={onMove}
onUpdateMeta={onUpdateMeta}
/>
)}
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { 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";
@@ -23,6 +23,30 @@ import CheckIcon from "@mui/icons-material/Check";
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,
PointerSensor,
closestCorners,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
type UniqueIdentifier,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Category, CategoryMetaInput } from "../../api/categories";
import { useConfirm } from "../../components/ConfirmProvider";
import CategoryMetaDialog from "./CategoryMetaDialog";
@@ -34,6 +58,7 @@ type Props = {
onRename: (id: string, name: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onReorder: (categoryIds: string[]) => Promise<void>;
onMove: (id: string, parentId: string | null, position: number) => Promise<void>;
onUpdateMeta: (id: string, meta: CategoryMetaInput) => Promise<void>;
};
@@ -47,15 +72,21 @@ function hasMeta(category: Category): boolean {
);
}
/** Subcategory ids per top-level category id, in display order. */
type Containers = Record<string, string[]>;
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,
busy,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
move,
sortable,
metaTitle,
metaDescription,
hasDetails,
@@ -67,10 +98,10 @@ function CategoryRow({
indent: number;
bold?: boolean;
busy: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
/** Up/down arrows; top-level categories only. */
move?: MoveButtons;
/** Subcategories are dragged instead. */
sortable?: SortableBinding;
metaTitle: string | null;
metaDescription: string | null;
hasDetails: boolean;
@@ -110,7 +141,7 @@ function CategoryRow({
if (editing) {
return (
<ListItem disableGutters sx={{ pl: indent, py: 0.5 }}>
<ListItem ref={sortable?.setNodeRef} style={sortable?.style} disableGutters sx={{ pl: indent, py: 0.5 }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ width: "100%" }}>
<TextField
size="small"
@@ -137,26 +168,32 @@ function CategoryRow({
return (
<ListItem
ref={sortable?.setNodeRef}
style={sortable?.style}
disableGutters
sx={{ pl: indent, py: 0.5 }}
secondaryAction={
<Stack direction="row" spacing={0.25}>
<IconButton
size="small"
onClick={onMoveUp}
disabled={busy || !canMoveUp}
aria-label={t("common:moveUp") ?? undefined}
>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={onMoveDown}
disabled={busy || !canMoveDown}
aria-label={t("common:moveDown") ?? undefined}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
{move && (
<>
<IconButton
size="small"
onClick={move.onMoveUp}
disabled={busy || !move.canMoveUp}
aria-label={t("common:moveUp") ?? undefined}
>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={move.onMoveDown}
disabled={busy || !move.canMoveDown}
aria-label={t("common:moveDown") ?? undefined}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
</>
)}
<Tooltip
title={
hasDetails ? (
@@ -207,13 +244,101 @@ function CategoryRow({
</Stack>
}
>
<Typography variant="body2" fontWeight={bold ? 600 : 400} noWrap sx={{ pr: 19 }}>
{sortable?.handle}
<Typography variant="body2" fontWeight={bold ? 600 : 400} noWrap sx={{ pr: move ? 19 : 12 }}>
{name}
</Typography>
</ListItem>
);
}
function DragHandle({
setActivatorNodeRef,
attributes,
listeners,
}: Pick<ReturnType<typeof useSortable>, "setActivatorNodeRef" | "attributes" | "listeners">) {
const { t } = useTranslation("models");
return (
<Box
component="span"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
aria-label={t("categories.manager.dragToMove") ?? undefined}
sx={{
display: "inline-flex",
mr: 0.75,
color: "action.active",
cursor: "grab",
touchAction: "none",
borderRadius: 0.5,
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main" },
}}
>
<DragIndicatorIcon fontSize="small" />
</Box>
);
}
function SortableSubcategoryRow({
id,
...rowProps
}: { id: string } & Omit<Parameters<typeof CategoryRow>[0], "sortable" | "move">) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
id,
});
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} />,
}}
/>
);
}
/** A drop target even when empty, so subcategories can be dragged into a category without any. */
function SubcategoryList({
parentId,
ids,
dragging,
children,
}: {
parentId: string;
ids: string[];
dragging: boolean;
children: ReactNode;
}) {
const { t } = useTranslation("models");
const { setNodeRef } = useDroppable({ id: parentId });
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>
);
}
function AddRow({
indent,
placeholder,
@@ -294,6 +419,7 @@ export default function CategoryManagerModal({
onRename,
onDelete,
onReorder,
onMove,
onUpdateMeta,
}: Props) {
const { t } = useTranslation(["models", "common"]);
@@ -370,16 +496,98 @@ export default function CategoryManagerModal({
roots[index].id,
);
const moveChild = (parentId: string, index: number, direction: -1 | 1) => {
const children = childrenByParent[parentId] || [];
return swapAndReorder(
children.map((c) => c.id),
index,
direction,
children[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 sensors = useSensors(
// A few pixels of travel before dragging, so clicks on the handle don't start one.
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
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 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);
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);
}
} finally {
setBusyId(null);
}
};
const activeCategory = activeId ? categoryById.get(activeId) : undefined;
return (
<>
<Dialog open onClose={onClose} fullWidth maxWidth="sm">
@@ -390,72 +598,97 @@ export default function CategoryManagerModal({
</IconButton>
</DialogTitle>
<DialogContent dividers>
<List disablePadding>
{roots.map((root, rootIndex) => {
const children = childrenByParent[root.id] || [];
return (
<Box key={root.id} sx={{ mb: 1.5 }}>
<CategoryRow
name={root.name || untitledLabel}
indent={0}
bold
busy={busyId === root.id}
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)}
/>
<List disablePadding>
{children.map((child, childIndex) => (
<CategoryRow
key={child.id}
name={child.name || untitledLabel}
indent={3}
busy={busyId === child.id}
canMoveUp={childIndex > 0}
canMoveDown={childIndex < children.length - 1}
onMoveUp={() => moveChild(root.id, childIndex, -1)}
onMoveDown={() => moveChild(root.id, childIndex, 1)}
metaTitle={child.meta_title}
metaDescription={child.meta_description}
hasDetails={hasMeta(child)}
onOpenMeta={() => setMetaCategory(child)}
onRename={(name) => onRename(child.id, name)}
onDelete={() => handleDeleteSubcategory(child)}
/>
))}
</List>
<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);
}
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<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);
}
}}
/>
</Box>
</Box>
</Box>
);
})}
);
})}
{!roots.length && (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{t("models:categories.manager.noCategories")}
</Typography>
)}
</List>
{!roots.length && (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{t("models:categories.manager.noCategories")}
</Typography>
)}
</List>
<DragOverlay>
{activeCategory && (
<Paper
elevation={6}
sx={{ display: "flex", alignItems: "center", px: 1, py: 0.75, cursor: "grabbing" }}
>
<DragIndicatorIcon fontSize="small" sx={{ mr: 0.75, color: "action.active" }} />
<Typography variant="body2" noWrap>
{activeCategory.name || untitledLabel}
</Typography>
</Paper>
)}
</DragOverlay>
</DndContext>
<Divider sx={{ my: 1.5 }} />
+14 -1
View File
@@ -208,12 +208,24 @@ export default function ModelsPage({
}
};
// Refetch even on failure: the manager shows the drop optimistically until fresh categories arrive.
const reorderCategories = async (categoryIds: string[]) => {
try {
await categoriesApi.reorder(categoryIds);
onCategoriesChanged();
} catch (err) {
handleError(err, t("models:errors.reorderCategoryFailed"));
} finally {
onCategoriesChanged();
}
};
const moveCategory = async (id: string, parentId: string | null, position: number) => {
try {
await categoriesApi.move(id, parentId, position);
} catch (err) {
handleError(err, t("models:errors.moveCategoryFailed"));
} finally {
onCategoriesChanged();
}
};
@@ -243,6 +255,7 @@ export default function ModelsPage({
onRename={renameCategory}
onDelete={deleteCategory}
onReorder={reorderCategories}
onMove={moveCategory}
onUpdateMeta={updateCategoryMeta}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>