feat(filters): make the storefront filter panel searchable and multi-select (#139)
The filter drawer did not scale with the taxonomy behind it. Categories were a bare antd `Tree` rendered at whatever depth it had grown to, with no search and single selection, and tags were a wall of every tag in the system. Neither said what was selected except through highlighting and chip colour. Both are now searchable multi-selects. Categories keep their hierarchy in a `TreeSelect`, matching the admin's `CategoryTreeSelect` so the two screens behave alike; tags become a multiple `Select` whose selected pills keep their colours, which is the only place a tag's colour was load-bearing. Several categories combine as OR. A customer picking Furniture and Decor wants both, not the empty intersection, and each selected id still expands to its descendants, so the answer is the union of the subtrees. That is deliberately the opposite of the tag rule, which stays AND, and both headings now state their rule rather than leaving it to be discovered. `ItemFilters.categoryId` becomes `categoryIds` end to end. The recursive CTE is seeded with `= ANY($n::int[])` rather than one id, which walks every selected root in one recursion and gives the OR for free; matching on `IN` keeps it a set test, so an item under two selected branches still appears once. The query parameter keeps its singular name and becomes comma-separated, the shape `tags` and `status` already use, so every `?category=1` link written before this still parses as a list of one. A list containing anything unreadable is still a 400, per decision 9 — honouring the readable half would answer a narrower question than the one asked and look indistinguishable from a filter that worked. The admin's inventory filter stays single-select, since it asks what is in a category rather than in any of several, but reads and writes a list of at most one so there is one shared filter type rather than two that drift. Closes #139
This commit is contained in:
@@ -65,8 +65,16 @@ export default function InventoryFilters({ categories, tags, filters, onChange,
|
||||
aria-label="Filter by category"
|
||||
style={{ minWidth: 200 }}
|
||||
treeData={treeData}
|
||||
value={filters.categoryId ?? undefined}
|
||||
onChange={(value) => onChange({ ...filters, categoryId: value ?? null })}
|
||||
// The filter shape went multi-valued for the storefront (#139). This
|
||||
// control stays single-select — the admin asks "what is in this
|
||||
// category", not "in any of these" — so it reads and writes a list of
|
||||
// at most one rather than growing a second shape.
|
||||
value={filters.categoryIds[0] ?? undefined}
|
||||
// Annotated nullable because allowClear hands back undefined, which
|
||||
// the control's own onChange type does not admit.
|
||||
onChange={(value: number | undefined) =>
|
||||
onChange({ ...filters, categoryIds: value === undefined ? [] : [value] })
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -28,17 +28,20 @@ export default function ActiveFilterChips({ options, filters, onChange, onClear
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.categoryId !== null) {
|
||||
const path = categoryPath(categories, filters.categoryId);
|
||||
// One chip per selected category, each removable on its own — removing the
|
||||
// whole set at once is what Clear all is for.
|
||||
for (const categoryId of filters.categoryIds) {
|
||||
const path = categoryPath(categories, 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}`;
|
||||
const label = path || `Category ${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.
|
||||
key: `category-${categoryId}`,
|
||||
// The chip shows the full path for context, since two categories can
|
||||
// share a leaf name under different parents.
|
||||
label,
|
||||
onRemove: () => onChange({ ...filters, categoryId: null })
|
||||
onRemove: () =>
|
||||
onChange({ ...filters, categoryIds: filters.categoryIds.filter((id) => id !== categoryId) })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Drawer from 'antd/es/drawer';
|
||||
import Button from 'antd/es/button';
|
||||
import Tree from 'antd/es/tree';
|
||||
import TreeSelect from 'antd/es/tree-select';
|
||||
import Select from 'antd/es/select';
|
||||
import Tag from 'antd/es/tag';
|
||||
import Slider from 'antd/es/slider';
|
||||
import InputNumber from 'antd/es/input-number';
|
||||
@@ -47,19 +48,13 @@ export default function FilterDrawer({
|
||||
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 });
|
||||
function selectCategories(ids: number[]) {
|
||||
onChange({ ...filters, categoryIds: ids });
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
// The selected pills are rendered by the Select, which is handed ids rather
|
||||
// than tags, so the colour has to be looked up rather than carried along.
|
||||
const tagColors = new Map(tags.map((tag) => [tag.id, tag.color]));
|
||||
|
||||
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
|
||||
|
||||
@@ -106,15 +101,27 @@ export default function FilterDrawer({
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
Category
|
||||
Categories — any of these
|
||||
</h4>
|
||||
{categories.length ? (
|
||||
<Tree
|
||||
// A TreeSelect rather than a Tree: it keeps the hierarchy a customer
|
||||
// browses by while adding search and multi-select, and it lists what
|
||||
// is chosen inside the control instead of leaving the selection to be
|
||||
// read off highlighting. The admin's CategoryTreeSelect is the same
|
||||
// control, so the two screens behave alike.
|
||||
<TreeSelect
|
||||
treeData={toTreeData(buildCategoryTree(categories))}
|
||||
selectedKeys={filters.categoryId === null ? [] : [filters.categoryId]}
|
||||
onSelect={selectCategory}
|
||||
defaultExpandAll
|
||||
blockNode
|
||||
value={filters.categoryIds}
|
||||
onChange={selectCategories}
|
||||
multiple
|
||||
showSearch
|
||||
// Search the visible label, not the value, which is a numeric id.
|
||||
treeNodeFilterProp="title"
|
||||
treeDefaultExpandAll
|
||||
allowClear
|
||||
placeholder="Any category"
|
||||
style={{ width: '100%' }}
|
||||
aria-label="Filter by category"
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
|
||||
@@ -123,32 +130,44 @@ export default function FilterDrawer({
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
Tags — must have all
|
||||
Tags — must have all of these
|
||||
</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>
|
||||
// Was a wall of every tag in the system, which read fine at a dozen
|
||||
// and not at a hundred. A searchable multi-select scales with the
|
||||
// taxonomy and, like the category control above it, states its
|
||||
// selection inside the control instead of in chip colouring.
|
||||
//
|
||||
// The colours survive as the selected pills, since that is the only
|
||||
// place a tag's colour was ever load-bearing.
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
placeholder="Any tags"
|
||||
style={{ width: '100%' }}
|
||||
aria-label="Filter by tags"
|
||||
value={filters.tagIds}
|
||||
onChange={(tagIds: number[]) => onChange({ ...filters, tagIds })}
|
||||
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
||||
tagRender={({ value, label, closable, onClose }) => (
|
||||
<Tag
|
||||
color={tagColors.get(Number(value))}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
// antd's default, which the custom renderer replaces: without
|
||||
// it the pill swallows the mousedown and reopens the list.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
style={{ marginInlineEnd: 4 }}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
|
||||
)}
|
||||
|
||||
+19
-5
@@ -3,7 +3,14 @@ import type { Category } from './api';
|
||||
export type ItemStatus = 'pending' | 'available' | 'reserved' | 'sold';
|
||||
|
||||
export interface ItemFilters {
|
||||
categoryId: number | null;
|
||||
// Several categories, combined as OR — picking Furniture and Decor means
|
||||
// either, not the empty intersection. Deliberately the opposite of tagIds
|
||||
// below, which is AND, and both controls label their rule so the difference
|
||||
// is stated rather than discovered.
|
||||
//
|
||||
// The admin's inventory filter is single-select and holds a list of one; the
|
||||
// type is shared, and one shape is better than two that drift.
|
||||
categoryIds: number[];
|
||||
tagIds: number[];
|
||||
minPriceCents: number | null;
|
||||
maxPriceCents: number | null;
|
||||
@@ -69,7 +76,7 @@ export function saleStateFromStatuses(
|
||||
}
|
||||
|
||||
export const EMPTY_FILTERS: ItemFilters = {
|
||||
categoryId: null,
|
||||
categoryIds: [],
|
||||
tagIds: [],
|
||||
minPriceCents: null,
|
||||
maxPriceCents: null,
|
||||
@@ -82,7 +89,9 @@ export const EMPTY_FILTERS: ItemFilters = {
|
||||
// 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));
|
||||
// Comma-separated under the singular name it has always had, so a link
|
||||
// written before this went multi-valued still means what it meant.
|
||||
if (filters.categoryIds.length) params.set('category', filters.categoryIds.join(','));
|
||||
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));
|
||||
@@ -98,6 +107,11 @@ function readInt(raw: string | null): number | null {
|
||||
}
|
||||
|
||||
export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||||
const categories = (params.get('category') || '')
|
||||
.split(',')
|
||||
.map((part) => readInt(part))
|
||||
.filter((id): id is number => id !== null && id > 0);
|
||||
|
||||
const tags = (params.get('tags') || '')
|
||||
.split(',')
|
||||
.map((part) => readInt(part))
|
||||
@@ -127,7 +141,7 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||||
const favorites = params.get('favorites');
|
||||
|
||||
return {
|
||||
categoryId: readInt(params.get('category')),
|
||||
categoryIds: categories,
|
||||
tagIds: tags,
|
||||
minPriceCents: readInt(params.get('min_price')),
|
||||
maxPriceCents: readInt(params.get('max_price')),
|
||||
@@ -145,7 +159,7 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||||
// displays its own position.
|
||||
export function activeFilterCount(filters: ItemFilters): number {
|
||||
let count = 0;
|
||||
if (filters.categoryId !== null) count++;
|
||||
count += filters.categoryIds.length;
|
||||
count += filters.tagIds.length;
|
||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||||
if (filters.favoritesOnly) count++;
|
||||
|
||||
Reference in New Issue
Block a user