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>
143 lines
5.6 KiB
TypeScript
143 lines
5.6 KiB
TypeScript
import { useState } from "react";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { act, screen } from "@testing-library/react";
|
|
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
|
|
import { renderWithMantine } from "../../../test/renderWithMantine";
|
|
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
|
|
import { JsonObjectInput } from "./JsonObjectInput";
|
|
|
|
const LABEL = "Payload JSON";
|
|
|
|
/** Controlled harness: the component never owns the value, only the draft. */
|
|
function Harness({
|
|
initial,
|
|
onChange,
|
|
}: {
|
|
initial: JsonObject;
|
|
onChange?: (v: JsonObject) => void;
|
|
}) {
|
|
const [value, setValue] = useState<JsonObject>(initial);
|
|
return (
|
|
<>
|
|
<JsonObjectInput
|
|
ariaLabel={LABEL}
|
|
value={value}
|
|
onChange={(next) => {
|
|
setValue(next);
|
|
onChange?.(next);
|
|
}}
|
|
/>
|
|
<output data-testid="emitted">{JSON.stringify(value)}</output>
|
|
</>
|
|
);
|
|
}
|
|
|
|
describe("JsonObjectInput", () => {
|
|
it("names the editor for assistive tech", () => {
|
|
renderWithMantine(<Harness initial={{}} />);
|
|
// Ace composes "<label>, Cursor at row N", so the match is by substring —
|
|
// the position readout is Ace's, the name is ours.
|
|
expect(screen.getByLabelText(aceLabel(LABEL))).toBeInTheDocument();
|
|
});
|
|
|
|
it("uses Ace's dark theme under a dark color scheme", () => {
|
|
// The editor picks its Ace theme from the computed scheme rather than
|
|
// inheriting Mantine tokens, so the dark branch is only exercised by
|
|
// actually rendering dark.
|
|
renderWithMantine(<Harness initial={{}} />, { colorScheme: "dark" });
|
|
// `ace-github-dark`, hyphenated — Ace's cssClass, not the module name
|
|
// (`theme-github_dark`). The App.css gutter-contrast override keys off this
|
|
// same class, so getting it wrong silently drops the fix in dark mode.
|
|
expect(document.querySelector(".ace-github-dark")).not.toBeNull();
|
|
expect(document.querySelector(".ace-github")).toBeNull();
|
|
});
|
|
|
|
it("uses Ace's light theme under a light color scheme", () => {
|
|
renderWithMantine(<Harness initial={{}} />, { colorScheme: "light" });
|
|
expect(document.querySelector(".ace-github")).not.toBeNull();
|
|
expect(document.querySelector(".ace-github_dark")).toBeNull();
|
|
});
|
|
|
|
it("opens with the value serialized two-space, nesting intact", () => {
|
|
renderWithMantine(<Harness initial={{ a: { b: [1, 2] } }} />);
|
|
expect(getAceText()).toBe(
|
|
'{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}',
|
|
);
|
|
});
|
|
|
|
it("emits the parsed object — a structured value stays structured", async () => {
|
|
const onChange = vi.fn();
|
|
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
|
|
await setAceText('{"trace":{"id":"x"},"n":3}');
|
|
expect(onChange).toHaveBeenLastCalledWith({ trace: { id: "x" }, n: 3 });
|
|
});
|
|
|
|
// Also the regression test for the paired-event bug `handleChange` coalesces:
|
|
// `setValue` is a remove followed by an insert, and acting on the remove
|
|
// emitted `{}` and wiped `keep` before the insert had landed.
|
|
it("shows the text as typed while it is invalid and does not emit", async () => {
|
|
const onChange = vi.fn();
|
|
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
|
await setAceText('{"a":');
|
|
console.log("[dbg] emitted:", screen.getByTestId("emitted").textContent);
|
|
console.log(
|
|
"[dbg] body has error?",
|
|
document.body.textContent?.includes("Not valid JSON"),
|
|
);
|
|
// Displayed verbatim — not re-escaped back through JSON.stringify.
|
|
expect(getAceText()).toBe('{"a":');
|
|
expect(
|
|
screen.getByText("Not valid JSON — changes are not applied"),
|
|
).toBeInTheDocument();
|
|
// Nothing emitted, so the caller still holds the last valid object.
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId("emitted").textContent).toBe('{"keep":"me"}');
|
|
});
|
|
|
|
it("flags valid JSON that is not an object without emitting", async () => {
|
|
const onChange = vi.fn();
|
|
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
|
await setAceText("[1]");
|
|
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats a cleared editor as the empty object", async () => {
|
|
const onChange = vi.fn();
|
|
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
|
|
await setAceText("");
|
|
expect(onChange).toHaveBeenLastCalledWith({});
|
|
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
|
|
});
|
|
|
|
it("re-syncs when the parent's value diverges from the draft", async () => {
|
|
// An external reset (switching servers, a reloaded catalog) must reach the
|
|
// editor; an in-progress edit must not be overwritten by what it emitted.
|
|
function Externally() {
|
|
const [value, setValue] = useState<JsonObject>({ a: 1 });
|
|
return (
|
|
<>
|
|
<JsonObjectInput
|
|
ariaLabel={LABEL}
|
|
value={value}
|
|
onChange={setValue}
|
|
/>
|
|
<button onClick={() => setValue({ b: 2 })}>reset</button>
|
|
</>
|
|
);
|
|
}
|
|
renderWithMantine(<Externally />);
|
|
await act(async () => {
|
|
screen.getByRole("button", { name: "reset" }).click();
|
|
});
|
|
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
|
|
});
|
|
|
|
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
|
|
renderWithMantine(<Harness initial={{}} />);
|
|
// The parent re-renders with the object this text parsed to; the draft must
|
|
// not be reformatted out from under the cursor.
|
|
await setAceText('{"a":1}');
|
|
expect(getAceText()).toBe('{"a":1}');
|
|
});
|
|
});
|