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:
+44
-11
@@ -8,7 +8,14 @@ export type { ItemStatus };
|
||||
export class FilterError extends Error {}
|
||||
|
||||
export interface ItemFilters {
|
||||
categoryId: number | null;
|
||||
// Several categories, combined as OR — a customer picking Furniture and
|
||||
// Decor wants both, not the empty intersection. Deliberately the opposite of
|
||||
// how tagIds combine below, which is AND; the two controls say so in the UI
|
||||
// rather than leaving it to be discovered.
|
||||
//
|
||||
// Each selected id still expands to its descendants, so choosing two parents
|
||||
// means "anything filed under either of them".
|
||||
categoryIds: number[];
|
||||
tagIds: number[];
|
||||
minPriceCents: number | null;
|
||||
maxPriceCents: number | null;
|
||||
@@ -110,12 +117,33 @@ function parsePrice(value: unknown, name: string): number | null {
|
||||
return parseNonNegativeInteger(raw, name);
|
||||
}
|
||||
|
||||
function parseCategoryId(value: unknown): number | null {
|
||||
/**
|
||||
* Comma-separated, the same shape `tags` and `status` already use.
|
||||
*
|
||||
* The parameter keeps its singular name so that every `?category=1` link,
|
||||
* bookmark and shared URL written before this went multi-valued still parses —
|
||||
* as a list of one, needing no alias and leaving no way to give the filter
|
||||
* twice with different meanings.
|
||||
*/
|
||||
function parseCategoryIds(value: unknown): number[] {
|
||||
const raw = singleValue(value, 'category');
|
||||
if (raw === null || raw === '') {
|
||||
return null;
|
||||
const categoryIds: number[] = [];
|
||||
if (!raw) {
|
||||
return categoryIds;
|
||||
}
|
||||
return parseId(raw, 'category');
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
const id = parseId(trimmed, 'category');
|
||||
// Duplicates are harmless to the OR below, but they would show twice in
|
||||
// any caller that renders the parsed filter back.
|
||||
if (!categoryIds.includes(id)) {
|
||||
categoryIds.push(id);
|
||||
}
|
||||
}
|
||||
return categoryIds;
|
||||
}
|
||||
|
||||
function parseTagIds(value: unknown): number[] {
|
||||
@@ -193,7 +221,7 @@ function parseFavoritesOnly(value: unknown): boolean {
|
||||
// deliberate and observable: a query wrong in two ways reports the first
|
||||
// field, so moving these lines around changes which error a caller sees.
|
||||
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
const categoryId = parseCategoryId(query.category);
|
||||
const categoryIds = parseCategoryIds(query.category);
|
||||
const tagIds = parseTagIds(query.tags);
|
||||
|
||||
const minPriceCents = parsePrice(query.min_price, 'min_price');
|
||||
@@ -205,7 +233,7 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
const status = parseStatus(query.status);
|
||||
const favoritesOnly = parseFavoritesOnly(query.favorites);
|
||||
|
||||
return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
|
||||
return { categoryIds, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
|
||||
}
|
||||
|
||||
// Returns WHERE fragments plus their parameters, with placeholders numbered
|
||||
@@ -226,15 +254,20 @@ export function buildItemFilterSql(
|
||||
const params: unknown[] = [];
|
||||
let next = startIndex;
|
||||
|
||||
if (filters.categoryId !== null) {
|
||||
params.push(filters.categoryId);
|
||||
if (filters.categoryIds.length) {
|
||||
params.push(filters.categoryIds);
|
||||
// Selecting a category means "and everything filed beneath it", so walk the
|
||||
// tree down from the chosen node. A recursive CTE keeps the tree
|
||||
// tree down from each chosen node. A recursive CTE keeps the tree
|
||||
// un-denormalized: reparenting stays a single UPDATE with no stored paths
|
||||
// to rewrite.
|
||||
//
|
||||
// Seeded with `= ANY(...)` rather than one id, so every selected root is
|
||||
// walked in the same recursion. That also gives the OR for free: the union
|
||||
// of the subtrees is exactly "filed under any of these", and an item filed
|
||||
// under two selected branches appears once because IN is a set test.
|
||||
clauses.push(`i.category_id IN (
|
||||
WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = $${next}
|
||||
SELECT id FROM categories WHERE id = ANY($${next}::int[])
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user