feat(admin): make the placeholder chips insert at the cursor (#143)
Linting / lint (pull_request) Successful in 2m4s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m52s

The chips above each email editor named the placeholders and left an admin to retype `{{holdDuration}}` by hand, getting the braces and the spelling right unaided. A typo did not announce itself either: a misspelled placeholder is not a required one, so the save succeeded and the email shipped with a literal `{{holdDuraton}}` in it.

Clicking one now inserts its tag at the caret in whichever field was last focused, replacing any selection.

Four things this needed that a click handler alone would not have given.

The field has to be remembered rather than read. Clicking a chip blurs whichever of the subject or body had focus, so `lastFocused` is tracked on focus instead. It starts on the body, because that is where placeholders almost always go and because a chip clicked on arrival should do something predictable rather than nothing.

The insert goes through setState, not the DOM. Writing into the element's `value` would appear to work and would not: both fields are controlled, so the next keystroke re-renders from state and the insert vanishes.

The caret has to be put back. A controlled re-render leaves it at the end, so the new position is stashed in a ref and applied in an effect once the value has landed — just past what was inserted, with focus retained, so typing carries on from there.

And the chips had to become buttons. An antd Tag renders a span, so a keyboard user could neither reach one nor activate it. The button carries the semantics and the Tag the appearance, which makes enter and space work with no key handling of our own.

The textarea is found by querying the wrapper rather than through MDEditor's ref, which exposes an internal store that is not part of its API. The editor renders exactly one.

Five end-to-end tests, checked against a naive implementation rather than only against the finished one: reverted to append-and-forget, four of the five fail. The one that still passes is the plain insert-into-empty case, which appending also satisfies — worth knowing, since on its own it would have proved nothing.

Two mistakes worth recording, because both were mine and both were caught by running things rather than reading them. The spec first drove the passwordReset template, which email-templates.spec.ts already owns; stored templates are global per database, so the two files raced across Playwright's workers. Moved to the verification template — different key, no race. And its last assertion claimed the body did not contain `{{greeting}}`, which is false for that template before any click, since its default body already has one. Comparing the body against its own earlier value is what was actually meant.

Verified: tsc clean over src and tests, lint unchanged, build clean, and the three email specs pass 18/18 together.

Closes #143
This commit is contained in:
2026-08-24 15:45:36 -05:00
parent 77565723d6
commit 7edc08e6eb
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>