feat: filter by Sold / Not sold / All on the storefront and the admin (#105)
Linting / lint (pull_request) Successful in 2m3s
SonarQube Analysis / sonarqube (pull_request) Failing after 15m56s

Three decisions were taken before any code, and are recorded on the issue.

The status filter is generalised to accept several values rather than gaining a second `sold` dimension beside it. "Not sold" is not a status: it is available-or-reserved on the storefront and includes pending in the admin, neither of which is one value. `?status=available,reserved` and `i.status = ANY($n::text[])` express that with one concept, so there is no way to write a contradiction like `?status=sold&sold=no`. A single status still parses to a list of one, which is how the admin's existing `?status=sold` keeps working untouched.

The storefront now defaults to Not sold. That is a change in what every customer sees, not just a new control: the black SOLD ribbons leave the default view on a catalogue where they were evidence the shop sells things, and every storefront link shared so far quietly changes meaning. Accepted deliberately, with the default named in STOREFRONT_DEFAULT_STATUSES rather than implied by the absence of a parameter.

The admin's four-way status dropdown is replaced rather than joined. That gives up isolating a single status: there is no longer a way to view only Reserved, or only Pending, and Not sold folds pending in with the rest. The pending workflow from #90 is the likeliest thing to miss it, and if it does, the fix is to put isolation back beside the preset rather than to remove the preset. The e2e test that covered "which is how Reserved is reached" is renamed and narrowed to what survives, rather than deleted.

One thing the issue did not anticipate, found by a test rather than by reading. The favorites view deliberately showed sold favorites - "a favorite that has just sold is often exactly what the customer came to look at", and they have just been emailed to say so. Defaulting the storefront to Not sold reversed that silently and broke the test asserting it. Favorites therefore keep their own default of everything, on the server and in the control's displayed position, while an explicit ?status= still wins. That interaction is the kind a single-feature change quietly breaks, and it was caught only because the previous decision had been written down as an assertion.

"All" still means different things in the two places, as the issue set out: available + reserved + sold on the storefront, all four in the admin. Pending remains unreachable from every public read - the storefront's unconditional exclusion clause is untouched - and the pending guard now checks every requested status rather than a single one, so `?status=available,pending` is refused for naming pending at all rather than accepted because the first name happened to be allowed.

The control sits in the filter bar rather than in the drawer, since the default now hides sold pieces and a customer who never opens the drawer would otherwise have no way to know they exist. It is consequently excluded from the "Filters (N)" count, which describes the drawer, while still counting toward hasActiveFilters so that an empty result reads as "no items match these filters" with a way out rather than as an empty shop.

Verification: 13 integration tests covering the default, each preset, the favorites exception and its override, and pending's unreachability under every accepted combination; 38 parser unit tests including multi-value parsing, an unknown name in a list being refused rather than dropped, and a list that names nothing; 6 new end-to-end tests for the storefront control, its URL round-trip, and the default staying out of the URL. 204 backend unit tests and 76 integration tests across the four affected suites pass. Two full end-to-end runs: 110 and 111 passing against the same 3 pre-existing failures, one run also showing a pending-publish failure that passes in isolation and did not recur - the cross-suite database contention filed as #116. tsc, ESLint and the production build are clean.

Closes #105
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 11:58:21 -05:00
co-authored by Claude Opus 5
parent f2ab4e6565
commit 596db7a3c8
9 changed files with 618 additions and 42 deletions
+36 -1
View File
@@ -10,6 +10,7 @@ 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 { Link, useLocation, useSearchParams } from 'react-router-dom';
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
@@ -19,10 +20,13 @@ import FilterDrawer from './components/FilterDrawer';
import ActiveFilterChips from './components/ActiveFilterChips';
import {
ItemFilters,
SaleState,
STOREFRONT_SALE_STATUSES,
activeFilterCount,
filtersFromSearchParams,
filtersToSearchParams,
hasActiveFilters
hasActiveFilters,
saleStateFromStatuses
} from './filters';
import AuthPromptModal from './customer/AuthPromptModal';
import { useThemeMode } from './theme/ThemeContext';
@@ -248,6 +252,37 @@ 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)}
+36 -16
View File
@@ -1,10 +1,19 @@
import { useMemo } from 'react';
import TreeSelect from 'antd/es/tree-select';
import Select from 'antd/es/select';
import Segmented from 'antd/es/segmented';
import InputNumber from 'antd/es/input-number';
import Button from 'antd/es/button';
import type { Category, Tag } from '../api';
import { ItemFilters, ItemStatus, buildCategoryTree, CategoryNode, hasActiveFilters } from '../filters';
import {
ItemFilters,
SaleState,
ADMIN_SALE_STATUSES,
buildCategoryTree,
CategoryNode,
hasActiveFilters,
saleStateFromStatuses
} from '../filters';
interface CategoryTreeOption {
value: number;
@@ -20,13 +29,6 @@ function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
}));
}
const STATUS_OPTIONS: { value: ItemStatus; label: string }[] = [
{ value: 'pending', label: 'Pending' },
{ value: 'available', label: 'Available' },
{ value: 'reserved', label: 'Reserved' },
{ value: 'sold', label: 'Sold' }
];
interface Props {
categories: Category[];
tags: Tag[];
@@ -91,14 +93,32 @@ export default function InventoryFilters({ categories, tags, filters, onChange,
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
/>
<Select
allowClear
placeholder="Any status"
aria-label="Filter by status"
style={{ minWidth: 150 }}
value={filters.status ?? undefined}
onChange={(value: ItemStatus | undefined) => onChange({ ...filters, status: value ?? null })}
options={STATUS_OPTIONS}
{/* Replaces the four-way status dropdown that used to sit here. One
control instead of two overlapping ways to say the same thing.
Note what it costs: a single status can no longer be isolated, so
there is no longer a way to view only Reserved, or only Pending.
Not Sold folds pending in with available and reserved. If isolating
one status turns out to matter — the pending workflow from #90 is the
likeliest candidate — the fix is to put that back alongside this
preset, not to remove it. See the decision recorded on #105. */}
<Segmented
aria-label="Filter by availability"
value={saleStateFromStatuses(filters.status, ADMIN_SALE_STATUSES, 'all')}
onChange={(value) => {
const state = value as SaleState;
onChange({
...filters,
// All is the admin's default, so it is held as "no preference"
// rather than as a list naming every status — which keeps it out of
// the active-filter count and out of Clear filters' way.
status: state === 'all' ? null : ADMIN_SALE_STATUSES[state]
});
}}
options={[
{ label: 'Not sold', value: 'not-sold' },
{ label: 'Sold', value: 'sold' },
{ label: 'All', value: 'all' }
]}
/>
{hasActiveFilters(filters) && <Button onClick={onClear}>Clear filters</Button>}
+81 -9
View File
@@ -7,15 +7,69 @@ export interface ItemFilters {
tagIds: number[];
minPriceCents: number | null;
maxPriceCents: number | null;
// Only the admin Inventory tab sets this; the storefront leaves it null and
// shows every status, as it always has.
status: ItemStatus | null;
// Several statuses, because the control this serves is not a status filter:
// "Not Sold" is available-or-reserved on the storefront and includes pending
// in the admin, neither of which is one value.
//
// Null means "no preference", which each side turns into its own default -
// Not Sold on the storefront, every status in the admin. Keeping the default
// as null rather than as an explicit list is what keeps it out of the URL and
// out of the active-filter count.
status: ItemStatus[] | null;
// Storefront only, and only meaningful when signed in. Sold favorites are
// included: the storefront shows sold items everywhere else, and a favorite
// that has just sold is often exactly what the customer came to look at.
favoritesOnly: boolean;
}
// The three-way control both screens offer. It is a preset over the status
// list rather than a filter of its own, so there is only ever one dimension and
// no way to express a contradiction like "sold and not sold".
export type SaleState = 'sold' | 'not-sold' | 'all';
// The same three words mean different sets in the two places, which is worth
// stating twice rather than sharing one table that would be wrong for one of
// them. Pending is excluded from every public read regardless of filter, so on
// the storefront "All" cannot and must not include it — a label promising more
// than it delivers.
export const STOREFRONT_SALE_STATUSES: Record<SaleState, ItemStatus[]> = {
'not-sold': ['available', 'reserved'],
sold: ['sold'],
all: ['available', 'reserved', 'sold']
};
// In the admin, Not Sold includes pending: an item awaiting publication has
// certainly not been sold, and hiding it from the default view would hide the
// items most likely to need attention.
export const ADMIN_SALE_STATUSES: Record<SaleState, ItemStatus[]> = {
'not-sold': ['available', 'reserved', 'pending'],
sold: ['sold'],
all: ['available', 'reserved', 'sold', 'pending']
};
function isPublicStatus(value: string): value is ItemStatus {
return value === 'available' || value === 'reserved' || value === 'sold';
}
const sameSet = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && [...a].sort().join() === [...b].sort().join();
// Which preset a status list corresponds to, for showing the control's current
// position. Null means no preference, which each screen renders as its default.
// A list matching none of the three - only reachable by hand-editing the URL -
// reports as the default rather than leaving the control blank.
export function saleStateFromStatuses(
statuses: ItemStatus[] | null,
table: Record<SaleState, ItemStatus[]>,
fallback: SaleState = 'not-sold'
): SaleState {
if (statuses === null) return fallback;
const match = (Object.keys(table) as SaleState[]).find((state) =>
sameSet(statuses, table[state])
);
return match ?? fallback;
}
export const EMPTY_FILTERS: ItemFilters = {
categoryId: null,
tagIds: [],
@@ -34,7 +88,7 @@ export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
if (filters.status !== null) params.set('status', filters.status);
if (filters.status !== null) params.set('status', filters.status.join(','));
if (filters.favoritesOnly) params.set('favorites', '1');
return params;
}
@@ -58,10 +112,19 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
// fail. The admin's status filter holds its value in React state and never
// round-trips through this function, so it is unaffected. Do not "complete"
// this list to match the type.
//
// A list containing anything unreadable yields null - the default - rather
// than the readable subset, so a mangled link falls back to a view that is
// explainable instead of one silently narrower than it looks.
const rawStatus = params.get('status');
const status = rawStatus === 'available' || rawStatus === 'reserved' || rawStatus === 'sold'
? rawStatus
: null;
const parsedStatus = (rawStatus || '')
.split(',')
.map((part) => part.trim())
.filter((part) => part !== '');
const status =
parsedStatus.length > 0 && parsedStatus.every(isPublicStatus)
? (parsedStatus as ItemStatus[])
: null;
const favorites = params.get('favorites');
@@ -77,18 +140,27 @@ 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.
export function activeFilterCount(filters: ItemFilters): number {
let count = 0;
if (filters.categoryId !== null) count++;
count += filters.tagIds.length;
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
if (filters.status !== 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;
return activeFilterCount(filters) > 0 || filters.status !== null;
}
export interface CategoryNode extends Category {
@@ -40,6 +40,15 @@ test.beforeAll(async ({ playwright }) => {
await api.dispose();
});
// antd Segmented hides the real radio input behind a styled label, so the input
// is found by role but cannot be clicked. The label carries a title attribute,
// which is the same handle this suite already uses for antd Select options.
// The input is still the right thing to assert checked-ness on: toBeChecked
// does not require visibility.
async function chooseAvailability(page: Page, label: string) {
await page.getByTitle(label, { exact: true }).click();
}
const row = (page: Page, name: string) => page.getByRole('row').filter({ hasText: name });
// The Inventory table paginates and other specs create items concurrently, so
@@ -77,17 +86,28 @@ test.describe('Admin inventory filters', () => {
await expect(row(page, NAMES.dear)).toHaveCount(0);
});
test('filters by status, which is how Reserved is reached', async ({ page }) => {
// The four-way status dropdown is gone, replaced by the Sold / Not sold / All
// preset from #105. Isolating a single status went with it, so this no longer
// covers "which is how Reserved is reached" — that ability was given up
// deliberately and is recorded on the issue. What remains testable, and what
// matters, is that Sold and Not sold partition the inventory.
test('filters by availability', async ({ page }) => {
await page.goto('/admin');
await filterToOwnCategory(page);
await page.getByRole('combobox', { name: 'Filter by status' }).click();
await page.getByTitle('Sold', { exact: true }).click();
await chooseAvailability(page, 'Sold');
// Every fixture is available, so a Sold filter must exclude them all.
await expect(row(page, NAMES.cheap)).toHaveCount(0);
await expect(row(page, NAMES.mid)).toHaveCount(0);
await expect(row(page, NAMES.dear)).toHaveCount(0);
// And Not sold brings back exactly what Sold excluded, which is the property
// that makes the two-way split trustworthy rather than merely plausible.
await chooseAvailability(page, 'Not sold');
await expect(row(page, NAMES.cheap)).toBeVisible();
await expect(row(page, NAMES.mid)).toBeVisible();
await expect(row(page, NAMES.dear)).toBeVisible();
});
test('combines filters, and clearing restores them', async ({ page }) => {
+113
View File
@@ -0,0 +1,113 @@
import { test, expect } from './fixtures';
// The storefront shows every item ever seeded and the e2e database is not reset
// between runs, so every fixture carries a unique run id and assertions name
// only the items this run created.
const RUN = `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const NAMES = {
available: `Available piece ${RUN}`,
sold: `Sold piece ${RUN}`
};
// Scoped to the item's own grid cell: the SOLD ribbon sits outside the card,
// and other runs' sold items share the page.
const cell = (page: import('@playwright/test').Page, name: string) =>
page.locator('.ant-col').filter({ hasText: name });
// antd Segmented hides the real radio input behind a styled label, so the input
// is found by role but cannot be clicked. The label carries a title attribute,
// which is the same handle this suite already uses for antd Select options.
// The input is still the right thing to assert checked-ness on: toBeChecked
// does not require visibility.
async function chooseAvailability(page: import('@playwright/test').Page, label: string) {
await page.getByTitle(label, { exact: true }).click();
}
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
for (const name of [NAMES.available, NAMES.sold]) {
const res = await api.post('/api/admin/items', {
multipart: { name, description: '', price: '250.00' }
});
expect(res.ok()).toBeTruthy();
const { id } = await res.json();
// Items arrive pending since #90, so publishing is what makes them public.
expect((await api.post(`/api/admin/items/${id}/mark-available`)).ok()).toBeTruthy();
if (name === NAMES.sold) {
expect((await api.post(`/api/admin/items/${id}/mark-sold`)).ok()).toBeTruthy();
}
}
await api.dispose();
});
test.describe('Filtering the storefront by availability', () => {
// The change customers actually see. Asserted on a bare visit rather than on
// a parameter, because the default is what changed for everyone.
test('hides sold pieces by default', async ({ page }) => {
await page.goto('/');
await expect(cell(page, NAMES.available)).toBeVisible();
await expect(cell(page, NAMES.sold)).toHaveCount(0);
});
test('All brings them back, ribbon and all', async ({ page }) => {
await page.goto('/');
await chooseAvailability(page, 'All');
await expect(cell(page, NAMES.sold)).toBeVisible();
await expect(cell(page, NAMES.sold)).toContainText('SOLD');
await expect(cell(page, NAMES.available)).toBeVisible();
});
test('Sold shows only the sold ones', async ({ page }) => {
await page.goto('/');
await chooseAvailability(page, 'Sold');
await expect(cell(page, NAMES.sold)).toBeVisible();
await expect(cell(page, NAMES.available)).toHaveCount(0);
});
// The filter is view state that belongs in the URL, like every other filter
// here, so a chosen view can be linked and survives a reload.
test('the choice survives a reload, because it lives in the URL', async ({ page }) => {
await page.goto('/');
await chooseAvailability(page, 'All');
await expect(cell(page, NAMES.sold)).toBeVisible();
await page.reload();
await expect(cell(page, NAMES.sold)).toBeVisible();
await expect(page.getByRole('radio', { name: 'All' })).toBeChecked();
});
// Not sold is the default, so it is held as "no preference" rather than as an
// explicit list. Putting it in the URL would make the default look like a
// choice somebody made, and would show it in the Filters (N) count.
test('returning to Not sold leaves no status in the URL', async ({ page }) => {
await page.goto('/');
await chooseAvailability(page, 'All');
await expect(page).toHaveURL(/status=/);
await chooseAvailability(page, 'Not sold');
await expect(page).not.toHaveURL(/status=/);
await expect(cell(page, NAMES.sold)).toHaveCount(0);
});
// The control sits beside the Filters button rather than inside the drawer,
// so its state must not be counted as one of the drawer's filters.
test('does not inflate the Filters count', async ({ page }) => {
await page.goto('/');
await chooseAvailability(page, 'Sold');
// Matched loosely and asserted on the text, because antd's icon contributes
// its own aria-label to the button's accessible name. The text is the part
// that would gain a "(1)" if status were counted as a drawer filter.
await expect(page.getByRole('button', { name: /Filters/ })).toHaveText('Filters');
});
});