feat(filters): move the availability preset into a bar dimension (#188)
This commit is contained in:
@@ -5,13 +5,17 @@ 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';
|
||||||
|
import Segmented from 'antd/es/segmented';
|
||||||
import {
|
import {
|
||||||
buildCategoryTree,
|
buildCategoryTree,
|
||||||
categoryPath,
|
categoryPath,
|
||||||
formatPriceRange,
|
formatPriceRange,
|
||||||
ItemStatus,
|
ItemStatus,
|
||||||
|
SaleState,
|
||||||
|
saleStateFromStatuses,
|
||||||
STATUS_OPTIONS,
|
STATUS_OPTIONS,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
|
STOREFRONT_SALE_STATUSES,
|
||||||
toCategoryTreeData
|
toCategoryTreeData
|
||||||
} from '../../filters';
|
} from '../../filters';
|
||||||
import type { FilterDimension } from './dimension';
|
import type { FilterDimension } from './dimension';
|
||||||
@@ -269,3 +273,59 @@ export const statusDimension: FilterDimension = {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SALE_STATE_LABELS: Record<SaleState, string> = {
|
||||||
|
'not-sold': 'Not sold',
|
||||||
|
sold: 'Sold',
|
||||||
|
all: 'All'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The storefront's three-way availability preset.
|
||||||
|
*
|
||||||
|
* A bar dimension rather than a drawer one: it is the coarsest cut a customer
|
||||||
|
* makes and is worth having visible without opening anything. Before #188 it
|
||||||
|
* was hand-written markup in App.tsx because the shared component had no way to
|
||||||
|
* say "this belongs in the bar", which is the gap that design closed.
|
||||||
|
*/
|
||||||
|
export const availabilityDimension: FilterDimension = {
|
||||||
|
key: 'availability',
|
||||||
|
placement: 'bar',
|
||||||
|
|
||||||
|
render: ({ filters, onChange }) => (
|
||||||
|
<Segmented
|
||||||
|
aria-label="Filter by availability"
|
||||||
|
value={saleStateFromStatuses(
|
||||||
|
filters.status,
|
||||||
|
STOREFRONT_SALE_STATUSES,
|
||||||
|
filters.favoritesOnly ? 'all' : 'not-sold'
|
||||||
|
)}
|
||||||
|
onChange={(value) => {
|
||||||
|
const state = value as SaleState;
|
||||||
|
// The default is stored as "no preference" rather than as an explicit
|
||||||
|
// list, which keeps it out of the URL and out of the chip row.
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
status: state === 'not-sold' ? null : STOREFRONT_SALE_STATUSES[state]
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ label: SALE_STATE_LABELS['not-sold'], value: 'not-sold' },
|
||||||
|
{ label: SALE_STATE_LABELS.sold, value: 'sold' },
|
||||||
|
{ label: SALE_STATE_LABELS.all, value: 'all' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
|
||||||
|
chips: ({ filters, onChange }) => {
|
||||||
|
const state = saleStateFromStatuses(filters.status, STOREFRONT_SALE_STATUSES, 'not-sold');
|
||||||
|
if (state === 'not-sold') return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'availability',
|
||||||
|
label: SALE_STATE_LABELS[state],
|
||||||
|
onRemove: () => onChange({ ...filters, status: null })
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 {
|
import {
|
||||||
|
availabilityDimension,
|
||||||
categoryDimension,
|
categoryDimension,
|
||||||
favoritesDimension,
|
favoritesDimension,
|
||||||
priceDimension,
|
priceDimension,
|
||||||
@@ -155,3 +156,36 @@ describe('statusDimension', () => {
|
|||||||
expect(state.latest?.status).toBeNull();
|
expect(state.latest?.status).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('availabilityDimension', () => {
|
||||||
|
it('renders in the bar rather than the drawer', () => {
|
||||||
|
expect(availabilityDimension.placement).toBe('bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not sold is the default. A filter nobody chose must not read as one, or it
|
||||||
|
// shows in the tally and in the chip row on a page nobody has filtered.
|
||||||
|
it('reports no chip at the default', () => {
|
||||||
|
expect(availabilityDimension.chips(contextFor({ status: null }).ctx)).toEqual([]);
|
||||||
|
expect(
|
||||||
|
availabilityDimension.chips(contextFor({ status: ['available', 'reserved'] }).ctx)
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The behaviour change in #188. This is what lets chips.length serve as both
|
||||||
|
// the tally and the "is anything filtered" predicate the empty state needs.
|
||||||
|
it('reports a chip for Sold and for All', () => {
|
||||||
|
expect(availabilityDimension.chips(contextFor({ status: ['sold'] }).ctx).map((c) => c.label))
|
||||||
|
.toEqual(['Sold']);
|
||||||
|
expect(
|
||||||
|
availabilityDimension
|
||||||
|
.chips(contextFor({ status: ['available', 'reserved', 'sold'] }).ctx)
|
||||||
|
.map((c) => c.label)
|
||||||
|
).toEqual(['All']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to the default when removed', () => {
|
||||||
|
const { ctx, state } = contextFor({ status: ['sold'] });
|
||||||
|
availabilityDimension.chips(ctx)[0]?.onRemove();
|
||||||
|
expect(state.latest?.status).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user