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 TreeSelect from 'antd/es/tree-select';
|
||||||
import Empty from 'antd/es/empty';
|
import Empty from 'antd/es/empty';
|
||||||
import Select from 'antd/es/select';
|
import Select from 'antd/es/select';
|
||||||
|
import Switch from 'antd/es/switch';
|
||||||
import Tag from 'antd/es/tag';
|
import Tag from 'antd/es/tag';
|
||||||
import Slider from 'antd/es/slider';
|
import Slider from 'antd/es/slider';
|
||||||
import InputNumber from 'antd/es/input-number';
|
import InputNumber from 'antd/es/input-number';
|
||||||
@@ -8,6 +9,9 @@ import {
|
|||||||
buildCategoryTree,
|
buildCategoryTree,
|
||||||
categoryPath,
|
categoryPath,
|
||||||
formatPriceRange,
|
formatPriceRange,
|
||||||
|
ItemStatus,
|
||||||
|
STATUS_OPTIONS,
|
||||||
|
statusLabel,
|
||||||
toCategoryTreeData
|
toCategoryTreeData
|
||||||
} from '../../filters';
|
} from '../../filters';
|
||||||
import type { FilterDimension } from './dimension';
|
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 { 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, 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';
|
import type { Category, Tag as ItemTag } from '../../src/api';
|
||||||
|
|
||||||
const CATEGORIES: Category[] = [
|
const CATEGORIES: Category[] = [
|
||||||
@@ -105,3 +111,47 @@ describe('priceDimension', () => {
|
|||||||
expect(state.latest?.maxPriceCents).toBeNull();
|
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