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/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..4582c1d --- /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, 18 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, 22 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, 22 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 22 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. 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). 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/App.tsx b/frontend/src/App.tsx index cad4bdb..34e2cc6 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,25 +10,22 @@ 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 { chipsFor } from './components/filters/dimension'; 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 +41,7 @@ type CatalogueProps = Readonly<{ failed: boolean; loading: boolean; items: Item[]; - filters: ItemFilters; + filtered: boolean; needsFavoritesAuth: boolean; onRetry: () => void; onSignIn: () => void; @@ -61,7 +58,7 @@ function Catalogue({ failed, loading, items, - filters, + filtered, needsFavoritesAuth, onRetry, onSignIn, @@ -93,7 +90,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 +108,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 +149,19 @@ 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. 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, + categories: options?.categories ?? [], + tags: options?.tags ?? [], + priceRange: options?.priceRange ?? null + }; + const filtered = chipsFor(STOREFRONT_DIMENSIONS, filterContext).length > 0; return ( @@ -191,50 +209,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 +248,7 @@ export default function App() { failed={failed} loading={loading} items={items} - filters={filters} + filtered={filtered} needsFavoritesAuth={needsFavoritesAuth} onRetry={retry} onSignIn={openAuthModal} @@ -278,19 +261,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/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 />
); 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/components/filters/FilterBar.tsx b/frontend/src/components/filters/FilterBar.tsx new file mode 100644 index 0000000..438333b --- /dev/null +++ b/frontend/src/components/filters/FilterBar.tsx @@ -0,0 +1,119 @@ +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 { chipsFor } from './dimension'; +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. + // + // 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'); + + 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)} +
+ ))} +
+ + ); +} 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} + + ))} + +
+ ); +} diff --git a/frontend/src/components/filters/dimension.ts b/frontend/src/components/filters/dimension.ts new file mode 100644 index 0000000..67b4883 --- /dev/null +++ b/frontend/src/components/filters/dimension.ts @@ -0,0 +1,65 @@ +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[]; +} + +/** + * 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 new file mode 100644 index 0000000..9e57a28 --- /dev/null +++ b/frontend/src/components/filters/standardDimensions.tsx @@ -0,0 +1,377 @@ +import TreeSelect from 'antd/es/tree-select'; +import Empty from 'antd/es/empty'; +import Select from 'antd/es/select'; +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'; + +/** + * 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) + }) + })) +}; + +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 }); + } + })) +}; + +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. + * + * 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', + 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 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', + // 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/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[]; } 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); + }); }); diff --git a/frontend/tests/unit/filterDimensions.test.ts b/frontend/tests/unit/filterDimensions.test.ts new file mode 100644 index 0000000..9a0fa29 --- /dev/null +++ b/frontend/tests/unit/filterDimensions.test.ts @@ -0,0 +1,225 @@ +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, + statusDimension, + tagDimension +} 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]); + }); +}); + +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); + }); + + // 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(); + expect(state.latest?.minPriceCents).toBeNull(); + 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(); + }); +}); + +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(); + }); + + // 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(); + }); +}); 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' + } +});