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:
2026-08-24 16:02:33 -05:00
parent 70cc3056e7
commit faf38be91a
9 changed files with 308 additions and 79 deletions
+39 -5
View File
@@ -3,7 +3,7 @@ import { parseItemFilters, FilterError, buildItemFilterSql } from '../../src/ite
describe('parseItemFilters', () => {
it('returns empty filters for an empty query', () => {
expect(parseItemFilters({})).toEqual({
categoryId: null,
categoryIds: [],
tagIds: [],
minPriceCents: null,
maxPriceCents: null,
@@ -12,8 +12,30 @@ describe('parseItemFilters', () => {
});
});
it('parses a category id', () => {
expect(parseItemFilters({ category: '7' }).categoryId).toBe(7);
// The parameter stayed singular when it went multi-valued (#139), so every
// link written before that still parses — as a list of one.
it('parses a single category id, as older links still send it', () => {
expect(parseItemFilters({ category: '7' }).categoryIds).toEqual([7]);
});
it('parses a comma-separated category list', () => {
expect(parseItemFilters({ category: '4,9,2' }).categoryIds).toEqual([4, 9, 2]);
});
it('collapses duplicate category ids', () => {
expect(parseItemFilters({ category: '3,3,8' }).categoryIds).toEqual([3, 8]);
});
it('treats an empty category list as no category filter', () => {
expect(parseItemFilters({ category: '' }).categoryIds).toEqual([]);
});
// Decision 9: a malformed filter shows itself rather than quietly returning
// the whole catalogue, and going multi-valued must not weaken that.
it('refuses a category list containing anything unreadable', () => {
expect(() => parseItemFilters({ category: '4,nope' })).toThrow(FilterError);
expect(() => parseItemFilters({ category: '0' })).toThrow(FilterError);
expect(() => parseItemFilters({ category: '-1,2' })).toThrow(FilterError);
});
it('parses a comma-separated tag list', () => {
@@ -158,7 +180,19 @@ describe('buildItemFilterSql', () => {
it('matches a category and all of its descendants', () => {
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1, null);
expect(built.clauses.join(' ')).toContain('RECURSIVE');
expect(built.params).toEqual([4]);
// One array parameter rather than one id: the CTE is seeded with ANY so
// several selected roots are walked in the same recursion.
expect(built.params).toEqual([[4]]);
});
it('seeds the descendant walk with every selected category', () => {
const built = buildItemFilterSql(parseItemFilters({ category: '4,9' }), 1, null);
const sql = built.clauses.join(' ');
expect(sql).toContain('RECURSIVE');
// ANY over the seeds is what makes several categories combine as OR: the
// result is the union of their subtrees.
expect(sql).toContain('= ANY($1::int[])');
expect(built.params).toEqual([[4, 9]]);
});
it('requires every listed tag rather than any of them', () => {
@@ -215,7 +249,7 @@ describe('buildItemFilterSql', () => {
1,
null
);
expect(built.params).toEqual([4, 100, 900]);
expect(built.params).toEqual([[4], 100, 900]);
const sql = built.clauses.join(' ');
expect(sql).toContain('$1');
expect(sql).toContain('$2');