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
Owner

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

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
bermudalamb self-assigned this 2026-08-24 15:47:54 -05:00
bermudalamb added this to the Customer and Admin UI review findings project 2026-08-24 15:47:54 -05:00
bermudalamb added 1 commit 2026-08-24 15:47:55 -05:00
feat(admin): make the placeholder chips insert at the cursor (#143)
SonarQube Analysis / sonarqube (pull_request) Failing after 4m52s
Linting / lint (pull_request) Successful in 2m4s
7edc08e6eb
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
bermudalamb moved this to Review in Customer and Admin UI review findings on 2026-08-24 15:48:32 -05:00
bermudalamb merged commit 70cc3056e7 into main 2026-08-24 15:48:42 -05:00
bermudalamb deleted branch feature/143-clickable-placeholders 2026-08-24 15:48:49 -05:00
bermudalamb moved this to Ready for Release in Customer and Admin UI review findings on 2026-09-09 13:33:42 -05:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#168