Feature/92 editable email templates #112
@@ -0,0 +1,115 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ 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 { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
|
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
|
||||||
|
import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
|
||||||
|
import EmailTemplateCard from './EmailTemplateCard';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAdminSettings()
|
fetchAdminSettings()
|
||||||
@@ -23,6 +26,22 @@ export default function Settings() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmailTemplates()
|
||||||
|
.then(setTemplates)
|
||||||
|
// Reported rather than swallowed: an empty list would otherwise read as
|
||||||
|
// "there are no templates" instead of "they could not be loaded".
|
||||||
|
.catch(() => message.error('Could not load the email templates'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Replaces the one that changed so the Customised badge and the Restore
|
||||||
|
// button reflect what the server now holds, without refetching the rest.
|
||||||
|
function handleTemplateChanged(updated: EmailTemplate) {
|
||||||
|
setTemplates(current =>
|
||||||
|
current.map(t => (t.key === updated.key ? { ...t, subject: updated.subject, body: updated.body } : t))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
await updateAdminSettings(values);
|
await updateAdminSettings(values);
|
||||||
@@ -30,6 +49,7 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ maxWidth: 720 }}>
|
||||||
<Card style={{ maxWidth: 480 }}>
|
<Card style={{ maxWidth: 480 }}>
|
||||||
<Title level={4}>Cart Settings</Title>
|
<Title level={4}>Cart Settings</Title>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
@@ -42,5 +62,16 @@ export default function Settings() {
|
|||||||
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Title level={4} style={{ marginTop: 32 }}>Customer emails</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
The wording customers receive. Leave one alone and it sends the built-in copy.
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
{templates.map(template => (
|
||||||
|
<EmailTemplateCard key={template.key} template={template} onChanged={handleTemplateChanged} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export interface EmailTemplate {
|
||||||
|
key: string;
|
||||||
|
/** Human name for the card, so it is identifiable without reading the body. */
|
||||||
|
label: string;
|
||||||
|
/** Placeholders the body must keep. Saving without one is refused. */
|
||||||
|
required: string[];
|
||||||
|
/** Every placeholder this template understands. */
|
||||||
|
available: string[];
|
||||||
|
defaultSubject: string;
|
||||||
|
defaultBody: string;
|
||||||
|
/** Null when never customised, which is distinct from "same as the default". */
|
||||||
|
subject: string | null;
|
||||||
|
body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json<T>(res: Response): Promise<T> {
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's refusals say which placeholder is missing, and that message is
|
||||||
|
// the whole point of the validation — so it is surfaced rather than replaced
|
||||||
|
// with something generic.
|
||||||
|
async function expectOk(res: Response, action: string): Promise<Response> {
|
||||||
|
if (res.ok) return res;
|
||||||
|
const detail = await res.json().catch(() => null);
|
||||||
|
throw new Error(detail?.error ? String(detail.error) : action);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEmailTemplates(): Promise<EmailTemplate[]> {
|
||||||
|
const res = await expectOk(await fetch('/api/admin/email-templates'), 'could not load templates');
|
||||||
|
return json<EmailTemplate[]>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEmailTemplate(
|
||||||
|
key: string,
|
||||||
|
subject: string,
|
||||||
|
body: string
|
||||||
|
): Promise<EmailTemplate> {
|
||||||
|
const res = await expectOk(
|
||||||
|
await fetch(`/api/admin/email-templates/${key}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subject, body })
|
||||||
|
}),
|
||||||
|
'could not save the template'
|
||||||
|
);
|
||||||
|
return json<EmailTemplate>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetEmailTemplate(key: string): Promise<EmailTemplate> {
|
||||||
|
const res = await expectOk(
|
||||||
|
await fetch(`/api/admin/email-templates/${key}`, { method: 'DELETE' }),
|
||||||
|
'could not restore the default'
|
||||||
|
);
|
||||||
|
return json<EmailTemplate>(res);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user