From 20a38b66bd944c545bd2eef6998df12a8ae2e03b Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 14:36:51 -0500 Subject: [PATCH 01/13] docs(design): filter dimensions, one composable component both screens extend (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #169 made the filter drawer shared, which was the right first move and not the finish. Per-screen differences are booleans, the bar around the drawer was never shared at all, and the storefront's availability preset sits outside the system because the shared component cannot express "this belongs in the bar, not the drawer". The design replaces the flags with composition: a screen contributes a list of filter dimensions, each declaring where it renders, how to render it, and what chips it contributes. A screen-specific control becomes an ordinary dimension, appearing in the chip row and counting toward the tally without the shared code knowing what it is. Dimensions are plain data rather than components or context, for a concrete reason rather than a stylistic one. The drawer sets destroyOnHidden, so its sections are unmounted whenever it is closed — exactly when the chip row matters most. Anything that registers on mount would lose those chips the moment the drawer closed, which rules out the otherwise-idiomatic context-and-children approach. The tally becomes the number of chips, so the count and the chip row cannot disagree — today they are computed by two routes and agree by coincidence, which the admin already has to correct by hand. Three visible behaviours change as a result, recorded in the spec rather than left to be discovered. Adding vitest is in scope. The design's value rests on chips() being pure, and the frontend has no unit runner at all, so without one the core of it would ship covered only indirectly and expensively through Playwright. The spec also records what is deliberately untouched: the filter state, the URL serialisation, the backend, and the two e2e assertions already failing on #186 — which must not be read as regressions from this work. Refs #188 --- .../2026-08-25-filter-dimensions-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-filter-dimensions-design.md diff --git a/docs/superpowers/specs/2026-08-25-filter-dimensions-design.md b/docs/superpowers/specs/2026-08-25-filter-dimensions-design.md new file mode 100644 index 0000000..54fca6f --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-filter-dimensions-design.md @@ -0,0 +1,167 @@ +# Filter Dimensions — Design + +**Issue:** [#188 — Make filtering one composable component both screens extend](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/188) +**Date:** 2026-08-25 +**Status:** Approved + +## Goal + +Filtering becomes one component that both the storefront and the admin inventory extend, where a screen contributes filter *dimensions* rather than the component carrying a flag per screen. + +## Where this starts from + +#169 made `FilterDrawer` and `ActiveFilterChips` shared. Three things it did not do: + +- **Per-screen differences are booleans.** `showFavorites`, `showStatus`, and `priceRange: null` standing in for "no slider here". A third screen means a third flag, and a screen-specific control means the shared component learning about that screen. +- **The bar was never shared.** The `Filters (N)` button, the drawer's open state and the tally are written twice — in `InventoryFilters.tsx` and inline in `App.tsx`. They have already diverged: the admin bolts `+ (filters.status === null ? 0 : 1)` onto the count by hand, because status is in its drawer and not in the storefront's. +- **One filter is outside the system.** The storefront's `Not sold / Sold / All` preset lives in the always-visible bar, and the shared component cannot express that placement. + +## Decisions + +Settled in conversation before this was written. + +| Question | Decision | +| --- | --- | +| What drives the change | Screens must be able to inject controls the shared component knows nothing about | +| What an injected control declares | A full filter dimension — render, chips, placement — so it behaves exactly like a built-in one | +| Placement | A dimension declares `bar` or `drawer`, which absorbs the availability preset | +| How a dimension is expressed | Plain data, not components or context | +| The tally | The number of chips | +| Test runner | Add vitest, scoped to the dimensions | + +## Architecture + +### The contract + +```ts +interface Chip { + key: string; + label: string; + /** Tags carry their own colour (#185); nothing else has one. */ + color?: string; + onRemove: () => void; +} + +interface FilterContext { + filters: ItemFilters; + onChange: (next: ItemFilters) => void; + categories: Category[]; + tags: ItemTag[]; + /** Null where a screen has no catalogue-wide range to bound a slider with. */ + priceRange: { min_cents: number; max_cents: number } | null; +} + +interface FilterDimension { + key: string; + placement: 'bar' | 'drawer'; + /** Drawer sections carry a heading; bar controls render bare. */ + heading?: string; + render(ctx: FilterContext): ReactNode; + /** Empty when this dimension is not filtering anything. */ + chips(ctx: FilterContext): Chip[]; +} +``` + +### Why plain data + +`chips()` must be callable without anything being rendered. `FilterDrawer` sets `destroyOnHidden`, so its sections are unmounted whenever the drawer is closed — which is exactly when the chip row matters most. A design where sections register themselves on mount would lose every drawer chip the moment the drawer closed. + +That rules out the otherwise-idiomatic React answer of context plus self-registering children, and it rules out components carrying static metadata, since reading their chips would mean rendering them. Plain data has no mount order, no lifecycle, and no dependency on the drawer being open. + +It also makes the interesting logic pure functions, which is what makes the test story below worth anything. + +### Files + +The new files go under `src/components/filters/` rather than a top-level `src/filters/`. A `src/filters/` directory beside the existing `src/filters.ts` would leave `import … from './filters'` resolving by bundler convention rather than by intent, which is not a thing to leave to convention. `filters.ts` keeps its current path and its current job. + +| File | Change | +| --- | --- | +| `src/components/filters/dimension.ts` | New. The three types above. No JSX. | +| `src/components/filters/standardDimensions.tsx` | New. `categories`, `tags`, `price`, `favorites`, `status`, `availability`. `.tsx`, since `render` returns JSX. | +| `src/components/filters/FilterBar.tsx` | New. Bar dimensions, the `Filters (N)` button, the chip row, the drawer. | +| `src/components/FilterDrawer.tsx` | Absorbed into `FilterBar` as its drawer shell, then deleted. | +| `src/components/ActiveFilterChips.tsx` | Becomes `src/components/filters/FilterChips.tsx`: takes `Chip[]` and `onClear`, knows nothing about filters. | +| `src/admin/InventoryFilters.tsx` | Reduces to composing four dimensions. | +| `src/App.tsx` | Loses the `Segmented`, the button, `drawerOpen` and `activeCount`. | +| `src/filters.ts` | `activeFilterCount` and `hasActiveFilters` deleted. | + +### Composition at each screen + +```tsx +// Storefront + + +// Admin inventory + +``` + +Order in the array is render order. `availability` is the only `bar` dimension today. + +`FilterBar` also takes what the context needs — `filters`, `onChange`, `onClear`, `categories`, `tags`, `priceRange` — plus `resultCount`, which the drawer footer reads for its `Show N items` button. + +The `favorites` dimension only sets `favoritesOnly`. Prompting a signed-out visitor to sign in stays where it is: `useCatalogue` reports `needsFavoritesAuth` and the page owns the modal. A dimension does not need to know a session exists. + +## Data flow + +`FilterBar` owns exactly one piece of state: whether the drawer is open. Everything else is derived. + +1. The page owns `ItemFilters` and keeps the URL as its source of truth. Unchanged. +2. `FilterBar` builds one `FilterContext` and passes it to every dimension. +3. Bar dimensions render inline; drawer dimensions render as sections inside the drawer. +4. Chips come from `dimensions.flatMap(d => d.chips(ctx))`. +5. The tally is `chips.length`. +6. A dimension's `onChange` replaces the whole `ItemFilters`, exactly as the controls do today. + +Dimensions never own filter state, never read the URL, and never fetch. Given the same context, a dimension renders the same thing and reports the same chips. + +## Behaviour changes + +Three, all consequences of the tally being the chips, and all intended. + +**Admin with three statuses shows `Filters (3)`, not `Filters (1)`.** Consistent with categories and tags, which already count per selection. + +**Choosing `Sold` or `All` on the storefront produces a removable chip.** Today that row shows nothing for availability. The chip makes the "way out" visible where the filter was set, rather than only on an empty grid. + +**`hasActiveFilters` is deleted.** Its job was deciding whether an empty grid reads as "No items match these filters" with a way out, or "No items yet — check back soon". That becomes `chips.length > 0`. This is why the availability chip is required rather than optional: without it, a storefront filtered to `Sold` with no results would report itself as an empty shop. + +`Not sold` remains the default and produces no chip, so it neither counts nor appears — a filter nobody chose should not read as one. + +## Testing + +### Unit — vitest, new + +The frontend has no unit runner. Adding one is in scope, kept minimal: vitest, jsdom only if a test needs it, and no component-rendering library. The target is `chips()` and the dimension helpers, which are pure and awkward to reach end-to-end. + +Cases worth having: + +- Each dimension reports no chips when its slice of `ItemFilters` is empty. +- Categories and tags report one chip per selection; status reports one per status. +- `availability` reports nothing at `Not sold` and a chip at `Sold` and at `All`. +- A tag chip carries the tag's colour; a tag missing from the loaded options still produces a chip, uncoloured. +- A chip's `onRemove` produces the expected `ItemFilters`, and removing one of several leaves the rest. + +### End-to-end — existing, extended + +`filters.spec.ts`, `admin-inventory-filters.spec.ts` and `favorites-filter.spec.ts` already cover this UI through page objects and should keep passing with changes only where the three behaviour changes above are visible. Two assertions to add: + +- The admin tally reads `Filters (3)` with three statuses selected. +- Choosing `Sold` on the storefront shows a removable chip that clears back to the default. + +### Known interference + +`filters.spec.ts` and `favorites-filter.spec.ts` each contain one assertion against the *unfiltered* grid that currently fails on any branch, because the development database has grown past what the unpaginated storefront can render inside a 5 second timeout. That is [#186](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/186) and is not caused by this work. Those two are expected to fail locally until #186 is resolved, and their failure must not be read as a regression here. + +## Error handling + +There is no new failure mode. Dimensions are pure and synchronous; they do no I/O. + +The one degraded state is data that has not arrived: `categories` and `tags` are empty and `priceRange` is null while `/api/filters` is in flight. Every dimension already handles this — the category and tag controls render antd's `Empty`, the price slider is omitted without bounds, and a chip for a tag missing from the options falls back to `Tag {id}` uncoloured rather than rendering blank. That behaviour is preserved rather than redesigned. + +A dimension that throws while rendering is a programming error and is caught by the catalogue's existing error boundary, as the current controls are. + +## Not in scope + +- `ItemFilters`, the URL serialisation and the parsing in `filters.ts`. +- Anything in the backend. +- `Categories.tsx`'s own tree adapter, which builds a different shape for a real antd `Tree` and is deliberately separate (#182). +- Pagination, and the two e2e assertions blocked on it (#186). From e695d91670e18d44801c8fff416eb1c1f17d3070 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 14:46:06 -0500 Subject: [PATCH 02/13] docs(plan): implementation plan for filter dimensions (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine tasks, each ending in something independently testable, in an order where every task leaves both screens working. The dimensions are built and unit-tested first, the shell after them, and the two screens are wired last — so the old components stay in place until the thing replacing them is proven. The two behaviour changes the design accepted are each covered twice: a unit test on the chips that produce them, and an end-to-end assertion on what a person sees. The admin tally reading three for three statuses, and a non-default availability producing a removable chip. Task 8 carries the deletions, deliberately last. Removing `activeFilterCount` and `hasActiveFilters` turns every remaining caller into a compile error, which is the cheapest way to find them. Two defects found reviewing the plan against the code rather than against itself: the test fixture omitted `Category.item_count` and would not have compiled, and Task 9 added a page object method that nothing used — `chooseAvailability` and `filterChip` already exist. The plan also records what must not be read as a regression. Two assertions in the storefront specs fail on every branch because the unpaginated grid cannot render 1,600+ development rows inside Playwright's default timeout, which is #186 and predates this work. Refs #188 --- .../plans/2026-08-25-filter-dimensions.md | 1293 +++++++++++++++++ 1 file changed, 1293 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-25-filter-dimensions.md diff --git a/docs/superpowers/plans/2026-08-25-filter-dimensions.md b/docs/superpowers/plans/2026-08-25-filter-dimensions.md new file mode 100644 index 0000000..eda28b2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-filter-dimensions.md @@ -0,0 +1,1293 @@ +# Filter Dimensions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the storefront and admin filter UIs with one `FilterBar` that both screens extend by composing filter *dimensions*. + +**Architecture:** A dimension is plain data — `{ key, placement, heading?, render(ctx), chips(ctx) }`. `FilterBar` renders `bar` dimensions inline and `drawer` dimensions as drawer sections, derives the chip row from `dimensions.flatMap(d => d.chips(ctx))`, and shows that array's length as the `Filters (N)` tally. `chips()` is pure and never mounts anything, because the drawer sets `destroyOnHidden` and the chip row must survive the drawer being closed. + +**Tech Stack:** React 18, TypeScript (strict, `noUncheckedIndexedAccess`), antd 5, Vite, Playwright. Vitest is added by Task 1. + +**Spec:** `docs/superpowers/specs/2026-08-25-filter-dimensions-design.md` +**Issue:** [#188](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/188) + +## Global Constraints + +- **antd imports are deep and use `es`:** `import Select from 'antd/es/select'`. Never `import { Select } from 'antd'`. +- **`noUncheckedIndexedAccess` is on.** Indexing an array yields `T | undefined`. Prefer `.map`, `.entries()` and destructuring over `arr[i]`. +- **No hard wrapping** in markdown, commit bodies or comments — one paragraph, one line. +- **Comments explain why, not what.** Match the density of the file being edited; this codebase comments decisions, not mechanics. +- **Commits** use conventional prefixes and reference the issue: `refactor(filters): … (#188)`. +- **Do not touch** `ItemFilters`, the URL serialisation, `filtersToSearchParams`, `filtersFromSearchParams`, or anything in `backend/`. +- **Vitest tests import their globals explicitly** — `import { describe, it, expect } from 'vitest'` — because `tsconfig.test.json` sets `"types": ["node"]` and must not grow a second entry. +- Working directory for every command is `frontend/` unless stated otherwise. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `src/components/filters/dimension.ts` | The `Chip`, `FilterContext`, `FilterDimension` types. No JSX, no imports from antd. | +| `src/components/filters/standardDimensions.tsx` | The six dimensions every screen picks from. | +| `src/components/filters/FilterChips.tsx` | Renders a `Chip[]` plus Clear all. Knows nothing about filters. | +| `src/components/filters/FilterBar.tsx` | Bar dimensions, the button, the chip row, the drawer. The only stateful piece. | +| `tests/unit/filterDimensions.test.ts` | Chip logic for all six dimensions. | +| `vitest.config.ts` | Test runner config, separate from `vite.config.ts`. | + +Deleted at the end: `src/components/FilterDrawer.tsx`, `src/components/ActiveFilterChips.tsx`, and `activeFilterCount` / `hasActiveFilters` from `src/filters.ts`. + +--- + +### Task 1: Test runner and the dimension contract + +**Files:** +- Create: `frontend/vitest.config.ts` +- Create: `frontend/src/components/filters/dimension.ts` +- Create: `frontend/src/components/filters/standardDimensions.tsx` +- Create: `frontend/tests/unit/filterDimensions.test.ts` +- Modify: `frontend/package.json` (scripts, devDependencies) + +**Interfaces:** +- Consumes: `ItemFilters`, `EMPTY_FILTERS`, `categoryPath` from `src/filters.ts`; `Category`, `Tag` from `src/api.ts`. +- Produces: `Chip`, `FilterContext`, `FilterDimension` types; `categoryDimension: FilterDimension`. + +- [ ] **Step 1: Install vitest** + +```bash +npm install --save-dev vitest@^2.1.0 +``` + +- [ ] **Step 2: Add the config** + +Create `frontend/vitest.config.ts`. Separate from `vite.config.ts` because that file exports an async factory for the coverage plugin, and merging a `test` key into it would mean typing around the promise for no benefit. + +```ts +import { defineConfig } from 'vitest/config'; + +// Unit tests only. The Playwright suite lives in tests/e2e and is run by +// `npm run test:e2e` — including it here would start a browser per run. +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts'], + environment: 'node' + } +}); +``` + +- [ ] **Step 3: Add the script** + +In `frontend/package.json`, add to `scripts`, after `"lint"`: + +```json + "test:unit": "vitest run", +``` + +- [ ] **Step 4: Write the contract** + +Create `frontend/src/components/filters/dimension.ts`: + +```ts +import type { ReactNode } from 'react'; +import type { Category, Tag as ItemTag } from '../../api'; +import type { ItemFilters } from '../../filters'; + +/** + * One removable filter, as shown beside the Filters button. + * + * Produced by a dimension rather than by a component, so the row can be built + * without the drawer being open — see FilterDimension.chips. + */ +export interface Chip { + key: string; + label: string; + /** Tags carry their own colour (#185). Nothing else has one. */ + color?: string; + onRemove: () => void; +} + +/** Everything a dimension is allowed to know. */ +export interface FilterContext { + filters: ItemFilters; + onChange: (next: ItemFilters) => void; + categories: Category[]; + tags: ItemTag[]; + /** Null on a screen with no catalogue-wide range to bound a slider with. */ + priceRange: { min_cents: number; max_cents: number } | null; +} + +/** + * One filter, as a screen declares it. + * + * Plain data rather than a component or a context provider, and deliberately: + * the drawer sets `destroyOnHidden`, so its sections are unmounted whenever it + * is closed — which is exactly when the chip row matters most. Anything that + * registered itself on mount would lose every drawer chip the moment the drawer + * closed. Nothing here depends on being rendered. + * + * A screen can define its own and FilterBar treats it identically: it appears + * in the chip row and counts toward the tally without FilterBar knowing what it + * filters on. + */ +export interface FilterDimension { + key: string; + /** `bar` renders inline and always visible; `drawer` renders as a section. */ + placement: 'bar' | 'drawer'; + /** Drawer sections carry a heading. Bar controls render bare. */ + heading?: string; + render(ctx: FilterContext): ReactNode; + /** Empty when this dimension is filtering nothing. */ + chips(ctx: FilterContext): Chip[]; +} +``` + +- [ ] **Step 5: Write the failing test** + +Create `frontend/tests/unit/filterDimensions.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { EMPTY_FILTERS, ItemFilters } from '../../src/filters'; +import type { FilterContext } from '../../src/components/filters/dimension'; +import { categoryDimension } from '../../src/components/filters/standardDimensions'; +import type { Category, Tag as ItemTag } from '../../src/api'; + +const CATEGORIES: Category[] = [ + { id: 1, name: 'Furniture', parent_id: null, sort_order: 0, item_count: 0 }, + { id: 2, name: 'Tables', parent_id: 1, sort_order: 0, item_count: 0 }, + { id: 3, name: 'Decor', parent_id: null, sort_order: 0, item_count: 0 } +]; + +const TAGS: ItemTag[] = [ + { id: 10, name: 'vintage', color: 'red', item_count: 1 }, + { id: 11, name: 'oak', color: 'lime', item_count: 1 } +]; + +/** The last filters a dimension's onRemove produced, for asserting on. */ +function contextFor(filters: Partial) { + const state: { latest: ItemFilters | null } = { latest: null }; + const ctx: FilterContext = { + filters: { ...EMPTY_FILTERS, ...filters }, + onChange: (next) => { state.latest = next; }, + categories: CATEGORIES, + tags: TAGS, + priceRange: { min_cents: 0, max_cents: 100000 } + }; + return { ctx, state }; +} + +describe('categoryDimension', () => { + it('reports no chips when nothing is selected', () => { + const { ctx } = contextFor({}); + expect(categoryDimension.chips(ctx)).toEqual([]); + }); + + it('reports one chip per selected category, labelled with its full path', () => { + const { ctx } = contextFor({ categoryIds: [2, 3] }); + expect(categoryDimension.chips(ctx).map((chip) => chip.label)).toEqual([ + 'Furniture / Tables', + 'Decor' + ]); + }); + + // The row renders before /api/filters resolves, and a chip with no label + // would be an empty box. + it('falls back to the id when the category is not loaded yet', () => { + const { ctx } = contextFor({ categoryIds: [99] }); + expect(categoryDimension.chips(ctx)[0]?.label).toBe('Category 99'); + }); + + it('removes only the chip that was closed', () => { + const { ctx, state } = contextFor({ categoryIds: [2, 3] }); + categoryDimension.chips(ctx)[0]?.onRemove(); + expect(state.latest?.categoryIds).toEqual([3]); + }); +}); +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `npm run test:unit` +Expected: FAIL — `standardDimensions` has no export `categoryDimension`. + +- [ ] **Step 7: Implement the dimension** + +Create `frontend/src/components/filters/standardDimensions.tsx`: + +```tsx +import TreeSelect from 'antd/es/tree-select'; +import Empty from 'antd/es/empty'; +import { buildCategoryTree, categoryPath, toCategoryTreeData } from '../../filters'; +import type { FilterDimension } from './dimension'; + +/** + * The dimensions every screen picks from. + * + * Each owns its control and its chips together, so adding a filter is one + * object rather than an edit in three files — which is what the flags on the + * old FilterDrawer had become. + */ +export const categoryDimension: FilterDimension = { + key: 'category', + placement: 'drawer', + // The rule is in the heading because it is the opposite of the tag rule + // directly below it, and a customer should not have to discover that. + heading: 'Categories — any of these', + + render: ({ categories, filters, onChange }) => + categories.length ? ( + onChange({ ...filters, categoryIds })} + multiple + showSearch + // Search the visible label, not the value, which is a numeric id. + treeNodeFilterProp="title" + treeDefaultExpandAll + allowClear + placeholder="Any category" + style={{ width: '100%' }} + aria-label="Filter by category" + /> + ) : ( + + ), + + chips: ({ categories, filters, onChange }) => + filters.categoryIds.map((categoryId) => ({ + key: `category-${categoryId}`, + // The full path, since two categories can share a leaf name under + // different parents. Falls back to the id while /api/filters is loading. + label: categoryPath(categories, categoryId) || `Category ${categoryId}`, + onRemove: () => + onChange({ + ...filters, + categoryIds: filters.categoryIds.filter((id) => id !== categoryId) + }) + })) +}; +``` + +- [ ] **Step 8: Run the tests** + +Run: `npm run test:unit` +Expected: PASS, 4 tests. + +- [ ] **Step 9: Type-check and lint** + +Run: `npx tsc --noEmit && npx tsc -p tsconfig.test.json --noEmit && npm run lint` +Expected: no errors. Two pre-existing `sonarjs/no-alphabetical-sort` warnings in `src/filters.ts` are expected and not yours. + +- [ ] **Step 10: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/vitest.config.ts frontend/src/components/filters frontend/tests/unit +git commit -m "test(filters): add a unit runner and the filter dimension contract (#188)" +``` + +--- + +### Task 2: Tag and price dimensions + +**Files:** +- Modify: `frontend/src/components/filters/standardDimensions.tsx` +- Modify: `frontend/tests/unit/filterDimensions.test.ts` + +**Interfaces:** +- Consumes: `FilterDimension`, `FilterContext` from Task 1. +- Produces: `tagDimension: FilterDimension`, `priceDimension: FilterDimension`. + +- [ ] **Step 1: Write the failing tests** + +Append to `frontend/tests/unit/filterDimensions.test.ts`: + +```ts +describe('tagDimension', () => { + it('reports no chips when nothing is selected', () => { + const { ctx } = contextFor({}); + expect(tagDimension.chips(ctx)).toEqual([]); + }); + + // #185: a tag looks like itself wherever it appears. + it('reports one chip per tag, carrying that tag colour', () => { + const { ctx } = contextFor({ tagIds: [10, 11] }); + expect(tagDimension.chips(ctx).map((chip) => [chip.label, chip.color])).toEqual([ + ['vintage', 'red'], + ['oak', 'lime'] + ]); + }); + + it('falls back to the id, uncoloured, for a tag not loaded yet', () => { + const { ctx } = contextFor({ tagIds: [99] }); + const [chip] = tagDimension.chips(ctx); + expect(chip?.label).toBe('Tag 99'); + expect(chip?.color).toBeUndefined(); + }); + + it('removes only the chip that was closed', () => { + const { ctx, state } = contextFor({ tagIds: [10, 11] }); + tagDimension.chips(ctx)[0]?.onRemove(); + expect(state.latest?.tagIds).toEqual([11]); + }); +}); + +describe('priceDimension', () => { + it('reports no chip when neither end is set', () => { + const { ctx } = contextFor({}); + expect(priceDimension.chips(ctx)).toEqual([]); + }); + + // One chip for the range rather than one per end: they are one filter, and + // removing half of it is not a thing anyone means. + it('reports a single chip when either end is set', () => { + expect(priceDimension.chips(contextFor({ minPriceCents: 1000 }).ctx)).toHaveLength(1); + expect(priceDimension.chips(contextFor({ maxPriceCents: 5000 }).ctx)).toHaveLength(1); + }); + + it('clears both ends when removed', () => { + const { ctx, state } = contextFor({ minPriceCents: 1000, maxPriceCents: 5000 }); + priceDimension.chips(ctx)[0]?.onRemove(); + expect(state.latest?.minPriceCents).toBeNull(); + expect(state.latest?.maxPriceCents).toBeNull(); + }); +}); +``` + +Extend the import at the top of the file to `import { categoryDimension, priceDimension, tagDimension } from '../../src/components/filters/standardDimensions';` + +- [ ] **Step 2: Run to verify failure** + +Run: `npm run test:unit` +Expected: FAIL — no exports `tagDimension`, `priceDimension`. + +- [ ] **Step 3: Implement `tagDimension`** + +Append to `standardDimensions.tsx`. Add `import Select from 'antd/es/select';`, `import Tag from 'antd/es/tag';` and extend the `../../filters` import with `formatPriceRange`. + +```tsx +export const tagDimension: FilterDimension = { + key: 'tag', + placement: 'drawer', + // AND, deliberately the opposite of the category rule above. + heading: 'Tags — must have all of these', + + render: ({ tags, filters, onChange }) => { + // The Select is handed ids, so a colour has to be looked up rather than + // carried along with the value. + const colours = new Map(tags.map((tag) => [tag.id, tag.color])); + return tags.length ? ( + + 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 }); + } + })) +}; +``` + +- [ ] **Step 4: Run the tests** + +Run: `npm run test:unit` +Expected: PASS, 19 tests. + +- [ ] **Step 5: Type-check, lint and commit** + +```bash +npx tsc --noEmit && npx tsc -p tsconfig.test.json --noEmit && npm run lint +git add frontend/src/components/filters/standardDimensions.tsx frontend/tests/unit/filterDimensions.test.ts +git commit -m "feat(filters): add favorites and status dimensions (#188)" +``` + +--- + +### Task 4: The availability dimension + +**Files:** +- Modify: `frontend/src/components/filters/standardDimensions.tsx` +- Modify: `frontend/tests/unit/filterDimensions.test.ts` + +**Interfaces:** +- Produces: `availabilityDimension: FilterDimension` — the only `placement: 'bar'` dimension. + +This is the second behaviour change, and the one the empty-state message depends on: `Sold` and `All` must produce a chip, or a storefront filtered to `Sold` with no results would report itself as an empty shop. + +- [ ] **Step 1: Write the failing tests** + +Append to the test file, extending the import with `availabilityDimension`: + +```ts +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(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npm run test:unit` +Expected: FAIL — no export `availabilityDimension`. + +- [ ] **Step 3: Implement it** + +Append to `standardDimensions.tsx`. Add `import Segmented from 'antd/es/segmented';` and extend the `../../filters` import with `SaleState`, `STOREFRONT_SALE_STATUSES` and `saleStateFromStatuses`. + +```tsx +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 }) + } + ]; + } +}; +``` + +- [ ] **Step 4: Run the tests** + +Run: `npm run test:unit` +Expected: PASS, 23 tests. + +- [ ] **Step 5: Type-check, lint and commit** + +```bash +npx tsc --noEmit && npx tsc -p tsconfig.test.json --noEmit && npm run lint +git add frontend/src/components/filters/standardDimensions.tsx frontend/tests/unit/filterDimensions.test.ts +git commit -m "feat(filters): move the availability preset into a bar dimension (#188)" +``` + +--- + +### Task 5: FilterChips + +**Files:** +- Create: `frontend/src/components/filters/FilterChips.tsx` + +**Interfaces:** +- Consumes: `Chip` from Task 1. +- Produces: `FilterChips`, default export, props `{ chips: Chip[]; onClear: () => void }`. + +A straight extraction of the rendering half of `ActiveFilterChips`, with every filter-shaped decision removed. It is not deleted from the old file yet — Task 8 does that, so the two screens keep working until then. + +- [ ] **Step 1: Write it** + +```tsx +import Tag from 'antd/es/tag'; +import Button from 'antd/es/button'; +import type { Chip } from './dimension'; + +type Props = Readonly<{ + chips: Chip[]; + onClear: () => void; +}>; + +/** + * The removable filter row. + * + * Knows nothing about filters — it is handed chips and renders them. Every + * decision about what a chip says, what colour it is and what removing it does + * belongs to the dimension that produced it. + */ +export default function FilterChips({ chips, onClear }: Props) { + if (!chips.length) return null; + + return ( + // Named as a group so this row's Clear all stays distinguishable from the + // identically-labelled one in the drawer's footer. +
+ {chips.map((chip) => ( + { + event.preventDefault(); + chip.onRemove(); + }} + // antd renders the close control as an icon with no text, so name it + // for screen readers and for anything driving the page by role. + closeIcon={ + × + } + > + {chip.label} + + ))} + +
+ ); +} +``` + +- [ ] **Step 2: Type-check, lint and commit** + +```bash +npx tsc --noEmit && npm run lint +git add frontend/src/components/filters/FilterChips.tsx +git commit -m "refactor(filters): extract the chip row from ActiveFilterChips (#188)" +``` + +--- + +### Task 6: FilterBar + +**Files:** +- Create: `frontend/src/components/filters/FilterBar.tsx` + +**Interfaces:** +- Consumes: `FilterDimension`, `FilterContext`, `Chip` from Task 1; `FilterChips` from Task 5. +- Produces: `FilterBar`, default export, props `{ dimensions: FilterDimension[]; filters: ItemFilters; onChange: (next: ItemFilters) => void; onClear: () => void; categories: Category[]; tags: ItemTag[]; priceRange: { min_cents: number; max_cents: number } | null; resultCount: number }`. + +- [ ] **Step 1: Write it** + +```tsx +import { useMemo, useState } from 'react'; +import Drawer from 'antd/es/drawer'; +import Button from 'antd/es/button'; +import Grid from 'antd/es/grid'; +import { FilterOutlined } from '@ant-design/icons'; +import type { Category, Tag as ItemTag } from '../../api'; +import type { ItemFilters } from '../../filters'; +import type { FilterContext, FilterDimension } from './dimension'; +import FilterChips from './FilterChips'; + +type Props = Readonly<{ + /** Render order. A screen composes the filters it offers. */ + dimensions: FilterDimension[]; + filters: ItemFilters; + onChange: (next: ItemFilters) => void; + onClear: () => void; + categories: Category[]; + tags: ItemTag[]; + priceRange: { min_cents: number; max_cents: number } | null; + resultCount: number; +}>; + +const sectionHeading: React.CSSProperties = { + margin: '0 0 8px', + fontSize: 12, + letterSpacing: '.06em', + textTransform: 'uppercase', + opacity: 0.65 +}; + +/** + * Filtering, for any screen that does it. + * + * The screen says which dimensions it offers; this owns everything around them + * — the always-visible controls, the Filters button and its tally, the chip + * row, and the drawer. Before #188 the drawer was shared and this was written + * twice, which is how the admin's tally drifted from the storefront's. + * + * The tally is the number of chips rather than a second count of the same + * thing, so the button and the chip row cannot disagree. + */ +export default function FilterBar({ + dimensions, + filters, + onChange, + onClear, + categories, + tags, + priceRange, + resultCount +}: Props) { + const [drawerOpen, setDrawerOpen] = useState(false); + const screens = Grid.useBreakpoint(); + + const context: FilterContext = useMemo( + () => ({ filters, onChange, categories, tags, priceRange }), + [filters, onChange, categories, tags, priceRange] + ); + + // Derived, not stored, and derived without rendering anything — the drawer + // unmounts its sections when closed, so anything that needed them mounted + // would lose the chips exactly when they matter. + const chips = dimensions.flatMap((dimension) => dimension.chips(context)); + + const barDimensions = dimensions.filter((dimension) => dimension.placement === 'bar'); + const drawerDimensions = dimensions.filter((dimension) => dimension.placement === 'drawer'); + + return ( + <> + {barDimensions.map((dimension) => ( +
{dimension.render(context)}
+ ))} + + + + + + setDrawerOpen(false)} + // Unmounting on close keeps a single copy of controls like "Clear all" + // in the document at any time. + destroyOnHidden + width={screens.md ? 380 : '90%'} + footer={ +
+ + +
+ } + > + {drawerDimensions.map((dimension, index) => ( +
+ {dimension.heading &&

{dimension.heading}

} + {dimension.render(context)} +
+ ))} +
+ + ); +} +``` + +- [ ] **Step 2: Type-check, lint and commit** + +```bash +npx tsc --noEmit && npm run lint +git add frontend/src/components/filters/FilterBar.tsx +git commit -m "feat(filters): add FilterBar, one component both screens compose (#188)" +``` + +--- + +### Task 7: Wire the admin inventory screen + +**Files:** +- Modify: `frontend/src/admin/InventoryFilters.tsx` (replace the whole file) + +**Interfaces:** +- Consumes: `FilterBar` from Task 6; the four dimensions from Tasks 1–3. +- Produces: unchanged props, so `Admin.tsx` needs no edit. + +- [ ] **Step 1: Replace the file** + +```tsx +import type { Category, Tag } from '../api'; +import { ItemFilters } from '../filters'; +import FilterBar from '../components/filters/FilterBar'; +import { + categoryDimension, + priceDimension, + statusDimension, + tagDimension +} from '../components/filters/standardDimensions'; + +type Props = Readonly<{ + categories: Category[]; + tags: Tag[]; + filters: ItemFilters; + onChange: (filters: ItemFilters) => void; + onClear: () => void; + resultCount: number; +}>; + +// Status, and no favorites: pending is excluded from every public read, so +// Published and Unpublished are distinctions only the admin can draw, and +// favoriting is a customer's idea. +const DIMENSIONS = [categoryDimension, tagDimension, priceDimension, statusDimension]; + +export default function InventoryFilters({ + categories, + tags, + filters, + onChange, + onClear, + resultCount +}: Props) { + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 2: Type-check and lint** + +Run: `npx tsc --noEmit && npm run lint` +Expected: no errors. + +- [ ] **Step 3: Verify the admin screen end-to-end** + +Start a backend on port 3001 and point the dev proxy at it: + +```bash +# from the repository root +sed -i 's|http://localhost:3000|http://localhost:3001|g' frontend/vite.config.ts +docker start redefined-designs-local-db +cd backend && PGHOST=localhost PGPORT=55500 PGUSER=redefined_local PGPASSWORD=redefined_local \ + PGDATABASE=redefined_local DEMO_MODE=true UPLOADS_DIR=./uploads PORT=3001 \ + npx tsx src/server.ts & +``` + +Run: `cd frontend && npx playwright test admin-inventory-filters.spec.ts --project=chromium --workers=2` +Expected: 7 passed. The `combines filters, and clearing restores them` case asserts the tally reads exactly `Filters`, which still holds because it clears everything first. + +- [ ] **Step 4: Restore the proxy and commit** + +```bash +git checkout -- frontend/vite.config.ts +git add frontend/src/admin/InventoryFilters.tsx +git commit -m "refactor(admin): compose the inventory filters from dimensions (#188)" +``` + +--- + +### Task 8: Wire the storefront and delete what it replaces + +**Files:** +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/filters.ts` (delete two functions) +- Delete: `frontend/src/components/FilterDrawer.tsx`, `frontend/src/components/ActiveFilterChips.tsx` + +**Interfaces:** +- Consumes: `FilterBar`, all five storefront dimensions. +- Produces: nothing new. This is the task that removes the old API. + +- [ ] **Step 1: Replace the storefront's filter region** + +In `App.tsx`, delete the `Segmented` block, the `Filters` button and the `ActiveFilterChips` element — everything from `` of `ActiveFilterChips` — and put in their place: + +```tsx + +``` + +Add above the component: + +```tsx +// Availability first and always visible, because it is the coarsest cut and +// worth seeing without opening anything. Favorites next, so someone who came +// for their favorites does not scroll past the catalogue controls. +const STOREFRONT_DIMENSIONS = [ + availabilityDimension, + favoritesDimension, + categoryDimension, + tagDimension, + priceDimension +]; +``` + +- [ ] **Step 2: Fix the imports** + +Remove from `App.tsx`: `Segmented`, `FilterOutlined` if now unused, `FilterDrawer`, `ActiveFilterChips`, `activeFilterCount`, `hasActiveFilters`, `SaleState`, `STOREFRONT_SALE_STATUSES`, `saleStateFromStatuses`. Remove the `drawerOpen` state and the `activeCount` const, and the `` element near the bottom of the file. + +Add: + +```tsx +import FilterBar from './components/filters/FilterBar'; +import { + availabilityDimension, + categoryDimension, + favoritesDimension, + priceDimension, + tagDimension +} from './components/filters/standardDimensions'; +``` + +- [ ] **Step 3: Replace the empty-state predicate** + +In `App.tsx`, the `Catalogue` component uses `hasActiveFilters(filters)` to choose between "No items match these filters" and "No items yet". Change its `filters` prop to a boolean the parent already knows. + +Change the `Catalogue` props type entry `filters: ItemFilters;` to `filtered: boolean;`, replace `const filtered = hasActiveFilters(filters);` with nothing and use the prop directly, and at the call site pass `filtered={...}`. The parent computes it the same way `FilterBar` does — but the parent has no chips, so compute it from the dimensions: + +```tsx + const filterContext = { filters, onChange: applyFilters, categories: options?.categories ?? [], tags: options?.tags ?? [], priceRange: options?.priceRange ?? null }; + const filtered = STOREFRONT_DIMENSIONS.some((dimension) => dimension.chips(filterContext).length > 0); +``` + +- [ ] **Step 4: Delete the replaced files and helpers** + +```bash +git rm frontend/src/components/FilterDrawer.tsx frontend/src/components/ActiveFilterChips.tsx +``` + +In `frontend/src/filters.ts`, delete `activeFilterCount` and `hasActiveFilters` entirely, including the comment block above `hasActiveFilters`. + +- [ ] **Step 5: Type-check, lint, unit** + +Run: `npx tsc --noEmit && npx tsc -p tsconfig.test.json --noEmit && npm run lint && npm run test:unit` +Expected: no errors, 23 unit tests pass. Any remaining reference to the deleted helpers is a compile error and names itself. + +- [ ] **Step 6: Verify the storefront end-to-end** + +With the backend and proxy set up as in Task 7 Step 3: + +Run: `npx playwright test filters.spec.ts favorites-filter.spec.ts --project=chromium --workers=2` + +Expected: all pass **except** `removing a chip widens the results again` and `shows a removable chip that restores the full catalogue`, which fail on every branch because of [#186](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/186) — the unpaginated storefront cannot render 1,600+ items inside a 5 second timeout. Confirm those two fail identically on `main` before treating either as a regression. + +- [ ] **Step 7: Restore the proxy and commit** + +```bash +git checkout -- frontend/vite.config.ts +git add -A +git commit -m "refactor(filters): compose the storefront from dimensions and delete the old components (#188)" +``` + +--- + +### Task 9: End-to-end coverage for the two behaviour changes + +**Files:** +- Modify: `frontend/tests/e2e/admin-inventory-filters.spec.ts` +- Modify: `frontend/tests/e2e/filters.spec.ts` + +**Interfaces:** +- Consumes: the page objects as they stand. `AdminInventory.filtersButton`, `StorefrontPage.chooseAvailability`, `StorefrontPage.filterChip` and `StorefrontPage.removeFilterChip` all already exist; this task adds no page object members. + +- [ ] **Step 1: Add the admin tally assertion** + +Append to `admin-inventory-filters.spec.ts`, inside the existing `describe`: + +```ts + // #188: the tally is the number of chips, so three statuses count as three. + // It read Filters (1) before, because status was added to the count by hand + // as a single flag regardless of how many were selected. + test('counts each selected status in the tally', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.toggleStatus('Available'); + await adminInventory.toggleStatus('Reserved'); + await adminInventory.toggleStatus('Sold'); + + await expect(adminInventory.filtersButton).toHaveText('Filters (3)'); + }); +``` + +- [ ] **Step 2: Add the availability chip assertion** + +No page object change is needed: `chooseAvailability` and `filterChip` both already exist on `StorefrontPage`. Append to `filters.spec.ts`, inside the existing `describe`: + +```ts + // #188: choosing anything but the default now shows a removable chip. This is + // what lets one predicate serve both the tally and the empty-state message — + // without it, a storefront filtered to Sold with no results would report + // itself as an empty shop rather than as a filter that matched nothing. + test('shows a removable chip for a non-default availability', async ({ storefront }) => { + await storefront.goto(); + await expect(storefront.filterChip('Sold')).toHaveCount(0); + + await storefront.chooseAvailability('Sold'); + await expect(storefront.filterChip('Sold')).toBeVisible(); + + await storefront.removeFilterChip('Sold').click(); + await expect(storefront.filterChip('Sold')).toHaveCount(0); + }); +``` + +- [ ] **Step 3: Run both specs** + +With the backend and proxy set up as in Task 7 Step 3: + +Run: `npx playwright test admin-inventory-filters.spec.ts filters.spec.ts --project=chromium --workers=2` +Expected: all pass except the one #186 case in `filters.spec.ts`. + +- [ ] **Step 4: Restore the proxy and commit** + +```bash +git checkout -- frontend/vite.config.ts +git add frontend/tests/e2e +git commit -m "test(filters): cover the tally and availability chip behaviour changes (#188)" +``` + +--- + +## Done when + +- `npm run test:unit` passes in `frontend/` with 23 tests. +- `npx tsc --noEmit`, `npx tsc -p tsconfig.test.json --noEmit` and `npm run lint` are clean in both workspaces. +- `FilterDrawer.tsx`, `ActiveFilterChips.tsx`, `activeFilterCount` and `hasActiveFilters` no longer exist. +- `admin-inventory-filters.spec.ts` passes in full; `filters.spec.ts` and `favorites-filter.spec.ts` pass except the two assertions blocked on #186. +- Adding a filter to either screen is one entry in that screen's `DIMENSIONS` array. From 5d0b66a982e505d5543edd90c2efbcbd7f3f2377 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 14:58:21 -0500 Subject: [PATCH 03/13] test(filters): add a unit runner and the filter dimension contract (#188) --- frontend/package-lock.json | 405 +++++++++++++++++- frontend/package.json | 4 +- frontend/src/components/filters/dimension.ts | 51 +++ .../components/filters/standardDimensions.tsx | 52 +++ frontend/tests/unit/filterDimensions.test.ts | 57 +++ frontend/vitest.config.ts | 10 + 6 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/filters/dimension.ts create mode 100644 frontend/src/components/filters/standardDimensions.tsx create mode 100644 frontend/tests/unit/filterDimensions.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c035a9c..85a934a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,7 +35,8 @@ "typescript": "^5.5.4", "typescript-eslint": "^8.67.0", "vite": "^5.4.0", - "vite-plugin-istanbul": "^6.0.2" + "vite-plugin-istanbul": "^6.0.2", + "vitest": "^2.1.9" } }, "node_modules/@ant-design/colors": { @@ -2221,6 +2222,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2502,6 +2616,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2664,6 +2788,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -2779,6 +2913,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2832,6 +2983,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -3078,6 +3239,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3316,6 +3487,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3751,6 +3929,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3761,6 +3949,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5544,6 +5742,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5553,6 +5758,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6895,6 +7110,23 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -8584,6 +8816,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8669,6 +8908,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8930,6 +9183,20 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -8947,6 +9214,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", @@ -9386,6 +9683,29 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-istanbul": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/vite-plugin-istanbul/-/vite-plugin-istanbul-6.0.2.tgz", @@ -9465,6 +9785,72 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -9586,6 +9972,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 156a2d4..9dcebea 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "dev": "vite", "build": "tsc && tsc -p tsconfig.test.json --noEmit && vite build", "lint": "eslint src tests", + "test:unit": "vitest run", "test:e2e": "playwright test", "test:e2e:cov": "cross-env COVERAGE=true playwright test", "coverage:report": "node scripts/coverage-report.js" @@ -38,6 +39,7 @@ "typescript": "^5.5.4", "typescript-eslint": "^8.67.0", "vite": "^5.4.0", - "vite-plugin-istanbul": "^6.0.2" + "vite-plugin-istanbul": "^6.0.2", + "vitest": "^2.1.9" } } diff --git a/frontend/src/components/filters/dimension.ts b/frontend/src/components/filters/dimension.ts new file mode 100644 index 0000000..2ff8e42 --- /dev/null +++ b/frontend/src/components/filters/dimension.ts @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; +import type { Category, Tag as ItemTag } from '../../api'; +import type { ItemFilters } from '../../filters'; + +/** + * One removable filter, as shown beside the Filters button. + * + * Produced by a dimension rather than by a component, so the row can be built + * without the drawer being open — see FilterDimension.chips. + */ +export interface Chip { + key: string; + label: string; + /** Tags carry their own colour (#185). Nothing else has one. */ + color?: string; + onRemove: () => void; +} + +/** Everything a dimension is allowed to know. */ +export interface FilterContext { + filters: ItemFilters; + onChange: (next: ItemFilters) => void; + categories: Category[]; + tags: ItemTag[]; + /** Null on a screen with no catalogue-wide range to bound a slider with. */ + priceRange: { min_cents: number; max_cents: number } | null; +} + +/** + * One filter, as a screen declares it. + * + * Plain data rather than a component or a context provider, and deliberately: + * the drawer sets `destroyOnHidden`, so its sections are unmounted whenever it + * is closed — which is exactly when the chip row matters most. Anything that + * registered itself on mount would lose every drawer chip the moment the drawer + * closed. Nothing here depends on being rendered. + * + * A screen can define its own and FilterBar treats it identically: it appears + * in the chip row and counts toward the tally without FilterBar knowing what it + * filters on. + */ +export interface FilterDimension { + key: string; + /** `bar` renders inline and always visible; `drawer` renders as a section. */ + placement: 'bar' | 'drawer'; + /** Drawer sections carry a heading. Bar controls render bare. */ + heading?: string; + render(ctx: FilterContext): ReactNode; + /** Empty when this dimension is filtering nothing. */ + chips(ctx: FilterContext): Chip[]; +} diff --git a/frontend/src/components/filters/standardDimensions.tsx b/frontend/src/components/filters/standardDimensions.tsx new file mode 100644 index 0000000..3ea2ef0 --- /dev/null +++ b/frontend/src/components/filters/standardDimensions.tsx @@ -0,0 +1,52 @@ +import TreeSelect from 'antd/es/tree-select'; +import Empty from 'antd/es/empty'; +import { buildCategoryTree, categoryPath, toCategoryTreeData } from '../../filters'; +import type { FilterDimension } from './dimension'; + +/** + * The dimensions every screen picks from. + * + * Each owns its control and its chips together, so adding a filter is one + * object rather than an edit in three files — which is what the flags on the + * old FilterDrawer had become. + */ +export const categoryDimension: FilterDimension = { + key: 'category', + placement: 'drawer', + // The rule is in the heading because it is the opposite of the tag rule + // directly below it, and a customer should not have to discover that. + heading: 'Categories — any of these', + + render: ({ categories, filters, onChange }) => + categories.length ? ( + onChange({ ...filters, categoryIds })} + multiple + showSearch + // Search the visible label, not the value, which is a numeric id. + treeNodeFilterProp="title" + treeDefaultExpandAll + allowClear + placeholder="Any category" + style={{ width: '100%' }} + aria-label="Filter by category" + /> + ) : ( + + ), + + chips: ({ categories, filters, onChange }) => + filters.categoryIds.map((categoryId) => ({ + key: `category-${categoryId}`, + // The full path, since two categories can share a leaf name under + // different parents. Falls back to the id while /api/filters is loading. + label: categoryPath(categories, categoryId) || `Category ${categoryId}`, + onRemove: () => + onChange({ + ...filters, + categoryIds: filters.categoryIds.filter((id) => id !== categoryId) + }) + })) +}; diff --git a/frontend/tests/unit/filterDimensions.test.ts b/frontend/tests/unit/filterDimensions.test.ts new file mode 100644 index 0000000..8704060 --- /dev/null +++ b/frontend/tests/unit/filterDimensions.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { EMPTY_FILTERS, ItemFilters } from '../../src/filters'; +import type { FilterContext } from '../../src/components/filters/dimension'; +import { categoryDimension } from '../../src/components/filters/standardDimensions'; +import type { Category, Tag as ItemTag } from '../../src/api'; + +const CATEGORIES: Category[] = [ + { id: 1, name: 'Furniture', parent_id: null, sort_order: 0, item_count: 0 }, + { id: 2, name: 'Tables', parent_id: 1, sort_order: 0, item_count: 0 }, + { id: 3, name: 'Decor', parent_id: null, sort_order: 0, item_count: 0 } +]; + +const TAGS: ItemTag[] = [ + { id: 10, name: 'vintage', color: 'red', item_count: 1 }, + { id: 11, name: 'oak', color: 'lime', item_count: 1 } +]; + +/** The last filters a dimension's onRemove produced, for asserting on. */ +function contextFor(filters: Partial) { + const state: { latest: ItemFilters | null } = { latest: null }; + const ctx: FilterContext = { + filters: { ...EMPTY_FILTERS, ...filters }, + onChange: (next) => { state.latest = next; }, + categories: CATEGORIES, + tags: TAGS, + priceRange: { min_cents: 0, max_cents: 100000 } + }; + return { ctx, state }; +} + +describe('categoryDimension', () => { + it('reports no chips when nothing is selected', () => { + const { ctx } = contextFor({}); + expect(categoryDimension.chips(ctx)).toEqual([]); + }); + + it('reports one chip per selected category, labelled with its full path', () => { + const { ctx } = contextFor({ categoryIds: [2, 3] }); + expect(categoryDimension.chips(ctx).map((chip) => chip.label)).toEqual([ + 'Furniture / Tables', + 'Decor' + ]); + }); + + // The row renders before /api/filters resolves, and a chip with no label + // would be an empty box. + it('falls back to the id when the category is not loaded yet', () => { + const { ctx } = contextFor({ categoryIds: [99] }); + expect(categoryDimension.chips(ctx)[0]?.label).toBe('Category 99'); + }); + + it('removes only the chip that was closed', () => { + const { ctx, state } = contextFor({ categoryIds: [2, 3] }); + categoryDimension.chips(ctx)[0]?.onRemove(); + expect(state.latest?.categoryIds).toEqual([3]); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..7d96220 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// Unit tests only. The Playwright suite lives in tests/e2e and is run by +// `npm run test:e2e` — including it here would start a browser per run. +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts'], + environment: 'node' + } +}); From abd3dd4e2e37f04c765f1396aedd00885c6cc02a Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:06:15 -0500 Subject: [PATCH 04/13] feat(filters): add tag and price dimensions (#188) --- .../components/filters/standardDimensions.tsx | 142 +++++++++++++++++- frontend/tests/unit/filterDimensions.test.ts | 52 ++++++- 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/filters/standardDimensions.tsx b/frontend/src/components/filters/standardDimensions.tsx index 3ea2ef0..a4ebefb 100644 --- a/frontend/src/components/filters/standardDimensions.tsx +++ b/frontend/src/components/filters/standardDimensions.tsx @@ -1,6 +1,15 @@ import TreeSelect from 'antd/es/tree-select'; import Empty from 'antd/es/empty'; -import { buildCategoryTree, categoryPath, toCategoryTreeData } from '../../filters'; +import Select from 'antd/es/select'; +import Tag from 'antd/es/tag'; +import Slider from 'antd/es/slider'; +import InputNumber from 'antd/es/input-number'; +import { + buildCategoryTree, + categoryPath, + formatPriceRange, + toCategoryTreeData +} from '../../filters'; import type { FilterDimension } from './dimension'; /** @@ -50,3 +59,134 @@ export const categoryDimension: FilterDimension = { }) })) }; + +export const tagDimension: FilterDimension = { + key: 'tag', + placement: 'drawer', + // AND, deliberately the opposite of the category rule above. + heading: 'Tags — must have all of these', + + render: ({ tags, filters, onChange }) => { + // The Select is handed ids, so a colour has to be looked up rather than + // carried along with the value. + const colours = new Map(tags.map((tag) => [tag.id, tag.color])); + return tags.length ? ( + + 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 }); + } + })) +}; diff --git a/frontend/tests/unit/filterDimensions.test.ts b/frontend/tests/unit/filterDimensions.test.ts index 54a4655..f55482e 100644 --- a/frontend/tests/unit/filterDimensions.test.ts +++ b/frontend/tests/unit/filterDimensions.test.ts @@ -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(); + }); +}); From 1d7f928c48e988e106a15f94e2eb6be322325ec4 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:18:23 -0500 Subject: [PATCH 07/13] 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(); + }); +}); From 918d6eeab9c4ef5f50c2eda889104512dc9d18e9 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:23:25 -0500 Subject: [PATCH 08/13] refactor(filters): extract the chip row from ActiveFilterChips (#188) --- .../src/components/filters/FilterChips.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/src/components/filters/FilterChips.tsx diff --git a/frontend/src/components/filters/FilterChips.tsx b/frontend/src/components/filters/FilterChips.tsx new file mode 100644 index 0000000..efafe21 --- /dev/null +++ b/frontend/src/components/filters/FilterChips.tsx @@ -0,0 +1,48 @@ +import Tag from 'antd/es/tag'; +import Button from 'antd/es/button'; +import type { Chip } from './dimension'; + +type Props = Readonly<{ + chips: Chip[]; + onClear: () => void; +}>; + +/** + * The removable filter row. + * + * Knows nothing about filters — it is handed chips and renders them. Every + * decision about what a chip says, what colour it is and what removing it does + * belongs to the dimension that produced it. + */ +export default function FilterChips({ chips, onClear }: Props) { + if (!chips.length) return null; + + return ( + // Named as a group so this row's Clear all stays distinguishable from the + // identically-labelled one in the drawer's footer. +
+ {chips.map((chip) => ( + { + event.preventDefault(); + chip.onRemove(); + }} + // antd renders the close control as an icon with no text, so name it + // for screen readers and for anything driving the page by role. + closeIcon={ + × + } + > + {chip.label} + + ))} + +
+ ); +} From 50d048a1d616d48f2ca813379b22efd7b57514af Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:26:21 -0500 Subject: [PATCH 09/13] feat(filters): add FilterBar, one component both screens compose (#188) --- frontend/src/components/filters/FilterBar.tsx | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 frontend/src/components/filters/FilterBar.tsx diff --git a/frontend/src/components/filters/FilterBar.tsx b/frontend/src/components/filters/FilterBar.tsx new file mode 100644 index 0000000..d2aeb90 --- /dev/null +++ b/frontend/src/components/filters/FilterBar.tsx @@ -0,0 +1,114 @@ +import { useMemo, useState } from 'react'; +import Drawer from 'antd/es/drawer'; +import Button from 'antd/es/button'; +import Grid from 'antd/es/grid'; +import { FilterOutlined } from '@ant-design/icons'; +import type { Category, Tag as ItemTag } from '../../api'; +import type { ItemFilters } from '../../filters'; +import type { FilterContext, FilterDimension } from './dimension'; +import FilterChips from './FilterChips'; + +type Props = Readonly<{ + /** Render order. A screen composes the filters it offers. */ + dimensions: FilterDimension[]; + filters: ItemFilters; + onChange: (next: ItemFilters) => void; + onClear: () => void; + categories: Category[]; + tags: ItemTag[]; + priceRange: { min_cents: number; max_cents: number } | null; + resultCount: number; +}>; + +const sectionHeading: React.CSSProperties = { + margin: '0 0 8px', + fontSize: 12, + letterSpacing: '.06em', + textTransform: 'uppercase', + opacity: 0.65 +}; + +/** + * Filtering, for any screen that does it. + * + * The screen says which dimensions it offers; this owns everything around them + * — the always-visible controls, the Filters button and its tally, the chip + * row, and the drawer. Before #188 the drawer was shared and this was written + * twice, which is how the admin's tally drifted from the storefront's. + * + * The tally is the number of chips rather than a second count of the same + * thing, so the button and the chip row cannot disagree. + */ +export default function FilterBar({ + dimensions, + filters, + onChange, + onClear, + categories, + tags, + priceRange, + resultCount +}: Props) { + const [drawerOpen, setDrawerOpen] = useState(false); + const screens = Grid.useBreakpoint(); + + const context: FilterContext = useMemo( + () => ({ filters, onChange, categories, tags, priceRange }), + [filters, onChange, categories, tags, priceRange] + ); + + // Derived, not stored, and derived without rendering anything — the drawer + // unmounts its sections when closed, so anything that needed them mounted + // would lose the chips exactly when they matter. + const chips = dimensions.flatMap((dimension) => dimension.chips(context)); + + const barDimensions = dimensions.filter((dimension) => dimension.placement === 'bar'); + const drawerDimensions = dimensions.filter((dimension) => dimension.placement === 'drawer'); + + return ( + <> + {barDimensions.map((dimension) => ( +
{dimension.render(context)}
+ ))} + + + + + + setDrawerOpen(false)} + // Unmounting on close keeps a single copy of controls like "Clear all" + // in the document at any time. + destroyOnHidden + width={screens.md ? 380 : '90%'} + footer={ +
+ + +
+ } + > + {drawerDimensions.map((dimension, index) => ( +
+ {dimension.heading &&

{dimension.heading}

} + {dimension.render(context)} +
+ ))} +
+ + ); +} From 2f0da9afe1b5ded4d9a72af6aeff8061ee96a146 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:30:27 -0500 Subject: [PATCH 10/13] refactor(admin): compose the inventory filters from dimensions (#188) --- frontend/src/admin/InventoryFilters.tsx | 59 +++++++------------------ 1 file changed, 15 insertions(+), 44 deletions(-) diff --git a/frontend/src/admin/InventoryFilters.tsx b/frontend/src/admin/InventoryFilters.tsx index 36f83d7..90e7163 100644 --- a/frontend/src/admin/InventoryFilters.tsx +++ b/frontend/src/admin/InventoryFilters.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react'; -import Button from 'antd/es/button'; -import { FilterOutlined } from '@ant-design/icons'; import type { Category, Tag } from '../api'; -import { ItemFilters, activeFilterCount } from '../filters'; -import FilterDrawer from '../components/FilterDrawer'; -import ActiveFilterChips from '../components/ActiveFilterChips'; +import { ItemFilters } from '../filters'; +import FilterBar from '../components/filters/FilterBar'; +import { + categoryDimension, + priceDimension, + statusDimension, + tagDimension +} from '../components/filters/standardDimensions'; type Props = Readonly<{ categories: Category[]; @@ -15,16 +17,11 @@ type Props = Readonly<{ resultCount: number; }>; -// The same flyout the storefront uses, rather than the row of controls this -// used to be (#169). -// -// That row was deliberate — it carried a comment arguing that hiding the -// controls above a data table costs more than the space it saves, and that a -// drawer overlays the very rows being filtered. Both are true, and both are -// traded for the two screens asking the same questions through the same UI. -// The chips are what makes the trade bearable: the active filter stays readable -// beside the button without opening anything, which is what the always-visible -// row was really protecting. +// Status, and no favorites: pending is excluded from every public read, so +// Published and Unpublished are distinctions only the admin can draw, and +// favoriting is a customer's idea. +const DIMENSIONS = [categoryDimension, tagDimension, priceDimension, statusDimension]; + export default function InventoryFilters({ categories, tags, @@ -33,45 +30,19 @@ export default function InventoryFilters({ onClear, resultCount }: Props) { - const [drawerOpen, setDrawerOpen] = useState(false); - - // Status lives in the drawer here, unlike on the storefront, so it belongs in - // the button's tally — activeFilterCount leaves it out precisely because the - // storefront filters status outside the drawer. - const activeCount = activeFilterCount(filters) + (filters.status === null ? 0 : 1); - return (
- - - - - setDrawerOpen(false)} categories={categories} tags={tags} // No slider: the admin has no catalogue-wide price range to bound one // with, and inventing bounds would misreport where the prices are. priceRange={null} - filters={filters} - onChange={onChange} - onClear={onClear} resultCount={resultCount} - showStatus />
); From 147e280f884dbc78d84b59fcd8fda1598ef40083 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:39:18 -0500 Subject: [PATCH 11/13] refactor(filters): compose the storefront from dimensions and delete the old components (#188) Replaces App.tsx's hand-written Segmented/Filters-button/ActiveFilterChips region with a single FilterBar composed from five dimensions, and removes the FilterDrawer and ActiveFilterChips components along with the activeFilterCount and hasActiveFilters helpers they were the only callers of. Catalogue now receives a filtered boolean computed the same way FilterBar computes its own chip tally, rather than the ItemFilters object it only ever used for that one check. --- frontend/src/App.tsx | 113 +++---- frontend/src/components/ActiveFilterChips.tsx | 127 -------- frontend/src/components/FilterDrawer.tsx | 277 ------------------ frontend/src/filters.ts | 25 -- 4 files changed, 40 insertions(+), 502 deletions(-) delete mode 100644 frontend/src/components/ActiveFilterChips.tsx delete mode 100644 frontend/src/components/FilterDrawer.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cad4bdb..b0c8949 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,25 +10,21 @@ import theme from 'antd/es/theme'; import Badge from 'antd/es/badge'; import Empty from 'antd/es/empty'; import Alert from 'antd/es/alert'; -import Segmented from 'antd/es/segmented'; -import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons'; +import { ShoppingCartOutlined } from '@ant-design/icons'; import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { Item } from './api'; import { useCatalogue } from './useCatalogue'; import ItemCard from './components/ItemCard'; import BrandMark from './components/BrandMark'; -import FilterDrawer from './components/FilterDrawer'; -import ActiveFilterChips from './components/ActiveFilterChips'; +import FilterBar from './components/filters/FilterBar'; import { - ItemFilters, - SaleState, - STOREFRONT_SALE_STATUSES, - activeFilterCount, - filtersFromSearchParams, - filtersToSearchParams, - hasActiveFilters, - saleStateFromStatuses -} from './filters'; + availabilityDimension, + categoryDimension, + favoritesDimension, + priceDimension, + tagDimension +} from './components/filters/standardDimensions'; +import { ItemFilters, filtersFromSearchParams, filtersToSearchParams } from './filters'; import AuthPromptModal from './customer/AuthPromptModal'; import { useThemeMode } from './theme/ThemeContext'; import { useCustomerAuth } from './customer/CustomerAuthContext'; @@ -44,7 +40,7 @@ type CatalogueProps = Readonly<{ failed: boolean; loading: boolean; items: Item[]; - filters: ItemFilters; + filtered: boolean; needsFavoritesAuth: boolean; onRetry: () => void; onSignIn: () => void; @@ -61,7 +57,7 @@ function Catalogue({ failed, loading, items, - filters, + filtered, needsFavoritesAuth, onRetry, onSignIn, @@ -93,7 +89,6 @@ function Catalogue({ if (!loading && !items.length) { // Distinguished so "no items match these filters" never reads as an empty // shop, and so the way out is offered only when there is one. - const filtered = hasActiveFilters(filters); return ( {filtered ? : null} @@ -112,8 +107,18 @@ function Catalogue({ ); } +// Availability first and always visible, because it is the coarsest cut and +// worth seeing without opening anything. Favorites next, so someone who came +// for their favorites does not scroll past the catalogue controls. +const STOREFRONT_DIMENSIONS = [ + availabilityDimension, + favoritesDimension, + categoryDimension, + tagDimension, + priceDimension +]; + export default function App() { - const [drawerOpen, setDrawerOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); @@ -143,7 +148,17 @@ export default function App() { setSearchParams(new URLSearchParams(), { replace: true }); }, [setSearchParams]); - const activeCount = activeFilterCount(filters); + // 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 + // same way FilterBar computes its own tally, from the same dimensions. + const filterContext = { + filters, + onChange: applyFilters, + categories: options?.categories ?? [], + tags: options?.tags ?? [], + priceRange: options?.priceRange ?? null + }; + const filtered = STOREFRONT_DIMENSIONS.some((dimension) => dimension.chips(filterContext).length > 0); return ( @@ -191,50 +206,15 @@ export default function App() {
- {/* In the bar rather than inside the drawer, deliberately. The default - now hides sold pieces, so a customer who never opens the drawer - would otherwise have no way to know sold items exist — and on a - one-of-a-kind catalogue the sold pieces are part of the story. */} - { - const state = value as SaleState; - applyFilters({ - ...filters, - // Not Sold is the default, so it is stored as "no preference" - // rather than as an explicit list. That keeps it out of the URL - // and out of the Filters (N) count, where it would otherwise - // show as an active filter nobody chose. - status: state === 'not-sold' ? null : STOREFRONT_SALE_STATUSES[state] - }); - }} - options={[ - { label: 'Not sold', value: 'not-sold' }, - { label: 'Sold', value: 'sold' }, - { label: 'All', value: 'all' } - ]} - /> - -
@@ -265,7 +245,7 @@ export default function App() { failed={failed} loading={loading} items={items} - filters={filters} + filtered={filtered} needsFavoritesAuth={needsFavoritesAuth} onRetry={retry} onSignIn={openAuthModal} @@ -278,19 +258,6 @@ export default function App() { Privacy Policy - setDrawerOpen(false)} - categories={options?.categories ?? []} - tags={options?.tags ?? []} - priceRange={options?.priceRange ?? null} - filters={filters} - onChange={applyFilters} - onClear={clearFilters} - resultCount={items.length} - showFavorites - /> - {/* The same prompt the heart button and Add to Cart use. Signing in resolves the gate above, and the filter then applies on its own — the customer never has to set it a second time. */} diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx deleted file mode 100644 index f12c508..0000000 --- a/frontend/src/components/ActiveFilterChips.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import Tag from 'antd/es/tag'; -import Button from 'antd/es/button'; -import type { Category, Tag as ItemTag } from '../api'; -import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters, statusLabel } from '../filters'; - -type Props = Readonly<{ - categories: Category[]; - tags: ItemTag[]; - filters: ItemFilters; - onChange: (filters: ItemFilters) => void; - onClear: () => void; - // Where status is one of the drawer's controls rather than a preset beside - // it (#169), it needs a chip too — otherwise the one filter most likely to - // empty a table is the one filter invisible without opening the drawer. - showStatus?: boolean; -}>; - -export default function ActiveFilterChips({ - categories, - tags, - filters, - onChange, - onClear, - showStatus = false -}: Props) { - if (!hasActiveFilters(filters)) return null; - - // `color` only ever set for tags, which are the only filter with one. That - // makes colour in this row mean "this is a tag", which is a useful thing for - // a row mixing four kinds of filter to say — and nothing depends on it, since - // every chip still carries its label. - const chips: { key: string; label: string; color?: string; onRemove: () => void }[] = []; - - // Listed first so it matches the drawer's ordering, and because it is the - // chip most worth noticing when a customer wonders why the grid looks short. - if (filters.favoritesOnly) { - chips.push({ - key: 'favorites', - label: 'My favorites', - onRemove: () => onChange({ ...filters, favoritesOnly: false }) - }); - } - - // One chip per selected category, each removable on its own — removing the - // whole set at once is what Clear all is for. - for (const categoryId of filters.categoryIds) { - const path = categoryPath(categories, categoryId); - // Falls back to the raw id while /api/filters is still loading, so the chip - // never renders as an empty box. - const label = path || `Category ${categoryId}`; - chips.push({ - key: `category-${categoryId}`, - // The chip shows the full path for context, since two categories can - // share a leaf name under different parents. - label, - onRemove: () => - onChange({ ...filters, categoryIds: filters.categoryIds.filter((id) => id !== categoryId) }) - }); - } - - for (const tagId of filters.tagIds) { - const tag = tags.find((candidate) => candidate.id === tagId); - chips.push({ - key: `tag-${tagId}`, - label: tag?.name ?? `Tag ${tagId}`, - // The same colour the drawer's control and the product cards show, so a - // tag looks like itself wherever it appears. Undefined while - // /api/filters is still loading, which is the case the label fallback - // above already covers — an uncoloured chip beats a missing one. - color: tag?.color, - onRemove: () => onChange({ ...filters, tagIds: filters.tagIds.filter((id) => id !== tagId) }) - }); - } - - if (showStatus && filters.status !== null) { - for (const status of filters.status) { - chips.push({ - key: `status-${status}`, - label: statusLabel(status), - onRemove: () => { - const rest = (filters.status ?? []).filter((value) => value !== status); - // Back to null rather than an empty list: emptying the control means - // "no status filter", not "no statuses", which would empty the table. - onChange({ ...filters, status: rest.length ? rest : null }); - } - }); - } - } - - if (filters.minPriceCents !== null || filters.maxPriceCents !== null) { - chips.push({ - key: 'price', - label: formatPriceRange(filters.minPriceCents, filters.maxPriceCents), - onRemove: () => onChange({ ...filters, minPriceCents: null, maxPriceCents: null }) - }); - } - - return ( - // Named as a group so the chip row's own "Clear all" stays distinguishable - // from the identically-labelled one in the filter drawer. -
- {chips.map((chip) => ( - { - event.preventDefault(); - chip.onRemove(); - }} - // antd renders the close control as an icon with no text, so name it - // for screen readers and for anything driving the page by role. - closeIcon={ - × - } - > - {chip.label} - - ))} - -
- ); -} diff --git a/frontend/src/components/FilterDrawer.tsx b/frontend/src/components/FilterDrawer.tsx deleted file mode 100644 index 84a2f03..0000000 --- a/frontend/src/components/FilterDrawer.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import Drawer from 'antd/es/drawer'; -import Button from 'antd/es/button'; -import TreeSelect from 'antd/es/tree-select'; -import Select from 'antd/es/select'; -import Tag from 'antd/es/tag'; -import Slider from 'antd/es/slider'; -import InputNumber from 'antd/es/input-number'; -import Empty from 'antd/es/empty'; -import Switch from 'antd/es/switch'; -import Grid from 'antd/es/grid'; -import type { Category, Tag as ItemTag } from '../api'; -import { ItemFilters, ItemStatus, STATUS_OPTIONS, buildCategoryTree, toCategoryTreeData } from '../filters'; - -// One drawer for the storefront and the admin, with the sections that differ -// driven by props rather than by a second component that would drift (#169). -// What is shared is not just the markup but the phrasing of the rules — that -// categories are OR and tags are AND has to read the same on both screens or it -// stops being one rule. -type Props = Readonly<{ - open: boolean; - onClose: () => void; - categories: Category[]; - tags: ItemTag[]; - // Bounds for the price slider, or null on a screen with no catalogue-wide - // range to draw one from, where the two number inputs stand alone. A slider - // needs real bounds: invented ones would misreport where the prices are. - priceRange: { min_cents: number; max_cents: number } | null; - filters: ItemFilters; - onChange: (filters: ItemFilters) => void; - onClear: () => void; - resultCount: number; - // Storefront only — signing in is what makes favorites mean anything. - showFavorites?: boolean; - // Admin only. The storefront keeps its three-way preset outside the drawer: - // pending is excluded from every public read, so Published and Unpublished - // are not distinctions a customer can draw. - showStatus?: boolean; -}>; - -const sectionHeading: React.CSSProperties = { - margin: '0 0 8px', - fontSize: 12, - letterSpacing: '.06em', - textTransform: 'uppercase', - opacity: 0.65 -}; - -const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100); -const dollarsToCents = (dollars: number | null): number | null => - dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100); - -export default function FilterDrawer({ - open, - onClose, - categories, - tags, - priceRange, - filters, - onChange, - onClear, - resultCount, - showFavorites = false, - showStatus = false -}: Props) { - const screens = Grid.useBreakpoint(); - const bounds = priceRange ?? { min_cents: 0, max_cents: 0 }; - - function selectCategories(ids: number[]) { - onChange({ ...filters, categoryIds: ids }); - } - - // The selected pills are rendered by the Select, which is handed ids rather - // than tags, so the colour has to be looked up rather than carried along. - const tagColors = new Map(tags.map((tag) => [tag.id, tag.color])); - - const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100); - - return ( - - - - - } - > - {/* First because it is the broadest cut, and because a customer who came - here for their favorites should not have to scroll past the catalogue - controls to find it. Shown to signed-out visitors too: switching it on - prompts them to sign in, which is how they learn favorites exist. */} - {showFavorites && ( -
-

- Favorites -

- {/* Deliberately not wrapped in a
- )} - -
-

- Categories — any of these -

- {categories.length ? ( - // A TreeSelect rather than a Tree: it keeps the hierarchy a customer - // browses by while adding search and multi-select, and it lists what - // is chosen inside the control instead of leaving the selection to be - // read off highlighting. The admin's CategoryTreeSelect is the same - // control, so the two screens behave alike. - - ) : ( - - )} -
- -
-

- Tags — must have all of these -

- {tags.length ? ( - // Was a wall of every tag in the system, which read fine at a dozen - // and not at a hundred. A searchable multi-select scales with the - // taxonomy and, like the category control above it, states its - // selection inside the control instead of in chip colouring. - // - // The colours survive as the selected pills, since that is the only - // place a tag's colour was ever load-bearing. - - // Empty means no filter, not "no statuses". A multi-select cleared - // back to nothing should show everything rather than an empty table. - onChange({ ...filters, status: value.length ? value : null }) - } - options={STATUS_OPTIONS} - /> -
- )} -
- ); -} diff --git a/frontend/src/filters.ts b/frontend/src/filters.ts index 02d82a5..a928915 100644 --- a/frontend/src/filters.ts +++ b/frontend/src/filters.ts @@ -150,13 +150,6 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters { }; } -// One count for the "Filters (N)" button. A price range counts once however -// many ends are set, since it reads as a single filter to the user. -// -// Status is deliberately not counted. It has its own always-visible control -// beside this button rather than living in the drawer, so counting it would put -// a number on a button whose drawer shows nothing set — and the control already -// displays its own position. // Named individually rather than grouped, because grouping is what the preset // this replaced did. Pending is listed first: "what is waiting to be published" // is the question that prompted #132. @@ -175,24 +168,6 @@ export function statusLabel(status: ItemStatus): string { return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status; } -export function activeFilterCount(filters: ItemFilters): number { - let count = 0; - count += filters.categoryIds.length; - count += filters.tagIds.length; - if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++; - if (filters.favoritesOnly) count++; - return count; -} - -// Broader than the count above, and intentionally so: this decides whether an -// empty result reads as "no items match these filters" with a way out, or as an -// empty shop. A status filter that matched nothing is exactly the case where -// that distinction matters, so it counts here even though it is not in the -// drawer's tally. -export function hasActiveFilters(filters: ItemFilters): boolean { - return activeFilterCount(filters) > 0 || filters.status !== null; -} - export interface CategoryNode extends Category { children: CategoryNode[]; } From 8771deeaca53decb7f36b9287974ce6da64d8a09 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:48:09 -0500 Subject: [PATCH 12/13] test(filters): cover the tally and availability chip behaviour changes (#188) --- .../tests/e2e/admin-inventory-filters.spec.ts | 12 ++++++++++++ frontend/tests/e2e/filters.spec.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/frontend/tests/e2e/admin-inventory-filters.spec.ts b/frontend/tests/e2e/admin-inventory-filters.spec.ts index 29a3338..c1e49cd 100644 --- a/frontend/tests/e2e/admin-inventory-filters.spec.ts +++ b/frontend/tests/e2e/admin-inventory-filters.spec.ts @@ -126,4 +126,16 @@ test.describe('Admin inventory filters', () => { await expect(adminInventory.clearFiltersButton).toHaveCount(0); await expect(adminInventory.filtersButton).toHaveText('Filters'); }); + + // #188: the tally is the number of chips, so three statuses count as three. + // It read Filters (1) before, because status was added to the count by hand + // as a single flag regardless of how many were selected. + test('counts each selected status in the tally', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.toggleStatus('Available'); + await adminInventory.toggleStatus('Reserved'); + await adminInventory.toggleStatus('Sold'); + + await expect(adminInventory.filtersButton).toHaveText('Filters (3)'); + }); }); diff --git a/frontend/tests/e2e/filters.spec.ts b/frontend/tests/e2e/filters.spec.ts index f9c26f5..55d550a 100644 --- a/frontend/tests/e2e/filters.spec.ts +++ b/frontend/tests/e2e/filters.spec.ts @@ -232,4 +232,19 @@ test.describe('Storefront filters', () => { await expect(wallArt.getByText(NAMES.vintage)).toBeVisible(); await expect(wallArt.getByText(NAMES.oak)).toBeVisible(); }); + + // #188: choosing anything but the default now shows a removable chip. This is + // what lets one predicate serve both the tally and the empty-state message — + // without it, a storefront filtered to Sold with no results would report + // itself as an empty shop rather than as a filter that matched nothing. + test('shows a removable chip for a non-default availability', async ({ storefront }) => { + await storefront.goto(); + await expect(storefront.filterChip('Sold')).toHaveCount(0); + + await storefront.chooseAvailability('Sold'); + await expect(storefront.filterChip('Sold')).toBeVisible(); + + await storefront.removeFilterChip('Sold').click(); + await expect(storefront.filterChip('Sold')).toHaveCount(0); + }); }); From 3242018782bf6af45d42f49841d724a4fb1cccb5 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 16:15:15 -0500 Subject: [PATCH 13/13] fix(filters): address the final review findings (#188) Restores the escape hatch for a status list matching no preset. filtersFromSearchParams accepts any non-empty subset of {available, reserved, sold}, and only three of those seven lists are presets; the other four reported as the not-sold fallback and produced no chip, so ?status=reserved was an empty grid reading "No items yet - check back soon" with no Clear filters button and no way out but editing the URL. hasActiveFilters covered all seven before this branch, so that was a regression against main. availabilityDimension now emits a chip for any status that is not the not-sold preset, labelled from SALE_STATE_LABELS when the list matches one and from statusLabel when it does not. The Segmented still reads "Not sold" beside such a chip, which is a cosmetic wart and the cheaper half of the trade. Runs the frontend unit suite in CI. The 22 tests added over the dimensions were invoked by nothing: the workflow's only frontend steps were the build and the end-to-end run, and its test:unit:cov step is the backend's. The new step is guarded and named in the gate like every other suite, per the invariant workflowGate.test.ts asserts. Restores the comment explaining why availabilityDimension's render and chips pass different fallbacks to saleStateFromStatuses. The control must read "All" while sold favorites are on screen; chips must stay silent because the customer never chose it. Unifying them would give every signed-in favorites view a phantom All chip and a tally of 2, and nothing said so after the old markup was deleted. Derives the storefront's "is anything filtered" and FilterBar's tally through one exported chipsFor rather than two expressions over two identically-built contexts. They agreed only by convention, which is the exact defect this branch exists to remove. Documents that statusDimension and availabilityDimension are alternatives over one field, since composing both type-checks and would render two controls that double-count it, and asserts the price chip's label text, which was the only chip label nothing checked. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/sonarqube.yml | 15 ++++++ frontend/src/App.tsx | 9 ++-- frontend/src/components/filters/FilterBar.tsx | 7 ++- frontend/src/components/filters/dimension.ts | 14 +++++ .../components/filters/standardDimensions.tsx | 52 +++++++++++++++++-- frontend/tests/unit/filterDimensions.test.ts | 34 ++++++++++++ 6 files changed, 124 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index 11f4141..78b86c2 100755 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -87,6 +87,20 @@ jobs: run: npm run build 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 run: node scripts/check-sonar-tsconfig.js @@ -233,6 +247,7 @@ jobs: - name: Fail if any guarded step failed if: >- always() && ( + steps.frontend_unit.outcome == 'failure' || steps.unit.outcome == 'failure' || steps.integration.outcome == 'failure' || steps.backend.outcome == 'failure' || diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0c8949..34e2cc6 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import { useCatalogue } from './useCatalogue'; import ItemCard from './components/ItemCard'; import BrandMark from './components/BrandMark'; import FilterBar from './components/filters/FilterBar'; +import { chipsFor } from './components/filters/dimension'; import { availabilityDimension, categoryDimension, @@ -149,8 +150,10 @@ export default function App() { }, [setSearchParams]); // 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 - // same way FilterBar computes its own tally, from the same dimensions. + // state's wording — but has no chip row of its own to count. Through the same + // 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 = { filters, onChange: applyFilters, @@ -158,7 +161,7 @@ export default function App() { tags: options?.tags ?? [], priceRange: options?.priceRange ?? null }; - const filtered = STOREFRONT_DIMENSIONS.some((dimension) => dimension.chips(filterContext).length > 0); + const filtered = chipsFor(STOREFRONT_DIMENSIONS, filterContext).length > 0; return ( diff --git a/frontend/src/components/filters/FilterBar.tsx b/frontend/src/components/filters/FilterBar.tsx index d2aeb90..438333b 100644 --- a/frontend/src/components/filters/FilterBar.tsx +++ b/frontend/src/components/filters/FilterBar.tsx @@ -5,6 +5,7 @@ import Grid from 'antd/es/grid'; import { FilterOutlined } from '@ant-design/icons'; import type { Category, Tag as ItemTag } from '../../api'; import type { ItemFilters } from '../../filters'; +import { chipsFor } from './dimension'; import type { FilterContext, FilterDimension } from './dimension'; import FilterChips from './FilterChips'; @@ -60,7 +61,11 @@ export default function FilterBar({ // Derived, not stored, and derived without rendering anything — the drawer // unmounts its sections when closed, so anything that needed them mounted // 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 drawerDimensions = dimensions.filter((dimension) => dimension.placement === 'drawer'); diff --git a/frontend/src/components/filters/dimension.ts b/frontend/src/components/filters/dimension.ts index 2ff8e42..67b4883 100644 --- a/frontend/src/components/filters/dimension.ts +++ b/frontend/src/components/filters/dimension.ts @@ -49,3 +49,17 @@ export interface FilterDimension { /** Empty when this dimension is filtering nothing. */ 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)); diff --git a/frontend/src/components/filters/standardDimensions.tsx b/frontend/src/components/filters/standardDimensions.tsx index 2668f28..9e57a28 100644 --- a/frontend/src/components/filters/standardDimensions.tsx +++ b/frontend/src/components/filters/standardDimensions.tsx @@ -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 = { key: 'status', placement: 'drawer', @@ -287,6 +296,11 @@ const SALE_STATE_LABELS: Record = { * 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. + * + * 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 = { key: 'availability', @@ -295,6 +309,12 @@ export const availabilityDimension: FilterDimension = { render: ({ filters, onChange }) => ( { - const state = saleStateFromStatuses(filters.status, STOREFRONT_SALE_STATUSES, 'not-sold'); - if (state === 'not-sold') return []; + const statuses = filters.status; + // 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 [ { 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 }) } ]; diff --git a/frontend/tests/unit/filterDimensions.test.ts b/frontend/tests/unit/filterDimensions.test.ts index fe9fd7a..9a0fa29 100644 --- a/frontend/tests/unit/filterDimensions.test.ts +++ b/frontend/tests/unit/filterDimensions.test.ts @@ -105,6 +105,14 @@ describe('priceDimension', () => { 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', () => { const { ctx, state } = contextFor({ minPriceCents: 1000, maxPriceCents: 5000 }); priceDimension.chips(ctx)[0]?.onRemove(); @@ -188,4 +196,30 @@ describe('availabilityDimension', () => { availabilityDimension.chips(ctx)[0]?.onRemove(); 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(); + }); });