feat: filter by Sold / Not sold / All on the storefront and the admin (#105)
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:
@@ -75,10 +75,41 @@ describe('parseItemFilters', () => {
|
||||
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
|
||||
});
|
||||
|
||||
// A single status parses to a list of one, which is what lets the admin's
|
||||
// existing ?status=sold keep working unchanged against the multi-value shape.
|
||||
it('parses each of the item statuses', () => {
|
||||
expect(parseItemFilters({ status: 'available' }).status).toBe('available');
|
||||
expect(parseItemFilters({ status: 'reserved' }).status).toBe('reserved');
|
||||
expect(parseItemFilters({ status: 'sold' }).status).toBe('sold');
|
||||
expect(parseItemFilters({ status: 'available' }).status).toEqual(['available']);
|
||||
expect(parseItemFilters({ status: 'reserved' }).status).toEqual(['reserved']);
|
||||
expect(parseItemFilters({ status: 'sold' }).status).toEqual(['sold']);
|
||||
expect(parseItemFilters({ status: 'pending' }).status).toEqual(['pending']);
|
||||
});
|
||||
|
||||
it('parses several statuses from one comma-separated value', () => {
|
||||
expect(parseItemFilters({ status: 'available,reserved' }).status).toEqual([
|
||||
'available',
|
||||
'reserved'
|
||||
]);
|
||||
expect(parseItemFilters({ status: ' available , sold ' }).status).toEqual([
|
||||
'available',
|
||||
'sold'
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops a repeated status rather than listing it twice', () => {
|
||||
expect(parseItemFilters({ status: 'sold,sold' }).status).toEqual(['sold']);
|
||||
});
|
||||
|
||||
// Refused rather than dropped. Ignoring the unknown name would turn this into
|
||||
// "available only" - narrower than what was asked for, and indistinguishable
|
||||
// from a filter that worked.
|
||||
it('rejects a list containing an unknown status', () => {
|
||||
expect(() => parseItemFilters({ status: 'available,sold_out' })).toThrow(FilterError);
|
||||
});
|
||||
|
||||
// Asked for something, named nothing. Answering null would mean "no status
|
||||
// filter", which on the storefront is the default rather than everything.
|
||||
it('rejects a list that names nothing', () => {
|
||||
expect(() => parseItemFilters({ status: ',,' })).toThrow(FilterError);
|
||||
});
|
||||
|
||||
it('treats an absent or empty status as no status filter', () => {
|
||||
@@ -145,8 +176,17 @@ describe('buildItemFilterSql', () => {
|
||||
|
||||
it('filters on status', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1, null);
|
||||
expect(built.clauses.join(' ')).toContain('i.status');
|
||||
expect(built.params).toEqual(['reserved']);
|
||||
expect(built.clauses.join(' ')).toContain('i.status = ANY');
|
||||
expect(built.params).toEqual([['reserved']]);
|
||||
});
|
||||
|
||||
// One clause for one status and for several, which is the whole reason the
|
||||
// filter was generalised rather than joined by a second dimension.
|
||||
it('filters on several statuses with the same single clause', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({ status: 'available,reserved' }), 1, null);
|
||||
expect(built.clauses).toHaveLength(1);
|
||||
expect(built.clauses[0]).toContain('i.status = ANY');
|
||||
expect(built.params).toEqual([['available', 'reserved']]);
|
||||
});
|
||||
|
||||
it('restricts to the favorites of the given customer', () => {
|
||||
|
||||
Reference in New Issue
Block a user