feat: tabs and a rendered preview for the email templates (#119)
Follow-up to #92, which shipped the editable templates as a column of stacked cards. With six templates the cart reminder sat below five editors, so reaching it meant scrolling past all of them and which one you were editing was knowable only from a card title you had already scrolled past. They are tabs now, and the Default/Customised tag moves onto the tab label, so which templates have been changed is visible without opening each one. The larger gap was that there was no way to see what the email would look like. The editor is a markdown textarea; what gets sent is rendered HTML with placeholders substituted and, for the two favorite templates, a consent footer appended by the server. An admin editing copy could not tell whether the result read correctly. POST /api/admin/email-templates/:key/preview renders the draft in the editor rather than what is stored, so the effect of an edit is visible before committing to it. It renders on the server deliberately: renderTemplate is the only thing in the system that turns this markdown into HTML, and markdown-it is configured there with html: false, which is the control that stops an admin putting script into a customer's inbox. A renderer in the browser would be a second implementation of both, and a preview that disagreed with the mailer would be worse than none. It does not enforce required placeholders - saving refuses a body that dropped one, and previewing it is how the admin sees what they have done. The preview renders into a sandboxed iframe rather than through dangerouslySetInnerHTML. The markup is safe by construction, but an email is its own styling context: rendered inline, the admin theme's CSS would change how it looks and the preview would lie about the result. Sample values live beside the template definitions rather than in the route, so adding a placeholder puts the missing sample next to the change that needs it. A unit test asserts every available placeholder has one, because a missing sample renders a literal {{placeholder}} into the preview and teaches the admin their copy is broken when it is not. This also fixes a test that has been failing on main. email-templates.spec.ts located the Save button by filtering .ant-card for the template name, which matched an outer card containing every template's Save button - six of them - and died on a strict mode violation, taking two more tests with it as unrun. Only the active tab's editor is mounted now, so the labels are unambiguous and the filter is gone. Verification: eight end-to-end tests, four for editing and four for the preview, covering the draft being previewed rather than the stored copy, sample values replacing placeholders, raw HTML being escaped exactly as the mailer escapes it, and the consent footer appearing on a favorite template and not on a password reset. The full suite goes from 100 passed / 3 failed / 2 unrun to 112 passed / 2 failed / 0 unrun; the two that remain are the pre-existing password-reset failures that need a database on port 55432 and fail identically on main. 38 backend unit tests pass, tsc and ESLint are clean. Closes #119 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user