feat(filters): add tag and price dimensions (#188)
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user