diff --git a/docs/superpowers/specs/2026-08-17-categories-and-tags-design.md b/docs/superpowers/specs/2026-08-17-categories-and-tags-design.md index 3fce74c..97e8fbc 100644 --- a/docs/superpowers/specs/2026-08-17-categories-and-tags-design.md +++ b/docs/superpowers/specs/2026-08-17-categories-and-tags-design.md @@ -169,9 +169,12 @@ described in `.claude/project-context.md` with no nginx change. - `GET|POST /api/admin/categories`, `PUT|DELETE /api/admin/categories/:id` - `GET|POST /api/admin/tags`, `PUT|DELETE /api/admin/tags/:id` -`PUT /api/admin/categories/:id` accepts `name`, `parent_id`, and `sort_order`. Reparenting is -validated against cycles server-side — a node may not become its own descendant — returning `400`. -`DELETE` responds with the affected counts so the UI can confirm before committing. +`GET /api/admin/categories` returns each node with an `item_count`, which is what lets the admin UI +state the blast radius of a delete before calling it — the tree and the counts are already on the +client, so no extra preview endpoint is needed. `PUT /api/admin/categories/:id` accepts `name`, +`parent_id`, and `sort_order`; reparenting is validated against cycles server-side — a node may not +become its own descendant — returning `400`. `DELETE` reports what it actually did, as +`{ deleted_categories, uncategorized_items }`. `POST`/`PUT /api/admin/items` gain two multipart fields: @@ -209,6 +212,9 @@ own line on mobile. The drawer enters from the right at every screen size (`plac near-full-width below the `md` breakpoint), with a sticky footer holding "Clear all" and "Show N items". +The chip row is marked `role="group" aria-label="Active filters"`, which keeps its "Clear all" +distinguishable from the identically-labelled control in the drawer. + The tag section is labelled **"Tags — must have all"** so that selecting a second tag and watching the grid shrink reads as intentional rather than broken. @@ -219,7 +225,9 @@ the grid shrink reads as intentional rather than broken. Two tabs added beside Inventory, Customers, and Settings: - **Categories** — antd `Tree` with drag-to-reparent, inline add/rename/delete, delete confirm - naming the affected subcategory and item counts + naming the affected subcategory and item counts. Expansion is controlled state rather than + `defaultExpandAll`: that prop is evaluated once at mount, so a branch created afterwards would + render collapsed and its children be unreachable. Creating or moving a node expands its parent. - **Tags** — list with rename, colour override from a palette, and delete showing the item count The item modal gains a category `TreeSelect` (with an explicit Uncategorized option) and a tags @@ -249,8 +257,19 @@ user rule. Existing files keep their barrel imports — churning them is out of **E2E** (`frontend/tests/e2e/filters.spec.ts`) - Open the drawer, filter by category, tags, and price; assert the grid narrows -- Remove a chip and assert the grid widens +- A nested category is selectable, not just the roots +- Remove a chip and assert the grid widens; "Clear all" resets everything - Reload a filtered URL and assert the filters are restored +- Tags render on the item card + +**E2E** (`frontend/tests/e2e/admin-taxonomy.spec.ts`) +- Create a category and a nested child, asserting the child stays visible +- Create a tag and confirm a colour was assigned +- The item form exposes category and tag fields + +Both e2e specs run against a database that is never reset between runs, so fixture names carry a +per-run suffix, and `filters.spec.ts` treats re-entry into its `beforeAll` as a no-op — a worker can +be handed the same spec file in more than one batch, and seeding twice would duplicate every item. ## Out of scope diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d550dbc..d34d380 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,18 @@ -import { useEffect, useState, useCallback } from 'react'; -import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge } from 'antd'; -import { ShoppingCartOutlined } from '@ant-design/icons'; -import { Link } from 'react-router-dom'; -import { Item, fetchItems } from './api'; +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty } from 'antd'; +import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons'; +import { Link, useSearchParams } from 'react-router-dom'; +import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api'; import ItemCard from './components/ItemCard'; +import FilterDrawer from './components/FilterDrawer'; +import ActiveFilterChips from './components/ActiveFilterChips'; +import { + ItemFilters, + activeFilterCount, + filtersFromSearchParams, + filtersToSearchParams, + hasActiveFilters +} from './filters'; import { useThemeMode } from './theme/ThemeContext'; import { useCustomerAuth } from './customer/CustomerAuthContext'; import { useCart } from './cart/CartContext'; @@ -11,18 +20,63 @@ import { useCart } from './cart/CartContext'; const { Header, Content, Footer } = Layout; const { Title } = Typography; +// Dragging the price slider fires a change per pixel; without this every one +// would become its own request. +const FILTER_DEBOUNCE_MS = 250; + export default function App() { const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [options, setOptions] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); const { mode, toggle } = useThemeMode(); const { customer } = useCustomerAuth(); const { items: cartItems } = useCart(); const { token } = theme.useToken(); + // The URL is the single source of truth for filter state, so a reload, a + // shared link, and the back button all restore the same view. + const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]); + const filterKey = filtersToSearchParams(filters).toString(); + + const applyFilters = useCallback( + (next: ItemFilters) => { + // replace, not push: dragging a slider shouldn't bury the previous page + // under dozens of history entries. + setSearchParams(filtersToSearchParams(next), { replace: true }); + }, + [setSearchParams] + ); + + const clearFilters = useCallback(() => { + setSearchParams(new URLSearchParams(), { replace: true }); + }, [setSearchParams]); + const load = useCallback(() => { - fetchItems().then(setItems); + return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey))) + .then(setItems) + .finally(() => setLoading(false)); + }, [filterKey]); + + useEffect(() => { + setLoading(true); + const timer = setTimeout(load, FILTER_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [load]); + + useEffect(() => { + fetchFilterOptions().then(setOptions).catch(() => setOptions(null)); }, []); - useEffect(() => { load(); }, [load]); + // Adding to cart flips an item to reserved, and the filter options' price + // bounds shift as inventory changes. + const reload = useCallback(() => { + load(); + fetchFilterOptions().then(setOptions).catch(() => undefined); + }, [load]); + + const activeCount = activeFilterCount(filters); return ( @@ -54,11 +108,38 @@ export default function App() { - {!items.length ? : ( +
+ + +
+ + {loading && !items.length ? : null} + {!loading && !items.length ? ( + + {hasActiveFilters(filters) ? : null} + + ) : ( {items.map(item => ( - + ))} @@ -67,6 +148,16 @@ export default function App() {
Privacy Policy
+ + setDrawerOpen(false)} + options={options} + filters={filters} + onChange={applyFilters} + onClear={clearFilters} + resultCount={items.length} + />
); } diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index d6ea2a7..c0cb761 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -1,17 +1,39 @@ import { useEffect, useState } from 'react'; import { Layout, Table, Button, Form, Input, InputNumber, Upload, Modal, - Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs + Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs, + TreeSelect, Select } from 'antd'; import { UploadOutlined, DeleteOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; import MDEditor from '@uiw/react-md-editor'; import '@uiw/react-md-editor/markdown-editor.css'; import '@uiw/react-markdown-preview/markdown.css'; -import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api'; +import { + Item, Category, Tag as TagRecord, + fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable, + fetchAdminCategories, fetchAdminTags +} from '../api'; +import { buildCategoryTree, CategoryNode } from '../filters'; import { useThemeMode } from '../theme/ThemeContext'; import Customers from './Customers'; import Settings from './Settings'; +import Categories from './Categories'; +import Tags from './Tags'; + +interface CategoryTreeOption { + value: number; + title: string; + children?: CategoryTreeOption[]; +} + +function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] { + return nodes.map(node => ({ + value: node.id, + title: node.name, + children: node.children.length ? toCategoryTreeData(node.children) : undefined + })); +} const { Header, Content } = Layout; const { Title } = Typography; @@ -23,24 +45,41 @@ function Inventory() { const [form] = Form.useForm(); const [fileList, setFileList] = useState([]); const [description, setDescription] = useState(''); + const [categories, setCategories] = useState([]); + const [tags, setTags] = useState([]); const { mode } = useThemeMode(); const load = () => fetchAdminItems().then(setItems); - useEffect(() => { load(); }, []); + + // The item form needs the current category tree and tag list; both change + // from the sibling tabs, so they're refetched whenever the modal opens. + const loadOptions = () => Promise.all([ + fetchAdminCategories().then(setCategories), + fetchAdminTags().then(setTags) + ]); + + useEffect(() => { load(); loadOptions(); }, []); function openNew() { setEditingItem(null); form.resetFields(); setFileList([]); setDescription(''); + loadOptions(); setModalOpen(true); } function openEdit(item: Item) { setEditingItem(item); - form.setFieldsValue({ name: item.name, price: item.price_cents / 100 }); + form.setFieldsValue({ + name: item.name, + price: item.price_cents / 100, + category_id: item.category_id ?? undefined, + tags: item.tags.map(tag => tag.name) + }); setFileList([]); setDescription(item.description || ''); + loadOptions(); setModalOpen(true); } @@ -50,11 +89,16 @@ function Inventory() { fd.append('name', values.name); fd.append('description', description); fd.append('price', String(values.price)); + // An empty string clears the category server-side; undefined would be sent + // as the literal text "undefined". + fd.append('category_id', values.category_id == null ? '' : String(values.category_id)); + fd.append('tags', JSON.stringify(values.tags ?? [])); fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); }); await saveItem(editingItem?.id ?? null, fd); message.success(editingItem ? 'Item updated' : 'Item added'); setModalOpen(false); load(); + loadOptions(); } async function handleDelete(id: number) { @@ -87,6 +131,19 @@ function Inventory() { ) : null }, { title: 'Name', dataIndex: 'name' }, + { + title: 'Category', + dataIndex: 'category_name', + render: (name: string | null) => name || Uncategorized + }, + { + title: 'Tags', + dataIndex: 'tags', + render: (itemTags: Item['tags']) => + itemTags.length + ? itemTags.map(tag => {tag.name}) + : null + }, { title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` }, { title: 'Status', @@ -129,6 +186,27 @@ function Inventory() { + + + + + setName(event.target.value)} + onPressEnter={handleSave} + /> + + setName(event.target.value)} + onPressEnter={handleSave} + /> + {editing && ( + <> + +