feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle

Extracts the Ace integration #2094 carried inline into a shared
`elements/JsonEditor`, and renders it on every surface where the web
client types or displays JSON.

The element is deliberately text in, text out. The two editing contracts
above it disagree about what an unparseable draft means — JsonObjectInput
keeps the last valid object and says nothing to its parent, SchemaJsonField
reports `undefined` and blocks submission (#2020) — so parsing, and the
draft/value split that goes with it, stays with each caller.

Surfaces: SchemaJsonField (inherited by all four SchemaForm consumers), a
new "Edit as JSON" switch for the whole arguments object, the server.json
File Contents box, the raw JSON-RPC request box, ContentViewer's JSON
branch (read-only, inherited by its eleven consumers), and a new "Edit and
replay" modal on a Protocol entry.

Ace read-only replaces the CodeHighlight JSON path and the Prism `json`
grammar loader is dropped: folding, not colorization, is what it adds, and
two highlighters for one language drift. `wrap={false}` callers and untyped
text that only looks like JSON keep the plain renderer.

Closes #2151

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 02:24:22 -04:00
commit 1a2a36f0b8
14 changed files with 2026 additions and 448 deletions

View file

@ -9,14 +9,11 @@ const meta: Meta<typeof CodeHighlight> = {
export default meta;
type Story = StoryObj<typeof CodeHighlight>;
export const Json: Story = {
// No JSON story: JSON is rendered by `JsonEditor` (read-only), not here (#2151).
export const Yaml: Story = {
args: {
language: "json",
code: JSON.stringify(
{ name: "my-app", version: "1.0.0", tags: ["a", "b"] },
null,
2,
),
language: "yaml",
code: "name: my-app\nversion: 1.0.0\ntags:\n - a\n - b",
},
};

View file

@ -22,9 +22,6 @@ vi.mock("react-syntax-highlighter/dist/esm/prism-light", () => ({
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/tomorrow", () => ({
default: {},
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/json", () => ({
default: { __grammar: "json" },
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markup", () => ({
default: { __grammar: "markup" },
}));
@ -53,15 +50,15 @@ beforeEach(() => {
describe("CodeHighlight", () => {
it("renders plain code initially, then upgrades to the highlighter", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="json" code='{"a":1}' />);
renderWithMantine(<CodeHighlight language="yaml" code="a: 1" />);
// Plain Mantine Code before the grammar resolves.
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(screen.getByText('{"a":1}')).toBeInTheDocument();
expect(screen.getByText("a: 1")).toBeInTheDocument();
// After the lazy grammar loads, the prism runtime takes over.
const prism = await screen.findByTestId("prism");
expect(prism).toHaveAttribute("data-language", "json");
expect(registerLanguage).toHaveBeenCalledWith("json", {
__grammar: "json",
expect(prism).toHaveAttribute("data-language", "yaml");
expect(registerLanguage).toHaveBeenCalledWith("yaml", {
__grammar: "yaml",
});
});
@ -113,14 +110,14 @@ describe("CodeHighlight", () => {
it("reuses an already-registered grammar without re-importing", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="json" code='{"a":1}' />,
<CodeHighlight language="yaml" code="a: 1" />,
);
await screen.findByTestId("prism");
expect(registerLanguage).toHaveBeenCalledTimes(1);
unmount();
// A second mount finds json already registered: ready synchronously, no
// A second mount finds yaml already registered: ready synchronously, no
// second registerLanguage call.
renderWithMantine(<CodeHighlight language="json" code='{"b":2}' />);
renderWithMantine(<CodeHighlight language="yaml" code="b: 2" />);
expect(await screen.findByTestId("prism")).toBeInTheDocument();
expect(registerLanguage).toHaveBeenCalledTimes(1);
});
@ -128,7 +125,7 @@ describe("CodeHighlight", () => {
it("loads a second language reusing the already-loaded runtime", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="json" code='{"a":1}' />,
<CodeHighlight language="yaml" code="a: 1" />,
);
await screen.findByTestId("prism");
unmount();
@ -146,7 +143,7 @@ describe("CodeHighlight", () => {
const CodeHighlight = await loadComponent();
renderWithMantine(
<>
<CodeHighlight language="json" code='{"a":1}' />
<CodeHighlight language="yaml" code="a: 1" />
<CodeHighlight language="css" code=".a{}" />
</>,
);

View file

@ -35,7 +35,11 @@ type PrismRuntime = ComponentType<{
* below if the canonical Prism name differs) as the type matrix grows.
*/
const LANGUAGE_LOADERS: Record<string, () => Promise<{ default: Grammar }>> = {
json: () => import("react-syntax-highlighter/dist/esm/languages/prism/json"),
// No `json` entry, deliberately (#2151). JSON is rendered by the Ace-backed
// `JsonEditor` in read-only mode, which folds and numbers a large payload —
// things a Prism block cannot do. Registering the grammar here as well would
// leave two highlighters for one language, differing in appearance and
// drifting apart; there is no caller left that asks for it.
markup: () =>
import("react-syntax-highlighter/dist/esm/languages/prism/markup"),
css: () => import("react-syntax-highlighter/dist/esm/languages/prism/css"),

View file

@ -5,6 +5,7 @@ import type {
TextResourceContents,
} from "@modelcontextprotocol/client";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText } from "../../../test/aceEditor";
import { ContentViewer } from "./ContentViewer";
// Stub the lazy highlighter so JSON/XML/CSS branches are assertable
@ -28,16 +29,19 @@ describe("ContentViewer", () => {
expect(screen.getByText("hello world")).toBeInTheDocument();
});
it("pretty-prints JSON text", () => {
it("pretty-prints JSON text into the JSON editor", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
expect(getAceText()).toBe('{\n "a": 1\n}');
});
// Text that merely *starts* like JSON stays plain: presenting it in a JSON
// gutter would frame the server's prose as a malformed document (#2151).
it("falls back to raw text when JSON is malformed", () => {
const block: ContentBlock = { type: "text", text: "{ broken" };
renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByText("{ broken")).toBeInTheDocument();
expect(document.querySelector(".ace_editor")).toBeNull();
});
it("renders a copy overlay when copyable", () => {
@ -206,14 +210,34 @@ describe("ContentViewer", () => {
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("highlights a JSON text block when mimeType is application/json", () => {
it("renders a declared JSON text block in the JSON editor", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(
<ContentViewer block={block} mimeType="application/json" />,
);
const probe = screen.getByTestId("code-highlight");
expect(probe).toHaveAttribute("data-language", "json");
expect(probe.textContent).toContain('"a": 1');
expect(getAceText()).toBe('{\n "a": 1\n}');
// The Prism path is no longer taken for JSON — one highlighter per
// language, and Ace is the one that folds (#2151).
expect(screen.queryByTestId("code-highlight")).not.toBeInTheDocument();
});
// The server card's fixed-height, single-line box (`wrap={false}`) must not
// become a multi-line editor, whatever MIME it declares.
it("keeps a non-wrapping JSON block on the plain renderer", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(
<ContentViewer block={block} mimeType="application/json" wrap={false} />,
);
expect(document.querySelector(".ace_editor")).toBeNull();
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
});
it("keeps the copy overlay above the JSON editor", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(
<ContentViewer block={block} mimeType="application/json" copyable />,
);
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
});
it("renders nothing when neither block nor contents is provided", () => {
@ -274,15 +298,13 @@ describe("ContentViewer (resource contents)", () => {
).toBeInTheDocument();
});
it("highlights JSON contents", () => {
it("renders JSON contents in the JSON editor", () => {
renderWithMantine(
<ContentViewer
contents={text({ text: '{"a":1}', mimeType: "application/json" })}
/>,
);
const probe = screen.getByTestId("code-highlight");
expect(probe).toHaveAttribute("data-language", "json");
expect(probe.textContent).toContain('"a": 1');
expect(getAceText()).toBe('{\n "a": 1\n}');
});
it("indents and highlights XML contents", () => {

View file

@ -10,11 +10,13 @@ import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeHighlight } from "../CodeHighlight/CodeHighlight";
import { CopyButton } from "../CopyButton/CopyButton";
import { JsonEditor } from "../JsonEditor/JsonEditor";
import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo";
import {
formatJson,
formatXml,
getMimeKind,
isJsonDocument,
isSafeHref,
isTextualKind,
looksLikeJson,
@ -161,6 +163,46 @@ function HighlightedContent({
);
}
/**
* A read-only JSON payload, rendered in the same Ace editor the JSON *input*
* surfaces use (#2151).
*
* Highlighting is not what this buys — JSON already highlighted here, through
* the lazily-imported Prism grammar `CodeHighlight` loads. What Ace adds is
* **folding**, line numbers and a gutter, which is the difference between
* reading a large `tools/call` result and scrolling past it. Prism's `json`
* grammar was dropped in the same change rather than kept beside this: two
* highlighters for one language drift, and nothing else asks for `json`.
*
* The copy affordance is unchanged — `CopyableWrapper` overlays it above
* whichever renderer was picked, so `copyable` keeps working across the swap.
* It copies the *original* text, not the pretty-printed form, matching every
* other branch here.
*/
function JsonContent({ text, copyable }: { text: string; copyable: boolean }) {
return (
<CopyableWrapper copyable={copyable} copyValue={text}>
<JsonEditor
ariaLabel="JSON content"
value={formatJson(text)}
readOnly
// Sized to the payload, both ways. A two-line result must not open six
// rows of blank editor — and an uncapped `maxLines` is what keeps a
// large one from growing its *own* scroller inside the host's: every
// consumer here already provides the scroll container it wants (the
// structured-output section caps at `mah` and scrolls; a result panel
// scrolls whole), and a second scrollbar nested in the first is both
// worse to use and a change to layout this swap has no business making.
// It also keeps every line in the DOM, as the preformatted block it
// replaces did — Ace renders only what its own viewport covers, so a
// capped editor would silently drop the rest from find-in-page.
minLines={1}
maxLines={Infinity}
/>
</CopyableWrapper>
);
}
function PlainTextContent({
text,
copyable,
@ -170,6 +212,13 @@ function PlainTextContent({
copyable: boolean;
wrap: boolean;
}) {
// Untyped text that really parses as JSON gets the JSON renderer too — the
// heuristic is how a server that sent no MIME type still reads well. Not when
// `wrap` is false, though: that caller wants one clipped line at a fixed
// height, which a multi-line editor is not.
if (wrap && isJsonDocument(text)) {
return <JsonContent text={text} copyable={copyable} />;
}
const displayText = looksLikeJson(text) ? formatJson(text) : text;
return (
<CopyableWrapper copyable={copyable} copyValue={text}>
@ -210,13 +259,14 @@ function TextualContent({
case "markdown":
return <MarkdownContent text={text} copyable={copyable} />;
case "json":
return (
<HighlightedContent
code={formatJson(text)}
language="json"
copyValue={text}
copyable={copyable}
/>
// `wrap={false}` is a fixed-height, single-line, ellipsis-clipped box
// (the server card's command/URL). Pretty-printed JSON collapsed onto one
// line is exactly what that prop is documented not to be used for, so
// that caller keeps the plain renderer.
return wrap ? (
<JsonContent text={text} copyable={copyable} />
) : (
<PlainTextContent text={text} copyable={copyable} wrap={wrap} />
);
case "xml":
return (

View file

@ -7,6 +7,7 @@ import {
getMimeKind,
isSafeHref,
isTextualKind,
isJsonDocument,
looksLikeJson,
PREVIEW_HTML_CSP,
tryDecodeBase64ToBytes,
@ -112,6 +113,29 @@ describe("looksLikeJson", () => {
});
});
describe("isJsonDocument", () => {
it("accepts text that really parses as JSON", () => {
expect(isJsonDocument(' {"a":1}')).toBe(true);
expect(isJsonDocument("[1,2]")).toBe(true);
});
// The stricter half of the heuristic (#2151). Text that merely *starts* like
// JSON must not be handed to the JSON editor: a note beginning `{` would then
// be presented as a document, in a JSON gutter, framed as malformed.
it("rejects text that only looks like JSON", () => {
expect(isJsonDocument("{ broken")).toBe(false);
expect(isJsonDocument("[unclosed")).toBe(false);
expect(isJsonDocument("plain")).toBe(false);
});
// A bare scalar is valid JSON but never looked like a document, so it stays
// on the plain renderer — otherwise ordinary prose that happens to be a
// number would open an editor.
it("rejects a scalar that never looked like a document", () => {
expect(isJsonDocument("42")).toBe(false);
});
});
describe("formatXml", () => {
it("indents nested elements", () => {
const out = formatXml("<a><b>x</b></a>");

View file

@ -142,6 +142,27 @@ export function looksLikeJson(text: string): boolean {
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
/**
* Whether untyped text really is a JSON document, not merely shaped like one.
*
* {@link looksLikeJson} only tests the first character, which is enough to
* decide whether to *try* pretty-printing — a failed parse falls back to the
* original text and nothing is lost. It is not enough to decide to hand the
* text to the JSON editor: a note beginning `{` would then be presented as a
* JSON document, in a JSON gutter, with the server's plain text framed as
* malformed. A declared `application/json` is different — there the server said
* what it sent, so its own broken payload is shown as JSON on purpose.
*/
export function isJsonDocument(text: string): boolean {
if (!looksLikeJson(text)) return false;
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
/**
* Indent a single-line or minified XML/HTML-ish document for readability before
* syntax highlighting. Hand-rolled: split on `>\s*<` boundaries, then track a

View file

@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { EditReplayButton } from "./EditReplayButton";
const meta: Meta<typeof EditReplayButton> = {
title: "Elements/EditReplayButton",
component: EditReplayButton,
args: { onClick: fn() },
};
export default meta;
type Story = StoryObj<typeof EditReplayButton>;
export const Default: Story = {};
export const Activated: Story = {
play: async ({ args, canvasElement, userEvent }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Edit and replay" }),
);
await expect(args.onClick).toHaveBeenCalled();
},
};

View file

@ -0,0 +1,21 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { EditReplayButton } from "./EditReplayButton";
describe("EditReplayButton", () => {
it("is named for assistive tech", () => {
renderWithMantine(<EditReplayButton onClick={vi.fn()} />);
expect(
screen.getByRole("button", { name: "Edit and replay" }),
).toBeInTheDocument();
});
it("invokes onClick when activated", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
renderWithMantine(<EditReplayButton onClick={onClick} />);
await user.click(screen.getByRole("button", { name: "Edit and replay" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,31 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { MdEditNote } from "react-icons/md";
export interface EditReplayButtonProps {
/** Open the editor seeded with the owning entry's request params. */
onClick: () => void;
}
/**
* "Edit and replay" — the sibling of {@link ReplayButton} that stops for the
* params instead of re-sending them verbatim (#2151).
*
* Same subtle gray icon-button styling as Replay and the pin toggle, so the
* three read as one control cluster on a Protocol entry.
*/
const EditReplayActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "md",
"aria-label": "Edit and replay",
});
export function EditReplayButton({ onClick }: EditReplayButtonProps) {
return (
<Tooltip label="Edit and replay">
<EditReplayActionIcon onClick={onClick}>
<MdEditNote size={20} />
</EditReplayActionIcon>
</Tooltip>
);
}

View file

@ -0,0 +1,117 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent } from "storybook/test";
import { JsonEditor } from "./JsonEditor";
const meta: Meta<typeof JsonEditor> = {
title: "Elements/JsonEditor",
component: JsonEditor,
};
export default meta;
type Story = StoryObj<typeof JsonEditor>;
const SAMPLE = JSON.stringify(
{
trace: { id: "abc-123", sampled: true },
tags: ["alpha", "beta"],
retries: 3,
},
null,
2,
);
/** The live editor instance, which Ace hangs off its container as `env.editor`. */
function aceEditor(canvasElement: HTMLElement): {
focus(): void;
getValue(): string;
} {
const container = canvasElement.querySelector(".ace_editor");
const editor = (
container as
| (HTMLElement & {
env?: { editor?: { focus(): void; getValue(): string } };
})
| null
)?.env?.editor;
if (!editor) throw new Error("Ace editor not mounted");
return editor;
}
/**
* Stateful wrapper: the editor is controlled on text by every real consumer, so
* a story without one would not accept a keystroke.
*
* Ace's *keyboard* behavior — auto-closing a brace, keeping an invalid draft
* raw, the worker's gutter annotation — is covered by the `JsonObjectInput`
* play functions, which drive this same editor through its object-shaped
* wrapper. What is left to pin here is the read-only mode those cannot reach.
*/
function Editable({ initial }: { initial: string }) {
const [text, setText] = useState(initial);
return (
<JsonEditor
ariaLabel="Payload JSON"
label="Payload"
value={text}
onChange={setText}
/>
);
}
export const Editing: Story = {
render: () => <Editable initial={SAMPLE} />,
};
export const Empty: Story = {
render: () => <Editable initial="" />,
};
export const WithError: Story = {
args: {
ariaLabel: "Payload JSON",
label: "Payload",
value: '{"a":',
description: "Sent with every request.",
error: "Not valid JSON — changes are not applied",
onChange: () => {},
},
};
export const Disabled: Story = {
args: {
ariaLabel: "Payload JSON",
label: "Payload",
value: SAMPLE,
disabled: true,
onChange: () => {},
},
};
/**
* Display mode — what every read-only JSON payload in the app renders as
* (#2151). It keeps the folding and the line numbers a large `tools/call`
* result gains from, and takes no input.
*
* The keystroke assertion is the point: a read-only Ace editor is still
* focusable and still owns a hidden textarea, so "does not edit" is a real
* behavior to pin rather than a property of the markup.
*/
export const ReadOnly: Story = {
args: {
ariaLabel: "JSON content",
value: SAMPLE,
readOnly: true,
},
play: async ({ canvasElement }) => {
const editor = aceEditor(canvasElement);
editor.focus();
await userEvent.keyboard("x");
await expect(editor.getValue()).toBe(SAMPLE);
// The caret is hidden too — a blinking cursor in a box that swallows every
// keystroke reads as a broken field.
await expect(
canvasElement.querySelector(".json-editor-readonly"),
).not.toBeNull();
},
};

View file

@ -0,0 +1,190 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
import { JsonEditor } from "./JsonEditor";
const LABEL = "Payload JSON";
/** Controlled on text, which is the contract every consumer uses. */
function Harness({
initial,
onChange,
}: {
initial: string;
onChange?: (text: string) => void;
}) {
const [text, setText] = useState(initial);
return (
<JsonEditor
ariaLabel={LABEL}
value={text}
onChange={(next) => {
setText(next);
onChange?.(next);
}}
/>
);
}
describe("JsonEditor", () => {
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("shows the text it is given, verbatim", () => {
// Braces, not a bare JSX string: a JSX string attribute does not process
// escapes, so `'…\n…'` written inline would be a literal backslash-n.
renderWithMantine(<Harness initial={'{\n "a": 1\n}'} />);
expect(getAceText()).toBe('{\n "a": 1\n}');
});
it("reports the settled text of an edit", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial="" onChange={onChange} />);
await setAceText('{"a":1}');
expect(onChange).toHaveBeenLastCalledWith('{"a":1}');
});
// Ace fires a *remove* then an *insert* for a replace. Reporting the first
// would tell the consumer the document is empty, which for `JsonObjectInput`
// means `{}` — the user's saved metadata, wiped a keystroke before the
// replacement lands.
it("reports a replace once, not as an empty document first", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial='{"keep":1}' onChange={onChange} />);
await setAceText('{"next":2}');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('{"next":2}');
});
it("uses Ace's dark theme under a dark color scheme", () => {
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();
});
describe("wrapper wiring", () => {
it("associates the label, description and error with the editor", () => {
renderWithMantine(
<JsonEditor
ariaLabel={LABEL}
value=""
onChange={vi.fn()}
label="Metadata"
description="Sent with every request."
error="Not valid JSON"
/>,
);
const input = screen.getByLabelText(aceLabel(LABEL));
expect(input).toHaveAttribute("aria-invalid", "true");
const describedBy = input.getAttribute("aria-describedby")?.split(" ");
expect(describedBy).toHaveLength(2);
// Both nodes exist and are the ones named — a description that is never
// announced, or an error pointing at nothing, is the failure mode here.
for (const id of describedBy ?? []) {
expect(document.getElementById(id)).toBeInTheDocument();
}
expect(screen.getByText("Not valid JSON")).toBeInTheDocument();
});
it("drops the error wiring once the text is valid again", () => {
const { rerender } = renderWithMantine(
<JsonEditor
ariaLabel={LABEL}
value=""
onChange={vi.fn()}
error="Not valid JSON"
/>,
);
rerender(<JsonEditor ariaLabel={LABEL} value="" onChange={vi.fn()} />);
const input = screen.getByLabelText(aceLabel(LABEL));
expect(input).not.toHaveAttribute("aria-invalid");
expect(input).not.toHaveAttribute("aria-describedby");
});
});
describe("read-only", () => {
it("is read-only when no change handler is supplied", () => {
renderWithMantine(<JsonEditor ariaLabel={LABEL} value='{"a":1}' />);
expect(getAceText()).toBe('{"a":1}');
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
});
it("is read-only when asked, even with a handler", () => {
renderWithMantine(
<JsonEditor
ariaLabel={LABEL}
value='{"a":1}'
onChange={vi.fn()}
readOnly
/>,
);
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
});
it("is read-only and dimmed when disabled", () => {
const { container } = renderWithMantine(
<JsonEditor
ariaLabel={LABEL}
value='{"a":1}'
onChange={vi.fn()}
disabled
/>,
);
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
// The only affordance a pointer user gets, since Ace has no disabled
// state of its own.
expect(container.querySelector('[style*="opacity: 0.6"]')).not.toBeNull();
});
// Read-only is derived state, so both transitions happen to an editor that
// is already mounted — and react-ace's `componentDidUpdate` reads
// `prevProps.className.trim()` with no guard when the class changes. An
// `undefined` on either side of that swap throws, or writes the literal
// class name "undefined" onto the element.
it("becomes editable when a handler arrives", () => {
const { rerender } = renderWithMantine(
<JsonEditor ariaLabel={LABEL} value='{"a":1}' />,
);
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
rerender(
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
);
expect(document.querySelector(".json-editor-readonly")).toBeNull();
expect(document.querySelector(".undefined")).toBeNull();
});
// The direction a tool form takes when a call goes in flight: the panel
// disables the whole form, so an editable editor becomes read-only in
// place. This threw before the class was made unconditionally a string.
it("becomes read-only when the form is disabled mid-edit", () => {
const { rerender } = renderWithMantine(
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
);
expect(document.querySelector(".json-editor-readonly")).toBeNull();
rerender(
<JsonEditor
ariaLabel={LABEL}
value='{"a":1}'
onChange={vi.fn()}
disabled
/>,
);
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
});
});
});

View file

@ -0,0 +1,263 @@
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
import { Input, useComputedColorScheme } from "@mantine/core";
import type { Ace } from "ace-builds";
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";
// 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);
export interface JsonEditorProps {
/**
* The text the editor displays. This is a **text** contract, not a JSON one:
* the editor never parses, so a caller holding a value keeps its own draft
* text and decides for itself what an unparseable draft means.
*/
value: string;
/**
* Settled text after an edit. Omitted for a read-only editor.
*
* Called once per edit, not once per Ace change event — see the coalescing
* note on `handleChange`.
*/
onChange?: (text: string) => void;
/**
* Display only: no caret, no editing, and no JSON worker (its annotations
* describe a document the user cannot fix). Implied when `onChange` is
* omitted.
*/
readOnly?: boolean;
/**
* 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;
label?: ReactNode;
description?: ReactNode;
/** Rendered by the wrapper and referenced from the editor's `aria-describedby`. */
error?: ReactNode;
withAsterisk?: boolean;
/**
* Non-editable *and* visibly inert, for a form whose whole surface is
* disabled. Ace has no disabled state of its own, so this is read-only plus
* a dimmed wrapper.
*/
disabled?: boolean;
/** Rows shown before the editor grows; it autosizes between these bounds. */
minLines?: number;
maxLines?: number;
}
/**
* The Ace-backed JSON editor every JSON-typing and JSON-displaying surface in
* the web client renders (#2151).
*
* 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 a nested
* payload, numbers its lines, and — via its JSON worker — marks the offending
* line in the gutter instead of reporting one message for the whole document.
* Read-only it keeps the folding and the line numbers, which is what a large
* response payload gains over a preformatted block.
*
* Deliberately **text in, text out**. The two editing contracts above it want
* different things from an unparseable draft — `JsonObjectInput` keeps the last
* valid object and says nothing to its parent, `SchemaJsonField` reports
* `undefined` and blocks submission — and neither can be expressed by a
* component that decides what a draft means. So parsing, and the draft/value
* split that goes with it, belongs to each caller; only the editing surface is
* shared.
*/
export function JsonEditor({
value,
onChange,
readOnly,
ariaLabel,
label,
description,
error,
withAsterisk,
disabled = false,
minLines = 6,
maxLines = 24,
}: JsonEditorProps) {
// `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,
});
// 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);
// Read through a ref so the coalescing flush always calls the current
// handler, without the handler's identity being part of any dependency list.
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
/**
* 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 report the momentarily-empty document as the
* user's answer: for `JsonObjectInput` that is `{}`, which overwrites the
* metadata they had just selected; for a raw-JSON form it is a cleared
* arguments object. If the replacement text then failed to parse, that empty
* value 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;
onChangeRef.current?.(pendingTextRef.current);
});
};
const wrapperId = useId();
const editorRef = useRef<Ace.Editor | null>(null);
const isReadOnly = readOnly === true || disabled || onChange === undefined;
const hasError = error !== undefined && error !== null && error !== false;
// `Input.Wrapper` labels and describes a Mantine input through context. Ace
// is not one — it renders its own DOM — so the wrapper's label and error node
// exist but point at nothing, leaving the control unassociated with the error
// a screen reader needs and its visible label inert on click.
//
// A genuine external-system sync, so an effect rather than `useValueChange`:
// it writes to a DOM node React does not own. Giving the textarea the
// wrapper's own id is what makes the `<label for>` focus the editor; the
// error wiring follows Mantine's `${id}-error` naming.
useEffect(() => {
const element = editorRef.current?.textInput.getElement();
/* v8 ignore next -- the ref is set in `onLoad`, which react-ace calls
synchronously on mount, so this effect always sees an element. */
if (!element) return;
element.id = wrapperId;
// Both the description and the error are rendered by the wrapper and both
// describe this control, so both ids belong here — a supplied description
// is otherwise never announced, and it must not be dropped just because the
// draft went invalid. Mantine renders each only when its prop is set, so
// referencing an absent one would point at nothing.
const describedBy = [
description === undefined ? null : `${wrapperId}-description`,
hasError ? `${wrapperId}-error` : null,
].filter((id) => id !== null);
if (describedBy.length > 0) {
element.setAttribute("aria-describedby", describedBy.join(" "));
} else {
element.removeAttribute("aria-describedby");
}
if (hasError) {
element.setAttribute("aria-invalid", "true");
} else {
element.removeAttribute("aria-invalid");
}
}, [wrapperId, hasError, description]);
return (
<Input.Wrapper
id={wrapperId}
label={label}
description={description}
error={error}
withAsterisk={withAsterisk}
// Ace has no disabled state, so the only signal a pointer user gets is
// that the surface is dimmed — the same affordance a disabled Mantine
// input carries.
opacity={disabled ? 0.6 : undefined}
w="100%"
>
<AceEditor
mode="json"
theme={colorScheme === "dark" ? "github_dark" : "github"}
// react-ace uses `name` as the editor container's DOM id, so a fixed
// value would collide the moment two of these render — and it must also
// differ from the id given to the textarea below, which is the one the
// wrapper's `<label for>` points at.
name={`${wrapperId}-editor`}
// A class, not a style: Ace renders its own DOM and paints a caret even
// when read-only, which reads as an editable field whose keystrokes are
// being swallowed. Hiding it needs a selector into that DOM, which is
// the same reason the gutter override in App.css exists.
//
// `""` rather than `undefined` for the off case: react-ace's
// `componentDidUpdate` reads `prevProps.className.trim()` whenever the
// class changes, with no guard — so going *to* a class from `undefined`
// throws, and going *from* one to `undefined` writes the literal class
// name "undefined" onto the element. Both are reachable here, because
// read-only is derived state: a tool form disables itself while a call
// is in flight, which flips this on an editor already mounted.
className={isReadOnly ? "json-editor-readonly" : ""}
value={value}
onChange={handleChange}
readOnly={isReadOnly}
width="100%"
minLines={minLines}
maxLines={maxLines}
tabSize={2}
showPrintMargin={false}
// A read-only editor is a rendering of someone else's payload, so it
// carries none of the caret furniture an editable one does.
highlightActiveLine={!isReadOnly}
editorProps={{ $blockScrolling: Infinity }}
onLoad={(editor) => {
editorRef.current = 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. Pointless on a read-only
// document — the reader cannot act on what it flags — and actively
// misleading where the text is a *fragment* rendered for display.
useWorker: !isReadOnly,
// 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,
highlightGutterLine: !isReadOnly,
}}
/>
</Input.Wrapper>
);
}

View file

@ -1,22 +1,8 @@
import { useEffect, useId, useRef, useState } from "react";
import { Input, useComputedColorScheme } from "@mantine/core";
import type { Ace } from "ace-builds";
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 { useState } from "react";
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import { useValueChange } from "../../../hooks/useValueChange";
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
// 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);
import { JsonEditor } from "../JsonEditor/JsonEditor";
/** Two-space canonical form — the same shape the catalog file is written in. */
function serialize(value: StrictJsonObject): string {
@ -28,12 +14,7 @@ export interface JsonObjectInputProps {
onChange: (value: StrictJsonObject) => void;
label?: string;
description?: 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.
*/
/** See {@link JsonEditor}'s prop of the same name — Ace names its own input. */
ariaLabel: string;
/** Rows shown before the editor grows; it autosizes between these bounds. */
minLines?: number;
@ -50,10 +31,9 @@ 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 editing surface is {@link JsonEditor}, shared with every other JSON
* surface in the client (#2151). What lives here is the *contract*: an object
* in, an object out, and a decision about what an unparseable draft means.
*
* 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
@ -75,8 +55,8 @@ export function JsonObjectInput({
label,
description,
ariaLabel,
minLines = 6,
maxLines = 24,
minLines,
maxLines,
}: JsonObjectInputProps) {
const [draft, setDraft] = useState(() => serialize(value));
// The canonical JSON of the last value this component emitted.
@ -85,63 +65,22 @@ export function JsonObjectInput({
// 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,
});
const handleChange = (text: string) => {
setDraft(text);
const result = parseJsonObjectDraft(text);
if (!result.ok) return;
setEchoed(serialize(result.value));
onChange(result.value);
};
const parsed = parseJsonObjectDraft(draft);
// 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);
});
};
const wrapperId = useId();
const editorRef = useRef<Ace.Editor | null>(null);
const parsed = parseJsonObjectDraft(draft);
useValueChange(serialize(value), (next) => {
if (next === echoed) return;
setDraft(next);
@ -152,100 +91,16 @@ export function JsonObjectInput({
setEchoed(next);
});
// `Input.Wrapper` labels and describes a Mantine input through context. Ace
// is not one — it renders its own DOM — so the wrapper's label and error node
// exist but point at nothing, leaving the control unassociated with the error
// a screen reader needs and its visible label inert on click.
//
// A genuine external-system sync, so an effect rather than `useValueChange`:
// it writes to a DOM node React does not own. Giving the textarea the
// wrapper's own id is what makes the `<label for>` focus the editor; the
// error wiring follows Mantine's `${id}-error` naming.
useEffect(() => {
const element = editorRef.current?.textInput.getElement();
/* v8 ignore next -- the ref is set in `onLoad`, which react-ace calls
synchronously on mount, so this effect always sees an element. */
if (!element) return;
element.id = wrapperId;
// Both the description and the error are rendered by the wrapper and both
// describe this control, so both ids belong here — a supplied description
// is otherwise never announced, and it must not be dropped just because the
// draft went invalid. Mantine renders each only when its prop is set, so
// referencing an absent one would point at nothing.
const describedBy = [
description === undefined ? null : `${wrapperId}-description`,
parsed.ok ? null : `${wrapperId}-error`,
].filter((id) => id !== null);
if (describedBy.length > 0) {
element.setAttribute("aria-describedby", describedBy.join(" "));
} else {
element.removeAttribute("aria-describedby");
}
if (parsed.ok) {
element.removeAttribute("aria-invalid");
} else {
element.setAttribute("aria-invalid", "true");
}
}, [wrapperId, parsed.ok, description]);
return (
<Input.Wrapper
id={wrapperId}
<JsonEditor
value={draft}
onChange={handleChange}
label={label}
description={description}
ariaLabel={ariaLabel}
error={parsed.ok ? undefined : parsed.error}
w="100%"
>
<AceEditor
mode="json"
theme={colorScheme === "dark" ? "github_dark" : "github"}
// react-ace uses `name` as the editor container's DOM id, so a fixed
// value would collide the moment two of these render — and it must also
// differ from the id given to the textarea below, which is the one the
// wrapper's `<label for>` points at.
name={`${wrapperId}-editor`}
value={draft}
onChange={handleChange}
width="100%"
minLines={minLines}
maxLines={maxLines}
tabSize={2}
showPrintMargin={false}
editorProps={{ $blockScrolling: Infinity }}
onLoad={(editor) => {
editorRef.current = 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>
minLines={minLines}
maxLines={maxLines}
/>
);
}