Merge pull request 'Feature/188 filter dimensions' (#189) from feature/188-filter-dimensions into main
Reviewed-on: #189
This commit was merged in pull request #189.
This commit is contained in:
@@ -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' ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
<FilterBar dimensions={[availability, favorites, categories, tags, price]} … />
|
||||
|
||||
// Admin inventory
|
||||
<FilterBar dimensions={[categories, tags, price, status]} … />
|
||||
```
|
||||
|
||||
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).
|
||||
Generated
+404
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+43
-73
@@ -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 (
|
||||
<Empty description={filtered ? 'No items match these filters' : 'No items yet — check back soon'}>
|
||||
{filtered ? <Button onClick={onClearFilters}>Clear filters</Button> : 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 (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
@@ -191,50 +209,15 @@ export default function App() {
|
||||
</Header>
|
||||
<Content style={{ padding: 24 }}>
|
||||
<div className="filter-bar">
|
||||
{/* 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. */}
|
||||
<Segmented
|
||||
aria-label="Filter by availability"
|
||||
// The fallback matches the server's: the favorites view defaults to
|
||||
// everything, so the control must not claim Not Sold while sold
|
||||
// favorites are on screen.
|
||||
value={saleStateFromStatuses(
|
||||
filters.status,
|
||||
STOREFRONT_SALE_STATUSES,
|
||||
filters.favoritesOnly ? 'all' : 'not-sold'
|
||||
)}
|
||||
onChange={(value) => {
|
||||
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' }
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type={activeCount ? 'primary' : 'default'}
|
||||
>
|
||||
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||
</Button>
|
||||
<ActiveFilterChips
|
||||
categories={options?.categories ?? []}
|
||||
tags={options?.tags ?? []}
|
||||
<FilterBar
|
||||
dimensions={STOREFRONT_DIMENSIONS}
|
||||
filters={filters}
|
||||
onChange={applyFilters}
|
||||
onClear={clearFilters}
|
||||
categories={options?.categories ?? []}
|
||||
tags={options?.tags ?? []}
|
||||
priceRange={options?.priceRange ?? null}
|
||||
resultCount={items.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<Link to="/privacy">Privacy Policy</Link>
|
||||
</Footer>
|
||||
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => 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. */}
|
||||
|
||||
@@ -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 (
|
||||
<div className="inventory-filters">
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type={activeCount ? 'primary' : 'default'}
|
||||
>
|
||||
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||
</Button>
|
||||
|
||||
<ActiveFilterChips
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
<FilterBar
|
||||
dimensions={DIMENSIONS}
|
||||
filters={filters}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
showStatus
|
||||
/>
|
||||
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => 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
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
<div className="active-filter-chips" role="group" aria-label="Active filters">
|
||||
{chips.map((chip) => (
|
||||
<Tag
|
||||
key={chip.key}
|
||||
// Undefined for every filter that has no colour of its own, which is
|
||||
// antd's default rendering — the same as before this distinguished
|
||||
// tags. The custom close icon below inherits the tag's text colour,
|
||||
// so a coloured chip gets a matching cross rather than a grey one.
|
||||
color={chip.color}
|
||||
closable
|
||||
onClose={(event) => {
|
||||
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={
|
||||
<span role="button" aria-label={`Remove filter ${chip.label.split(' / ').pop()}`}>×</span>
|
||||
}
|
||||
>
|
||||
{chip.label}
|
||||
</Tag>
|
||||
))}
|
||||
<Button size="small" type="link" onClick={onClear}>Clear all</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Drawer
|
||||
title="Filters"
|
||||
placement="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
// 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={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button block onClick={onClear}>Clear all</Button>
|
||||
<Button block type="primary" onClick={onClose}>
|
||||
Show {resultCount} {resultCount === 1 ? 'item' : 'items'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* 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 && (
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Favorites
|
||||
</h4>
|
||||
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
|
||||
button, which is labelable, so a wrapping label can forward a click
|
||||
the switch already handled and toggle it twice. The accessible name
|
||||
comes from aria-label instead. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Switch
|
||||
checked={filters.favoritesOnly}
|
||||
onChange={(checked) => onChange({ ...filters, favoritesOnly: checked })}
|
||||
aria-label="Only my favorites"
|
||||
/>
|
||||
<span>Only my favorites</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Categories — any of these
|
||||
</h4>
|
||||
{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.
|
||||
<TreeSelect
|
||||
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
||||
value={filters.categoryIds}
|
||||
onChange={selectCategories}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Tags — must have all of these
|
||||
</h4>
|
||||
{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.
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
placeholder="Any tags"
|
||||
style={{ width: '100%' }}
|
||||
aria-label="Filter by tags"
|
||||
value={filters.tagIds}
|
||||
onChange={(tagIds: number[]) => onChange({ ...filters, tagIds })}
|
||||
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
||||
tagRender={({ value, label, closable, onClose }) => (
|
||||
<Tag
|
||||
color={tagColors.get(Number(value))}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
// antd's default, which the custom renderer replaces: without
|
||||
// it the pill swallows the mousedown and reopens the list.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
style={{ marginInlineEnd: 4 }}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={showStatus ? { marginBottom: 28 } : undefined}>
|
||||
<h4 style={sectionHeading}>
|
||||
Price
|
||||
</h4>
|
||||
{priceRange && (
|
||||
<Slider
|
||||
range
|
||||
min={bounds.min_cents}
|
||||
max={sliderMax}
|
||||
step={100}
|
||||
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
||||
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
||||
onChange={([min, max]) =>
|
||||
// antd types the slider's value as number[], so destructuring gives
|
||||
// `number | undefined`. A range slider always emits both ends; the
|
||||
// fallbacks are the bounds it was given rather than nulls, which
|
||||
// would read as "no filter" and widen the results.
|
||||
onChange({
|
||||
...filters,
|
||||
minPriceCents: min ?? bounds.min_cents,
|
||||
maxPriceCents: max ?? sliderMax
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<InputNumber
|
||||
aria-label="Minimum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={centsToDollars(filters.minPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
<span style={{ opacity: 0.6 }}>to</span>
|
||||
<InputNumber
|
||||
aria-label="Maximum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={centsToDollars(filters.maxPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showStatus && (
|
||||
<section>
|
||||
<h4 style={sectionHeading}>
|
||||
Status — any of these
|
||||
</h4>
|
||||
{/* The status dimension itself rather than presets over it, which
|
||||
#105's Sold / Not sold / All control was. Presets could not express
|
||||
Published or Unpublished, could not isolate Reserved, and would
|
||||
have grown a new button for every new question. Selecting statuses
|
||||
answers all of them: Unpublished is Pending, Published is the other
|
||||
three, and Not sold is everything except Sold.
|
||||
|
||||
A second control for publication would have read more naturally and
|
||||
reintroduced what #105 avoided — Sold and Unpublished is an
|
||||
impossible pair, since a sold item is necessarily published. One
|
||||
dimension cannot contradict itself. See #132. */}
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="Any status"
|
||||
aria-label="Filter by status"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.status ?? []}
|
||||
onChange={(value: ItemStatus[]) =>
|
||||
// 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}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<div key={dimension.key}>{dimension.render(context)}</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type={chips.length ? 'primary' : 'default'}
|
||||
>
|
||||
Filters{chips.length ? ` (${chips.length})` : ''}
|
||||
</Button>
|
||||
|
||||
<FilterChips chips={chips} onClear={onClear} />
|
||||
|
||||
<Drawer
|
||||
title="Filters"
|
||||
placement="right"
|
||||
open={drawerOpen}
|
||||
onClose={() => 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={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button block onClick={onClear}>Clear all</Button>
|
||||
<Button block type="primary" onClick={() => setDrawerOpen(false)}>
|
||||
Show {resultCount} {resultCount === 1 ? 'item' : 'items'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{drawerDimensions.map((dimension, index) => (
|
||||
<section
|
||||
key={dimension.key}
|
||||
style={index < drawerDimensions.length - 1 ? { marginBottom: 28 } : undefined}
|
||||
>
|
||||
{dimension.heading && <h4 style={sectionHeading}>{dimension.heading}</h4>}
|
||||
{dimension.render(context)}
|
||||
</section>
|
||||
))}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
<div className="active-filter-chips" role="group" aria-label="Active filters">
|
||||
{chips.map((chip) => (
|
||||
<Tag
|
||||
key={chip.key}
|
||||
// Undefined for every filter with no colour of its own, which is
|
||||
// antd's default. The close icon below inherits the tag's text
|
||||
// colour, so a coloured chip gets a matching cross.
|
||||
color={chip.color}
|
||||
closable
|
||||
onClose={(event) => {
|
||||
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={
|
||||
<span role="button" aria-label={`Remove filter ${chip.label.split(' / ').pop()}`}>×</span>
|
||||
}
|
||||
>
|
||||
{chip.label}
|
||||
</Tag>
|
||||
))}
|
||||
<Button size="small" type="link" onClick={onClear}>Clear all</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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 ? (
|
||||
<TreeSelect
|
||||
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
||||
value={filters.categoryIds}
|
||||
onChange={(categoryIds: number[]) => 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"
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
|
||||
),
|
||||
|
||||
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 ? (
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
placeholder="Any tags"
|
||||
style={{ width: '100%' }}
|
||||
aria-label="Filter by tags"
|
||||
value={filters.tagIds}
|
||||
onChange={(tagIds: number[]) => onChange({ ...filters, tagIds })}
|
||||
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
||||
tagRender={({ value, label, closable, onClose }) => (
|
||||
<Tag
|
||||
color={colours.get(Number(value))}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
// antd's default, which this renderer replaces: without it the pill
|
||||
// swallows the mousedown and reopens the list.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
style={{ marginInlineEnd: 4 }}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
|
||||
);
|
||||
},
|
||||
|
||||
chips: ({ tags, filters, onChange }) =>
|
||||
filters.tagIds.map((tagId) => {
|
||||
const tag = tags.find((candidate) => candidate.id === tagId);
|
||||
return {
|
||||
key: `tag-${tagId}`,
|
||||
label: tag?.name ?? `Tag ${tagId}`,
|
||||
// Undefined while /api/filters is loading, which the label fallback
|
||||
// already covers — an uncoloured chip beats a missing one.
|
||||
color: tag?.color,
|
||||
onRemove: () =>
|
||||
onChange({ ...filters, tagIds: filters.tagIds.filter((id) => id !== tagId) })
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
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 const priceDimension: FilterDimension = {
|
||||
key: 'price',
|
||||
placement: 'drawer',
|
||||
heading: 'Price',
|
||||
|
||||
render: ({ priceRange, filters, onChange }) => {
|
||||
const bounds = priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
|
||||
return (
|
||||
<>
|
||||
{/* Only where there are real bounds. Invented ones would misreport
|
||||
where the prices actually are. */}
|
||||
{priceRange && (
|
||||
<Slider
|
||||
range
|
||||
min={bounds.min_cents}
|
||||
max={sliderMax}
|
||||
step={100}
|
||||
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
||||
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
||||
onChange={([min, max]) =>
|
||||
// antd types the value as number[], so destructuring gives
|
||||
// `number | undefined`. A range slider always emits both ends;
|
||||
// the fallbacks are the bounds rather than nulls, which would
|
||||
// read as "no filter" and widen the results.
|
||||
onChange({
|
||||
...filters,
|
||||
minPriceCents: min ?? bounds.min_cents,
|
||||
maxPriceCents: max ?? sliderMax
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<InputNumber
|
||||
aria-label="Minimum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={centsToDollars(filters.minPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
<span style={{ opacity: 0.6 }}>to</span>
|
||||
<InputNumber
|
||||
aria-label="Maximum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={centsToDollars(filters.maxPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
chips: ({ filters, onChange }) =>
|
||||
filters.minPriceCents === null && filters.maxPriceCents === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'price',
|
||||
label: formatPriceRange(filters.minPriceCents, filters.maxPriceCents),
|
||||
onRemove: () => onChange({ ...filters, minPriceCents: null, maxPriceCents: null })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const favoritesDimension: FilterDimension = {
|
||||
key: 'favorites',
|
||||
placement: 'drawer',
|
||||
heading: 'Favorites',
|
||||
|
||||
// Shown to signed-out visitors too: switching it on prompts them to sign in,
|
||||
// which is how they learn favorites exist. The prompt itself is not this
|
||||
// dimension's business — useCatalogue reports needsFavoritesAuth and the page
|
||||
// owns the modal.
|
||||
render: ({ filters, onChange }) => (
|
||||
// Deliberately not wrapped in a <label>: antd renders the switch as a
|
||||
// button, which is labelable, so a wrapping label can forward a click the
|
||||
// switch already handled and toggle it twice.
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Switch
|
||||
checked={filters.favoritesOnly}
|
||||
onChange={(checked) => onChange({ ...filters, favoritesOnly: checked })}
|
||||
aria-label="Only my favorites"
|
||||
/>
|
||||
<span>Only my favorites</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
chips: ({ filters, onChange }) =>
|
||||
filters.favoritesOnly
|
||||
? [
|
||||
{
|
||||
key: 'favorites',
|
||||
label: 'My favorites',
|
||||
onRemove: () => onChange({ ...filters, favoritesOnly: false })
|
||||
}
|
||||
]
|
||||
: []
|
||||
};
|
||||
|
||||
/**
|
||||
* The statuses themselves, as a multi-select.
|
||||
*
|
||||
* Mutually exclusive with availabilityDimension below, which is the preset
|
||||
* alternative over the same `filters.status` field: a screen composes one or
|
||||
* the other, never both. Composing both type-checks and renders two controls
|
||||
* over one field, each fighting the other's writes and each contributing chips,
|
||||
* so the tally counts the same filter twice.
|
||||
*/
|
||||
export const statusDimension: FilterDimension = {
|
||||
key: 'status',
|
||||
placement: 'drawer',
|
||||
heading: 'Status — any of these',
|
||||
|
||||
// The status dimension itself rather than presets over it, which #105's Sold
|
||||
// / Not sold / All control was. Presets could not express Published or
|
||||
// Unpublished and could not isolate Reserved. Selecting statuses answers all
|
||||
// of them: Unpublished is Pending, Published is the other three, and Not sold
|
||||
// is everything except Sold. See #132.
|
||||
render: ({ filters, onChange }) => (
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="Any status"
|
||||
aria-label="Filter by status"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.status ?? []}
|
||||
onChange={(value: ItemStatus[]) =>
|
||||
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<SaleState, string> = {
|
||||
'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 }) => (
|
||||
<Segmented
|
||||
aria-label="Filter by availability"
|
||||
// The fallback depends on favoritesOnly, and must. Favorites deliberately
|
||||
// include sold items (see ItemFilters), so with the favorites switch on
|
||||
// and no explicit status the grid really is showing everything — a
|
||||
// control reading "Not sold" over sold items on screen would be lying
|
||||
// about what the customer is looking at. chips() below passes a different
|
||||
// fallback on purpose; the two are not a copy of each other to unify.
|
||||
value={saleStateFromStatuses(
|
||||
filters.status,
|
||||
STOREFRONT_SALE_STATUSES,
|
||||
filters.favoritesOnly ? 'all' : 'not-sold'
|
||||
)}
|
||||
onChange={(value) => {
|
||||
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 })
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ItemFilters>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user