feat(admin): make the placeholder chips insert at the cursor (#143) #168

Merged
bermudalamb merged 1 commits from feature/143-clickable-placeholders into main 2026-08-24 15:48:42 -05:00
3 changed files with 217 additions and 4 deletions
+88 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import Input from 'antd/es/input';
import type { InputRef } from 'antd/es/input';
import Button from 'antd/es/button';
import Space from 'antd/es/space';
import Tag from 'antd/es/tag';
@@ -41,6 +42,70 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
const [preview, setPreview] = useState<{ subject: string; html: string } | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
/**
* Which field a placeholder chip inserts into.
*
* Clicking a chip blurs whichever field had focus, so the choice has to be
* remembered rather than read at click time. It starts on the body because
* that is where placeholders almost always go, and because landing on the tab
* and clicking a chip should do something predictable rather than nothing.
*/
const [lastFocused, setLastFocused] = useState<'subject' | 'body'>('body');
const subjectRef = useRef<InputRef>(null);
// The textarea is queried from the wrapper rather than taken from MDEditor's
// ref, which exposes an internal store whose shape is not part of its API.
// The editor renders exactly one textarea.
const bodyWrapRef = useRef<HTMLDivElement>(null);
/**
* Where to put the caret once React has re-rendered with the new value.
*
* Both fields are controlled, so an insert is a string splice plus a state
* update, after which the caret would otherwise jump to the end. A ref rather
* than state: this is a message to the effect below, not something render
* depends on.
*/
const pendingCaret = useRef<{ field: 'subject' | 'body'; at: number } | null>(null);
function fieldElement(field: 'subject' | 'body'): HTMLInputElement | HTMLTextAreaElement | null {
if (field === 'subject') return subjectRef.current?.input ?? null;
return bodyWrapRef.current?.querySelector('textarea') ?? null;
}
/**
* Inserts a placeholder at the caret, replacing any selection.
*
* Goes through the same setState the keyboard does. Writing into the
* element's `value` directly would appear to work and would not: the next
* keystroke re-renders from state and the insert vanishes.
*/
function insertPlaceholder(name: string) {
const tag = `{{${name}}}`;
const field = lastFocused;
const element = fieldElement(field);
const current = field === 'subject' ? subject : body;
// Falls back to the end of the text when the field has never been focused,
// so a chip clicked immediately still does something sensible.
const start = element?.selectionStart ?? current.length;
const end = element?.selectionEnd ?? current.length;
const next = current.slice(0, start) + tag + current.slice(end);
if (field === 'subject') setSubject(next);
else setBody(next);
pendingCaret.current = { field, at: start + tag.length };
}
// Runs after the value has landed, so the caret sits just past what was
// inserted and typing carries on from there.
useEffect(() => {
const pending = pendingCaret.current;
if (!pending) return;
pendingCaret.current = null;
const element = fieldElement(pending.field);
if (!element) return;
element.focus();
element.setSelectionRange(pending.at, pending.at);
}, [subject, body]);
const customised = template.subject !== null || template.body !== null;
// Previews the draft in the editor, not what is stored, so the effect of an
@@ -103,16 +168,32 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
.
</Paragraph>
{/* Real buttons, not decorated spans. An antd Tag renders a span, so
before this a keyboard user could neither reach a chip nor activate
one. The button carries the semantics and the Tag the appearance, so
enter and space work without any key handling of our own. */}
<Space wrap size={[4, 4]} style={{ marginBottom: 12 }}>
{template.available.map((name) => (
<Tag key={name} style={{ fontFamily: 'monospace' }}>{`{{${name}}}`}</Tag>
<button
key={name}
type="button"
onClick={() => insertPlaceholder(name)}
aria-label={`Insert {{${name}}}`}
style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer', lineHeight: 1 }}
>
<Tag style={{ fontFamily: 'monospace', margin: 0, cursor: 'pointer' }}>
{`{{${name}}}`}
</Tag>
</button>
))}
</Space>
<Input
ref={subjectRef}
aria-label={`${template.label} subject`}
value={subject}
onChange={(e) => setSubject(e.target.value)}
onFocus={() => setLastFocused('subject')}
placeholder="Subject"
style={{ marginBottom: 8 }}
/>
@@ -125,7 +206,7 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
a literal {{resetUrl}} and no consent footer. The pane on the right
is the server's rendering of the actual email, and two previews
disagreeing about the thing being edited is worse than one. */}
<div data-color-mode={mode} style={{ marginBottom: 12 }}>
<div ref={bodyWrapRef} data-color-mode={mode} style={{ marginBottom: 12 }}>
<MDEditor
value={body}
onChange={(val) => setBody(val || '')}
@@ -135,7 +216,10 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
// textarea, so the label has to be passed through — without it every
// email template test loses its handle on the field, and a screen
// reader loses the only thing naming it.
textareaProps={{ 'aria-label': `${template.label} body` }}
textareaProps={{
'aria-label': `${template.label} body`,
onFocus: () => setLastFocused('body')
}}
/>
</div>
+10
View File
@@ -20,6 +20,16 @@ export class AdminEmails {
this.restoreDefaultButton = page.getByRole('button', { name: 'Restore default' });
}
/**
* A placeholder chip, which inserts its tag at the caret (#143).
*
* Located by the accessible name rather than the visible text, because the
* visible text is `{{name}}` and the braces make an awkward locator.
*/
placeholderChip(name: string): Locator {
return this.page.getByRole('button', { name: `Insert {{${name}}}` });
}
/** One template's entry in the rail. */
railTab(label: string | RegExp): Locator {
return this.page.getByRole('tab', { name: label });
@@ -0,0 +1,119 @@
import { test, expect } from './fixtures';
// Uses the verification template, not the password reset one. Stored templates
// live in admin_settings and are global per database, so two spec files editing
// the same key race each other across Playwright's workers — which is exactly
// what happened when this was first written against passwordReset, the key
// email-templates.spec.ts already owns. Different key, no race.
//
// Each test still restores what it touched, so a later spec reads the built-in
// copy rather than whatever this left behind.
async function restore(request: import('@playwright/test').APIRequestContext, key: string) {
await request.delete(`/api/admin/email-templates/${key}`);
}
const LABEL = 'Email verification';
test.describe.configure({ mode: 'serial' });
test.describe('Inserting placeholders from the chips', () => {
test.afterEach(async ({ page }) => {
await restore(page.request, 'verification');
});
test('inserts into the body at the caret, not at the end', async ({ admin, adminEmails }) => {
await admin.open('Emails');
await adminEmails.openTemplate(LABEL);
const body = adminEmails.body(LABEL);
await body.fill('BEFORE AFTER');
// Caret between the two words. Appending would put the tag after AFTER,
// which is the failure this test exists to catch.
await body.click();
await body.press('Control+Home');
for (let i = 0; i < 'BEFORE '.length; i++) await body.press('ArrowRight');
await adminEmails.placeholderChip('greeting').click();
await expect(body).toHaveValue('BEFORE {{greeting}}AFTER');
});
test('leaves the caret after the tag, so typing continues from there', async ({
admin,
adminEmails
}) => {
await admin.open('Emails');
await adminEmails.openTemplate(LABEL);
const body = adminEmails.body(LABEL);
await body.fill('');
await body.click();
await adminEmails.placeholderChip('greeting').click();
// No click in between: the field must still hold focus with the caret
// placed just past what was inserted.
await body.pressSequentially('!');
await expect(body).toHaveValue('{{greeting}}!');
});
test('replaces a selection rather than inserting alongside it', async ({
admin,
adminEmails
}) => {
await admin.open('Emails');
await adminEmails.openTemplate(LABEL);
const body = adminEmails.body(LABEL);
await body.fill('REPLACE ME');
await body.click();
await body.press('Control+a');
await adminEmails.placeholderChip('verifyUrl').click();
await expect(body).toHaveValue('{{verifyUrl}}');
});
test('inserts into the subject when that was the field last used', async ({
admin,
adminEmails
}) => {
await admin.open('Emails');
await adminEmails.openTemplate(LABEL);
// Captured first: this template's default body already contains
// {{greeting}}, so "the body does not contain the tag" would be false
// before the click as well as after. Unchanged is the real claim.
const bodyBefore = await adminEmails.body(LABEL).inputValue();
const subject = adminEmails.subject(LABEL);
await subject.fill('Hi there');
await subject.click();
await subject.press('Control+Home');
for (let i = 0; i < 'Hi '.length; i++) await subject.press('ArrowRight');
await adminEmails.placeholderChip('greeting').click();
await expect(subject).toHaveValue('Hi {{greeting}} there');
await expect(adminEmails.body(LABEL)).toHaveValue(bodyBefore);
});
test('is reachable and operable from the keyboard', async ({ page, admin, adminEmails }) => {
await admin.open('Emails');
await adminEmails.openTemplate(LABEL);
await adminEmails.body(LABEL).fill('');
await adminEmails.body(LABEL).click();
// Focused directly rather than tabbed to, since the number of tab stops
// before it is a layout detail. What matters is that a button can take
// focus at all and responds to a key — an antd Tag renders a span and
// could do neither.
const chip = adminEmails.placeholderChip('greeting');
await chip.focus();
await expect(chip).toBeFocused();
await page.keyboard.press('Enter');
await expect(adminEmails.body(LABEL)).toHaveValue('{{greeting}}');
});
});