From e695d91670e18d44801c8fff416eb1c1f17d3070 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 14:46:06 -0500 Subject: [PATCH] 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.