feat(admin): give the customer emails a tab of their own (#135) #138
@@ -29,6 +29,7 @@ import {
|
|||||||
} from '../api';
|
} from '../api';
|
||||||
import { useThemeMode } from '../theme/ThemeContext';
|
import { useThemeMode } from '../theme/ThemeContext';
|
||||||
import Customers from './Customers';
|
import Customers from './Customers';
|
||||||
|
import Emails from './Emails';
|
||||||
import Settings from './Settings';
|
import Settings from './Settings';
|
||||||
import Categories from './Categories';
|
import Categories from './Categories';
|
||||||
import Tags from './Tags';
|
import Tags from './Tags';
|
||||||
@@ -387,6 +388,7 @@ export default function Admin() {
|
|||||||
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||||
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||||
|
{ key: 'emails', label: 'Emails', children: <Emails /> },
|
||||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
|
|||||||
const { mode } = useThemeMode();
|
const { mode } = useThemeMode();
|
||||||
// Falls back to the default so the editor starts from the real copy rather
|
// 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",
|
// than an empty box. `subject`/`body` being null means "never customised",
|
||||||
// which is why the badge on the tab can say so.
|
// which is what the rail's dot and the tag below are reading.
|
||||||
const [subject, setSubject] = useState(template.subject ?? template.defaultSubject);
|
const [subject, setSubject] = useState(template.subject ?? template.defaultSubject);
|
||||||
const [body, setBody] = useState(template.body ?? template.defaultBody);
|
const [body, setBody] = useState(template.body ?? template.defaultBody);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -139,6 +139,8 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The rail marks a customised template with a dot only. The state in
|
||||||
|
full sits here, beside the button that acts on it. */}
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" loading={saving} onClick={handleSave}>
|
<Button type="primary" loading={saving} onClick={handleSave}>
|
||||||
Save
|
Save
|
||||||
@@ -150,6 +152,7 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
|
|||||||
Restore default
|
Restore default
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{customised ? <Tag color="blue">Customised</Tag> : <Tag>Default</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Tabs from 'antd/es/tabs';
|
||||||
|
import Spin from 'antd/es/spin';
|
||||||
|
import Typography from 'antd/es/typography';
|
||||||
|
import message from 'antd/es/message';
|
||||||
|
import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
|
||||||
|
import EmailTemplateEditor from './EmailTemplateEditor';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function Emails() {
|
||||||
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
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'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Replaces the one that changed so the customised marker 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">
|
||||||
|
The wording customers receive. Leave one alone and it sends the built-in copy.
|
||||||
|
</Text>
|
||||||
|
{/* Spinner rather than an empty rail: with templates starting empty, the
|
||||||
|
gap before the fetch lands reads as "there are no emails to edit". */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* A left rail rather than a strip across the top. Six labels wrapped on
|
||||||
|
narrower displays, and stacking them leaves the editor the full width
|
||||||
|
it needs for a markdown pane and a rendered preview side by side. */
|
||||||
|
<Tabs
|
||||||
|
tabPosition="left"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
items={templates.map(template => ({
|
||||||
|
key: template.key,
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
{template.label}
|
||||||
|
{/* A dot, not a Tag — six tags down a rail stop it being
|
||||||
|
scannable. aria-label keeps the word in the tab's
|
||||||
|
accessible name, so the state is not colour-only. */}
|
||||||
|
{(template.subject !== null || template.body !== null) && (
|
||||||
|
<span aria-label="Customised" title="Customised" style={{ marginLeft: 6 }}>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<EmailTemplateEditor template={template} onChanged={handleTemplateChanged} />
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,19 +5,13 @@ 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 EmailTemplateEditor from './EmailTemplateEditor';
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -29,22 +23,6 @@ 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);
|
||||||
@@ -52,7 +30,6 @@ 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">
|
||||||
@@ -65,33 +42,5 @@ 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>
|
|
||||||
{/* Tabs rather than a stacked column. With six templates the cart reminder
|
|
||||||
sat below five editors, so which one you were editing was knowable only
|
|
||||||
from a card title you had already scrolled past. The Default/Customised
|
|
||||||
tag moves onto the tab label, so which templates have been changed is
|
|
||||||
visible without opening each one. */}
|
|
||||||
<Tabs
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
items={templates.map(template => ({
|
|
||||||
key: template.key,
|
|
||||||
label: (
|
|
||||||
<Space size={4}>
|
|
||||||
{template.label}
|
|
||||||
{template.subject !== null || template.body !== null
|
|
||||||
? <Tag color="blue">Customised</Tag>
|
|
||||||
: <Tag>Default</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<EmailTemplateEditor template={template} onChanged={handleTemplateChanged} />
|
|
||||||
)
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ async function restore(page: import('@playwright/test').Page, key: string) {
|
|||||||
await page.request.delete(`/api/admin/email-templates/${key}`);
|
await page.request.delete(`/api/admin/email-templates/${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSettings(page: import('@playwright/test').Page) {
|
async function openEmails(page: import('@playwright/test').Page) {
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
await page.getByRole('tab', { name: 'Emails' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Customer emails' })).toBeVisible();
|
// The rail's first entry, rather than a heading — the tab label is the
|
||||||
|
// heading now, so there is no second one inside the page to wait on.
|
||||||
|
await expect(page.getByRole('tab', { name: /Email verification/ })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opens one template's tab. Only the active tab's editor is mounted, which is
|
// Opens one template's tab. Only the active tab's editor is mounted, which is
|
||||||
@@ -36,7 +38,7 @@ test.describe('Editing the customer emails', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('offers every template as a tab, 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 openEmails(page);
|
||||||
|
|
||||||
for (const label of [
|
for (const label of [
|
||||||
'Email verification',
|
'Email verification',
|
||||||
@@ -49,14 +51,15 @@ test.describe('Editing the customer emails', () => {
|
|||||||
await expect(page.getByRole('tab', { name: new RegExp(label) })).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
|
// Only a customised template is marked, so which ones have been changed is
|
||||||
// visible without opening each one.
|
// visible without opening each one. An untouched template carries nothing.
|
||||||
await expect(page.getByRole('tab', { name: /Password reset.*Default/ })).toBeVisible();
|
await expect(page.getByRole('tab', { name: /Password reset/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page.getByLabel('Password reset subject').fill(subject);
|
await page.getByLabel('Password reset subject').fill(subject);
|
||||||
@@ -77,7 +80,7 @@ test.describe('Editing the customer emails', () => {
|
|||||||
// looks fine in the log, so the save has to be refused rather than warned
|
// 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.
|
// 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 openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
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.');
|
||||||
@@ -96,7 +99,10 @@ test.describe('Editing the customer emails', () => {
|
|||||||
data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' }
|
data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' }
|
||||||
});
|
});
|
||||||
|
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
|
// Marked in the rail before it is opened, which is the whole point of the
|
||||||
|
// dot — the stored template above was never touched through the UI.
|
||||||
|
await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toBeVisible();
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
await page.getByRole('button', { name: 'Restore default' }).click();
|
await page.getByRole('button', { name: 'Restore default' }).click();
|
||||||
|
|
||||||
@@ -116,7 +122,7 @@ test.describe('Previewing the customer emails', () => {
|
|||||||
|
|
||||||
test('shows the draft being edited, not the stored copy', async ({ page }) => {
|
test('shows the draft being edited, not the stored copy', async ({ page }) => {
|
||||||
const wording = `Wording ${suffix()}`;
|
const wording = `Wording ${suffix()}`;
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page
|
await page
|
||||||
@@ -132,7 +138,7 @@ test.describe('Previewing the customer emails', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => {
|
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => {
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
const frame = previewFrame(page, 'Password reset');
|
const frame = previewFrame(page, 'Password reset');
|
||||||
@@ -144,7 +150,7 @@ test.describe('Previewing the customer emails', () => {
|
|||||||
// markdown-it's html: false on the server. The preview has to show the same
|
// 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.
|
// thing the mailer emits, or it would be reassuring about the wrong output.
|
||||||
test('escapes raw HTML exactly as the mailer does', async ({ page }) => {
|
test('escapes raw HTML exactly as the mailer does', async ({ page }) => {
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page
|
await page
|
||||||
@@ -157,7 +163,7 @@ test.describe('Previewing the customer emails', () => {
|
|||||||
// Appended by the server and not editable, so it has to appear in the preview
|
// 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.
|
// 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 }) => {
|
test('includes the consent footer on a favorite template, and not on others', async ({ page }) => {
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
|
|
||||||
await openTemplate(page, 'Favorited item sold');
|
await openTemplate(page, 'Favorited item sold');
|
||||||
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible();
|
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user