Feature/188 filter dimensions #189

Merged
bermudalamb merged 13 commits from feature/188-filter-dimensions into main 2026-08-25 16:25:22 -05:00
2 changed files with 192 additions and 2 deletions
Showing only changes of commit abd3dd4e2e - Show all commits
@@ -1,6 +1,15 @@
import TreeSelect from 'antd/es/tree-select'; import TreeSelect from 'antd/es/tree-select';
import Empty from 'antd/es/empty'; 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'; 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 })
}
]
};
+51 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { EMPTY_FILTERS, ItemFilters } from '../../src/filters'; import { EMPTY_FILTERS, ItemFilters } from '../../src/filters';
import type { FilterContext } from '../../src/components/filters/dimension'; import type { FilterContext } from '../../src/components/filters/dimension';
import { categoryDimension } from '../../src/components/filters/standardDimensions'; import { categoryDimension, priceDimension, tagDimension } from '../../src/components/filters/standardDimensions';
import type { Category, Tag as ItemTag } from '../../src/api'; import type { Category, Tag as ItemTag } from '../../src/api';
const CATEGORIES: Category[] = [ const CATEGORIES: Category[] = [
@@ -55,3 +55,53 @@ describe('categoryDimension', () => {
expect(state.latest?.categoryIds).toEqual([3]); expect(state.latest?.categoryIds).toEqual([3]);
}); });
}); });
describe('tagDimension', () => {
it('reports no chips when nothing is selected', () => {
const { ctx } = contextFor({});
expect(tagDimension.chips(ctx)).toEqual([]);
});
// #185: a tag looks like itself wherever it appears.
it('reports one chip per tag, carrying that tag colour', () => {
const { ctx } = contextFor({ tagIds: [10, 11] });
expect(tagDimension.chips(ctx).map((chip) => [chip.label, chip.color])).toEqual([
['vintage', 'red'],
['oak', 'lime']
]);
});
it('falls back to the id, uncoloured, for a tag not loaded yet', () => {
const { ctx } = contextFor({ tagIds: [99] });
const [chip] = tagDimension.chips(ctx);
expect(chip?.label).toBe('Tag 99');
expect(chip?.color).toBeUndefined();
});
it('removes only the chip that was closed', () => {
const { ctx, state } = contextFor({ tagIds: [10, 11] });
tagDimension.chips(ctx)[0]?.onRemove();
expect(state.latest?.tagIds).toEqual([11]);
});
});
describe('priceDimension', () => {
it('reports no chip when neither end is set', () => {
const { ctx } = contextFor({});
expect(priceDimension.chips(ctx)).toEqual([]);
});
// One chip for the range rather than one per end: they are one filter, and
// removing half of it is not a thing anyone means.
it('reports a single chip when either end is set', () => {
expect(priceDimension.chips(contextFor({ minPriceCents: 1000 }).ctx)).toHaveLength(1);
expect(priceDimension.chips(contextFor({ maxPriceCents: 5000 }).ctx)).toHaveLength(1);
});
it('clears both ends when removed', () => {
const { ctx, state } = contextFor({ minPriceCents: 1000, maxPriceCents: 5000 });
priceDimension.chips(ctx)[0]?.onRemove();
expect(state.latest?.minPriceCents).toBeNull();
expect(state.latest?.maxPriceCents).toBeNull();
});
});