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:
parent
b0ab6842aa
commit
ed799a0c0b
10 changed files with 432 additions and 94 deletions
39
clients/web/package-lock.json
generated
39
clients/web/package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
||||||
"@mantine/hooks": "^8.3.17",
|
"@mantine/hooks": "^8.3.17",
|
||||||
"@mantine/notifications": "^8.3.17",
|
"@mantine/notifications": "^8.3.17",
|
||||||
"@napi-rs/keyring": "^1.3.0",
|
"@napi-rs/keyring": "^1.3.0",
|
||||||
|
"ace-builds": "^1.44.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"atomically": "^2.1.1",
|
"atomically": "^2.1.1",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"papaparse": "^5.5.4",
|
"papaparse": "^5.5.4",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-ace": "^15.0.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|
@ -4383,6 +4385,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ace-builds": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||||
|
|
@ -5061,6 +5069,12 @@
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/diff-match-patch": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
|
|
@ -5487,6 +5501,15 @@
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
|
|
@ -8294,6 +8317,22 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-ace": {
|
||||||
|
"version": "15.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
|
||||||
|
"integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ace-builds": "^1.36.3",
|
||||||
|
"diff-match-patch": "^1.0.5",
|
||||||
|
"fast-equals": "^5.3.3",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-docgen": {
|
"node_modules/react-docgen": {
|
||||||
"version": "8.0.3",
|
"version": "8.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"@mantine/hooks": "^8.3.17",
|
"@mantine/hooks": "^8.3.17",
|
||||||
"@mantine/notifications": "^8.3.17",
|
"@mantine/notifications": "^8.3.17",
|
||||||
"@napi-rs/keyring": "^1.3.0",
|
"@napi-rs/keyring": "^1.3.0",
|
||||||
|
"ace-builds": "^1.44.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"atomically": "^2.1.1",
|
"atomically": "^2.1.1",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
|
|
@ -51,6 +52,7 @@
|
||||||
"papaparse": "^5.5.4",
|
"papaparse": "^5.5.4",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-ace": "^15.0.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,16 @@
|
||||||
ScrollArea would break flex sizing, e.g. the Resources accordion) blend in. */
|
ScrollArea would break flex sizing, e.g. the Resources accordion) blend in. */
|
||||||
--inspector-scrollbar-thumb: rgba(0, 0, 0, 0.4);
|
--inspector-scrollbar-thumb: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
/* ── Code editor (Ace) ─────────────────────────────────── */
|
||||||
|
/* Ace's own `github` themes fail WCAG AA on the gutter: `#AAA` on the light
|
||||||
|
gutter's `#e8e8e8` is 1.89:1, and `#7388b5` on the dark gutter's `#24292e`
|
||||||
|
is 4.13:1 — both under 4.5:1 at the 12px the line numbers render. The
|
||||||
|
numbers are `aria-hidden`, which exempts nothing here: contrast is a
|
||||||
|
concern for sighted users, and axe flags them accordingly.
|
||||||
|
`gray-7` is 5.82:1 on the light gutter; the dark block lifts it to
|
||||||
|
`gray-5`, 4.88:1 on the dark one. */
|
||||||
|
--inspector-code-gutter-text: var(--mantine-color-gray-7);
|
||||||
|
|
||||||
/* ── Connection status ─────────────────────────────────── */
|
/* ── Connection status ─────────────────────────────────── */
|
||||||
--inspector-status-connected: var(--mantine-color-green-6);
|
--inspector-status-connected: var(--mantine-color-green-6);
|
||||||
--inspector-status-connecting: var(--mantine-color-yellow-6);
|
--inspector-status-connecting: var(--mantine-color-yellow-6);
|
||||||
|
|
@ -161,6 +171,9 @@
|
||||||
schemes, which sits too dark on charcoal — lift them one to two steps so
|
schemes, which sits too dark on charcoal — lift them one to two steps so
|
||||||
they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */
|
they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */
|
||||||
|
|
||||||
|
/* Code editor (Ace) — see the note in `:root`. */
|
||||||
|
--inspector-code-gutter-text: var(--mantine-color-gray-5);
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
--inspector-status-connected: var(--mantine-color-green-4);
|
--inspector-status-connected: var(--mantine-color-green-4);
|
||||||
--inspector-status-connecting: var(--mantine-color-yellow-4);
|
--inspector-status-connecting: var(--mantine-color-yellow-4);
|
||||||
|
|
@ -593,3 +606,12 @@ body.resizing-col {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ace renders its own DOM, so its gutter can only be reached by selector — it
|
||||||
|
is not a Mantine component with a theme entry. The doubled class raises
|
||||||
|
specificity above Ace's own `.ace-github .ace_gutter`, which is injected into
|
||||||
|
<head> at runtime and therefore wins on source order otherwise. */
|
||||||
|
.ace_editor.ace-github .ace_gutter,
|
||||||
|
.ace_editor.ace-github-dark .ace_gutter {
|
||||||
|
color: var(--inspector-code-gutter-text);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,23 @@ import { expect, userEvent, within } from "storybook/test";
|
||||||
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||||
import { JsonObjectInput } from "./JsonObjectInput";
|
import { JsonObjectInput } from "./JsonObjectInput";
|
||||||
|
|
||||||
|
const LABEL = "Request metadata JSON";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The component is controlled, so every story drives it through a small
|
* 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.
|
* 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 }) {
|
function ControlledJsonObjectInput({ initial }: { initial: JsonObject }) {
|
||||||
const [value, setValue] = useState<JsonObject>(initial);
|
const [value, setValue] = useState<JsonObject>(initial);
|
||||||
return (
|
return (
|
||||||
<JsonObjectInput
|
<JsonObjectInput
|
||||||
label="Request metadata"
|
label="Request metadata"
|
||||||
aria-label="Request metadata JSON"
|
ariaLabel={LABEL}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
/>
|
/>
|
||||||
|
|
@ -29,6 +35,57 @@ const meta: Meta<typeof ControlledJsonObjectInput> = {
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof ControlledJsonObjectInput>;
|
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. */
|
/** A structured payload renders as formatted JSON, nesting intact. */
|
||||||
export const Populated: Story = {
|
export const Populated: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
|
@ -39,9 +96,7 @@ export const Populated: Story = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
await expect(JSON.parse(editorText(canvasElement))).toEqual({
|
||||||
const box = await canvas.findByLabelText("Request metadata JSON");
|
|
||||||
await expect(JSON.parse((box as HTMLTextAreaElement).value)).toEqual({
|
|
||||||
tenant: "acme",
|
tenant: "acme",
|
||||||
trace: { id: "abc123", sampled: true },
|
trace: { id: "abc123", sampled: true },
|
||||||
features: ["apps", "tasks"],
|
features: ["apps", "tasks"],
|
||||||
|
|
@ -54,8 +109,36 @@ export const Empty: Story = {
|
||||||
args: { initial: {} },
|
args: { initial: {} },
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
const box = await canvas.findByLabelText("Request metadata JSON");
|
await expect(editorText(canvasElement)).toBe("{}");
|
||||||
await expect(box).toHaveValue("{}");
|
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();
|
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -69,22 +152,31 @@ export const InvalidDraft: Story = {
|
||||||
args: { initial: {} },
|
args: { initial: {} },
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
const box = await canvas.findByLabelText("Request metadata JSON");
|
await focusAndClear(canvasElement);
|
||||||
await userEvent.clear(box);
|
// Genuinely half-typed — the state a user is in partway through writing an
|
||||||
await userEvent.type(box, '{{"a":');
|
// entry, rather than a contrived bad string.
|
||||||
await expect(box).toHaveValue('{"a":');
|
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();
|
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 = {
|
export const NotAnObject: Story = {
|
||||||
args: { initial: {} },
|
args: { initial: {} },
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
const box = await canvas.findByLabelText("Request metadata JSON");
|
await focusAndClear(canvasElement);
|
||||||
await userEvent.clear(box);
|
await userEvent.keyboard("42");
|
||||||
await userEvent.type(box, "[[1,2]");
|
await expect(editorText(canvasElement)).toBe("42");
|
||||||
await expect(
|
await expect(
|
||||||
await canvas.findByText(/Must be a JSON object/),
|
await canvas.findByText(/Must be a JSON object/),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { screen } from "@testing-library/react";
|
import { act, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||||
import { renderWithMantine } from "../../../test/renderWithMantine";
|
import { renderWithMantine } from "../../../test/renderWithMantine";
|
||||||
|
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
|
||||||
import { JsonObjectInput } from "./JsonObjectInput";
|
import { JsonObjectInput } from "./JsonObjectInput";
|
||||||
|
|
||||||
const LABEL = "Payload JSON";
|
const LABEL = "Payload JSON";
|
||||||
|
|
@ -20,7 +20,7 @@ function Harness({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<JsonObjectInput
|
<JsonObjectInput
|
||||||
aria-label={LABEL}
|
ariaLabel={LABEL}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setValue(next);
|
setValue(next);
|
||||||
|
|
@ -32,38 +32,60 @@ function Harness({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function box() {
|
|
||||||
return screen.getByLabelText(LABEL) as HTMLTextAreaElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("JsonObjectInput", () => {
|
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", () => {
|
it("opens with the value serialized two-space, nesting intact", () => {
|
||||||
renderWithMantine(<Harness initial={{ a: { b: [1, 2] } }} />);
|
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}',
|
'{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits the parsed object — a structured value stays structured", async () => {
|
it("emits the parsed object — a structured value stays structured", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
|
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
|
||||||
await user.clear(box());
|
await setAceText('{"trace":{"id":"x"},"n":3}');
|
||||||
await user.type(box(), '{{"trace":{{"id":"x"},"n":3}');
|
|
||||||
expect(onChange).toHaveBeenLastCalledWith({ 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 () => {
|
it("shows the text as typed while it is invalid and does not emit", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
||||||
// Append a stray character rather than clearing: clearing is itself a
|
await setAceText('{"a":');
|
||||||
// valid edit (it means `{}`) and would emit, which is not what is under
|
console.log("[dbg] emitted:", screen.getByTestId("emitted").textContent);
|
||||||
// test here.
|
console.log(
|
||||||
await user.click(box());
|
"[dbg] body has error?",
|
||||||
await user.keyboard("{End}x");
|
document.body.textContent?.includes("Not valid JSON"),
|
||||||
|
);
|
||||||
// Displayed verbatim — not re-escaped back through JSON.stringify.
|
// Displayed verbatim — not re-escaped back through JSON.stringify.
|
||||||
expect(box().value.endsWith("}x")).toBe(true);
|
expect(getAceText()).toBe('{"a":');
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Not valid JSON — changes are not applied"),
|
screen.getByText("Not valid JSON — changes are not applied"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
@ -73,36 +95,30 @@ describe("JsonObjectInput", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags valid JSON that is not an object without emitting", async () => {
|
it("flags valid JSON that is not an object without emitting", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
||||||
await user.clear(box());
|
await setAceText("[1]");
|
||||||
// The clear is a legitimate edit to `{}`; only what follows is under test.
|
|
||||||
expect(onChange).toHaveBeenLastCalledWith({});
|
|
||||||
onChange.mockClear();
|
|
||||||
await user.type(box(), "[[1]");
|
|
||||||
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
|
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
|
||||||
expect(onChange).not.toHaveBeenCalled();
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a cleared box as the empty object", async () => {
|
it("treats a cleared editor as the empty object", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
|
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
|
||||||
await user.clear(box());
|
await setAceText("");
|
||||||
expect(onChange).toHaveBeenLastCalledWith({});
|
expect(onChange).toHaveBeenLastCalledWith({});
|
||||||
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
|
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-syncs when the parent's value diverges from the draft", async () => {
|
it("re-syncs when the parent's value diverges from the draft", async () => {
|
||||||
// An external reset (switching servers, a reloaded catalog) must reach the
|
// 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() {
|
function Externally() {
|
||||||
const [value, setValue] = useState<JsonObject>({ a: 1 });
|
const [value, setValue] = useState<JsonObject>({ a: 1 });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<JsonObjectInput
|
<JsonObjectInput
|
||||||
aria-label={LABEL}
|
ariaLabel={LABEL}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
/>
|
/>
|
||||||
|
|
@ -110,19 +126,18 @@ describe("JsonObjectInput", () => {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(<Externally />);
|
renderWithMantine(<Externally />);
|
||||||
await user.click(screen.getByRole("button", { name: "reset" }));
|
await act(async () => {
|
||||||
expect(JSON.parse(box().value)).toEqual({ b: 2 });
|
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 () => {
|
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(<Harness initial={{}} />);
|
renderWithMantine(<Harness initial={{}} />);
|
||||||
await user.clear(box());
|
// The parent re-renders with the object this text parsed to; the draft must
|
||||||
// Typed without the closing brace auto-pair: the text is what the user has
|
// not be reformatted out from under the cursor.
|
||||||
// so far, and the echo of the parsed value must not reformat it mid-edit.
|
await setAceText('{"a":1}');
|
||||||
await user.type(box(), '{{"a":1}');
|
expect(getAceText()).toBe('{"a":1}');
|
||||||
expect(box().value).toBe('{"a":1}');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { JsonInput } from "@mantine/core";
|
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 type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||||
import { useValueChange } from "../../../hooks/useValueChange";
|
import { useValueChange } from "../../../hooks/useValueChange";
|
||||||
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
|
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
|
||||||
|
|
||||||
const ObjectJsonInput = JsonInput.withProps({
|
// Module scope: registering the worker URL is global to Ace and idempotent, so
|
||||||
formatOnBlur: true,
|
// it must not be repeated per mount.
|
||||||
autosize: true,
|
ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl);
|
||||||
minRows: 4,
|
|
||||||
spellCheck: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Two-space canonical form — the same shape the catalog file is written in. */
|
/** Two-space canonical form — the same shape the catalog file is written in. */
|
||||||
function serialize(value: JsonObject): string {
|
function serialize(value: JsonObject): string {
|
||||||
|
|
@ -21,9 +27,16 @@ export interface JsonObjectInputProps {
|
||||||
onChange: (value: JsonObject) => void;
|
onChange: (value: JsonObject) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
placeholder?: string;
|
/**
|
||||||
"aria-label"?: string;
|
* Accessible name for the editor's text input. Ace labels its hidden
|
||||||
"data-testid"?: string;
|
* 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
|
* `_meta` value to a string, so a `{ key, value }` row editor cannot express
|
||||||
* what the protocol allows.
|
* 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 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
|
* 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
|
* 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
|
* object stands. The alternative — emitting `{}` — would silently discard a
|
||||||
* user's configured metadata the moment they typed a stray character, and this
|
* 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.
|
* 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({
|
export function JsonObjectInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
...inputProps
|
label,
|
||||||
|
description,
|
||||||
|
ariaLabel,
|
||||||
|
minLines = 6,
|
||||||
|
maxLines = 24,
|
||||||
}: JsonObjectInputProps) {
|
}: JsonObjectInputProps) {
|
||||||
const [draft, setDraft] = useState(() => serialize(value));
|
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 parsed = parseJsonObjectDraft(draft);
|
||||||
const draftJson = parsed.ok ? serialize(parsed.value) : null;
|
|
||||||
useValueChange(serialize(value), (next) => {
|
useValueChange(serialize(value), (next) => {
|
||||||
if (next !== draftJson) setDraft(next);
|
if (next !== echoed) setDraft(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ObjectJsonInput
|
<Input.Wrapper
|
||||||
{...inputProps}
|
label={label}
|
||||||
value={draft}
|
description={description}
|
||||||
error={parsed.ok ? undefined : parsed.error}
|
error={parsed.ok ? undefined : parsed.error}
|
||||||
onChange={(text) => {
|
w="100%"
|
||||||
setDraft(text);
|
>
|
||||||
const result = parseJsonObjectDraft(text);
|
<AceEditor
|
||||||
if (result.ok) onChange(result.value);
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||||
import { within } from "@testing-library/react";
|
import { within } from "@testing-library/react";
|
||||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { getAceText, setAceText } from "../../../test/aceEditor";
|
||||||
import {
|
import {
|
||||||
ServerSettingsForm,
|
ServerSettingsForm,
|
||||||
type ServerSettingsSection,
|
type ServerSettingsSection,
|
||||||
|
|
@ -249,8 +250,8 @@ describe("ServerSettingsForm", () => {
|
||||||
screen.getByText("No custom headers configured"),
|
screen.getByText("No custom headers configured"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
// Metadata has no rows to be absent — the editor is always present, and
|
// Metadata has no rows to be absent — the editor is always present, and
|
||||||
// "none configured" is spelled `{}` in the box itself.
|
// "none configured" is spelled `{}` in it.
|
||||||
expect(screen.getByLabelText("Request metadata JSON")).toHaveValue("{}");
|
expect(getAceText()).toBe("{}");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onAddHeader when + Add Header is clicked", async () => {
|
it("invokes onAddHeader when + Add Header is clicked", async () => {
|
||||||
|
|
@ -331,17 +332,15 @@ describe("ServerSettingsForm", () => {
|
||||||
expandedSections={["metadata"]}
|
expandedSections={["metadata"]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const editor = screen.getByLabelText("Request metadata JSON");
|
|
||||||
// The point of #1910: a nested object survives into the editor instead of
|
// The point of #1910: a nested object survives into the editor instead of
|
||||||
// being flattened into a `{ key, value }` string row.
|
// being flattened into a `{ key, value }` string row.
|
||||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toEqual({
|
expect(JSON.parse(getAceText())).toEqual({
|
||||||
userId: "u-1",
|
userId: "u-1",
|
||||||
tenant: { id: 7, tags: ["a"] },
|
tenant: { id: 7, tags: ["a"] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports the whole edited object through onMetadataChange", async () => {
|
it("reports the whole edited object through onMetadataChange", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const onMetadataChange = vi.fn();
|
const onMetadataChange = vi.fn();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ServerSettingsForm
|
<ServerSettingsForm
|
||||||
|
|
@ -351,9 +350,7 @@ describe("ServerSettingsForm", () => {
|
||||||
expandedSections={["metadata"]}
|
expandedSections={["metadata"]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const editor = screen.getByLabelText("Request metadata JSON");
|
await setAceText('{"n":[1,2]}');
|
||||||
await user.clear(editor);
|
|
||||||
await user.type(editor, '{{"n":[[1,2]}');
|
|
||||||
expect(onMetadataChange).toHaveBeenLastCalledWith({ n: [1, 2] });
|
expect(onMetadataChange).toHaveBeenLastCalledWith({ n: [1, 2] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -735,8 +735,7 @@ export function ServerSettingsForm({
|
||||||
strings. Leave it as <Code>{"{}"}</Code> to send none.
|
strings. Leave it as <Code>{"{}"}</Code> to send none.
|
||||||
</HintText>
|
</HintText>
|
||||||
<JsonObjectInput
|
<JsonObjectInput
|
||||||
aria-label="Request metadata JSON"
|
ariaLabel="Request metadata JSON"
|
||||||
placeholder={'{\n "example": { "nested": true }\n}'}
|
|
||||||
value={settings.metadata}
|
value={settings.metadata}
|
||||||
onChange={onMetadataChange}
|
onChange={onMetadataChange}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { setAceText } from "../../../test/aceEditor";
|
||||||
import { ServerSettingsModal } from "./ServerSettingsModal";
|
import { ServerSettingsModal } from "./ServerSettingsModal";
|
||||||
|
|
||||||
const initialSettings: InspectorServerSettings = {
|
const initialSettings: InspectorServerSettings = {
|
||||||
|
|
@ -647,9 +648,7 @@ describe("ServerSettingsModal", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
|
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
|
||||||
const editor = screen.getByLabelText("Request metadata JSON");
|
await setAceText('{"trace":{"id":"abc"}}');
|
||||||
await user.clear(editor);
|
|
||||||
await user.type(editor, '{{"trace":{{"id":"abc"}}}');
|
|
||||||
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
|
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
|
||||||
// The nested object arrives as an object, not a stringified one — the
|
// The nested object arrives as an object, not a stringified one — the
|
||||||
// whole point of #1910.
|
// whole point of #1910.
|
||||||
|
|
|
||||||
58
clients/web/src/test/aceEditor.ts
Normal file
58
clients/web/src/test/aceEditor.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import type { Ace } from "ace-builds";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reach the live Ace editor a component mounted, and drive it.
|
||||||
|
*
|
||||||
|
* Typing is deliberately **not** simulated. Ace reads keystrokes through a 1px
|
||||||
|
* offscreen textarea plus selection and composition state that happy-dom does
|
||||||
|
* not implement, so `userEvent.type` reaches the element and produces no edit
|
||||||
|
* at all — a test written that way asserts nothing while looking like it
|
||||||
|
* asserts a lot. Real keyboard behaviour (auto-closing a brace on Enter, and
|
||||||
|
* the draft surviving invalid text) is covered by the `JsonObjectInput`
|
||||||
|
* Storybook play functions, which run in Chromium.
|
||||||
|
*
|
||||||
|
* Ace hangs the instance off its container element as `env.editor`; that is the
|
||||||
|
* supported way back to it from the DOM.
|
||||||
|
*/
|
||||||
|
export function getAceEditor(container: ParentNode = document): Ace.Editor {
|
||||||
|
const node = container.querySelector(".ace_editor");
|
||||||
|
const editor = (
|
||||||
|
node as (HTMLElement & { env?: { editor?: Ace.Editor } }) | null
|
||||||
|
)?.env?.editor;
|
||||||
|
if (!editor) throw new Error("Ace editor not mounted");
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The document the editor currently holds. */
|
||||||
|
export function getAceText(container?: ParentNode): string {
|
||||||
|
return getAceEditor(container).getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the document the way a select-all-and-retype would, and let React
|
||||||
|
* flush.
|
||||||
|
*
|
||||||
|
* Async because `JsonObjectInput` coalesces the paired remove/insert events
|
||||||
|
* Ace fires for a replace; an `await act` is what drains that microtask.
|
||||||
|
*/
|
||||||
|
export async function setAceText(
|
||||||
|
text: string,
|
||||||
|
container?: ParentNode,
|
||||||
|
): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
// `1` parks the cursor at the end, matching where typing would leave it.
|
||||||
|
getAceEditor(container).setValue(text, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matcher for an Ace-labelled control.
|
||||||
|
*
|
||||||
|
* Ace composes its text input's `aria-label` as `"<label>, Cursor at row N"`,
|
||||||
|
* so an exact-string `getByLabelText` never matches — the position readout is
|
||||||
|
* Ace's, only the prefix is ours.
|
||||||
|
*/
|
||||||
|
export function aceLabel(label: string): RegExp {
|
||||||
|
return new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue