From 1d7f928c48e988e106a15f94e2eb6be322325ec4 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:18:23 -0500 Subject: [PATCH] feat(filters): move the availability preset into a bar dimension (#188) --- .../components/filters/standardDimensions.tsx | 60 +++++++++++++++++++ frontend/tests/unit/filterDimensions.test.ts | 34 +++++++++++ 2 files changed, 94 insertions(+) diff --git a/frontend/src/components/filters/standardDimensions.tsx b/frontend/src/components/filters/standardDimensions.tsx index d3d7372..2668f28 100644 --- a/frontend/src/components/filters/standardDimensions.tsx +++ b/frontend/src/components/filters/standardDimensions.tsx @@ -5,13 +5,17 @@ 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'; +import Segmented from 'antd/es/segmented'; import { buildCategoryTree, categoryPath, formatPriceRange, ItemStatus, + SaleState, + saleStateFromStatuses, STATUS_OPTIONS, statusLabel, + STOREFRONT_SALE_STATUSES, toCategoryTreeData } from '../../filters'; import type { FilterDimension } from './dimension'; @@ -269,3 +273,59 @@ export const statusDimension: FilterDimension = { } })) }; + +const SALE_STATE_LABELS: Record = { + '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 }) => ( + { + 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 }) + } + ]; + } +}; diff --git a/frontend/tests/unit/filterDimensions.test.ts b/frontend/tests/unit/filterDimensions.test.ts index f55482e..fe9fd7a 100644 --- a/frontend/tests/unit/filterDimensions.test.ts +++ b/frontend/tests/unit/filterDimensions.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { EMPTY_FILTERS, ItemFilters } from '../../src/filters'; import type { FilterContext } from '../../src/components/filters/dimension'; import { + availabilityDimension, categoryDimension, favoritesDimension, priceDimension, @@ -155,3 +156,36 @@ describe('statusDimension', () => { 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(); + }); +});