Feature/188 filter dimensions #189

Merged
bermudalamb merged 13 commits from feature/188-filter-dimensions into main 2026-08-25 16:25:22 -05:00
6 changed files with 124 additions and 7 deletions
Showing only changes of commit 3242018782 - Show all commits
+15
View File
@@ -87,6 +87,20 @@ jobs:
run: npm run build run: npm run build
working-directory: frontend working-directory: frontend
# The frontend's build was the only thing this workspace ran, so the unit
# suite #188 added over the filter dimensions was run by nothing but the
# author's terminal. A suite CI never runs decays into a record of what
# the code used to do, and its value is highest exactly here: chips() is
# pure, and the end-to-end run reaches it only through a browser.
#
# Guarded and named in the gate like every suite: a failing test should
# fail the job at the end, not abort it and take the scan with it.
- name: Frontend unit tests
id: frontend_unit
continue-on-error: true
run: npm run test:unit
working-directory: frontend
- name: Check the Sonar tsconfig has not drifted - name: Check the Sonar tsconfig has not drifted
run: node scripts/check-sonar-tsconfig.js run: node scripts/check-sonar-tsconfig.js
@@ -233,6 +247,7 @@ jobs:
- name: Fail if any guarded step failed - name: Fail if any guarded step failed
if: >- if: >-
always() && ( always() && (
steps.frontend_unit.outcome == 'failure' ||
steps.unit.outcome == 'failure' || steps.unit.outcome == 'failure' ||
steps.integration.outcome == 'failure' || steps.integration.outcome == 'failure' ||
steps.backend.outcome == 'failure' || steps.backend.outcome == 'failure' ||
+6 -3
View File
@@ -17,6 +17,7 @@ import { useCatalogue } from './useCatalogue';
import ItemCard from './components/ItemCard'; import ItemCard from './components/ItemCard';
import BrandMark from './components/BrandMark'; import BrandMark from './components/BrandMark';
import FilterBar from './components/filters/FilterBar'; import FilterBar from './components/filters/FilterBar';
import { chipsFor } from './components/filters/dimension';
import { import {
availabilityDimension, availabilityDimension,
categoryDimension, categoryDimension,
@@ -149,8 +150,10 @@ export default function App() {
}, [setSearchParams]); }, [setSearchParams]);
// The parent needs to know whether anything is filtering — for the empty // The parent needs to know whether anything is filtering — for the empty
// state's wording — but has no chip row of its own to count. Computed the // state's wording — but has no chip row of its own to count. Through the same
// same way FilterBar computes its own tally, from the same dimensions. // chipsFor call FilterBar's tally goes through, not a second expression over
// the same dimensions: those agreed only by convention, which is the defect
// #188 exists to remove.
const filterContext = { const filterContext = {
filters, filters,
onChange: applyFilters, onChange: applyFilters,
@@ -158,7 +161,7 @@ export default function App() {
tags: options?.tags ?? [], tags: options?.tags ?? [],
priceRange: options?.priceRange ?? null priceRange: options?.priceRange ?? null
}; };
const filtered = STOREFRONT_DIMENSIONS.some((dimension) => dimension.chips(filterContext).length > 0); const filtered = chipsFor(STOREFRONT_DIMENSIONS, filterContext).length > 0;
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
@@ -5,6 +5,7 @@ import Grid from 'antd/es/grid';
import { FilterOutlined } from '@ant-design/icons'; import { FilterOutlined } from '@ant-design/icons';
import type { Category, Tag as ItemTag } from '../../api'; import type { Category, Tag as ItemTag } from '../../api';
import type { ItemFilters } from '../../filters'; import type { ItemFilters } from '../../filters';
import { chipsFor } from './dimension';
import type { FilterContext, FilterDimension } from './dimension'; import type { FilterContext, FilterDimension } from './dimension';
import FilterChips from './FilterChips'; import FilterChips from './FilterChips';
@@ -60,7 +61,11 @@ export default function FilterBar({
// Derived, not stored, and derived without rendering anything — the drawer // Derived, not stored, and derived without rendering anything — the drawer
// unmounts its sections when closed, so anything that needed them mounted // unmounts its sections when closed, so anything that needed them mounted
// would lose the chips exactly when they matter. // would lose the chips exactly when they matter.
const chips = dimensions.flatMap((dimension) => dimension.chips(context)); //
// Through chipsFor rather than inline: the page asks the same question for its
// empty state, and one shared call is what stops the tally and the chip row
// from being two expressions that only happen to agree.
const chips = chipsFor(dimensions, context);
const barDimensions = dimensions.filter((dimension) => dimension.placement === 'bar'); const barDimensions = dimensions.filter((dimension) => dimension.placement === 'bar');
const drawerDimensions = dimensions.filter((dimension) => dimension.placement === 'drawer'); const drawerDimensions = dimensions.filter((dimension) => dimension.placement === 'drawer');
@@ -49,3 +49,17 @@ export interface FilterDimension {
/** Empty when this dimension is filtering nothing. */ /** Empty when this dimension is filtering nothing. */
chips(ctx: FilterContext): Chip[]; chips(ctx: FilterContext): Chip[];
} }
/**
* Every chip a set of dimensions reports, in the order the screen composed them.
*
* The single derivation both sides share. FilterBar's tally is this list's
* length, and a page's "is anything filtered" — which is what decides whether an
* empty grid reads as "no matches, here is the way out" or as an empty shop — is
* whether it is empty. Those were two separate expressions over two
* identically-built contexts until #188's review, agreeing only by convention.
* That is precisely the tally-versus-chip-row disagreement this design exists to
* make impossible, so there is one call and no second opinion.
*/
export const chipsFor = (dimensions: FilterDimension[], ctx: FilterContext): Chip[] =>
dimensions.flatMap((dimension) => dimension.chips(ctx));
@@ -234,6 +234,15 @@ export const favoritesDimension: FilterDimension = {
: [] : []
}; };
/**
* The statuses themselves, as a multi-select.
*
* Mutually exclusive with availabilityDimension below, which is the preset
* alternative over the same `filters.status` field: a screen composes one or
* the other, never both. Composing both type-checks and renders two controls
* over one field, each fighting the other's writes and each contributing chips,
* so the tally counts the same filter twice.
*/
export const statusDimension: FilterDimension = { export const statusDimension: FilterDimension = {
key: 'status', key: 'status',
placement: 'drawer', placement: 'drawer',
@@ -287,6 +296,11 @@ const SALE_STATE_LABELS: Record<SaleState, string> = {
* makes and is worth having visible without opening anything. Before #188 it * 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 * 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. * say "this belongs in the bar", which is the gap that design closed.
*
* Mutually exclusive with statusDimension above, which is the multi-select
* alternative over the same `filters.status` field: a screen composes one or
* the other, never both, or two controls write one field and it is counted
* twice.
*/ */
export const availabilityDimension: FilterDimension = { export const availabilityDimension: FilterDimension = {
key: 'availability', key: 'availability',
@@ -295,6 +309,12 @@ export const availabilityDimension: FilterDimension = {
render: ({ filters, onChange }) => ( render: ({ filters, onChange }) => (
<Segmented <Segmented
aria-label="Filter by availability" aria-label="Filter by availability"
// The fallback depends on favoritesOnly, and must. Favorites deliberately
// include sold items (see ItemFilters), so with the favorites switch on
// and no explicit status the grid really is showing everything — a
// control reading "Not sold" over sold items on screen would be lying
// about what the customer is looking at. chips() below passes a different
// fallback on purpose; the two are not a copy of each other to unify.
value={saleStateFromStatuses( value={saleStateFromStatuses(
filters.status, filters.status,
STOREFRONT_SALE_STATUSES, STOREFRONT_SALE_STATUSES,
@@ -318,12 +338,38 @@ export const availabilityDimension: FilterDimension = {
), ),
chips: ({ filters, onChange }) => { chips: ({ filters, onChange }) => {
const state = saleStateFromStatuses(filters.status, STOREFRONT_SALE_STATUSES, 'not-sold'); const statuses = filters.status;
if (state === 'not-sold') return []; // No preference is not a filter, whatever the control happens to read.
if (statuses === null) return [];
// 'not-sold' unconditionally, deliberately unlike render above: that
// fallback follows favoritesOnly so the control tells the truth about what
// is on screen, but the customer never *chose* All, and a filter nobody
// chose must not produce a chip or count toward the tally. Unifying the two
// calls would give every signed-in favorites view a phantom All chip and a
// tally of 2.
const state = saleStateFromStatuses(statuses, STOREFRONT_SALE_STATUSES, 'not-sold');
// saleStateFromStatuses reports a list matching no preset as its fallback,
// so asking twice with different fallbacks is what separates a real match
// from a fallback: the answers agree only when the list matched something.
const isPreset = state === saleStateFromStatuses(statuses, STOREFRONT_SALE_STATUSES, 'all');
// Only the actual default earns silence. filtersFromSearchParams accepts
// any non-empty subset of {available, reserved, sold} — seven lists, of
// which three are presets — and before #188 hasActiveFilters reported all
// seven as filtered. Without a chip for the other four, ?status=reserved is
// an empty grid reading "No items yet — check back soon" with no Clear
// filters button, and the only way out is editing the URL by hand.
if (isPreset && state === 'not-sold') return [];
return [ return [
{ {
key: 'availability', key: 'availability',
label: SALE_STATE_LABELS[state], // A known cosmetic wart, and the cheaper half of the trade: for a list
// matching no preset the Segmented still reads "Not sold" while this
// chip is showing, because there is no fourth position to move it to.
// A control one word out beats a dead end with no way back.
label: isPreset ? SALE_STATE_LABELS[state] : statuses.map(statusLabel).join(', '),
onRemove: () => onChange({ ...filters, status: null }) onRemove: () => onChange({ ...filters, status: null })
} }
]; ];
@@ -105,6 +105,14 @@ describe('priceDimension', () => {
expect(priceDimension.chips(contextFor({ maxPriceCents: 5000 }).ctx)).toHaveLength(1); expect(priceDimension.chips(contextFor({ maxPriceCents: 5000 }).ctx)).toHaveLength(1);
}); });
// formatPriceRange's real output, en dash and all. The only chip whose label
// text nothing asserted on, which is how a chip reading "$1000$5000" — cents
// shown as dollars — would have reached a customer unnoticed.
it('labels the chip with the formatted range', () => {
const { ctx } = contextFor({ minPriceCents: 1000, maxPriceCents: 5000 });
expect(priceDimension.chips(ctx)[0]?.label).toBe('$10$50');
});
it('clears both ends when removed', () => { it('clears both ends when removed', () => {
const { ctx, state } = contextFor({ minPriceCents: 1000, maxPriceCents: 5000 }); const { ctx, state } = contextFor({ minPriceCents: 1000, maxPriceCents: 5000 });
priceDimension.chips(ctx)[0]?.onRemove(); priceDimension.chips(ctx)[0]?.onRemove();
@@ -188,4 +196,30 @@ describe('availabilityDimension', () => {
availabilityDimension.chips(ctx)[0]?.onRemove(); availabilityDimension.chips(ctx)[0]?.onRemove();
expect(state.latest?.status).toBeNull(); expect(state.latest?.status).toBeNull();
}); });
// filtersFromSearchParams accepts any non-empty subset of the three public
// statuses — seven lists, of which only three are presets. The other four
// report as the 'not-sold' fallback, so a chip keyed off the preset alone
// leaves ?status=reserved as an empty grid reading "No items yet" with no
// Clear filters button. hasActiveFilters covered all seven before #188; this
// is what replaces it.
it('reports one chip for a status list matching no preset', () => {
const chips = availabilityDimension.chips(contextFor({ status: ['reserved'] }).ctx);
expect(chips.map((c) => c.label)).toEqual(['Reserved']);
// Several statuses read as a list rather than as a preset's name.
expect(
availabilityDimension
.chips(contextFor({ status: ['available', 'sold'] }).ctx)
.map((c) => c.label)
).toEqual(['Available, Sold']);
});
it('clears the filter when a chip for no preset is removed', () => {
const { ctx, state } = contextFor({ status: ['reserved', 'sold'] });
const chips = availabilityDimension.chips(ctx);
expect(chips).toHaveLength(1);
chips[0]?.onRemove();
expect(state.latest?.status).toBeNull();
});
}); });