feat(filters): add favorites and status dimensions (#188)

This commit is contained in:
2026-08-25 15:12:35 -05:00
parent 60b76cae82
commit eb5799dd17
2 changed files with 130 additions and 1 deletions
@@ -1,6 +1,7 @@
import TreeSelect from 'antd/es/tree-select';
import Empty from 'antd/es/empty';
import Select from 'antd/es/select';
import Switch from 'antd/es/switch';
import Tag from 'antd/es/tag';
import Slider from 'antd/es/slider';
import InputNumber from 'antd/es/input-number';
@@ -8,6 +9,9 @@ import {
buildCategoryTree,
categoryPath,
formatPriceRange,
ItemStatus,
STATUS_OPTIONS,
statusLabel,
toCategoryTreeData
} from '../../filters';
import type { FilterDimension } from './dimension';
@@ -190,3 +194,78 @@ export const priceDimension: FilterDimension = {
}
]
};
export const favoritesDimension: FilterDimension = {
key: 'favorites',
placement: 'drawer',
heading: 'Favorites',
// Shown to signed-out visitors too: switching it on prompts them to sign in,
// which is how they learn favorites exist. The prompt itself is not this
// dimension's business — useCatalogue reports needsFavoritesAuth and the page
// owns the modal.
render: ({ filters, onChange }) => (
// Deliberately not wrapped in a <label>: antd renders the switch as a
// button, which is labelable, so a wrapping label can forward a click the
// switch already handled and toggle it twice.
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Switch
checked={filters.favoritesOnly}
onChange={(checked) => onChange({ ...filters, favoritesOnly: checked })}
aria-label="Only my favorites"
/>
<span>Only my favorites</span>
</div>
),
chips: ({ filters, onChange }) =>
filters.favoritesOnly
? [
{
key: 'favorites',
label: 'My favorites',
onRemove: () => onChange({ ...filters, favoritesOnly: false })
}
]
: []
};
export const statusDimension: FilterDimension = {
key: 'status',
placement: 'drawer',
heading: 'Status — any of these',
// The status dimension itself rather than presets over it, which #105's Sold
// / Not sold / All control was. Presets could not express Published or
// Unpublished and could not isolate Reserved. Selecting statuses answers all
// of them: Unpublished is Pending, Published is the other three, and Not sold
// is everything except Sold. See #132.
render: ({ filters, onChange }) => (
<Select
allowClear
mode="multiple"
showSearch
optionFilterProp="label"
placeholder="Any status"
aria-label="Filter by status"
style={{ width: '100%' }}
value={filters.status ?? []}
onChange={(value: ItemStatus[]) =>
onChange({ ...filters, status: value.length ? value : null })
}
options={STATUS_OPTIONS}
/>
),
chips: ({ filters, onChange }) =>
(filters.status ?? []).map((status) => ({
key: `status-${status}`,
label: statusLabel(status),
onRemove: () => {
const rest = (filters.status ?? []).filter((value) => value !== status);
// Null rather than an empty list: emptying it means "no status filter",
// where an empty list would mean "no statuses" and show nothing.
onChange({ ...filters, status: rest.length ? rest : null });
}
}))
};
+51 -1
View File
@@ -1,7 +1,13 @@
import { describe, it, expect } from 'vitest';
import { EMPTY_FILTERS, ItemFilters } from '../../src/filters';
import type { FilterContext } from '../../src/components/filters/dimension';
import { categoryDimension, priceDimension, tagDimension } from '../../src/components/filters/standardDimensions';
import {
categoryDimension,
favoritesDimension,
priceDimension,
statusDimension,
tagDimension
} from '../../src/components/filters/standardDimensions';
import type { Category, Tag as ItemTag } from '../../src/api';
const CATEGORIES: Category[] = [
@@ -105,3 +111,47 @@ describe('priceDimension', () => {
expect(state.latest?.maxPriceCents).toBeNull();
});
});
describe('favoritesDimension', () => {
it('reports no chip when off', () => {
expect(favoritesDimension.chips(contextFor({}).ctx)).toEqual([]);
});
it('reports one chip when on', () => {
const { ctx } = contextFor({ favoritesOnly: true });
expect(favoritesDimension.chips(ctx).map((chip) => chip.label)).toEqual(['My favorites']);
});
it('switches off when removed', () => {
const { ctx, state } = contextFor({ favoritesOnly: true });
favoritesDimension.chips(ctx)[0]?.onRemove();
expect(state.latest?.favoritesOnly).toBe(false);
});
});
describe('statusDimension', () => {
it('reports no chips when no status is filtered', () => {
expect(statusDimension.chips(contextFor({ status: null }).ctx)).toEqual([]);
});
// The behaviour change in #188: one chip each, so the tally reads 3 rather
// than 1, consistent with how categories and tags already count.
it('reports one chip per status, labelled for a human', () => {
const { ctx } = contextFor({ status: ['pending', 'sold'] });
expect(statusDimension.chips(ctx).map((chip) => chip.label)).toEqual(['Pending', 'Sold']);
});
it('removes one status and keeps the rest', () => {
const { ctx, state } = contextFor({ status: ['pending', 'sold'] });
statusDimension.chips(ctx)[0]?.onRemove();
expect(state.latest?.status).toEqual(['sold']);
});
// Emptying the control means "no status filter", not "no statuses" — the
// latter would empty the table.
it('returns to no filter when the last status is removed', () => {
const { ctx, state } = contextFor({ status: ['sold'] });
statusDimension.chips(ctx)[0]?.onRemove();
expect(state.latest?.status).toBeNull();
});
});