Files
bermudalamb 7edc08e6eb
Linting / lint (pull_request) Successful in 2m4s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m52s
feat(admin): make the placeholder chips insert at the cursor (#143)
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
2026-08-24 15:45:36 -05:00

120 lines
4.2 KiB
TypeScript

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}}');
});
});