feat(web): use the Ace editor for the request-metadata JSON

A textarea is a poor place to hand-write a nested `_meta` payload. Ace closes a
brace when you open one, folds nested objects, and — through its JSON worker —
marks the offending line in the gutter instead of reporting one message for the
whole document.

Three things the swap turned up:

- Ace fires *two* change events for a replace (a remove, then an insert), so
  select-all-and-retype briefly leaves an empty document. Acting on the first
  emitted `{}` and wiped the user's saved metadata the instant they selected
  it. `handleChange` now coalesces the pair in a microtask, and the component
  tracks what it last emitted so the parent echoing that value back is not
  mistaken for an external reset.

- Ace's own `github` themes fail WCAG AA on the gutter — `#AAA` on `#e8e8e8` is
  1.89:1, and the dark theme is 4.13:1. Folding needs the gutter, so the colors
  are overridden with palette tokens (5.82:1 and 4.88:1) rather than hiding it.

- `textInputAriaLabel` alone never reaches the DOM: Ace composes the label as
  "<label>, Cursor at row N" and only recomputes it when the cursor moves, so
  it is forced once on load. Until then the control's only accessible name is a
  position readout.

Testing is split because Ace's input path does not work under happy-dom at all
— `userEvent.type` reaches the textarea and produces no edit, so a test written
that way asserts nothing. Unit tests drive the editor instance through a shared
`src/test/aceEditor.ts` helper; the real keyboard behaviour (`{` then Enter
giving `{\n  \n}`) is asserted by Storybook play functions in Chromium.

`react-ace`/`ace-builds` are declared in `clients/web` rather than the root, per
the rule for a dependency that renders React components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 16:54:58 -04:00
commit ed799a0c0b
3 changed files with 432 additions and 94 deletions

View file

@ -4,17 +4,23 @@ import { expect, userEvent, within } from "storybook/test";
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
import { JsonObjectInput } from "./JsonObjectInput";
const LABEL = "Request metadata JSON";
/**
* The component is controlled, so every story drives it through a small
* stateful wrapper — otherwise the box would not accept a keystroke and the
* stateful wrapper — otherwise the editor would not accept a keystroke and the
* play functions could not exercise the draft behavior at all.
*
* These stories run in real Chromium, which is what makes them the *only*
* place Ace's keyboard behavior can be tested: the unit suite runs in
* happy-dom, where Ace's hidden-textarea input path does not work at all.
*/
function ControlledJsonObjectInput({ initial }: { initial: JsonObject }) {
const [value, setValue] = useState<JsonObject>(initial);
return (
<JsonObjectInput
label="Request metadata"
aria-label="Request metadata JSON"
ariaLabel={LABEL}
value={value}
onChange={setValue}
/>
@ -29,6 +35,57 @@ const meta: Meta<typeof ControlledJsonObjectInput> = {
export default meta;
type Story = StoryObj<typeof ControlledJsonObjectInput>;
/** The live editor instance, which Ace hangs off its container as `env.editor`. */
function aceEditor(canvasElement: HTMLElement): {
focus(): void;
selectAll(): void;
getValue(): string;
} {
const container = canvasElement.querySelector(".ace_editor");
const editor = (
container as
| (HTMLElement & {
env?: {
editor?: {
focus(): void;
selectAll(): void;
getValue(): string;
};
};
})
| null
)?.env?.editor;
if (!editor) throw new Error("Ace editor not mounted");
return editor;
}
/**
* Focus the editor and empty it, so the next keystrokes are the whole document
* and the typing itself — the thing under test — is left to `userEvent`.
*
* Emptied rather than merely selected: typing an opening brace or quote with a
* selection makes Ace **wrap** the selection instead of replacing it, so typing
* `{` over a selected `{}` yields `{{}}` and the assertion stops being about
* auto-closing at all.
*
* Focus and select go through Ace's API on purpose. `userEvent.click` does not
* focus the editor — Ace's input is a 1px offscreen textarea, so the click
* lands nowhere and the keystrokes reach the body instead. And select-all is
* not `Control+A` everywhere (Ace binds `Cmd-A` on mac), so pressing it would
* make these stories pass or fail by host OS.
*/
async function focusAndClear(canvasElement: HTMLElement): Promise<void> {
const editor = aceEditor(canvasElement);
editor.focus();
editor.selectAll();
await userEvent.keyboard("{Backspace}");
}
/** Ace renders each token in its own element, so read the document, not text. */
function editorText(canvasElement: HTMLElement): string {
return aceEditor(canvasElement).getValue();
}
/** A structured payload renders as formatted JSON, nesting intact. */
export const Populated: Story = {
args: {
@ -39,9 +96,7 @@ export const Populated: Story = {
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const box = await canvas.findByLabelText("Request metadata JSON");
await expect(JSON.parse((box as HTMLTextAreaElement).value)).toEqual({
await expect(JSON.parse(editorText(canvasElement))).toEqual({
tenant: "acme",
trace: { id: "abc123", sampled: true },
features: ["apps", "tasks"],
@ -54,8 +109,36 @@ export const Empty: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const box = await canvas.findByLabelText("Request metadata JSON");
await expect(box).toHaveValue("{}");
await expect(editorText(canvasElement)).toBe("{}");
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
},
};
/**
* Opening a brace closes it — one of the two reasons this is an editor rather
* than a textarea, and the thing that makes hand-writing a nested `_meta`
* payload bearable.
*
* Deliberately one keystroke: Ace also auto-pairs quotes and skips over a
* closing character you type yourself, so a longer sequence asserts the sum of
* several behaviours and stops being a readable statement about this one.
*/
export const ClosesBracesWhileTyping: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await focusAndClear(canvasElement);
// `{{` is userEvent's escape for a literal `{`.
await userEvent.keyboard("{{");
// Ace does not close it on the keystroke — an opening brace at the end of a
// line is often the start of a block the user is about to fill in.
await expect(editorText(canvasElement)).toBe("{");
// Enter is what completes the pair, leaving the cursor indented between
// them. This is the behaviour that makes writing nested `_meta` by hand
// bearable, and the reason this is an editor rather than a textarea.
await userEvent.keyboard("{Enter}");
await expect(editorText(canvasElement)).toBe("{\n \n}");
// The result parses, so nothing is flagged.
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
},
};
@ -69,22 +152,31 @@ export const InvalidDraft: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const box = await canvas.findByLabelText("Request metadata JSON");
await userEvent.clear(box);
await userEvent.type(box, '{{"a":');
await expect(box).toHaveValue('{"a":');
await focusAndClear(canvasElement);
// Genuinely half-typed — the state a user is in partway through writing an
// entry, rather than a contrived bad string.
await userEvent.keyboard('{{"a":');
// Shown exactly as typed. The old textarea re-escaped its own contents on
// every keystroke here, which is what made it unusable (#1928).
await expect(editorText(canvasElement)).toBe('{"a":');
await expect(await canvas.findByText(/Not valid JSON/)).toBeVisible();
},
};
/** Valid JSON that is not an object is rejected with its own message. */
/**
* Valid JSON that is not an object is rejected with its own message, distinct
* from unparseable text.
*
* A bare number rather than an array: brackets are auto-paired too, so typing
* one would test the pairing as much as the rejection.
*/
export const NotAnObject: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const box = await canvas.findByLabelText("Request metadata JSON");
await userEvent.clear(box);
await userEvent.type(box, "[[1,2]");
await focusAndClear(canvasElement);
await userEvent.keyboard("42");
await expect(editorText(canvasElement)).toBe("42");
await expect(
await canvas.findByText(/Must be a JSON object/),
).toBeVisible();

View file

@ -1,9 +1,9 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { act, screen } from "@testing-library/react";
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
import { JsonObjectInput } from "./JsonObjectInput";
const LABEL = "Payload JSON";
@ -20,7 +20,7 @@ function Harness({
return (
<>
<JsonObjectInput
aria-label={LABEL}
ariaLabel={LABEL}
value={value}
onChange={(next) => {
setValue(next);
@ -32,38 +32,60 @@ function Harness({
);
}
function box() {
return screen.getByLabelText(LABEL) as HTMLTextAreaElement;
}
describe("JsonObjectInput", () => {
it("names the editor for assistive tech", () => {
renderWithMantine(<Harness initial={{}} />);
// Ace composes "<label>, Cursor at row N", so the match is by substring —
// the position readout is Ace's, the name is ours.
expect(screen.getByLabelText(aceLabel(LABEL))).toBeInTheDocument();
});
it("uses Ace's dark theme under a dark color scheme", () => {
// The editor picks its Ace theme from the computed scheme rather than
// inheriting Mantine tokens, so the dark branch is only exercised by
// actually rendering dark.
renderWithMantine(<Harness initial={{}} />, { colorScheme: "dark" });
// `ace-github-dark`, hyphenated — Ace's cssClass, not the module name
// (`theme-github_dark`). The App.css gutter-contrast override keys off this
// same class, so getting it wrong silently drops the fix in dark mode.
expect(document.querySelector(".ace-github-dark")).not.toBeNull();
expect(document.querySelector(".ace-github")).toBeNull();
});
it("uses Ace's light theme under a light color scheme", () => {
renderWithMantine(<Harness initial={{}} />, { colorScheme: "light" });
expect(document.querySelector(".ace-github")).not.toBeNull();
expect(document.querySelector(".ace-github_dark")).toBeNull();
});
it("opens with the value serialized two-space, nesting intact", () => {
renderWithMantine(<Harness initial={{ a: { b: [1, 2] } }} />);
expect(box().value).toBe(
expect(getAceText()).toBe(
'{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}',
);
});
it("emits the parsed object — a structured value stays structured", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
await user.clear(box());
await user.type(box(), '{{"trace":{{"id":"x"},"n":3}');
await setAceText('{"trace":{"id":"x"},"n":3}');
expect(onChange).toHaveBeenLastCalledWith({ trace: { id: "x" }, n: 3 });
});
// Also the regression test for the paired-event bug `handleChange` coalesces:
// `setValue` is a remove followed by an insert, and acting on the remove
// emitted `{}` and wiped `keep` before the insert had landed.
it("shows the text as typed while it is invalid and does not emit", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
// Append a stray character rather than clearing: clearing is itself a
// valid edit (it means `{}`) and would emit, which is not what is under
// test here.
await user.click(box());
await user.keyboard("{End}x");
await setAceText('{"a":');
console.log("[dbg] emitted:", screen.getByTestId("emitted").textContent);
console.log(
"[dbg] body has error?",
document.body.textContent?.includes("Not valid JSON"),
);
// Displayed verbatim — not re-escaped back through JSON.stringify.
expect(box().value.endsWith("}x")).toBe(true);
expect(getAceText()).toBe('{"a":');
expect(
screen.getByText("Not valid JSON — changes are not applied"),
).toBeInTheDocument();
@ -73,36 +95,30 @@ describe("JsonObjectInput", () => {
});
it("flags valid JSON that is not an object without emitting", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
await user.clear(box());
// The clear is a legitimate edit to `{}`; only what follows is under test.
expect(onChange).toHaveBeenLastCalledWith({});
onChange.mockClear();
await user.type(box(), "[[1]");
await setAceText("[1]");
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it("treats a cleared box as the empty object", async () => {
const user = userEvent.setup();
it("treats a cleared editor as the empty object", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
await user.clear(box());
await setAceText("");
expect(onChange).toHaveBeenLastCalledWith({});
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
});
it("re-syncs when the parent's value diverges from the draft", async () => {
// An external reset (switching servers, a reloaded catalog) must reach the
// box; an in-progress edit must not be overwritten by the value it emitted.
// editor; an in-progress edit must not be overwritten by what it emitted.
function Externally() {
const [value, setValue] = useState<JsonObject>({ a: 1 });
return (
<>
<JsonObjectInput
aria-label={LABEL}
ariaLabel={LABEL}
value={value}
onChange={setValue}
/>
@ -110,19 +126,18 @@ describe("JsonObjectInput", () => {
</>
);
}
const user = userEvent.setup();
renderWithMantine(<Externally />);
await user.click(screen.getByRole("button", { name: "reset" }));
expect(JSON.parse(box().value)).toEqual({ b: 2 });
await act(async () => {
screen.getByRole("button", { name: "reset" }).click();
});
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
});
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
const user = userEvent.setup();
renderWithMantine(<Harness initial={{}} />);
await user.clear(box());
// Typed without the closing brace auto-pair: the text is what the user has
// so far, and the echo of the parsed value must not reformat it mid-edit.
await user.type(box(), '{{"a":1}');
expect(box().value).toBe('{"a":1}');
// The parent re-renders with the object this text parsed to; the draft must
// not be reformatted out from under the cursor.
await setAceText('{"a":1}');
expect(getAceText()).toBe('{"a":1}');
});
});

View file

@ -1,15 +1,21 @@
import { useState } from "react";
import { JsonInput } from "@mantine/core";
import { useRef, useState } from "react";
import { Input, useComputedColorScheme } from "@mantine/core";
import AceEditor from "react-ace";
import ace from "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import "ace-builds/src-noconflict/theme-github_dark";
// `?url` so Vite emits the worker as its own asset and hands back a real URL.
// Without it Ace fetches `worker-json.js` from a path that does not exist in a
// bundled app, silently loses its annotations, and logs a fetch error.
import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json.js?url";
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
import { useValueChange } from "../../../hooks/useValueChange";
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
const ObjectJsonInput = JsonInput.withProps({
formatOnBlur: true,
autosize: true,
minRows: 4,
spellCheck: false,
});
// Module scope: registering the worker URL is global to Ace and idempotent, so
// it must not be repeated per mount.
ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl);
/** Two-space canonical form — the same shape the catalog file is written in. */
function serialize(value: JsonObject): string {
@ -21,9 +27,16 @@ export interface JsonObjectInputProps {
onChange: (value: JsonObject) => void;
label?: string;
description?: string;
placeholder?: string;
"aria-label"?: string;
"data-testid"?: string;
/**
* Accessible name for the editor's text input. Ace labels its hidden
* textarea "Cursor at row N" by default, which is a position readout rather
* than a name — so this is what makes the control identifiable to a screen
* reader, and what `getByLabelText` finds in a test.
*/
ariaLabel: string;
/** Rows shown before the editor grows; it autosizes between these bounds. */
minLines?: number;
maxLines?: number;
}
/**
@ -36,6 +49,11 @@ export interface JsonObjectInputProps {
* `_meta` value to a string, so a `{ key, value }` row editor cannot express
* what the protocol allows.
*
* Built on Ace rather than a plain textarea because hand-writing JSON in a bare
* box is the actual pain: Ace closes a brace when you open one, folds nested
* objects, and — via its JSON worker — marks the offending line in the gutter
* instead of reporting one message for the whole document.
*
* The text the user is typing is the source of truth for what is *displayed*;
* the parent only ever sees a parsed object. That split is what makes the box
* typeable: driving it off the parent's value and stringifying on every render
@ -47,35 +65,132 @@ export interface JsonObjectInputProps {
* object stands. The alternative — emitting `{}` — would silently discard a
* user's configured metadata the moment they typed a stray character, and this
* form has no Save button to gate instead: `onChange` writes straight through.
* The state is not silent, though; the field says so on itself via `error`.
* The state is not silent, though: Ace flags the position in the gutter, and
* the wrapper repeats the reason underneath.
*/
export function JsonObjectInput({
value,
onChange,
...inputProps
label,
description,
ariaLabel,
minLines = 6,
maxLines = 24,
}: JsonObjectInputProps) {
const [draft, setDraft] = useState(() => serialize(value));
// The canonical JSON of the last value this component emitted.
//
// The re-sync below has to tell an *external* change apart from the parent
// echoing back what we just sent it, and the draft alone cannot: while the
// text is invalid it parses to nothing, so every parent change would look
// external and overwrite what is being typed.
//
// That is not hypothetical. Ace's `setValue` — which is what a select-all
// paste and any programmatic replace go through — fires *two* change events,
// a remove then an insert. The remove leaves an empty document, which is a
// valid `{}` and gets emitted; the insert then leaves invalid text. Comparing
// against the draft, the `{}` echo reads as an external reset and wipes the
// pasted text a keystroke after it lands.
const [echoed, setEchoed] = useState(() => serialize(value));
// `getInitialValueInEffect: false` so the first render already has the real
// scheme; otherwise the editor mounts light and repaints, which is visible.
const colorScheme = useComputedColorScheme("light", {
getInitialValueInEffect: false,
});
// Re-sync only on a value this component did not produce, so an external
// reset (switching servers, a reloaded catalog) reaches the editor while an
// in-progress edit is left alone. Compared through canonical JSON because the
// parent hands back a fresh object each time.
// Text from the most recent change event in the current tick, and whether a
// flush is already queued to consume it. Two refs rather than one nullable
// one so the flush reads a plain `string` — folding them together would add
// a null check that nothing can ever satisfy. See `handleChange`.
const pendingTextRef = useRef("");
const flushScheduledRef = useRef(false);
/**
* Coalesce the change events Ace fires within one edit, and act on the last.
*
* A replace — select-all-and-retype, or a paste over a selection — is a
* *remove* followed by an *insert*, and Ace fires a change event for each.
* Acting on the first would read the momentarily-empty document as the user
* clearing the field, emit `{}` to the parent, and so wipe their saved
* metadata the instant they selected it. If the replacement text then failed
* to parse, `{}` is what they would be left with.
*
* A microtask is enough because both events are dispatched synchronously
* inside the same edit, so the flush sees only the settled text.
*/
const handleChange = (text: string) => {
pendingTextRef.current = text;
if (flushScheduledRef.current) return;
flushScheduledRef.current = true;
queueMicrotask(() => {
flushScheduledRef.current = false;
const settled = pendingTextRef.current;
setDraft(settled);
const result = parseJsonObjectDraft(settled);
if (!result.ok) return;
setEchoed(serialize(result.value));
onChange(result.value);
});
};
// Re-sync only when the parent's value genuinely diverges from what the draft
// parses to, so an external reset (switching servers, a reloaded catalog)
// reaches the box while an in-progress edit is left alone. Compared through
// canonical JSON because the parent hands back a fresh object each time.
const parsed = parseJsonObjectDraft(draft);
const draftJson = parsed.ok ? serialize(parsed.value) : null;
useValueChange(serialize(value), (next) => {
if (next !== draftJson) setDraft(next);
if (next !== echoed) setDraft(next);
});
return (
<ObjectJsonInput
{...inputProps}
value={draft}
<Input.Wrapper
label={label}
description={description}
error={parsed.ok ? undefined : parsed.error}
onChange={(text) => {
setDraft(text);
const result = parseJsonObjectDraft(text);
if (result.ok) onChange(result.value);
}}
/>
w="100%"
>
<AceEditor
mode="json"
theme={colorScheme === "dark" ? "github_dark" : "github"}
name="json-object-input"
value={draft}
onChange={handleChange}
width="100%"
minLines={minLines}
maxLines={maxLines}
tabSize={2}
showPrintMargin={false}
editorProps={{ $blockScrolling: Infinity }}
onLoad={(editor) => {
// `textInputAriaLabel` alone is not enough: Ace composes the hidden
// textarea's label as "<label>, Cursor at row N" inside
// `TextInput.setAriaLabel`, and only recomputes it when the cursor
// moves — so an option applied at mount does not reach the DOM until
// the user clicks into the editor. Until then the control's only
// accessible name is the position readout, which names nothing.
// Setting it here and recomputing once is what makes the editor
// identifiable from first paint.
editor.setOption("textInputAriaLabel", ariaLabel);
editor.textInput.setAriaLabel();
}}
setOptions={{
// The JSON worker is what puts a marker on the offending line rather
// than one message for the whole document.
useWorker: true,
// Closes a brace/bracket/quote when you open one, and skips over the
// closing one you then type. On by default; named because it is half
// of why this component exists.
behavioursEnabled: true,
// Roving-tabindex arrow-key navigation plus an Esc-to-exit-trap, so
// the editor does not swallow Tab for keyboard users.
enableKeyboardAccessibility: true,
textInputAriaLabel: ariaLabel,
showFoldWidgets: true,
displayIndentGuides: true,
useSoftTabs: true,
scrollPastEnd: false,
}}
/>
</Input.Wrapper>
);
}