Feature/188 filter dimensions #189
@@ -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 })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user