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:
@@ -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/categories`, `PUT|DELETE /api/admin/categories/:id`
|
||||||
- `GET|POST /api/admin/tags`, `PUT|DELETE /api/admin/tags/: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
|
`GET /api/admin/categories` returns each node with an `item_count`, which is what lets the admin UI
|
||||||
validated against cycles server-side — a node may not become its own descendant — returning `400`.
|
state the blast radius of a delete before calling it — the tree and the counts are already on the
|
||||||
`DELETE` responds with the affected counts so the UI can confirm before committing.
|
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:
|
`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
|
near-full-width below the `md` breakpoint), with a sticky footer holding "Clear all" and
|
||||||
"Show N items".
|
"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 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.
|
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:
|
Two tabs added beside Inventory, Customers, and Settings:
|
||||||
|
|
||||||
- **Categories** — antd `Tree` with drag-to-reparent, inline add/rename/delete, delete confirm
|
- **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
|
- **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
|
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`)
|
**E2E** (`frontend/tests/e2e/filters.spec.ts`)
|
||||||
- Open the drawer, filter by category, tags, and price; assert the grid narrows
|
- 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
|
- 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
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
+100
-9
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge } from 'antd';
|
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty } from 'antd';
|
||||||
import { ShoppingCartOutlined } from '@ant-design/icons';
|
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { Item, fetchItems } from './api';
|
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||||
import ItemCard from './components/ItemCard';
|
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 { useThemeMode } from './theme/ThemeContext';
|
||||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||||
import { useCart } from './cart/CartContext';
|
import { useCart } from './cart/CartContext';
|
||||||
@@ -11,18 +20,63 @@ import { useCart } from './cart/CartContext';
|
|||||||
const { Header, Content, Footer } = Layout;
|
const { Header, Content, Footer } = Layout;
|
||||||
const { Title } = Typography;
|
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() {
|
export default function App() {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { mode, toggle } = useThemeMode();
|
const { mode, toggle } = useThemeMode();
|
||||||
const { customer } = useCustomerAuth();
|
const { customer } = useCustomerAuth();
|
||||||
const { items: cartItems } = useCart();
|
const { items: cartItems } = useCart();
|
||||||
const { token } = theme.useToken();
|
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(() => {
|
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 (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
@@ -54,11 +108,38 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
<Content style={{ padding: 24 }}>
|
<Content style={{ padding: 24 }}>
|
||||||
{!items.length ? <Spin /> : (
|
<div className="filter-bar">
|
||||||
|
<Button
|
||||||
|
icon={<FilterOutlined />}
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
type={activeCount ? 'primary' : 'default'}
|
||||||
|
>
|
||||||
|
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||||
|
</Button>
|
||||||
|
<ActiveFilterChips
|
||||||
|
options={options}
|
||||||
|
filters={filters}
|
||||||
|
onChange={applyFilters}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !items.length ? <Spin /> : null}
|
||||||
|
{!loading && !items.length ? (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
hasActiveFilters(filters)
|
||||||
|
? 'No items match these filters'
|
||||||
|
: 'No items yet — check back soon'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasActiveFilters(filters) ? <Button onClick={clearFilters}>Clear filters</Button> : null}
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
<Row gutter={[20, 20]}>
|
<Row gutter={[20, 20]}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
<ItemCard item={item} onChanged={load} />
|
<ItemCard item={item} onChanged={reload} />
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -67,6 +148,16 @@ export default function App() {
|
|||||||
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
|
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
|
||||||
<Link to="/privacy">Privacy Policy</Link>
|
<Link to="/privacy">Privacy Policy</Link>
|
||||||
</Footer>
|
</Footer>
|
||||||
|
|
||||||
|
<FilterDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
options={options}
|
||||||
|
filters={filters}
|
||||||
|
onChange={applyFilters}
|
||||||
|
onClear={clearFilters}
|
||||||
|
resultCount={items.length}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
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';
|
} from 'antd';
|
||||||
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
import MDEditor from '@uiw/react-md-editor';
|
import MDEditor from '@uiw/react-md-editor';
|
||||||
import '@uiw/react-md-editor/markdown-editor.css';
|
import '@uiw/react-md-editor/markdown-editor.css';
|
||||||
import '@uiw/react-markdown-preview/markdown.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 { useThemeMode } from '../theme/ThemeContext';
|
||||||
import Customers from './Customers';
|
import Customers from './Customers';
|
||||||
import Settings from './Settings';
|
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 { Header, Content } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
@@ -23,24 +45,41 @@ function Inventory() {
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const [description, setDescription] = useState<string>('');
|
const [description, setDescription] = useState<string>('');
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||||
const { mode } = useThemeMode();
|
const { mode } = useThemeMode();
|
||||||
|
|
||||||
const load = () => fetchAdminItems().then(setItems);
|
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() {
|
function openNew() {
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setFileList([]);
|
setFileList([]);
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
loadOptions();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(item: Item) {
|
function openEdit(item: Item) {
|
||||||
setEditingItem(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([]);
|
setFileList([]);
|
||||||
setDescription(item.description || '');
|
setDescription(item.description || '');
|
||||||
|
loadOptions();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +89,16 @@ function Inventory() {
|
|||||||
fd.append('name', values.name);
|
fd.append('name', values.name);
|
||||||
fd.append('description', description);
|
fd.append('description', description);
|
||||||
fd.append('price', String(values.price));
|
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); });
|
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
||||||
await saveItem(editingItem?.id ?? null, fd);
|
await saveItem(editingItem?.id ?? null, fd);
|
||||||
message.success(editingItem ? 'Item updated' : 'Item added');
|
message.success(editingItem ? 'Item updated' : 'Item added');
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
load();
|
load();
|
||||||
|
loadOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
@@ -87,6 +131,19 @@ function Inventory() {
|
|||||||
) : null
|
) : null
|
||||||
},
|
},
|
||||||
{ title: 'Name', dataIndex: 'name' },
|
{ title: 'Name', dataIndex: 'name' },
|
||||||
|
{
|
||||||
|
title: 'Category',
|
||||||
|
dataIndex: 'category_name',
|
||||||
|
render: (name: string | null) => name || <span style={{ opacity: 0.45 }}>Uncategorized</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tags',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
render: (itemTags: Item['tags']) =>
|
||||||
|
itemTags.length
|
||||||
|
? itemTags.map(tag => <Tag key={tag.id} color={tag.color}>{tag.name}</Tag>)
|
||||||
|
: null
|
||||||
|
},
|
||||||
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
@@ -129,6 +186,27 @@ function Inventory() {
|
|||||||
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
|
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="category_id" label="Category">
|
||||||
|
<TreeSelect
|
||||||
|
allowClear
|
||||||
|
placeholder="Uncategorized"
|
||||||
|
treeDefaultExpandAll
|
||||||
|
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="tags"
|
||||||
|
label="Tags"
|
||||||
|
extra="Pick existing tags or type a new one and press Enter to create it."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
placeholder="Add tags"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={tags.map(tag => ({ value: tag.name, label: tag.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
{editingItem && editingItem.images.length > 0 && (
|
{editingItem && editingItem.images.length > 0 && (
|
||||||
<Form.Item label="Existing Images (front / back / etc.)">
|
<Form.Item label="Existing Images (front / back / etc.)">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
@@ -172,6 +250,8 @@ export default function Admin() {
|
|||||||
defaultActiveKey="inventory"
|
defaultActiveKey="inventory"
|
||||||
items={[
|
items={[
|
||||||
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
||||||
|
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||||
|
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Table from 'antd/lib/table';
|
||||||
|
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 Tag from 'antd/lib/tag';
|
||||||
|
import Typography from 'antd/lib/typography';
|
||||||
|
import message from 'antd/lib/message';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { Tag as TagRecord, fetchAdminTags, createTag, updateTag, deleteTag } from '../api';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
// Mirrors TAG_COLORS in the backend's utils.ts — the server rejects anything
|
||||||
|
// outside this set, so the two lists have to stay aligned.
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||||
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Tags() {
|
||||||
|
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<TagRecord | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [color, setColor] = useState<string>(TAG_COLORS[0]);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
return fetchAdminTags()
|
||||||
|
.then(setTags)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
setEditing(null);
|
||||||
|
setName('');
|
||||||
|
setColor(TAG_COLORS[0]);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(tag: TagRecord) {
|
||||||
|
setEditing(tag);
|
||||||
|
setName(tag.name);
|
||||||
|
setColor(tag.color);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
message.error('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateTag(editing.id, { name: trimmed, color });
|
||||||
|
message.success('Tag updated');
|
||||||
|
} else {
|
||||||
|
// New tags take the colour the server derives from the name; it can be
|
||||||
|
// overridden straight afterwards by editing.
|
||||||
|
await createTag(trimmed);
|
||||||
|
message.success('Tag added');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(tag: TagRecord) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Delete "${tag.name}"?`,
|
||||||
|
content: `This removes the tag from ${tag.item_count} item${tag.item_count === 1 ? '' : 's'}. The items themselves are kept.`,
|
||||||
|
okText: 'Delete',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await deleteTag(tag.id);
|
||||||
|
message.success('Tag deleted');
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<TagRecord> = [
|
||||||
|
{
|
||||||
|
title: 'Tag',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag>
|
||||||
|
},
|
||||||
|
{ title: 'Colour', dataIndex: 'color' },
|
||||||
|
{ title: 'Items', dataIndex: 'item_count' },
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
render: (_: unknown, tag) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => openEdit(tag)}>Edit</Button>
|
||||||
|
<Button size="small" danger onClick={() => handleDelete(tag)}>Delete</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>Tags</Title>
|
||||||
|
<Button type="primary" onClick={openNew}>Add Tag</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table rowKey="id" dataSource={tags} columns={columns} loading={loading} scroll={{ x: true }} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? 'Edit Tag' : 'Add Tag'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<label htmlFor="tag-name">Name</label>
|
||||||
|
<Input
|
||||||
|
id="tag-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
onPressEnter={handleSave}
|
||||||
|
/>
|
||||||
|
{editing && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="tag-color">Colour</label>
|
||||||
|
<Select
|
||||||
|
id="tag-color"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={color}
|
||||||
|
onChange={setColor}
|
||||||
|
options={TAG_COLORS.map((option) => ({
|
||||||
|
value: option,
|
||||||
|
label: <Tag color={option}>{option}</Tag>
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+96
-2
@@ -1,3 +1,12 @@
|
|||||||
|
import type { ItemFilters } from './filters';
|
||||||
|
import { filtersToSearchParams } from './filters';
|
||||||
|
|
||||||
|
export interface ItemTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5,6 +14,30 @@ export interface Item {
|
|||||||
price_cents: number;
|
price_cents: number;
|
||||||
images: { id: number; image_path: string; sort_order: number }[];
|
images: { id: number; image_path: string; sort_order: number }[];
|
||||||
status: 'available' | 'reserved' | 'sold';
|
status: 'available' | 'reserved' | 'sold';
|
||||||
|
category_id: number | null;
|
||||||
|
category_name: string | null;
|
||||||
|
tags: ItemTag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parent_id: number | null;
|
||||||
|
sort_order: number;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOptions {
|
||||||
|
categories: Category[];
|
||||||
|
tags: Tag[];
|
||||||
|
priceRange: { min_cents: number; max_cents: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SiteConfig {
|
export interface SiteConfig {
|
||||||
@@ -18,8 +51,14 @@ export async function fetchConfig(): Promise<SiteConfig> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchItems(): Promise<Item[]> {
|
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
||||||
const res = await fetch('/api/items');
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
||||||
|
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
||||||
|
const res = await fetch('/api/filters');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +90,58 @@ export async function markAvailable(id: number): Promise<Item> {
|
|||||||
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
|
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The admin endpoints return a JSON error body on 4xx; surfacing its message
|
||||||
|
// lets the UI say "that name is already used here" instead of a generic
|
||||||
|
// failure.
|
||||||
|
async function sendJson<T>(url: string, method: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.json().catch(() => ({ error: 'request failed' }));
|
||||||
|
throw new Error(detail.error || 'request failed');
|
||||||
|
}
|
||||||
|
return res.status === 204 ? (undefined as T) : res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminCategories(): Promise<Category[]> {
|
||||||
|
const res = await fetch('/api/admin/categories');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCategory(name: string, parentId: number | null): Promise<Category> {
|
||||||
|
return sendJson('/api/admin/categories', 'POST', { name, parent_id: parentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategory(
|
||||||
|
id: number,
|
||||||
|
changes: { name?: string; parent_id?: number | null }
|
||||||
|
): Promise<Category> {
|
||||||
|
return sendJson(`/api/admin/categories/${id}`, 'PUT', changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCategory(
|
||||||
|
id: number
|
||||||
|
): Promise<{ deleted_categories: number; uncategorized_items: number }> {
|
||||||
|
return sendJson(`/api/admin/categories/${id}`, 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminTags(): Promise<Tag[]> {
|
||||||
|
const res = await fetch('/api/admin/tags');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTag(name: string): Promise<Tag> {
|
||||||
|
return sendJson('/api/admin/tags', 'POST', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTag(id: number, changes: { name?: string; color?: string }): Promise<Tag> {
|
||||||
|
return sendJson(`/api/admin/tags/${id}`, 'PUT', changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTag(id: number): Promise<void> {
|
||||||
|
return sendJson(`/api/admin/tags/${id}`, 'DELETE');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Tag from 'antd/lib/tag';
|
||||||
|
import Button from 'antd/lib/button';
|
||||||
|
import type { FilterOptions } from '../api';
|
||||||
|
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: FilterOptions | null;
|
||||||
|
filters: ItemFilters;
|
||||||
|
onChange: (filters: ItemFilters) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActiveFilterChips({ options, filters, onChange, onClear }: Props) {
|
||||||
|
if (!hasActiveFilters(filters)) return null;
|
||||||
|
|
||||||
|
const categories = options?.categories ?? [];
|
||||||
|
const tags = options?.tags ?? [];
|
||||||
|
|
||||||
|
const chips: { key: string; label: string; onRemove: () => void }[] = [];
|
||||||
|
|
||||||
|
if (filters.categoryId !== null) {
|
||||||
|
const path = categoryPath(categories, filters.categoryId);
|
||||||
|
// Falls back to the raw id while /api/filters is still loading, so the chip
|
||||||
|
// never renders as an empty box.
|
||||||
|
const label = path || `Category ${filters.categoryId}`;
|
||||||
|
chips.push({
|
||||||
|
key: `category-${filters.categoryId}`,
|
||||||
|
// The removable name is the leaf, matching what the user clicked in the
|
||||||
|
// tree, while the chip itself shows the full path for context.
|
||||||
|
label,
|
||||||
|
onRemove: () => onChange({ ...filters, categoryId: null })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tagId of filters.tagIds) {
|
||||||
|
const tag = tags.find((candidate) => candidate.id === tagId);
|
||||||
|
chips.push({
|
||||||
|
key: `tag-${tagId}`,
|
||||||
|
label: tag?.name ?? `Tag ${tagId}`,
|
||||||
|
onRemove: () => onChange({ ...filters, tagIds: filters.tagIds.filter((id) => id !== tagId) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) {
|
||||||
|
chips.push({
|
||||||
|
key: 'price',
|
||||||
|
label: formatPriceRange(filters.minPriceCents, filters.maxPriceCents),
|
||||||
|
onRemove: () => onChange({ ...filters, minPriceCents: null, maxPriceCents: null })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Named as a group so the chip row's own "Clear all" stays distinguishable
|
||||||
|
// from the identically-labelled one in the filter drawer.
|
||||||
|
<div className="active-filter-chips" role="group" aria-label="Active filters">
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<Tag
|
||||||
|
key={chip.key}
|
||||||
|
closable
|
||||||
|
onClose={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
chip.onRemove();
|
||||||
|
}}
|
||||||
|
// antd renders the close control as an icon with no text, so name it
|
||||||
|
// for screen readers and for anything driving the page by role.
|
||||||
|
closeIcon={
|
||||||
|
<span role="button" aria-label={`Remove filter ${chip.label.split(' / ').pop()}`}>×</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
<Button size="small" type="link" onClick={onClear}>Clear all</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import Drawer from 'antd/lib/drawer';
|
||||||
|
import Button from 'antd/lib/button';
|
||||||
|
import Tree from 'antd/lib/tree';
|
||||||
|
import Tag from 'antd/lib/tag';
|
||||||
|
import Slider from 'antd/lib/slider';
|
||||||
|
import InputNumber from 'antd/lib/input-number';
|
||||||
|
import Empty from 'antd/lib/empty';
|
||||||
|
import Grid from 'antd/lib/grid';
|
||||||
|
import type { DataNode } from 'antd/es/tree';
|
||||||
|
import type { FilterOptions } from '../api';
|
||||||
|
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
options: FilterOptions | null;
|
||||||
|
filters: ItemFilters;
|
||||||
|
onChange: (filters: ItemFilters) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
resultCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTreeData(nodes: CategoryNode[]): DataNode[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
key: node.id,
|
||||||
|
title: node.name,
|
||||||
|
children: node.children.length ? toTreeData(node.children) : undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
||||||
|
const dollarsToCents = (dollars: number | null): number | null =>
|
||||||
|
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
||||||
|
|
||||||
|
export default function FilterDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
options,
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
resultCount
|
||||||
|
}: Props) {
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const categories = options?.categories ?? [];
|
||||||
|
const tags = options?.tags ?? [];
|
||||||
|
const bounds = options?.priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||||
|
|
||||||
|
function toggleTag(tagId: number) {
|
||||||
|
const next = filters.tagIds.includes(tagId)
|
||||||
|
? filters.tagIds.filter((id) => id !== tagId)
|
||||||
|
: [...filters.tagIds, tagId];
|
||||||
|
onChange({ ...filters, tagIds: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selecting the already-selected node clears the filter, so the tree doubles
|
||||||
|
// as its own "all items" control.
|
||||||
|
function selectCategory(keys: React.Key[]) {
|
||||||
|
const picked = keys.length ? Number(keys[0]) : null;
|
||||||
|
onChange({ ...filters, categoryId: picked === filters.categoryId ? null : picked });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title="Filters"
|
||||||
|
placement="right"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
// Unmounting on close keeps a single copy of controls like "Clear all" in
|
||||||
|
// the document at any time.
|
||||||
|
destroyOnHidden
|
||||||
|
width={screens.md ? 380 : '90%'}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button block onClick={onClear}>Clear all</Button>
|
||||||
|
<Button block type="primary" onClick={onClose}>
|
||||||
|
Show {resultCount} {resultCount === 1 ? 'item' : 'items'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section style={{ marginBottom: 28 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||||
|
Category
|
||||||
|
</h4>
|
||||||
|
{categories.length ? (
|
||||||
|
<Tree
|
||||||
|
treeData={toTreeData(buildCategoryTree(categories))}
|
||||||
|
selectedKeys={filters.categoryId === null ? [] : [filters.categoryId]}
|
||||||
|
onSelect={selectCategory}
|
||||||
|
defaultExpandAll
|
||||||
|
blockNode
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: 28 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||||
|
Tags — must have all
|
||||||
|
</h4>
|
||||||
|
{tags.length ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const selected = filters.tagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
// A real button rather than a styled span, so the pills are
|
||||||
|
// reachable by keyboard and announce their on/off state.
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => toggleTag(tag.id)}
|
||||||
|
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
color={selected ? tag.color : undefined}
|
||||||
|
style={{ margin: 0, opacity: selected ? 1 : 0.75 }}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Tag>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||||
|
Price
|
||||||
|
</h4>
|
||||||
|
<Slider
|
||||||
|
range
|
||||||
|
min={bounds.min_cents}
|
||||||
|
max={sliderMax}
|
||||||
|
step={100}
|
||||||
|
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
||||||
|
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
||||||
|
onChange={([min, max]) =>
|
||||||
|
onChange({ ...filters, minPriceCents: min, maxPriceCents: max })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||||
|
<InputNumber
|
||||||
|
aria-label="Minimum price"
|
||||||
|
prefix="$"
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={centsToDollars(filters.minPriceCents)}
|
||||||
|
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
|
||||||
|
/>
|
||||||
|
<span style={{ opacity: 0.6 }}>to</span>
|
||||||
|
<InputNumber
|
||||||
|
aria-label="Maximum price"
|
||||||
|
prefix="$"
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={centsToDollars(filters.maxPriceCents)}
|
||||||
|
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Card, Badge, Typography, Carousel, Button, message } from 'antd';
|
import { Card, Badge, Typography, Carousel, Button, message, Tag } from 'antd';
|
||||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import type { CarouselRef } from 'antd/es/carousel';
|
import type { CarouselRef } from 'antd/es/carousel';
|
||||||
import { Item } from '../api';
|
import { Item } from '../api';
|
||||||
@@ -88,7 +88,15 @@ export default function ItemCard({ item, onChanged }: Props) {
|
|||||||
const card = (
|
const card = (
|
||||||
<Card hoverable cover={cover} className="item-card">
|
<Card hoverable cover={cover} className="item-card">
|
||||||
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
||||||
|
{item.category_name && <Text type="secondary" className="item-category">{item.category_name}</Text>}
|
||||||
<MarkdownView content={item.description} />
|
<MarkdownView content={item.description} />
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<div className="item-tags">
|
||||||
|
{item.tags.map(tag => (
|
||||||
|
<Tag key={tag.id} color={tag.color} style={{ marginInlineEnd: 4 }}>{tag.name}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
|
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
|
||||||
{actionButton}
|
{actionButton}
|
||||||
<AuthPromptModal
|
<AuthPromptModal
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Category } from './api';
|
||||||
|
|
||||||
|
export interface ItemFilters {
|
||||||
|
categoryId: number | null;
|
||||||
|
tagIds: number[];
|
||||||
|
minPriceCents: number | null;
|
||||||
|
maxPriceCents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_FILTERS: ItemFilters = {
|
||||||
|
categoryId: null,
|
||||||
|
tagIds: [],
|
||||||
|
minPriceCents: null,
|
||||||
|
maxPriceCents: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filters live in the URL so a filtered view can be linked, bookmarked, and
|
||||||
|
// walked back through with the browser's back button. The param names match
|
||||||
|
// what GET /api/items accepts, so the same object serializes for both.
|
||||||
|
export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.categoryId !== null) params.set('category', String(filters.categoryId));
|
||||||
|
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
||||||
|
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
||||||
|
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInt(raw: string | null): number | null {
|
||||||
|
if (raw === null || raw.trim() === '') return null;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||||||
|
const tags = (params.get('tags') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => readInt(part))
|
||||||
|
.filter((id): id is number => id !== null && id > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryId: readInt(params.get('category')),
|
||||||
|
tagIds: tags,
|
||||||
|
minPriceCents: readInt(params.get('min_price')),
|
||||||
|
maxPriceCents: readInt(params.get('max_price'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// One count for the "Filters (N)" button. A price range counts once however
|
||||||
|
// many ends are set, since it reads as a single filter to the user.
|
||||||
|
export function activeFilterCount(filters: ItemFilters): number {
|
||||||
|
let count = 0;
|
||||||
|
if (filters.categoryId !== null) count++;
|
||||||
|
count += filters.tagIds.length;
|
||||||
|
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActiveFilters(filters: ItemFilters): boolean {
|
||||||
|
return activeFilterCount(filters) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryNode extends Category {
|
||||||
|
children: CategoryNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API returns categories flat; the tree is rebuilt here so the drawer and
|
||||||
|
// the admin tab share one nesting implementation.
|
||||||
|
export function buildCategoryTree(categories: Category[]): CategoryNode[] {
|
||||||
|
const byId = new Map<number, CategoryNode>();
|
||||||
|
for (const category of categories) {
|
||||||
|
byId.set(category.id, { ...category, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: CategoryNode[] = [];
|
||||||
|
for (const node of byId.values()) {
|
||||||
|
const parent = node.parent_id === null ? undefined : byId.get(node.parent_id);
|
||||||
|
// A node whose parent is missing is treated as a root rather than dropped,
|
||||||
|
// so nothing can silently disappear from the tree.
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Furniture / Tables / Coffee Tables" — used on chips and in the admin form so
|
||||||
|
// a leaf name like "Vintage" isn't ambiguous between branches.
|
||||||
|
export function categoryPath(categories: Category[], id: number): string {
|
||||||
|
const byId = new Map(categories.map((category) => [category.id, category]));
|
||||||
|
const parts: string[] = [];
|
||||||
|
let current = byId.get(id);
|
||||||
|
while (current) {
|
||||||
|
parts.unshift(current.name);
|
||||||
|
current = current.parent_id === null ? undefined : byId.get(current.parent_id);
|
||||||
|
// Guards against a cycle that somehow reached the client.
|
||||||
|
if (parts.length > 32) break;
|
||||||
|
}
|
||||||
|
return parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPriceRange(minCents: number | null, maxCents: number | null): string {
|
||||||
|
const dollars = (cents: number) => `$${(cents / 100).toFixed(0)}`;
|
||||||
|
if (minCents !== null && maxCents !== null) return `${dollars(minCents)}–${dollars(maxCents)}`;
|
||||||
|
if (minCents !== null) return `${dollars(minCents)}+`;
|
||||||
|
if (maxCents !== null) return `Up to ${dollars(maxCents)}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -73,3 +73,45 @@ body { margin: 0; }
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Filter bar: the Filters button sits inline with the active-filter chips on
|
||||||
|
desktop; on a narrow screen the chips wrap onto their own line beneath it. */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-filter-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-filter-chips .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-category {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 0;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.filter-bar {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.filter-bar > .ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// The e2e database is shared and never reset, so every fixture name carries a
|
||||||
|
// unique suffix and assertions are scoped to the nodes this run created. The
|
||||||
|
// suffix is generated per test rather than per module: a worker can run this
|
||||||
|
// file more than once, and a module-level constant would collide with itself.
|
||||||
|
const suffix = () => `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
test.describe('Admin taxonomy', () => {
|
||||||
|
test('creates a category and a nested child that stays visible', async ({ page }) => {
|
||||||
|
const RUN = suffix();
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Categories' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Category' }).click();
|
||||||
|
await page.getByLabel('Name').fill(`Furniture ${RUN}`);
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
await expect(page.getByText(`Furniture ${RUN}`)).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('treeitem')
|
||||||
|
.filter({ hasText: `Furniture ${RUN}` })
|
||||||
|
.getByRole('button', { name: 'Add child' })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel('Name').fill(`Tables ${RUN}`);
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
|
||||||
|
// The tree is mounted before this branch exists, so the child is only
|
||||||
|
// visible if expansion follows newly created nodes rather than the state
|
||||||
|
// captured at first render.
|
||||||
|
await expect(page.getByText(`Tables ${RUN}`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a tag with an automatically assigned colour', async ({ page }) => {
|
||||||
|
const RUN = suffix();
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Tag' }).click();
|
||||||
|
await page.getByLabel('Name').fill(`vintage-${RUN}`);
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
// Clicking OK only dispatches the request; wait for the confirmation so the
|
||||||
|
// lookup below can't race the create.
|
||||||
|
await expect(page.getByText('Tag added')).toBeVisible();
|
||||||
|
|
||||||
|
// The table paginates and the shared database holds many tags, so the new
|
||||||
|
// row is confirmed through the API rather than hunted for across pages.
|
||||||
|
const tags = await (await page.request.get('/api/admin/tags')).json();
|
||||||
|
const created = tags.find((tag: { name: string }) => tag.name === `vintage-${RUN}`);
|
||||||
|
expect(created).toBeTruthy();
|
||||||
|
expect(created.color).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers category and tag fields on the item form', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||||
|
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
await expect(modal.getByText('Category', { exact: true })).toBeVisible();
|
||||||
|
await expect(modal.getByText('Pick existing tags')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { test, expect, APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
// The storefront shows every item ever seeded, and the e2e database is not
|
||||||
|
// reset between runs. Every fixture below is therefore suffixed with a unique
|
||||||
|
// run id so assertions can name exactly the items this run created.
|
||||||
|
// Playwright runs beforeAll once per worker, so the suffix mixes a timestamp
|
||||||
|
// with randomness — two workers starting in the same millisecond would
|
||||||
|
// otherwise seed colliding category names and 409 against each other.
|
||||||
|
const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
const NAMES = {
|
||||||
|
furniture: `Furniture ${RUN}`,
|
||||||
|
tables: `Tables ${RUN}`,
|
||||||
|
decor: `Decor ${RUN}`,
|
||||||
|
vintage: `vintage-${RUN}`,
|
||||||
|
oak: `oak-${RUN}`,
|
||||||
|
deepItem: `Deep table ${RUN}`,
|
||||||
|
midItem: `Mid chair ${RUN}`,
|
||||||
|
otherItem: `Wall art ${RUN}`,
|
||||||
|
dearItem: `Dear cabinet ${RUN}`
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createCategory(api: APIRequestContext, name: string, parentId: number | null) {
|
||||||
|
const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } });
|
||||||
|
expect(res.status()).toBe(201);
|
||||||
|
return (await res.json()).id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag(api: APIRequestContext, name: string) {
|
||||||
|
const res = await api.post('/api/admin/tags', { data: { name } });
|
||||||
|
expect(res.status()).toBe(201);
|
||||||
|
return (await res.json()).id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem(
|
||||||
|
api: APIRequestContext,
|
||||||
|
name: string,
|
||||||
|
price: string,
|
||||||
|
categoryId: number | null,
|
||||||
|
tags: string[]
|
||||||
|
) {
|
||||||
|
const res = await api.post('/api/admin/items', {
|
||||||
|
multipart: {
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
price,
|
||||||
|
category_id: categoryId === null ? '' : String(categoryId),
|
||||||
|
tags: JSON.stringify(tags)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async ({ playwright }) => {
|
||||||
|
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
|
||||||
|
|
||||||
|
// A worker can be handed tests from this file in more than one batch, which
|
||||||
|
// re-runs beforeAll against the module-cached suffix. Seeding twice would
|
||||||
|
// collide on the category names and duplicate every item, so re-entry is a
|
||||||
|
// no-op once the fixtures are in place.
|
||||||
|
const alreadySeeded = ((await (await api.get('/api/admin/categories')).json()) as { name: string }[])
|
||||||
|
.some((category) => category.name === NAMES.furniture);
|
||||||
|
if (alreadySeeded) {
|
||||||
|
await api.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const furniture = await createCategory(api, NAMES.furniture, null);
|
||||||
|
const tables = await createCategory(api, NAMES.tables, furniture);
|
||||||
|
const decor = await createCategory(api, NAMES.decor, null);
|
||||||
|
await createTag(api, NAMES.vintage);
|
||||||
|
await createTag(api, NAMES.oak);
|
||||||
|
|
||||||
|
// Filed one level below the category the tests select, to prove descendant
|
||||||
|
// matching rather than an exact-node match.
|
||||||
|
await createItem(api, NAMES.deepItem, '340', tables, [NAMES.vintage, NAMES.oak]);
|
||||||
|
await createItem(api, NAMES.midItem, '120', furniture, [NAMES.vintage]);
|
||||||
|
await createItem(api, NAMES.otherItem, '90', decor, [NAMES.vintage, NAMES.oak]);
|
||||||
|
await createItem(api, NAMES.dearItem, '5000', tables, [NAMES.vintage, NAMES.oak]);
|
||||||
|
|
||||||
|
await api.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
function card(page: import('@playwright/test').Page, name: string) {
|
||||||
|
return page.getByRole('heading', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Storefront filters', () => {
|
||||||
|
test('filters by category, including everything filed beneath it', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(card(page, NAMES.otherItem)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
|
||||||
|
|
||||||
|
// Both the item filed directly in Furniture and the one nested under
|
||||||
|
// Furniture > Tables must survive.
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.otherItem)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a nested category is reachable in the drawer', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
|
||||||
|
// The tree loads after the drawer mounts, so anything below the roots is
|
||||||
|
// only reachable if expansion tracks the loaded data rather than the state
|
||||||
|
// at mount time.
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.tables }).click();
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires every selected tag rather than any of them', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: NAMES.vintage }).click();
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: NAMES.oak }).click();
|
||||||
|
// midItem carries only `vintage`, so adding `oak` must drop it.
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeHidden();
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by price range', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Minimum price').fill('200');
|
||||||
|
await page.getByLabel('Maximum price').fill('1000');
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeHidden();
|
||||||
|
await expect(card(page, NAMES.dearItem)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a chip widens the results again', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: `Remove filter ${NAMES.decor}` }).click();
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear all removes every active filter', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
||||||
|
await page.getByRole('button', { name: NAMES.vintage }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
// Scoped to the chip row: the drawer carries a "Clear all" of its own.
|
||||||
|
await page
|
||||||
|
.getByRole('group', { name: 'Active filters' })
|
||||||
|
.getByRole('button', { name: 'Clear all' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.otherItem)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a filtered view survives a reload', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/category=\d+/);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.otherItem)).toBeHidden();
|
||||||
|
await expect(page.getByRole('button', { name: `Remove filter ${NAMES.furniture}` })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an item\'s tags on its card', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
const wallArt = page.locator('.item-card').filter({ hasText: NAMES.otherItem });
|
||||||
|
await expect(wallArt.getByText(NAMES.vintage)).toBeVisible();
|
||||||
|
await expect(wallArt.getByText(NAMES.oak)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user