Files
redefined-designs/frontend/src/admin/Categories.tsx
T
bermudalambandClaude Opus 5 5f9172512c refactor: clear the 85 minutes of technical debt (#81)
Thirteen of SonarQube's fourteen code smells, with the fourteenth argued as a false positive rather than coded around.

Four of these were not stylistic. The Remove button in Cart's List.Item actions array carried no key, so React could not match it across renders and rebuilt it on every cart render. The cart, customer-auth and favorites providers each passed a freshly allocated object as their context value, which re-renders every consumer whenever the provider renders, cart or session unchanged — and these three wrap the entire storefront, so the fan-out was the whole tree. Two of them also rebuilt a Set on every render for good measure. All four now memoized on the values they actually derive from.

The four cognitive-complexity findings wanted real restructuring rather than suppression. parseItemFilters splits into one helper per field, leaving the function with the order those helpers run in and the single rule that spans two fields; the order is preserved exactly, because a query wrong in two ways reports the first field and rearranging the calls would change which error a caller sees. adminCategories' PUT extracts the parent resolution — the existence check and the cycle check — into a resolver that returns the refusal rather than sending it. App's four-way render chain becomes a Catalogue component, which also removes two of the nested ternaries. Customers' confirm dialog moves to module level, since a function's cognitive complexity counts everything declared inside it and that dialog branches on `disabling` five times.

The rest were mechanical: two more nested ternaries — a status-colour lookup and a pluralisation helper — and one type assertion that asserted the type the expression already had.

Left alone: S6478 on CategoryTreeSelect's popupRender. That is antd's render prop, called as a function and spliced in, never mounted as a component type, so the destroy-the-subtree failure the rule describes cannot happen. Marked false positive in SonarQube with that reasoning rather than contorting the component around a rule that misread it.

Verified rather than assumed. Backend unit 78 pass, integration 134 pass, end-to-end 83 pass, both workspaces build clean. ESLint warnings drop from 35 to 31 with no new file warning — the same React and SonarJS rules #60 turned on are what surfaced this backlog in the first place.

Refs #81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:10:12 -05:00

265 lines
9.0 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import type { Key } from 'react';
import Tree from 'antd/es/tree';
import Button from 'antd/es/button';
import Input from 'antd/es/input';
import Modal from 'antd/es/modal';
import Select from 'antd/es/select';
import Space from 'antd/es/space';
import Typography from 'antd/es/typography';
import Empty from 'antd/es/empty';
import Spin from 'antd/es/spin';
import message from 'antd/es/message';
import type { DataNode, TreeProps } from 'antd/es/tree';
import {
Category,
fetchAdminCategories,
createCategory,
updateCategory,
deleteCategory
} from '../api';
import { buildCategoryTree, categoryPath, CategoryNode } from '../filters';
const { Title, Text } = Typography;
// Counts the whole branch, so the delete confirmation can say what a parent
// takes down with it rather than only naming itself.
function branchTotals(node: CategoryNode): { categories: number; items: number } {
return node.children.reduce(
(acc, child) => {
const nested = branchTotals(child);
return { categories: acc.categories + nested.categories, items: acc.items + nested.items };
},
{ categories: 1, items: node.item_count }
);
}
function findNode(nodes: CategoryNode[], id: number): CategoryNode | null {
for (const node of nodes) {
if (node.id === id) return node;
const nested = findNode(node.children, id);
if (nested) return nested;
}
return null;
}
export default function Categories() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Category | null>(null);
const [name, setName] = useState('');
const [parentId, setParentId] = useState<number | null>(null);
// The tree stays mounted across reloads, so `defaultExpandAll` would only
// ever apply to the categories present on first render — a branch added
// afterwards would render collapsed and its children be unreachable.
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
const expansionInitialized = useRef(false);
const tree = buildCategoryTree(categories);
function expand(id: number) {
setExpandedKeys((keys) => (keys.includes(id) ? keys : [...keys, id]));
}
function load() {
setLoading(true);
return fetchAdminCategories()
.then((list) => {
setCategories(list);
// Start fully expanded, then leave expansion to the user — reloading
// must not silently re-open branches they collapsed.
if (!expansionInitialized.current) {
setExpandedKeys(list.map((category) => category.id));
expansionInitialized.current = true;
}
})
.catch(() => message.error('Could not load categories'))
.finally(() => setLoading(false));
}
useEffect(() => { void load(); }, []);
function openNew(parent: number | null) {
setEditing(null);
setName('');
setParentId(parent);
setModalOpen(true);
}
function openEdit(category: Category) {
setEditing(category);
setName(category.name);
setParentId(category.parent_id);
setModalOpen(true);
}
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
message.error('Name is required');
return;
}
try {
if (editing) {
await updateCategory(editing.id, { name: trimmed, parent_id: parentId });
message.success('Category updated');
} else {
await createCategory(trimmed, parentId);
message.success('Category added');
}
// Reveal where the category just landed instead of filing it out of sight.
if (parentId !== null) expand(parentId);
setModalOpen(false);
void load();
} catch (err) {
message.error((err as Error).message);
}
}
// Pluralized in one place because the count drives both whether the clause
// appears at all and which suffix it takes.
function describeSubcategories(count: number): string {
if (count === 0) return 'no subcategories';
return `${count} subcategor${count === 1 ? 'y' : 'ies'}`;
}
function handleDelete(category: Category) {
const node = findNode(tree, category.id);
const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count };
const subcategories = totals.categories - 1;
Modal.confirm({
title: `Delete "${category.name}"?`,
content: (
<span>
This deletes {describeSubcategories(subcategories)}
{' '}and uncategorizes {totals.items} item{totals.items === 1 ? '' : 's'}. The items themselves are kept.
</span>
),
okText: 'Delete',
okButtonProps: { danger: true },
onOk: async () => {
const result = await deleteCategory(category.id);
message.success(
`Deleted ${result.deleted_categories} categor${result.deleted_categories === 1 ? 'y' : 'ies'}, ` +
`uncategorized ${result.uncategorized_items} item${result.uncategorized_items === 1 ? '' : 's'}`
);
void load();
}
});
}
// Dragging a node onto another reparents it. The server rejects a move that
// would put a node beneath its own descendant, so a refused drop just
// reloads the unchanged tree.
const dropCategory = async (info: Parameters<NonNullable<TreeProps['onDrop']>>[0]) => {
const dragId = Number(info.dragNode.key);
const dropId = Number(info.node.key);
const dropToGap = !info.dropToGap ? dropId : findNode(tree, dropId)?.parent_id ?? null;
try {
await updateCategory(dragId, { parent_id: dropToGap });
if (dropToGap !== null) expand(dropToGap);
message.success('Category moved');
} catch (err) {
message.error((err as Error).message);
}
void load();
};
// Kept separate from dropCategory so the handler antd receives returns void,
// as its type says. An async function here would hand Tree a promise it never
// awaits.
const handleDrop: TreeProps['onDrop'] = (info) => void dropCategory(info);
function toTreeData(nodes: CategoryNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.id,
title: (
<span className="admin-category-node">
<span>{node.name}</span>
<Text type="secondary" style={{ fontSize: 12 }}>
{node.item_count} item{node.item_count === 1 ? '' : 's'}
</Text>
<Space size={4} onClick={(event) => event.stopPropagation()}>
<Button size="small" type="link" onClick={() => openNew(node.id)}>Add child</Button>
<Button size="small" type="link" onClick={() => openEdit(node)}>Rename</Button>
<Button size="small" type="link" danger onClick={() => handleDelete(node)}>Delete</Button>
</Space>
</span>
),
children: node.children.length ? toTreeData(node.children) : undefined
}));
}
// A category may not be moved beneath itself or its own descendants, so those
// options are withheld from the parent picker too.
const forbidden = new Set<number>();
if (editing) {
const collect = (node: CategoryNode) => {
forbidden.add(node.id);
node.children.forEach(collect);
};
const node = findNode(tree, editing.id);
if (node) collect(node);
}
const parentOptions = [
{ value: null as number | null, label: 'No parent (top level)' },
...categories
.filter((category) => !forbidden.has(category.id))
.map((category) => ({ value: category.id as number | null, label: categoryPath(categories, category.id) }))
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<Title level={4} style={{ margin: 0 }}>Categories</Title>
<Button type="primary" onClick={() => openNew(null)}>Add Category</Button>
</div>
{loading && !categories.length ? <Spin /> : null}
{!loading && !categories.length ? (
<Empty description="No categories yet. Add one to start organizing items." />
) : (
<Tree
treeData={toTreeData(tree)}
draggable
blockNode
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
selectable={false}
onDrop={handleDrop}
/>
)}
<Modal
title={editing ? 'Edit Category' : 'Add Category'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }}>
<label htmlFor="category-name">Name</label>
<Input
id="category-name"
value={name}
onChange={(event) => setName(event.target.value)}
onPressEnter={handleSave}
/>
<label htmlFor="category-parent">Parent</label>
<Select
id="category-parent"
style={{ width: '100%' }}
value={parentId}
onChange={setParentId}
options={parentOptions}
/>
</Space>
</Modal>
</div>
);
}