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