Merge branch 'main' into feature/110-resend-verification
Linting / lint (pull_request) Successful in 1m58s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m37s

This commit is contained in:
2026-08-22 12:58:56 -05:00
17 changed files with 1004 additions and 176 deletions
+24
View File
@@ -160,6 +160,30 @@ function substitute(text: string, values: Record<string, string>): string {
);
}
/**
* Representative values for every placeholder any template accepts, used to
* render a preview in the admin.
*
* Kept here beside the definitions rather than in the route, so that adding a
* placeholder to a template puts the missing sample right next to the change
* that needs it. A unit test asserts every `available` name has an entry, since
* a missing one would render the preview with a literal {{placeholder}} in it
* and quietly teach the admin that their copy is broken when it is not.
*
* itemList is markdown because values are substituted into the markdown source
* before rendering, which is the same reason the real caller supplies markdown.
*/
export const SAMPLE_VALUES: Record<string, string> = {
greeting: 'Hi Ada,',
verifyUrl: 'https://example.com/verify-email?token=sample-token',
resetUrl: 'https://example.com/reset-password?token=sample-token',
itemName: 'Walnut sideboard',
siteUrl: 'https://example.com',
newEmail: 'new.address@example.com',
itemList: '- Walnut sideboard\n- Brass table lamp',
cartUrl: 'https://example.com/cart'
};
export interface StoredTemplate {
subject?: string | null;
body?: string | null;
+55 -5
View File
@@ -9,7 +9,17 @@ export interface ItemFilters {
tagIds: number[];
minPriceCents: number | null;
maxPriceCents: number | null;
status: ItemStatus | null;
// Several statuses rather than one, because the control this exists to serve
// is not a status filter. "Not sold" is available-or-reserved on the
// storefront and available-or-reserved-or-pending in the admin, so it cannot
// be expressed as equality against a single value. A single-status filter is
// still expressible: it arrives as a list of one, which is how the admin's
// old `?status=sold` keeps working unchanged.
//
// Null means the caller expressed no preference, which is distinct from
// asking for every status — the storefront turns the first into its default
// and the second into an explicit list.
status: ItemStatus[] | null;
// Storefront only: "just the items I have favorited". Which customer that
// means is not part of the parsed filter — it comes from the session at build
// time, so a query string can never name someone else's favorites.
@@ -28,6 +38,19 @@ const ITEM_STATUSES: readonly string[] = ['pending', 'available', 'reserved', 's
// to refuse it themselves rather than the parser refusing it for everyone.
export const NON_PUBLIC_STATUSES: readonly ItemStatus[] = ['pending'];
// What the storefront lists when the caller expressed no preference. Named here
// rather than implied by the absence of a parameter, because the absence is now
// meaningful: before this change no status meant every status, and afterwards it
// means these two. Anything reading a shared link from before will get the new
// meaning, which is the accepted cost of the default changing.
export const STOREFRONT_DEFAULT_STATUSES: readonly ItemStatus[] = ['available', 'reserved'];
// What "All" can mean on the storefront, which is not all of them. Pending items
// are excluded from every public read unconditionally, so a filter labelled All
// must not promise the fourth — a label that delivers less than it says is the
// shape this codebase keeps designing against.
export const STOREFRONT_ALL_STATUSES: readonly ItemStatus[] = ['available', 'reserved', 'sold'];
export interface BuiltFilter {
clauses: string[];
params: unknown[];
@@ -115,15 +138,39 @@ function parseTagIds(value: unknown): number[] {
return tagIds;
}
function parseStatus(value: unknown): ItemStatus | null {
// Comma-separated, matching how `tags` already works, so the two multi-value
// parameters in this parser read the same way in a URL.
//
// An unrecognised name is refused rather than dropped. Silently ignoring one
// would turn `?status=available,sold_out` into "available only" — narrower than
// what was asked for, and indistinguishable from a filter that worked.
function parseStatus(value: unknown): ItemStatus[] | null {
const raw = singleValue(value, 'status');
if (raw === null || raw === '') {
return null;
}
if (!ITEM_STATUSES.includes(raw)) {
const statuses: ItemStatus[] = [];
for (const part of raw.split(',')) {
const trimmed = part.trim();
if (trimmed === '') {
continue;
}
if (!ITEM_STATUSES.includes(trimmed)) {
throw new FilterError('invalid status');
}
// Duplicates are harmless in `= ANY(...)`, but removing them keeps the
// parsed filter a faithful description of what was asked for.
if (!statuses.includes(trimmed as ItemStatus)) {
statuses.push(trimmed as ItemStatus);
}
}
// `?status=,,` asked for something and named nothing. Returning null would
// silently mean "no status filter", which on the storefront now means the
// default rather than everything — a different answer from the one requested.
if (statuses.length === 0) {
throw new FilterError('invalid status');
}
return raw as ItemStatus;
return statuses;
}
function parseFavoritesOnly(value: unknown): boolean {
@@ -221,7 +268,10 @@ export function buildItemFilterSql(
if (filters.status !== null) {
params.push(filters.status);
clauses.push(`i.status = $${next}`);
// ANY rather than equality, so one status and several use the same clause.
// The ::text[] cast is explicit because `status` is a text column and the
// driver would otherwise have to infer the array's element type.
clauses.push(`i.status = ANY($${next}::text[])`);
next++;
}
+40 -1
View File
@@ -1,7 +1,14 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { TEMPLATES, TemplateKey, StoredTemplate, missingPlaceholders } from '../emailTemplates';
import {
TEMPLATES,
TemplateKey,
StoredTemplate,
missingPlaceholders,
renderTemplate,
SAMPLE_VALUES
} from '../emailTemplates';
const router = Router();
@@ -53,6 +60,38 @@ router.get('/', asyncRoute(async (_req: Request, res: Response) => {
);
}));
// Renders what an email would look like, from the subject and body in the
// editor rather than from what is stored — so an admin sees the effect of an
// edit before committing to it.
//
// Rendered here rather than in the browser, deliberately. renderTemplate is the
// only thing that turns this markdown into HTML, and markdown-it is configured
// with html: false, which is what stops an admin putting script into a
// customer's inbox. A second renderer in the frontend would be a second place
// for that setting to be wrong, and a preview that differs from the mailer is
// worse than no preview.
//
// Deliberately does not enforce required placeholders. Saving refuses a body
// that dropped one; previewing it is how an admin sees what they have done.
router.post('/:key/preview', asyncRoute(async (req: Request, res: Response) => {
const key = req.params.key;
if (!isTemplateKey(key)) {
return res.status(404).json({ error: 'unknown template' });
}
const { subject, body } = req.body ?? {};
const rendered = renderTemplate(
key,
{
subject: typeof subject === 'string' ? subject : null,
body: typeof body === 'string' ? body : null
},
SAMPLE_VALUES
);
res.json(rendered);
}));
router.put('/:key', asyncRoute(async (req: Request, res: Response) => {
const key = req.params.key;
if (!isTemplateKey(key)) {
+33 -3
View File
@@ -2,7 +2,14 @@ import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
import { parseItemFilters, buildItemFilterSql, FilterError, NON_PUBLIC_STATUSES } from '../itemFilters';
import {
parseItemFilters,
buildItemFilterSql,
FilterError,
NON_PUBLIC_STATUSES,
STOREFRONT_DEFAULT_STATUSES,
STOREFRONT_ALL_STATUSES
} from '../itemFilters';
// Applied to every public read, unconditionally. This route has never had a
// status filter of its own — sold items are listed and rendered with a Sold
@@ -38,11 +45,34 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
// admin routes, where 'pending' is valid, so it parses here too — and with
// the exclusion below it would return an empty list, which reads as "no items
// match" rather than "you may not ask that".
if (filters.status && NON_PUBLIC_STATUSES.includes(filters.status)) {
//
// Checked across every requested status, not just a single one: `?status=
// available,pending` must be refused for naming pending at all, rather than
// quietly answered because the first name in the list happened to be allowed.
if (filters.status?.some((status) => NON_PUBLIC_STATUSES.includes(status))) {
return res.status(400).json({ error: 'invalid status' });
}
const { clauses, params } = buildItemFilterSql(filters, 1, req.customerId ?? null);
// No preference means Not Sold rather than everything. Applied here rather
// than in the parser, which is shared with the admin, where the same absence
// has to go on meaning "every status including pending".
//
// Except when the customer asked for their own favorites, where the default
// stays everything. A favorite that has just sold is often exactly what the
// customer came to look at — they were emailed to say so — and hiding it
// would make an item they curated vanish without explanation. That was a
// deliberate decision before this filter existed, and defaulting favorites to
// Not Sold would have quietly reversed it. An explicit ?status= still wins,
// so the choice remains theirs.
const defaultStatuses = filters.favoritesOnly
? STOREFRONT_ALL_STATUSES
: STOREFRONT_DEFAULT_STATUSES;
const effectiveFilters = {
...filters,
status: filters.status ?? [...defaultStatuses]
};
const { clauses, params } = buildItemFilterSql(effectiveFilters, 1, req.customerId ?? null);
const where = [EXCLUDE_PENDING, ...clauses].join(' AND ');
const { rows } = await pool.query(
`${PUBLIC_ITEM_SELECT} WHERE ${where} ORDER BY i.created_at DESC`,
@@ -0,0 +1,196 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
// Direct insert so a test can put an item in a specific state without going
// through the transitions that put it there.
async function insertItem(name: string, status: string): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents, status) VALUES ($1, $2, $3) RETURNING id`,
[name, 10000, status]
);
return rows[0].id;
}
async function seedOneOfEach() {
await insertItem('Pending piece', 'pending');
await insertItem('Available piece', 'available');
await insertItem('Reserved piece', 'reserved');
await insertItem('Sold piece', 'sold');
}
const namesFrom = (body: { name: string }[]) => body.map((i) => i.name).sort();
describe('the storefront status filter', () => {
// The product decision this change carries: with no filter the storefront no
// longer lists sold items. Asserted on the default rather than on the
// parameter, because the default is the part that changed for everyone.
it('lists neither sold nor pending items by default', async () => {
await seedOneOfEach();
const res = await request(app).get('/api/items');
expect(res.status).toBe(200);
expect(namesFrom(res.body)).toEqual(['Available piece', 'Reserved piece']);
});
it('lists only sold items when asked for them', async () => {
await seedOneOfEach();
const res = await request(app).get('/api/items?status=sold');
expect(namesFrom(res.body)).toEqual(['Sold piece']);
});
it('brings sold items back for an explicit All', async () => {
await seedOneOfEach();
const res = await request(app).get('/api/items?status=available,reserved,sold');
expect(namesFrom(res.body)).toEqual(['Available piece', 'Reserved piece', 'Sold piece']);
});
it('lists not-sold items for an explicit Not Sold, matching the default', async () => {
await seedOneOfEach();
const explicit = await request(app).get('/api/items?status=available,reserved');
const implied = await request(app).get('/api/items');
expect(namesFrom(explicit.body)).toEqual(namesFrom(implied.body));
});
// The guarantee that must survive the filter being generalised. Pending is
// excluded from every public read by a clause the caller cannot opt out of,
// and these assert it directly rather than trusting the parser to go on
// refusing the name.
describe('pending stays unreachable from the storefront', () => {
it('refuses a request naming pending on its own', async () => {
await seedOneOfEach();
const res = await request(app).get('/api/items?status=pending');
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid status');
});
// The case a single-value check would have let through: the list is refused
// for naming pending at all, not accepted because the first name is fine.
it('refuses a request naming pending among allowed statuses', async () => {
await seedOneOfEach();
const res = await request(app).get('/api/items?status=available,pending');
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid status');
});
it('never returns a pending item under any accepted combination', async () => {
await seedOneOfEach();
for (const query of ['', '?status=sold', '?status=available,reserved,sold', '?status=reserved']) {
const res = await request(app).get(`/api/items${query}`);
expect(res.status).toBe(200);
expect(res.body.map((i: { name: string }) => i.name)).not.toContain('Pending piece');
}
});
});
it('refuses an unknown status rather than ignoring it', async () => {
const res = await request(app).get('/api/items?status=available,sold_out');
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid status');
});
});
describe('the admin inventory status filter', () => {
// All means all four here, unlike the storefront. Same word, two meanings,
// which is why each is asserted where it applies rather than assumed shared.
it('lists every status including pending when none is named', async () => {
await seedOneOfEach();
const res = await request(app).get('/api/admin/items');
expect(namesFrom(res.body)).toEqual([
'Available piece',
'Pending piece',
'Reserved piece',
'Sold piece'
]);
});
it('still accepts a single status, which is what the old dropdown sent', async () => {
await seedOneOfEach();
const res = await request(app).get('/api/admin/items?status=pending');
expect(namesFrom(res.body)).toEqual(['Pending piece']);
});
// Not Sold in the admin includes pending, which it does not on the storefront.
it('accepts a multi-status Not Sold that includes pending', async () => {
await seedOneOfEach();
const res = await request(app).get('/api/admin/items?status=available,reserved,pending');
expect(namesFrom(res.body)).toEqual(['Available piece', 'Pending piece', 'Reserved piece']);
});
});
describe('the favorites view keeps its own default', () => {
// Favorites defaulted to showing sold items before this filter existed, on
// the reasoning that a favorite which has just sold is often exactly what the
// customer came to look at — they were emailed to say so. Defaulting them to
// Not Sold alongside everything else would have quietly reversed that.
it('shows a sold favorite with no status named', async () => {
const agent = request.agent(app);
expect(
(await agent.post('/api/customers/register').send({
email: 'favdefault@example.com',
password: 'supersecret123',
firstName: 'Thom',
lastName: 'Lamb'
})).status
).toBe(200);
const soldId = await insertItem('Sold favorite', 'sold');
const availableId = await insertItem('Available favorite', 'available');
expect((await agent.post(`/api/customers/me/favorites/${soldId}`)).status).toBeLessThan(300);
expect((await agent.post(`/api/customers/me/favorites/${availableId}`)).status).toBeLessThan(300);
const res = await agent.get('/api/items?favorites=1');
expect(res.status).toBe(200);
expect(namesFrom(res.body)).toEqual(['Available favorite', 'Sold favorite']);
});
// The default is a default, not an override: naming a status still wins.
it('honours an explicit Not Sold within the favorites view', async () => {
const agent = request.agent(app);
await agent.post('/api/customers/register').send({
email: 'favexplicit@example.com',
password: 'supersecret123',
firstName: 'Thom',
lastName: 'Lamb'
});
const soldId = await insertItem('Sold favorite', 'sold');
const availableId = await insertItem('Available favorite', 'available');
await agent.post(`/api/customers/me/favorites/${soldId}`);
await agent.post(`/api/customers/me/favorites/${availableId}`);
const res = await agent.get('/api/items?favorites=1&status=available,reserved');
expect(namesFrom(res.body)).toEqual(['Available favorite']);
});
});
+18 -1
View File
@@ -2,7 +2,8 @@ import {
TEMPLATES,
TemplateKey,
missingPlaceholders,
renderTemplate
renderTemplate,
SAMPLE_VALUES
} from '../../src/emailTemplates';
const KEYS: TemplateKey[] = [
@@ -147,3 +148,19 @@ describe('renderTemplate', () => {
expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});
});
describe('SAMPLE_VALUES, which the admin preview renders with', () => {
// A missing sample renders the preview with a literal {{placeholder}} in it,
// which teaches the admin their copy is broken when it is not. Adding a
// placeholder to a template must mean adding a sample for it.
it.each(KEYS)('%s has a sample for every placeholder it accepts', (key) => {
const missing = TEMPLATES[key].available.filter((name) => !(name in SAMPLE_VALUES));
expect(missing).toEqual([]);
});
it.each(KEYS)('%s previews with no placeholder left unsubstituted', (key) => {
const { html, subject } = renderTemplate(key, {}, SAMPLE_VALUES);
expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/);
expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});
});
+45 -5
View File
@@ -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', () => {
+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)}
-115
View File
@@ -1,115 +0,0 @@
import { useState } from 'react';
import Card from 'antd/es/card';
import Input from 'antd/es/input';
import Button from 'antd/es/button';
import Space from 'antd/es/space';
import Tag from 'antd/es/tag';
import Typography from 'antd/es/typography';
import message from 'antd/es/message';
import { EmailTemplate, saveEmailTemplate, resetEmailTemplate } from './emailTemplatesApi';
const { Text, Paragraph } = Typography;
type Props = Readonly<{
template: EmailTemplate;
onChanged: (updated: EmailTemplate) => void;
}>;
export default function EmailTemplateCard({ template, onChanged }: Props) {
// Falls back to the default so the editor starts from the real copy rather
// than an empty box. `subject`/`body` being null means "never customised",
// which is why the badge below can say so.
const [subject, setSubject] = useState(template.subject ?? template.defaultSubject);
const [body, setBody] = useState(template.body ?? template.defaultBody);
const [saving, setSaving] = useState(false);
const customised = template.subject !== null || template.body !== null;
async function handleSave() {
setSaving(true);
try {
const updated = await saveEmailTemplate(template.key, subject, body);
onChanged(updated);
message.success(`${template.label} saved`);
} catch (err) {
// The server's refusal names the placeholder that is missing, which is
// the only useful thing to say here — so it is shown rather than replaced
// with something generic.
message.error((err as Error).message);
} finally {
setSaving(false);
}
}
async function handleReset() {
setSaving(true);
try {
const restored = await resetEmailTemplate(template.key);
setSubject(template.defaultSubject);
setBody(template.defaultBody);
onChanged(restored);
message.success(`${template.label} restored to the default`);
} catch (err) {
message.error((err as Error).message);
} finally {
setSaving(false);
}
}
return (
<Card
style={{ marginBottom: 16 }}
title={
<Space>
{template.label}
{customised ? <Tag color="blue">Customised</Tag> : <Tag>Default</Tag>}
</Space>
}
>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
Markdown. Placeholders are replaced when the email is sent
{template.required.length > 0 && (
<>
{' '} <Text strong>{template.required.map((n) => `{{${n}}}`).join(' and ')}</Text>{' '}
{template.required.length === 1 ? 'is' : 'are'} required and cannot be removed
</>
)}
.
</Paragraph>
<Space wrap size={[4, 4]} style={{ marginBottom: 12 }}>
{template.available.map((name) => (
<Tag key={name} style={{ fontFamily: 'monospace' }}>{`{{${name}}}`}</Tag>
))}
</Space>
<Input
aria-label={`${template.label} subject`}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Subject"
style={{ marginBottom: 8 }}
/>
<Input.TextArea
aria-label={`${template.label} body`}
value={body}
onChange={(e) => setBody(e.target.value)}
autoSize={{ minRows: 6, maxRows: 16 }}
style={{ fontFamily: 'monospace', marginBottom: 12 }}
/>
<Space>
<Button type="primary" loading={saving} onClick={handleSave}>
Save
</Button>
{/* Only offered when there is something to restore, so the button is
not a no-op that looks like it did something. */}
{customised && (
<Button loading={saving} onClick={handleReset}>
Restore default
</Button>
)}
</Space>
</Card>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
import Input from 'antd/es/input';
import Button from 'antd/es/button';
import Space from 'antd/es/space';
import Tag from 'antd/es/tag';
import Row from 'antd/es/row';
import Col from 'antd/es/col';
import Alert from 'antd/es/alert';
import Typography from 'antd/es/typography';
import message from 'antd/es/message';
import {
EmailTemplate,
saveEmailTemplate,
resetEmailTemplate,
previewEmailTemplate
} from './emailTemplatesApi';
const { Text, Paragraph } = Typography;
type Props = Readonly<{
template: EmailTemplate;
onChanged: (updated: EmailTemplate) => void;
}>;
// Long enough that the preview is not re-rendered on every keystroke, short
// enough that it feels like it is following what you type.
const PREVIEW_DEBOUNCE_MS = 400;
export default function EmailTemplateEditor({ template, onChanged }: Props) {
// Falls back to the default so the editor starts from the real copy rather
// than an empty box. `subject`/`body` being null means "never customised",
// which is why the badge on the tab can say so.
const [subject, setSubject] = useState(template.subject ?? template.defaultSubject);
const [body, setBody] = useState(template.body ?? template.defaultBody);
const [saving, setSaving] = useState(false);
const [preview, setPreview] = useState<{ subject: string; html: string } | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const customised = template.subject !== null || template.body !== null;
// Previews the draft in the editor, not what is stored, so the effect of an
// edit is visible before committing to it. Debounced, and the timer is
// cleared on change so an abandoned keystroke never issues a request.
useEffect(() => {
const timer = setTimeout(() => {
previewEmailTemplate(template.key, subject, body)
.then((rendered) => {
setPreview(rendered);
setPreviewError(null);
})
.catch((err: Error) => setPreviewError(err.message));
}, PREVIEW_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [template.key, subject, body]);
async function handleSave() {
setSaving(true);
try {
const updated = await saveEmailTemplate(template.key, subject, body);
onChanged(updated);
message.success(`${template.label} saved`);
} catch (err) {
// The server's refusal names the placeholder that is missing, which is
// the only useful thing to say here — so it is shown rather than replaced
// with something generic.
message.error((err as Error).message);
} finally {
setSaving(false);
}
}
async function handleReset() {
setSaving(true);
try {
const restored = await resetEmailTemplate(template.key);
setSubject(template.defaultSubject);
setBody(template.defaultBody);
onChanged(restored);
message.success(`${template.label} restored to the default`);
} catch (err) {
message.error((err as Error).message);
} finally {
setSaving(false);
}
}
return (
<Row gutter={24}>
<Col xs={24} lg={13}>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
Markdown. Placeholders are replaced when the email is sent
{template.required.length > 0 && (
<>
{' '} <Text strong>{template.required.map((n) => `{{${n}}}`).join(' and ')}</Text>{' '}
{template.required.length === 1 ? 'is' : 'are'} required and cannot be removed
</>
)}
.
</Paragraph>
<Space wrap size={[4, 4]} style={{ marginBottom: 12 }}>
{template.available.map((name) => (
<Tag key={name} style={{ fontFamily: 'monospace' }}>{`{{${name}}}`}</Tag>
))}
</Space>
<Input
aria-label={`${template.label} subject`}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Subject"
style={{ marginBottom: 8 }}
/>
<Input.TextArea
aria-label={`${template.label} body`}
value={body}
onChange={(e) => setBody(e.target.value)}
autoSize={{ minRows: 10, maxRows: 24 }}
style={{ fontFamily: 'monospace', marginBottom: 12 }}
/>
<Space>
<Button type="primary" loading={saving} onClick={handleSave}>
Save
</Button>
{/* Only offered when there is something to restore, so the button is
not a no-op that looks like it did something. */}
{customised && (
<Button loading={saving} onClick={handleReset}>
Restore default
</Button>
)}
</Space>
</Col>
<Col xs={24} lg={11}>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
Preview, with sample values in place of the placeholders.
</Paragraph>
{previewError && (
<Alert type="error" showIcon message={previewError} style={{ marginBottom: 8 }} />
)}
<div
style={{
border: '1px solid rgba(128,128,128,0.3)',
borderRadius: 8,
overflow: 'hidden'
}}
>
<div style={{ padding: '8px 12px', borderBottom: '1px solid rgba(128,128,128,0.3)' }}>
<Text type="secondary">Subject: </Text>
<Text strong data-testid="preview-subject">{preview?.subject ?? ''}</Text>
</div>
{/* An iframe rather than dangerouslySetInnerHTML. The markup is safe
by construction — the server renders it with markdown-it's raw HTML
disabled — but an email is its own styling context, and rendering
it inline would let the admin theme's CSS change how it looks and
so make the preview lie. sandbox with no allow-* keeps script
inert even if that guarantee ever slipped. */}
<iframe
title={`${template.label} preview`}
sandbox=""
srcDoc={preview?.html ?? ''}
style={{ width: '100%', height: 420, border: 0, background: '#fff' }}
/>
</div>
</Col>
</Row>
);
}
+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>}
+26 -6
View File
@@ -5,9 +5,12 @@ import Button from 'antd/es/button';
import Typography from 'antd/es/typography';
import message from 'antd/es/message';
import Card from 'antd/es/card';
import Tabs from 'antd/es/tabs';
import Tag from 'antd/es/tag';
import Space from 'antd/es/space';
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
import EmailTemplateCard from './EmailTemplateCard';
import EmailTemplateEditor from './EmailTemplateEditor';
const { Title, Text } = Typography;
@@ -67,11 +70,28 @@ export default function Settings() {
<Text type="secondary">
The wording customers receive. Leave one alone and it sends the built-in copy.
</Text>
<div style={{ marginTop: 16 }}>
{templates.map(template => (
<EmailTemplateCard key={template.key} template={template} onChanged={handleTemplateChanged} />
))}
</div>
{/* Tabs rather than a stacked column. With six templates the cart reminder
sat below five editors, so which one you were editing was knowable only
from a card title you had already scrolled past. The Default/Customised
tag moves onto the tab label, so which templates have been changed is
visible without opening each one. */}
<Tabs
style={{ marginTop: 16 }}
items={templates.map(template => ({
key: template.key,
label: (
<Space size={4}>
{template.label}
{template.subject !== null || template.body !== null
? <Tag color="blue">Customised</Tag>
: <Tag>Default</Tag>}
</Space>
),
children: (
<EmailTemplateEditor template={template} onChanged={handleTemplateChanged} />
)
}))}
/>
</div>
);
}
+25
View File
@@ -54,3 +54,28 @@ export async function resetEmailTemplate(key: string): Promise<EmailTemplate> {
);
return json<EmailTemplate>(res);
}
export interface RenderedEmail {
subject: string;
html: string;
}
// Rendered by the server, which owns the only markdown renderer in the system
// and the html: false setting that keeps script out of a customer's inbox. A
// preview rendered in the browser would be a second implementation of both, and
// one that disagreed with the mailer would be worse than none.
export async function previewEmailTemplate(
key: string,
subject: string,
body: string
): Promise<RenderedEmail> {
const res = await expectOk(
await fetch(`/api/admin/email-templates/${key}/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject, body })
}),
'could not render the preview'
);
return json<RenderedEmail>(res);
}
+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 }) => {
+83 -11
View File
@@ -14,6 +14,17 @@ async function openSettings(page: import('@playwright/test').Page) {
await expect(page.getByRole('heading', { name: 'Customer emails' })).toBeVisible();
}
// Opens one template's tab. Only the active tab's editor is mounted, which is
// what makes the labels below unambiguous — the previous stacked layout had
// every editor on screen at once and a locator for "Save" matched all six.
async function openTemplate(page: import('@playwright/test').Page, label: string) {
await page.getByRole('tab', { name: new RegExp(label) }).click();
await expect(page.getByLabel(`${label} subject`)).toBeVisible();
}
const previewFrame = (page: import('@playwright/test').Page, label: string) =>
page.frameLocator(`iframe[title="${label} preview"]`);
// Serial: these edit one shared stored template, and the suite runs fully
// parallel by default — so run concurrently they would race, one asserting a
// template is unset while another has just saved it.
@@ -24,7 +35,7 @@ test.describe('Editing the customer emails', () => {
await restore(page, 'passwordReset');
});
test('shows every template, marked default until it is edited', async ({ page }) => {
test('offers every template as a tab, marked default until it is edited', async ({ page }) => {
await openSettings(page);
for (const label of [
@@ -32,23 +43,27 @@ test.describe('Editing the customer emails', () => {
'Password reset',
'Favorited item sold',
'Favorited item withdrawn',
'Cart reminder'
'Cart reminder',
'Email address changed'
]) {
await expect(page.getByText(label, { exact: true })).toBeVisible();
await expect(page.getByRole('tab', { name: new RegExp(label) })).toBeVisible();
}
// The badge lives on the tab now, so which templates have been changed is
// visible without opening each one.
await expect(page.getByRole('tab', { name: /Password reset.*Default/ })).toBeVisible();
});
test('saves a replacement subject and body', async ({ page }) => {
const subject = `Reset ${suffix()}`;
await openSettings(page);
await openTemplate(page, 'Password reset');
await page.getByLabel('Password reset subject').fill(subject);
await page
.getByLabel('Password reset body')
.fill('Fresh wording. [Choose a new password]({{resetUrl}}).');
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
await card.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Password reset saved')).toBeVisible();
@@ -63,11 +78,10 @@ test.describe('Editing the customer emails', () => {
// about — and the admin has to be told which placeholder is missing.
test('refuses a body that drops the required placeholder, and says which', async ({ page }) => {
await openSettings(page);
await openTemplate(page, 'Password reset');
await page.getByLabel('Password reset body').fill('Just click the thing in your email.');
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
await card.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
@@ -83,8 +97,8 @@ test.describe('Editing the customer emails', () => {
});
await openSettings(page);
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
await card.getByRole('button', { name: 'Restore default' }).click();
await openTemplate(page, 'Password reset');
await page.getByRole('button', { name: 'Restore default' }).click();
await expect(page.getByText('Password reset restored to the default')).toBeVisible();
@@ -94,3 +108,61 @@ test.describe('Editing the customer emails', () => {
expect(reset.body).toBeNull();
});
});
test.describe('Previewing the customer emails', () => {
test.afterEach(async ({ page }) => {
await restore(page, 'passwordReset');
});
test('shows the draft being edited, not the stored copy', async ({ page }) => {
const wording = `Wording ${suffix()}`;
await openSettings(page);
await openTemplate(page, 'Password reset');
await page
.getByLabel('Password reset body')
.fill(`${wording}. [Choose a new password]({{resetUrl}}).`);
// Nothing has been saved. The preview still reflects it, which is the whole
// point: an admin sees the effect before committing to it.
await expect(previewFrame(page, 'Password reset').getByText(wording)).toBeVisible();
const stored = await (await page.request.get('/api/admin/email-templates')).json();
expect(stored.find((t: { key: string }) => t.key === 'passwordReset').body).toBeNull();
});
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => {
await openSettings(page);
await openTemplate(page, 'Password reset');
const frame = previewFrame(page, 'Password reset');
await expect(frame.getByRole('link')).toHaveAttribute('href', /reset-password\?token=/);
await expect(frame.locator('body')).not.toContainText('{{resetUrl}}');
});
// The control that stops an admin putting script into a customer's inbox is
// markdown-it's html: false on the server. The preview has to show the same
// thing the mailer emits, or it would be reassuring about the wrong output.
test('escapes raw HTML exactly as the mailer does', async ({ page }) => {
await openSettings(page);
await openTemplate(page, 'Password reset');
await page
.getByLabel('Password reset body')
.fill('<script>alert(1)</script> [link]({{resetUrl}})');
await expect(previewFrame(page, 'Password reset').getByText('<script>alert(1)</script>')).toBeVisible();
});
// Appended by the server and not editable, so it has to appear in the preview
// of the two templates it belongs to and nowhere else.
test('includes the consent footer on a favorite template, and not on others', async ({ page }) => {
await openSettings(page);
await openTemplate(page, 'Favorited item sold');
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible();
await openTemplate(page, 'Password reset');
await expect(previewFrame(page, 'Password reset').locator('body')).not.toContainText('account 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');
});
});