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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:34:37 -05:00
co-authored by Claude Opus 5
parent 9222e97deb
commit d28fb5634a
13 changed files with 1374 additions and 22 deletions
@@ -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