test(filters): add a unit runner and the filter dimension contract (#188)

This commit is contained in:
2026-08-25 14:58:21 -05:00
parent e695d91670
commit 5d0b66a982
6 changed files with 577 additions and 2 deletions
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import type { Category, Tag as ItemTag } from '../../api';
import type { ItemFilters } from '../../filters';
/**
* One removable filter, as shown beside the Filters button.
*
* Produced by a dimension rather than by a component, so the row can be built
* without the drawer being open — see FilterDimension.chips.
*/
export interface Chip {
key: string;
label: string;
/** Tags carry their own colour (#185). Nothing else has one. */
color?: string;
onRemove: () => void;
}
/** Everything a dimension is allowed to know. */
export interface FilterContext {
filters: ItemFilters;
onChange: (next: ItemFilters) => void;
categories: Category[];
tags: ItemTag[];
/** Null on a screen with no catalogue-wide range to bound a slider with. */
priceRange: { min_cents: number; max_cents: number } | null;
}
/**
* One filter, as a screen declares it.
*
* Plain data rather than a component or a context provider, and deliberately:
* the drawer sets `destroyOnHidden`, so its sections are unmounted whenever it
* is closed — which is exactly when the chip row matters most. Anything that
* registered itself on mount would lose every drawer chip the moment the drawer
* closed. Nothing here depends on being rendered.
*
* A screen can define its own and FilterBar treats it identically: it appears
* in the chip row and counts toward the tally without FilterBar knowing what it
* filters on.
*/
export interface FilterDimension {
key: string;
/** `bar` renders inline and always visible; `drawer` renders as a section. */
placement: 'bar' | 'drawer';
/** Drawer sections carry a heading. Bar controls render bare. */
heading?: string;
render(ctx: FilterContext): ReactNode;
/** Empty when this dimension is filtering nothing. */
chips(ctx: FilterContext): Chip[];
}
@@ -0,0 +1,52 @@
import TreeSelect from 'antd/es/tree-select';
import Empty from 'antd/es/empty';
import { buildCategoryTree, categoryPath, toCategoryTreeData } from '../../filters';
import type { FilterDimension } from './dimension';
/**
* The dimensions every screen picks from.
*
* Each owns its control and its chips together, so adding a filter is one
* object rather than an edit in three files — which is what the flags on the
* old FilterDrawer had become.
*/
export const categoryDimension: FilterDimension = {
key: 'category',
placement: 'drawer',
// The rule is in the heading because it is the opposite of the tag rule
// directly below it, and a customer should not have to discover that.
heading: 'Categories — any of these',
render: ({ categories, filters, onChange }) =>
categories.length ? (
<TreeSelect
treeData={toCategoryTreeData(buildCategoryTree(categories))}
value={filters.categoryIds}
onChange={(categoryIds: number[]) => onChange({ ...filters, categoryIds })}
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" />
),
chips: ({ categories, filters, onChange }) =>
filters.categoryIds.map((categoryId) => ({
key: `category-${categoryId}`,
// The full path, since two categories can share a leaf name under
// different parents. Falls back to the id while /api/filters is loading.
label: categoryPath(categories, categoryId) || `Category ${categoryId}`,
onRemove: () =>
onChange({
...filters,
categoryIds: filters.categoryIds.filter((id) => id !== categoryId)
})
}))
};