Merge branch 'main' into feature/110-resend-verification
This commit is contained in:
@@ -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 {
|
export interface StoredTemplate {
|
||||||
subject?: string | null;
|
subject?: string | null;
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
|
|||||||
@@ -9,7 +9,17 @@ export interface ItemFilters {
|
|||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
minPriceCents: number | null;
|
minPriceCents: number | null;
|
||||||
maxPriceCents: 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
|
// 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
|
// 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.
|
// 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.
|
// to refuse it themselves rather than the parser refusing it for everyone.
|
||||||
export const NON_PUBLIC_STATUSES: readonly ItemStatus[] = ['pending'];
|
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 {
|
export interface BuiltFilter {
|
||||||
clauses: string[];
|
clauses: string[];
|
||||||
params: unknown[];
|
params: unknown[];
|
||||||
@@ -115,15 +138,39 @@ function parseTagIds(value: unknown): number[] {
|
|||||||
return tagIds;
|
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');
|
const raw = singleValue(value, 'status');
|
||||||
if (raw === null || raw === '') {
|
if (raw === null || raw === '') {
|
||||||
return null;
|
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');
|
throw new FilterError('invalid status');
|
||||||
}
|
}
|
||||||
return raw as ItemStatus;
|
return statuses;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFavoritesOnly(value: unknown): boolean {
|
function parseFavoritesOnly(value: unknown): boolean {
|
||||||
@@ -221,7 +268,10 @@ export function buildItemFilterSql(
|
|||||||
|
|
||||||
if (filters.status !== null) {
|
if (filters.status !== null) {
|
||||||
params.push(filters.status);
|
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++;
|
next++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
import { asyncRoute } from '../asyncRoute';
|
||||||
import { TEMPLATES, TemplateKey, StoredTemplate, missingPlaceholders } from '../emailTemplates';
|
import {
|
||||||
|
TEMPLATES,
|
||||||
|
TemplateKey,
|
||||||
|
StoredTemplate,
|
||||||
|
missingPlaceholders,
|
||||||
|
renderTemplate,
|
||||||
|
SAMPLE_VALUES
|
||||||
|
} from '../emailTemplates';
|
||||||
|
|
||||||
const router = Router();
|
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) => {
|
router.put('/:key', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const key = req.params.key;
|
const key = req.params.key;
|
||||||
if (!isTemplateKey(key)) {
|
if (!isTemplateKey(key)) {
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { Router, Request, Response } from 'express';
|
|||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
import { asyncRoute } from '../asyncRoute';
|
||||||
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
|
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
|
// 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
|
// 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
|
// 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
|
// the exclusion below it would return an empty list, which reads as "no items
|
||||||
// match" rather than "you may not ask that".
|
// 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' });
|
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 where = [EXCLUDE_PENDING, ...clauses].join(' AND ');
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`${PUBLIC_ITEM_SELECT} WHERE ${where} ORDER BY i.created_at DESC`,
|
`${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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
TEMPLATES,
|
TEMPLATES,
|
||||||
TemplateKey,
|
TemplateKey,
|
||||||
missingPlaceholders,
|
missingPlaceholders,
|
||||||
renderTemplate
|
renderTemplate,
|
||||||
|
SAMPLE_VALUES
|
||||||
} from '../../src/emailTemplates';
|
} from '../../src/emailTemplates';
|
||||||
|
|
||||||
const KEYS: TemplateKey[] = [
|
const KEYS: TemplateKey[] = [
|
||||||
@@ -147,3 +148,19 @@ describe('renderTemplate', () => {
|
|||||||
expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/);
|
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*\}\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,10 +75,41 @@ describe('parseItemFilters', () => {
|
|||||||
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
|
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', () => {
|
it('parses each of the item statuses', () => {
|
||||||
expect(parseItemFilters({ status: 'available' }).status).toBe('available');
|
expect(parseItemFilters({ status: 'available' }).status).toEqual(['available']);
|
||||||
expect(parseItemFilters({ status: 'reserved' }).status).toBe('reserved');
|
expect(parseItemFilters({ status: 'reserved' }).status).toEqual(['reserved']);
|
||||||
expect(parseItemFilters({ status: 'sold' }).status).toBe('sold');
|
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', () => {
|
it('treats an absent or empty status as no status filter', () => {
|
||||||
@@ -145,8 +176,17 @@ describe('buildItemFilterSql', () => {
|
|||||||
|
|
||||||
it('filters on status', () => {
|
it('filters on status', () => {
|
||||||
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1, null);
|
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1, null);
|
||||||
expect(built.clauses.join(' ')).toContain('i.status');
|
expect(built.clauses.join(' ')).toContain('i.status = ANY');
|
||||||
expect(built.params).toEqual(['reserved']);
|
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', () => {
|
it('restricts to the favorites of the given customer', () => {
|
||||||
|
|||||||
+36
-1
@@ -10,6 +10,7 @@ import theme from 'antd/es/theme';
|
|||||||
import Badge from 'antd/es/badge';
|
import Badge from 'antd/es/badge';
|
||||||
import Empty from 'antd/es/empty';
|
import Empty from 'antd/es/empty';
|
||||||
import Alert from 'antd/es/alert';
|
import Alert from 'antd/es/alert';
|
||||||
|
import Segmented from 'antd/es/segmented';
|
||||||
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||||
@@ -19,10 +20,13 @@ import FilterDrawer from './components/FilterDrawer';
|
|||||||
import ActiveFilterChips from './components/ActiveFilterChips';
|
import ActiveFilterChips from './components/ActiveFilterChips';
|
||||||
import {
|
import {
|
||||||
ItemFilters,
|
ItemFilters,
|
||||||
|
SaleState,
|
||||||
|
STOREFRONT_SALE_STATUSES,
|
||||||
activeFilterCount,
|
activeFilterCount,
|
||||||
filtersFromSearchParams,
|
filtersFromSearchParams,
|
||||||
filtersToSearchParams,
|
filtersToSearchParams,
|
||||||
hasActiveFilters
|
hasActiveFilters,
|
||||||
|
saleStateFromStatuses
|
||||||
} from './filters';
|
} from './filters';
|
||||||
import AuthPromptModal from './customer/AuthPromptModal';
|
import AuthPromptModal from './customer/AuthPromptModal';
|
||||||
import { useThemeMode } from './theme/ThemeContext';
|
import { useThemeMode } from './theme/ThemeContext';
|
||||||
@@ -248,6 +252,37 @@ export default function App() {
|
|||||||
</Header>
|
</Header>
|
||||||
<Content style={{ padding: 24 }}>
|
<Content style={{ padding: 24 }}>
|
||||||
<div className="filter-bar">
|
<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
|
<Button
|
||||||
icon={<FilterOutlined />}
|
icon={<FilterOutlined />}
|
||||||
onClick={() => setDrawerOpen(true)}
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import TreeSelect from 'antd/es/tree-select';
|
import TreeSelect from 'antd/es/tree-select';
|
||||||
import Select from 'antd/es/select';
|
import Select from 'antd/es/select';
|
||||||
|
import Segmented from 'antd/es/segmented';
|
||||||
import InputNumber from 'antd/es/input-number';
|
import InputNumber from 'antd/es/input-number';
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
import type { Category, Tag } from '../api';
|
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 {
|
interface CategoryTreeOption {
|
||||||
value: number;
|
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 {
|
interface Props {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
@@ -91,14 +93,32 @@ export default function InventoryFilters({ categories, tags, filters, onChange,
|
|||||||
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
{/* Replaces the four-way status dropdown that used to sit here. One
|
||||||
allowClear
|
control instead of two overlapping ways to say the same thing.
|
||||||
placeholder="Any status"
|
Note what it costs: a single status can no longer be isolated, so
|
||||||
aria-label="Filter by status"
|
there is no longer a way to view only Reserved, or only Pending.
|
||||||
style={{ minWidth: 150 }}
|
Not Sold folds pending in with available and reserved. If isolating
|
||||||
value={filters.status ?? undefined}
|
one status turns out to matter — the pending workflow from #90 is the
|
||||||
onChange={(value: ItemStatus | undefined) => onChange({ ...filters, status: value ?? null })}
|
likeliest candidate — the fix is to put that back alongside this
|
||||||
options={STATUS_OPTIONS}
|
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>}
|
{hasActiveFilters(filters) && <Button onClick={onClear}>Clear filters</Button>}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import Button from 'antd/es/button';
|
|||||||
import Typography from 'antd/es/typography';
|
import Typography from 'antd/es/typography';
|
||||||
import message from 'antd/es/message';
|
import message from 'antd/es/message';
|
||||||
import Card from 'antd/es/card';
|
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 { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
|
||||||
import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
|
import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
|
||||||
import EmailTemplateCard from './EmailTemplateCard';
|
import EmailTemplateEditor from './EmailTemplateEditor';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -67,11 +70,28 @@ export default function Settings() {
|
|||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
The wording customers receive. Leave one alone and it sends the built-in copy.
|
The wording customers receive. Leave one alone and it sends the built-in copy.
|
||||||
</Text>
|
</Text>
|
||||||
<div style={{ marginTop: 16 }}>
|
{/* Tabs rather than a stacked column. With six templates the cart reminder
|
||||||
{templates.map(template => (
|
sat below five editors, so which one you were editing was knowable only
|
||||||
<EmailTemplateCard key={template.key} template={template} onChanged={handleTemplateChanged} />
|
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
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,3 +54,28 @@ export async function resetEmailTemplate(key: string): Promise<EmailTemplate> {
|
|||||||
);
|
);
|
||||||
return json<EmailTemplate>(res);
|
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
@@ -7,15 +7,69 @@ export interface ItemFilters {
|
|||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
minPriceCents: number | null;
|
minPriceCents: number | null;
|
||||||
maxPriceCents: number | null;
|
maxPriceCents: number | null;
|
||||||
// Only the admin Inventory tab sets this; the storefront leaves it null and
|
// Several statuses, because the control this serves is not a status filter:
|
||||||
// shows every status, as it always has.
|
// "Not Sold" is available-or-reserved on the storefront and includes pending
|
||||||
status: ItemStatus | null;
|
// 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
|
// Storefront only, and only meaningful when signed in. Sold favorites are
|
||||||
// included: the storefront shows sold items everywhere else, and a favorite
|
// 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.
|
// that has just sold is often exactly what the customer came to look at.
|
||||||
favoritesOnly: boolean;
|
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 = {
|
export const EMPTY_FILTERS: ItemFilters = {
|
||||||
categoryId: null,
|
categoryId: null,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
@@ -34,7 +88,7 @@ export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
|
|||||||
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
||||||
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
||||||
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
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');
|
if (filters.favoritesOnly) params.set('favorites', '1');
|
||||||
return params;
|
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
|
// 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"
|
// round-trips through this function, so it is unaffected. Do not "complete"
|
||||||
// this list to match the type.
|
// 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 rawStatus = params.get('status');
|
||||||
const status = rawStatus === 'available' || rawStatus === 'reserved' || rawStatus === 'sold'
|
const parsedStatus = (rawStatus || '')
|
||||||
? rawStatus
|
.split(',')
|
||||||
: null;
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part !== '');
|
||||||
|
const status =
|
||||||
|
parsedStatus.length > 0 && parsedStatus.every(isPublicStatus)
|
||||||
|
? (parsedStatus as ItemStatus[])
|
||||||
|
: null;
|
||||||
|
|
||||||
const favorites = params.get('favorites');
|
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
|
// 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.
|
// 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 {
|
export function activeFilterCount(filters: ItemFilters): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (filters.categoryId !== null) count++;
|
if (filters.categoryId !== null) count++;
|
||||||
count += filters.tagIds.length;
|
count += filters.tagIds.length;
|
||||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||||||
if (filters.status !== null) count++;
|
|
||||||
if (filters.favoritesOnly) count++;
|
if (filters.favoritesOnly) count++;
|
||||||
return 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 {
|
export function hasActiveFilters(filters: ItemFilters): boolean {
|
||||||
return activeFilterCount(filters) > 0;
|
return activeFilterCount(filters) > 0 || filters.status !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryNode extends Category {
|
export interface CategoryNode extends Category {
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ test.beforeAll(async ({ playwright }) => {
|
|||||||
await api.dispose();
|
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 });
|
const row = (page: Page, name: string) => page.getByRole('row').filter({ hasText: name });
|
||||||
|
|
||||||
// The Inventory table paginates and other specs create items concurrently, so
|
// 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);
|
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 page.goto('/admin');
|
||||||
await filterToOwnCategory(page);
|
await filterToOwnCategory(page);
|
||||||
|
|
||||||
await page.getByRole('combobox', { name: 'Filter by status' }).click();
|
await chooseAvailability(page, 'Sold');
|
||||||
await page.getByTitle('Sold', { exact: true }).click();
|
|
||||||
|
|
||||||
// Every fixture is available, so a Sold filter must exclude them all.
|
// 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.cheap)).toHaveCount(0);
|
||||||
await expect(row(page, NAMES.mid)).toHaveCount(0);
|
await expect(row(page, NAMES.mid)).toHaveCount(0);
|
||||||
await expect(row(page, NAMES.dear)).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 }) => {
|
test('combines filters, and clearing restores them', async ({ page }) => {
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ async function openSettings(page: import('@playwright/test').Page) {
|
|||||||
await expect(page.getByRole('heading', { name: 'Customer emails' })).toBeVisible();
|
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
|
// Serial: these edit one shared stored template, and the suite runs fully
|
||||||
// parallel by default — so run concurrently they would race, one asserting a
|
// parallel by default — so run concurrently they would race, one asserting a
|
||||||
// template is unset while another has just saved it.
|
// template is unset while another has just saved it.
|
||||||
@@ -24,7 +35,7 @@ test.describe('Editing the customer emails', () => {
|
|||||||
await restore(page, 'passwordReset');
|
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);
|
await openSettings(page);
|
||||||
|
|
||||||
for (const label of [
|
for (const label of [
|
||||||
@@ -32,23 +43,27 @@ test.describe('Editing the customer emails', () => {
|
|||||||
'Password reset',
|
'Password reset',
|
||||||
'Favorited item sold',
|
'Favorited item sold',
|
||||||
'Favorited item withdrawn',
|
'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 }) => {
|
test('saves a replacement subject and body', async ({ page }) => {
|
||||||
const subject = `Reset ${suffix()}`;
|
const subject = `Reset ${suffix()}`;
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page.getByLabel('Password reset subject').fill(subject);
|
await page.getByLabel('Password reset subject').fill(subject);
|
||||||
await page
|
await page
|
||||||
.getByLabel('Password reset body')
|
.getByLabel('Password reset body')
|
||||||
.fill('Fresh wording. [Choose a new password]({{resetUrl}}).');
|
.fill('Fresh wording. [Choose a new password]({{resetUrl}}).');
|
||||||
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
|
|
||||||
await card.getByRole('button', { name: 'Save', exact: true }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('Password reset saved')).toBeVisible();
|
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.
|
// 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 }) => {
|
test('refuses a body that drops the required placeholder, and says which', async ({ page }) => {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page.getByLabel('Password reset body').fill('Just click the thing in your email.');
|
await page.getByLabel('Password reset body').fill('Just click the thing in your email.');
|
||||||
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
|
|
||||||
await card.getByRole('button', { name: 'Save', exact: true }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
|
await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
|
||||||
|
|
||||||
@@ -83,8 +97,8 @@ test.describe('Editing the customer emails', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
|
await openTemplate(page, 'Password reset');
|
||||||
await card.getByRole('button', { name: 'Restore default' }).click();
|
await page.getByRole('button', { name: 'Restore default' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Password reset restored to the default')).toBeVisible();
|
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();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user