feat(ui): storefront filters and admin category/tag management (#23)
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user