From 3a64ce7f4e9d0fe6b1199b256d239f5694715b1f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 23 Aug 2026 12:29:27 -0400 Subject: [PATCH] refactor: move the JSON-object draft parser into utils `react-refresh/only-export-components` rejects a non-component export from a component module, so `parseObjectDraft` could not live beside `JsonObjectInput`. It is a pure transform, which puts it in `utils` under the `lib`/`utils` rule anyway. Also render the metadata hint's inline literals through `` rather than markdown backticks, which a Mantine `Text` shows verbatim. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall --- .../JsonObjectInput/JsonObjectInput.test.tsx | 46 +----------------- .../JsonObjectInput/JsonObjectInput.tsx | 38 ++------------- .../ServerSettingsForm/ServerSettingsForm.tsx | 8 ++-- clients/web/src/utils/jsonObjectDraft.test.ts | 46 ++++++++++++++++++ clients/web/src/utils/jsonObjectDraft.ts | 47 +++++++++++++++++++ 5 files changed, 101 insertions(+), 84 deletions(-) create mode 100644 clients/web/src/utils/jsonObjectDraft.test.ts create mode 100644 clients/web/src/utils/jsonObjectDraft.ts diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx index 37d02dfb..b1da0ffb 100644 --- a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx +++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx @@ -4,7 +4,7 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { JsonObject } from "@inspector/core/json/jsonUtils.js"; import { renderWithMantine } from "../../../test/renderWithMantine"; -import { JsonObjectInput, parseObjectDraft } from "./JsonObjectInput"; +import { JsonObjectInput } from "./JsonObjectInput"; const LABEL = "Payload JSON"; @@ -36,50 +36,6 @@ function box() { return screen.getByLabelText(LABEL) as HTMLTextAreaElement; } -describe("parseObjectDraft", () => { - it("reads empty text as the empty object rather than an error", () => { - expect(parseObjectDraft("")).toEqual({ ok: true, value: {} }); - expect(parseObjectDraft(" \n ")).toEqual({ ok: true, value: {} }); - }); - - it("accepts a JSON object with values of any JSON type", () => { - const result = parseObjectDraft( - '{"s":"a","n":1,"b":true,"z":null,"arr":[1,{"k":2}],"o":{"deep":{"er":1}}}', - ); - expect(result).toEqual({ - ok: true, - value: { - s: "a", - n: 1, - b: true, - z: null, - arr: [1, { k: 2 }], - o: { deep: { er: 1 } }, - }, - }); - }); - - it("rejects text that is not JSON", () => { - expect(parseObjectDraft('{"a":')).toEqual({ - ok: false, - error: "Not valid JSON — changes are not applied", - }); - }); - - it.each([ - ["an array", "[1,2]"], - ["a string", '"hello"'], - ["a number", "42"], - ["null", "null"], - ])("rejects valid JSON that is not an object: %s", (_label, text) => { - const result = parseObjectDraft(text); - expect(result.ok).toBe(false); - expect(result.ok === false && result.error).toMatch( - /Must be a JSON object/, - ); - }); -}); - describe("JsonObjectInput", () => { it("opens with the value serialized two-space, nesting intact", () => { renderWithMantine(); diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx index 93df2516..fbec5ee4 100644 --- a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx +++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { JsonInput } from "@mantine/core"; import type { JsonObject } from "@inspector/core/json/jsonUtils.js"; import { useValueChange } from "../../../hooks/useValueChange"; +import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft"; const ObjectJsonInput = JsonInput.withProps({ formatOnBlur: true, @@ -15,39 +16,6 @@ function serialize(value: JsonObject): string { return JSON.stringify(value, null, 2); } -/** A plain JSON object, i.e. not an array and not a scalar. */ -function isJsonObject(value: unknown): value is JsonObject { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -/** - * What a draft's text currently means: the object to emit, or why it can't be. - * - * Empty text is the object `{}` rather than an error — clearing the box is how - * "send no metadata" is spelled, and an empty editor showing a red error would - * read as broken. - */ -type DraftState = - | { ok: true; value: JsonObject } - | { ok: false; error: string }; - -export function parseObjectDraft(text: string): DraftState { - if (text.trim() === "") return { ok: true, value: {} }; - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return { ok: false, error: "Not valid JSON — changes are not applied" }; - } - if (!isJsonObject(parsed)) { - return { - ok: false, - error: "Must be a JSON object (`{ … }`) — changes are not applied", - }; - } - return { ok: true, value: parsed }; -} - export interface JsonObjectInputProps { value: JsonObject; onChange: (value: JsonObject) => void; @@ -92,7 +60,7 @@ export function JsonObjectInput({ // 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 = parseObjectDraft(draft); + const parsed = parseJsonObjectDraft(draft); const draftJson = parsed.ok ? serialize(parsed.value) : null; useValueChange(serialize(value), (next) => { if (next !== draftJson) setDraft(next); @@ -105,7 +73,7 @@ export function JsonObjectInput({ error={parsed.ok ? undefined : parsed.error} onChange={(text) => { setDraft(text); - const result = parseObjectDraft(text); + const result = parseJsonObjectDraft(text); if (result.ok) onChange(result.value); }} /> diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 36179c9b..0a61a8db 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -729,10 +729,10 @@ export function ServerSettingsForm({ - Metadata merged into the `_meta` field of every MCP request to - this server, as a JSON object. Values may be any JSON — objects, - arrays, numbers, booleans, `null` — not just strings. Leave it as{" "} - {"{}"} to send none. + Metadata merged into the _meta field of every MCP + request to this server, as a JSON object. Values may be any JSON — + objects, arrays, numbers, booleans, null — not just + strings. Leave it as {"{}"} to send none. { + it("reads empty text as the empty object rather than an error", () => { + expect(parseJsonObjectDraft("")).toEqual({ ok: true, value: {} }); + expect(parseJsonObjectDraft(" \n ")).toEqual({ ok: true, value: {} }); + }); + + it("accepts a JSON object with values of any JSON type", () => { + const result = parseJsonObjectDraft( + '{"s":"a","n":1,"b":true,"z":null,"arr":[1,{"k":2}],"o":{"deep":{"er":1}}}', + ); + expect(result).toEqual({ + ok: true, + value: { + s: "a", + n: 1, + b: true, + z: null, + arr: [1, { k: 2 }], + o: { deep: { er: 1 } }, + }, + }); + }); + + it("rejects text that is not JSON", () => { + expect(parseJsonObjectDraft('{"a":')).toEqual({ + ok: false, + error: "Not valid JSON — changes are not applied", + }); + }); + + it.each([ + ["an array", "[1,2]"], + ["a string", '"hello"'], + ["a number", "42"], + ["null", "null"], + ])("rejects valid JSON that is not an object: %s", (_label, text) => { + const result = parseJsonObjectDraft(text); + expect(result.ok).toBe(false); + expect(result.ok === false && result.error).toMatch( + /Must be a JSON object/, + ); + }); +}); diff --git a/clients/web/src/utils/jsonObjectDraft.ts b/clients/web/src/utils/jsonObjectDraft.ts new file mode 100644 index 00000000..b5397079 --- /dev/null +++ b/clients/web/src/utils/jsonObjectDraft.ts @@ -0,0 +1,47 @@ +import type { JsonObject } from "@inspector/core/json/jsonUtils.js"; + +/** + * What a JSON-object editor's draft text currently means: the object to emit, + * or why it cannot be emitted. + */ +export type JsonObjectDraft = + | { ok: true; value: JsonObject } + | { ok: false; error: string }; + +/** A plain JSON object, i.e. not an array and not a scalar. */ +export function isJsonObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Read a JSON-object editor's draft text. + * + * Empty text is the object `{}` rather than an error — clearing the box is how + * "send nothing" is spelled, and an empty editor wearing a red error would read + * as broken. + * + * The two failure modes are reported separately because they are different + * mistakes: text that is not JSON at all is usually mid-edit, while text that + * is valid JSON but not an object (`[1,2]`, `"a"`, `42`) is a shape the caller + * cannot use no matter how finished it is. + * + * Lives in `utils` rather than beside its component because it is a pure + * transform — and because exporting it from the component's module trips + * `react-refresh/only-export-components`. + */ +export function parseJsonObjectDraft(text: string): JsonObjectDraft { + if (text.trim() === "") return { ok: true, value: {} }; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, error: "Not valid JSON — changes are not applied" }; + } + if (!isJsonObject(parsed)) { + return { + ok: false, + error: "Must be a JSON object (`{ … }`) — changes are not applied", + }; + } + return { ok: true, value: parsed }; +}