feat(ui): storefront filters and admin category/tag management (#23)
SonarQube Analysis / sonarqube (pull_request) Successful in 2m49s
Tests / backend-unit (pull_request) Successful in 37s
Tests / backend-integration (pull_request) Failing after 3h2m23s
Tests / frontend-e2e (pull_request) Failing after 3m54s

Storefront gains a Filters drawer holding the category tree, colour-coded
tag pills, and a price range, with applied filters shown as removable
chips. Filter state lives in the URL query string, so a filtered view is
shareable and the back button works. Item cards now show their category
and tags.

Admin gains Categories and Tags tabs, and the item form gains a category
TreeSelect plus a tags Select that creates new tags on the fly.

The admin category tree tracks expansion in state rather than using
defaultExpandAll: that prop is evaluated once at mount, so a branch added
afterwards rendered collapsed and its children were unreachable. Creating
or moving a node now expands its parent. Caught by the new admin e2e spec.

The chip row is marked as a named group so its "Clear all" stays
distinguishable from the drawer's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:34:37 -05:00
co-authored by Claude Opus 5
parent 9222e97deb
commit d28fb5634a
13 changed files with 1374 additions and 22 deletions
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import type { Key } from 'react';
import Tree from 'antd/lib/tree';
import Button from 'antd/lib/button';
import Input from 'antd/lib/input';
import Modal from 'antd/lib/modal';
import Select from 'antd/lib/select';
import Space from 'antd/lib/space';
import Typography from 'antd/lib/typography';
import Empty from 'antd/lib/empty';
import Spin from 'antd/lib/spin';
import message from 'antd/lib/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;
}
})
.finally(() => setLoading(false));
}
useEffect(() => { 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);
load();
} catch (err) {
message.error((err as Error).message);
}
}
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 {subcategories === 0 ? 'no subcategories' : `${subcategories} subcategor${subcategories === 1 ? 'y' : 'ies'}`}
{' '}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'}`
);
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 handleDrop: TreeProps['onDrop'] = async (info) => {
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);
}
load();
};
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>
);
}