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