Feature/categories and tags #24

Merged
bermudalamb merged 3 commits from feature/categories-and-tags into main 2026-08-17 09:49:37 -05:00
13 changed files with 1374 additions and 22 deletions
Showing only changes of commit d28fb5634a - Show all commits
@@ -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
+100 -9
View File
@@ -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<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 { 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 (
<Layout style={{ minHeight: '100vh' }}>
@@ -54,11 +108,38 @@ export default function App() {
</div>
</Header>
<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]}>
{items.map(item => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
<ItemCard item={item} onChanged={load} />
<ItemCard item={item} onChanged={reload} />
</Col>
))}
</Row>
@@ -67,6 +148,16 @@ export default function App() {
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
<Link to="/privacy">Privacy Policy</Link>
</Footer>
<FilterDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
options={options}
filters={filters}
onChange={applyFilters}
onClear={clearFilters}
resultCount={items.length}
/>
</Layout>
);
}
+84 -4
View File
@@ -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<UploadFile[]>([]);
const [description, setDescription] = useState<string>('');
const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<TagRecord[]>([]);
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 || <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: 'Status',
@@ -129,6 +186,27 @@ function Inventory() {
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</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 && (
<Form.Item label="Existing Images (front / back / etc.)">
<Space wrap>
@@ -172,6 +250,8 @@ export default function Admin() {
defaultActiveKey="inventory"
items={[
{ 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: 'settings', label: 'Settings', children: <Settings /> }
]}
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import type { Key } from 'react';
import Tree from 'antd/lib/tree';
import Button from 'antd/lib/button';
import Input from 'antd/lib/input';
import Modal from 'antd/lib/modal';
import Select from 'antd/lib/select';
import Space from 'antd/lib/space';
import Typography from 'antd/lib/typography';
import Empty from 'antd/lib/empty';
import Spin from 'antd/lib/spin';
import message from 'antd/lib/message';
import type { DataNode, TreeProps } from 'antd/es/tree';
import {
Category,
fetchAdminCategories,
createCategory,
updateCategory,
deleteCategory
} from '../api';
import { buildCategoryTree, categoryPath, CategoryNode } from '../filters';
const { Title, Text } = Typography;
// Counts the whole branch, so the delete confirmation can say what a parent
// takes down with it rather than only naming itself.
function branchTotals(node: CategoryNode): { categories: number; items: number } {
return node.children.reduce(
(acc, child) => {
const nested = branchTotals(child);
return { categories: acc.categories + nested.categories, items: acc.items + nested.items };
},
{ categories: 1, items: node.item_count }
);
}
function findNode(nodes: CategoryNode[], id: number): CategoryNode | null {
for (const node of nodes) {
if (node.id === id) return node;
const nested = findNode(node.children, id);
if (nested) return nested;
}
return null;
}
export default function Categories() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Category | null>(null);
const [name, setName] = useState('');
const [parentId, setParentId] = useState<number | null>(null);
// The tree stays mounted across reloads, so `defaultExpandAll` would only
// ever apply to the categories present on first render — a branch added
// afterwards would render collapsed and its children be unreachable.
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
const expansionInitialized = useRef(false);
const tree = buildCategoryTree(categories);
function expand(id: number) {
setExpandedKeys((keys) => (keys.includes(id) ? keys : [...keys, id]));
}
function load() {
setLoading(true);
return fetchAdminCategories()
.then((list) => {
setCategories(list);
// Start fully expanded, then leave expansion to the user — reloading
// must not silently re-open branches they collapsed.
if (!expansionInitialized.current) {
setExpandedKeys(list.map((category) => category.id));
expansionInitialized.current = true;
}
})
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
function openNew(parent: number | null) {
setEditing(null);
setName('');
setParentId(parent);
setModalOpen(true);
}
function openEdit(category: Category) {
setEditing(category);
setName(category.name);
setParentId(category.parent_id);
setModalOpen(true);
}
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
message.error('Name is required');
return;
}
try {
if (editing) {
await updateCategory(editing.id, { name: trimmed, parent_id: parentId });
message.success('Category updated');
} else {
await createCategory(trimmed, parentId);
message.success('Category added');
}
// Reveal where the category just landed instead of filing it out of sight.
if (parentId !== null) expand(parentId);
setModalOpen(false);
load();
} catch (err) {
message.error((err as Error).message);
}
}
function handleDelete(category: Category) {
const node = findNode(tree, category.id);
const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count };
const subcategories = totals.categories - 1;
Modal.confirm({
title: `Delete "${category.name}"?`,
content: (
<span>
This deletes {subcategories === 0 ? 'no subcategories' : `${subcategories} subcategor${subcategories === 1 ? 'y' : 'ies'}`}
{' '}and uncategorizes {totals.items} item{totals.items === 1 ? '' : 's'}. The items themselves are kept.
</span>
),
okText: 'Delete',
okButtonProps: { danger: true },
onOk: async () => {
const result = await deleteCategory(category.id);
message.success(
`Deleted ${result.deleted_categories} categor${result.deleted_categories === 1 ? 'y' : 'ies'}, ` +
`uncategorized ${result.uncategorized_items} item${result.uncategorized_items === 1 ? '' : 's'}`
);
load();
}
});
}
// Dragging a node onto another reparents it. The server rejects a move that
// would put a node beneath its own descendant, so a refused drop just
// reloads the unchanged tree.
const handleDrop: TreeProps['onDrop'] = async (info) => {
const dragId = Number(info.dragNode.key);
const dropId = Number(info.node.key);
const dropToGap = !info.dropToGap ? dropId : findNode(tree, dropId)?.parent_id ?? null;
try {
await updateCategory(dragId, { parent_id: dropToGap });
if (dropToGap !== null) expand(dropToGap);
message.success('Category moved');
} catch (err) {
message.error((err as Error).message);
}
load();
};
function toTreeData(nodes: CategoryNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.id,
title: (
<span className="admin-category-node">
<span>{node.name}</span>
<Text type="secondary" style={{ fontSize: 12 }}>
{node.item_count} item{node.item_count === 1 ? '' : 's'}
</Text>
<Space size={4} onClick={(event) => event.stopPropagation()}>
<Button size="small" type="link" onClick={() => openNew(node.id)}>Add child</Button>
<Button size="small" type="link" onClick={() => openEdit(node)}>Rename</Button>
<Button size="small" type="link" danger onClick={() => handleDelete(node)}>Delete</Button>
</Space>
</span>
),
children: node.children.length ? toTreeData(node.children) : undefined
}));
}
// A category may not be moved beneath itself or its own descendants, so those
// options are withheld from the parent picker too.
const forbidden = new Set<number>();
if (editing) {
const collect = (node: CategoryNode) => {
forbidden.add(node.id);
node.children.forEach(collect);
};
const node = findNode(tree, editing.id);
if (node) collect(node);
}
const parentOptions = [
{ value: null as number | null, label: 'No parent (top level)' },
...categories
.filter((category) => !forbidden.has(category.id))
.map((category) => ({ value: category.id as number | null, label: categoryPath(categories, category.id) }))
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<Title level={4} style={{ margin: 0 }}>Categories</Title>
<Button type="primary" onClick={() => openNew(null)}>Add Category</Button>
</div>
{loading && !categories.length ? <Spin /> : null}
{!loading && !categories.length ? (
<Empty description="No categories yet. Add one to start organizing items." />
) : (
<Tree
treeData={toTreeData(tree)}
draggable
blockNode
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
selectable={false}
onDrop={handleDrop}
/>
)}
<Modal
title={editing ? 'Edit Category' : 'Add Category'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }}>
<label htmlFor="category-name">Name</label>
<Input
id="category-name"
value={name}
onChange={(event) => setName(event.target.value)}
onPressEnter={handleSave}
/>
<label htmlFor="category-parent">Parent</label>
<Select
id="category-parent"
style={{ width: '100%' }}
value={parentId}
onChange={setParentId}
options={parentOptions}
/>
</Space>
</Modal>
</div>
);
}
+153
View File
@@ -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
View File
@@ -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 {
id: number;
name: string;
@@ -5,6 +14,30 @@ export interface Item {
price_cents: number;
images: { id: number; image_path: string; sort_order: number }[];
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 {
@@ -18,8 +51,14 @@ export async function fetchConfig(): Promise<SiteConfig> {
return res.json();
}
export async function fetchItems(): Promise<Item[]> {
const res = await fetch('/api/items');
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
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();
}
@@ -51,3 +90,58 @@ export async function markAvailable(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
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>
);
}
+171
View File
@@ -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>
);
}
+9 -1
View File
@@ -1,5 +1,5 @@
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 type { CarouselRef } from 'antd/es/carousel';
import { Item } from '../api';
@@ -88,7 +88,15 @@ export default function ItemCard({ item, onChanged }: Props) {
const card = (
<Card hoverable cover={cover} className="item-card">
<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} />
{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>
{actionButton}
<AuthPromptModal
+110
View File
@@ -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 '';
}
+42
View File
@@ -73,3 +73,45 @@ body { margin: 0; }
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%;
}
}
+62
View File
@@ -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();
});
});
+195
View File
@@ -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();
});
});