test(filters): add a unit runner and the filter dimension contract (#188)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}))
|
||||
};
|
||||
Reference in New Issue
Block a user