feat(filters): add tag and price dimensions (#188)

This commit is contained in:
2026-08-25 15:06:15 -05:00
parent 5d0b66a982
commit abd3dd4e2e
2 changed files with 192 additions and 2 deletions
@@ -1,6 +1,15 @@
import TreeSelect from 'antd/es/tree-select';
import Empty from 'antd/es/empty';
import { buildCategoryTree, categoryPath, toCategoryTreeData } from '../../filters';
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';
import {
buildCategoryTree,
categoryPath,
formatPriceRange,
toCategoryTreeData
} from '../../filters';
import type { FilterDimension } from './dimension';
/**
@@ -50,3 +59,134 @@ export const categoryDimension: FilterDimension = {
})
}))
};
export const tagDimension: FilterDimension = {
key: 'tag',
placement: 'drawer',
// AND, deliberately the opposite of the category rule above.
heading: 'Tags — must have all of these',
render: ({ tags, filters, onChange }) => {
// The Select is handed ids, so a colour has to be looked up rather than
// carried along with the value.
const colours = new Map(tags.map((tag) => [tag.id, tag.color]));
return tags.length ? (
<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={colours.get(Number(value))}
closable={closable}
onClose={onClose}
// antd's default, which this 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" />
);
},
chips: ({ tags, filters, onChange }) =>
filters.tagIds.map((tagId) => {
const tag = tags.find((candidate) => candidate.id === tagId);
return {
key: `tag-${tagId}`,
label: tag?.name ?? `Tag ${tagId}`,
// Undefined while /api/filters is loading, which the label fallback
// already covers — an uncoloured chip beats a missing one.
color: tag?.color,
onRemove: () =>
onChange({ ...filters, tagIds: filters.tagIds.filter((id) => id !== tagId) })
};
})
};
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
const dollarsToCents = (dollars: number | null): number | null =>
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
export const priceDimension: FilterDimension = {
key: 'price',
placement: 'drawer',
heading: 'Price',
render: ({ priceRange, filters, onChange }) => {
const bounds = priceRange ?? { min_cents: 0, max_cents: 0 };
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
return (
<>
{/* Only where there are real bounds. Invented ones would misreport
where the prices actually are. */}
{priceRange && (
<Slider
range
min={bounds.min_cents}
max={sliderMax}
step={100}
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
onChange={([min, max]) =>
// antd types the value as number[], so destructuring gives
// `number | undefined`. A range slider always emits both ends;
// the fallbacks are the bounds rather than nulls, which would
// read as "no filter" and widen the results.
onChange({
...filters,
minPriceCents: min ?? bounds.min_cents,
maxPriceCents: max ?? sliderMax
})
}
/>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
<InputNumber
aria-label="Minimum price"
prefix="$"
min={0}
style={{ width: '100%' }}
value={centsToDollars(filters.minPriceCents)}
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
/>
<span style={{ opacity: 0.6 }}>to</span>
<InputNumber
aria-label="Maximum price"
prefix="$"
min={0}
style={{ width: '100%' }}
value={centsToDollars(filters.maxPriceCents)}
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
/>
</div>
</>
);
},
chips: ({ filters, onChange }) =>
filters.minPriceCents === null && filters.maxPriceCents === null
? []
: [
{
key: 'price',
label: formatPriceRange(filters.minPriceCents, filters.maxPriceCents),
onRemove: () => onChange({ ...filters, minPriceCents: null, maxPriceCents: null })
}
]
};