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
+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>
);
}