feat(frontend): edit the customer emails from Admin → Settings (#92)
SonarQube Analysis / sonarqube (pull_request) Failing after 13m7s

A card per email under the existing settings screen: subject, body, the placeholders it understands, and which of them it cannot lose.

Each card starts from the copy that is actually in use — the stored version if there is one, the built-in default otherwise — rather than an empty box, so editing means changing words rather than writing the email from scratch. A badge distinguishes customised from default, which is why the API reports an unedited template as null rather than as its default text: the two are different states and the screen has to be able to tell them apart.

Restore default is offered only when there is something to restore, so it is never a button that looks like it did something and did not. It removes the stored rows rather than writing the defaults into them, which is what keeps the badge honest afterwards.

The server's refusal is shown verbatim. When a body drops a placeholder it needs, the message names which one, and that message is the entire value of the validation — replacing it with a generic failure would leave an admin guessing at which of five templates and which of three placeholders they broke.

A textarea rather than the markdown editor already used for item descriptions. That editor is a heavy dependency to load into the settings screen for five short bodies, and its preview would render markdown as the browser shows it rather than as the email renderer will — a preview that quietly disagrees with the output is worse than none. Worth revisiting if the copy gets longer.

Two things the end-to-end spec found rather than assumed. The refusal assertion first matched three elements, because the placeholder appears as the required marker, as an available tag, and inside the error — it now asserts the whole sentence. And the four tests raced each other: the suite runs fully parallel and they all edit one shared stored template, so one asserted a template was unset while another had just saved it. That describe block now runs serially, which is the honest fix for tests that mutate shared server state rather than making the assertions vaguer.

Verified: 99 end-to-end tests passing on a freshly created container, up from 95, with the whole suite run rather than the new spec alone — precisely because these tests write state other suites read. Build clean, lint unchanged at 27 warnings.

Refs #92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:05:29 -05:00
co-authored by Claude Opus 5
parent 4ed9513ad2
commit 6baa769520
4 changed files with 298 additions and 0 deletions
@@ -0,0 +1,96 @@
import { test, expect } from './fixtures';
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// Each test leaves the templates as it found them, because they are stored in
// admin_settings and would otherwise change the copy a later test reads.
async function restore(page: import('@playwright/test').Page, key: string) {
await page.request.delete(`/api/admin/email-templates/${key}`);
}
async function openSettings(page: import('@playwright/test').Page) {
await page.goto('/admin');
await page.getByRole('tab', { name: 'Settings' }).click();
await expect(page.getByRole('heading', { name: 'Customer emails' })).toBeVisible();
}
// 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.
test.describe.configure({ mode: 'serial' });
test.describe('Editing the customer emails', () => {
test.afterEach(async ({ page }) => {
await restore(page, 'passwordReset');
});
test('shows every template, marked default until it is edited', async ({ page }) => {
await openSettings(page);
for (const label of [
'Email verification',
'Password reset',
'Favorited item sold',
'Favorited item withdrawn',
'Cart reminder'
]) {
await expect(page.getByText(label, { exact: true })).toBeVisible();
}
});
test('saves a replacement subject and body', async ({ page }) => {
const subject = `Reset ${suffix()}`;
await openSettings(page);
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 expect(page.getByText('Password reset saved')).toBeVisible();
// Persisted, not merely accepted by the form.
const stored = await (await page.request.get('/api/admin/email-templates')).json();
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
expect(reset.subject).toBe(subject);
});
// The assertion that matters. A body without its link still sends and still
// looks fine in the log, so the save has to be refused rather than warned
// 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 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 expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
// And nothing was stored.
const stored = await (await page.request.get('/api/admin/email-templates')).json();
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
expect(reset.body).toBeNull();
});
test('restores the built-in copy', async ({ page }) => {
await page.request.put('/api/admin/email-templates/passwordReset', {
data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' }
});
await openSettings(page);
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
await card.getByRole('button', { name: 'Restore default' }).click();
await expect(page.getByText('Password reset restored to the default')).toBeVisible();
const stored = await (await page.request.get('/api/admin/email-templates')).json();
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
expect(reset.subject).toBeNull();
expect(reset.body).toBeNull();
});
});