/** * `SchemaEditor` — the shared control for AUTHORING a JSON Schema. It is a VALIDATED * code editor, NOT a visual builder: the operator writes the schema as JSON in a * textarea, and the editor gives loud parse feedback, a structural lint (top-level * object; a `"title"` when `requireTitle`), and a live PREVIEW of the authored shape * through the same {@link SchemaForm} renderer the run side uses (falling back to a * {@link JsonTree} of the schema dict when the shape is not form-renderable). * * A JSON Schema is more expressive than any builder UI (`$defs`, `anyOf`, constraint * keywords), and the backend rejects an invalid schema LOUDLY — so full meta-schema * validation stays server-side and this control adds no schema-validation dependency. * * CONTROLLED-BY-SEED: `value` seeds the editor's text ONCE (on mount); thereafter the * text is the source of truth and every edit reports up through `onChange`. Validity * is exposed there too so the consumer can gate its submit — always alongside a * VISIBLE inline message, never a silently disabled button. The parsed dict is passed * through untouched, so a schema the editor does not understand round-trips exactly. * * SAFETY: the schema text is operator-authored and every derived value renders as * React TEXT (through the DS controls / `JsonTree` / `SchemaForm`), never an HTML sink. */ import { Component, type ReactNode, useMemo, useState } from 'react'; import { Field } from '../components/field'; import { Textarea } from '../components/inputs'; import { JsonTree } from '../components/json-tree'; import { ScrollRegion } from '../components/scroll-region'; import { classifySchema } from '../schema-form/classify'; import { defaultValueForSchema } from '../schema-form/default-value'; import { SchemaForm } from '../schema-form/SchemaForm'; import type { JsonSchema } from '../schema-form/types'; import { lintSchemaText } from './lint'; /** What {@link SchemaEditor} reports up on every edit. */ export interface SchemaEditorChange { /** The authored schema dict when valid and non-empty; `null` when the editor is empty. */ readonly schema: Record | null; /** False when the text is non-empty but unparseable or failing lint — gate submit on this. */ readonly valid: boolean; } export interface SchemaEditorProps { /** The schema dict to seed the editor with, or `null` for an empty editor. */ readonly value: Record | null; /** Fired on every edit with the parsed schema (or `null`) and its validity. */ readonly onChange: (change: SchemaEditorChange) => void; /** When true, a top-level `"title"` is required (the `response_format` contract). */ readonly requireTitle: boolean; readonly label?: string; readonly description?: string; readonly disabled?: boolean; readonly idPrefix?: string; /** * Render the field label for ASSISTIVE TECH ONLY — visually hidden but still the editor's * accessible name. Used when a host control already draws the visible label above this * editor (e.g. an authored-body control whose inline editor this is), so the heading is not * drawn twice. Forwarded to the underlying {@link Field}. */ readonly hideLabel?: boolean; } function seedText(value: Record | null): string { return value === null ? '' : JSON.stringify(value, null, 2); } /** * Whether {@link SchemaForm} can render this schema's root as a STRUCTURED form — * i.e. it classifies to a concrete field kind rather than the free-form `json` * fallback. A schema whose root has no structure to preview (a property-less * object, an open/`allOf`/multi-type shape), or whose classification throws on a * bad `$ref`, previews as a `JsonTree` of the schema dict instead. (Inside a live * form such a node still renders the JSON editor; here, previewing a shapeless * root as its own schema tree is the more legible affordance.) */ function canRenderWithForm(schema: JsonSchema): boolean { try { return classifySchema(schema, schema).model.kind !== 'json'; } catch { return false; } } /** * An interactive preview of the authored schema. Seeds a throwaway value from the * schema's defaults so the operator can see (and poke) the shape they defined. */ function PreviewForm({ schema }: { readonly schema: JsonSchema }): ReactNode { const [value, setValue] = useState(() => defaultValueForSchema(schema)); return ; } /** * Catch a render-time throw from a deep, malformed schema (e.g. a nested unresolvable * `$ref`) so the preview degrades to the `JsonTree` fallback instead of crashing the * enclosing dialog. Reset per authored schema by keying this boundary on the schema. */ class PreviewBoundary extends Component< { readonly fallback: ReactNode; readonly children: ReactNode }, { readonly failed: boolean } > { override state = { failed: false }; static getDerivedStateFromError(_error: unknown): { failed: boolean } { return { failed: true }; } override render(): ReactNode { return this.state.failed ? this.props.fallback : this.props.children; } } /** The preview pane's accessible name, wherever the scrolling box turns out to be. */ const PREVIEW_LABEL = 'Schema preview'; /** * The preview pane, framed as a card and scrollable — a deep schema's preview can * outrun its column, so it scrolls in place rather than pushing the dialog sideways. * * WHICH element scrolls depends on the branch, and only the one that actually * scrolls may carry the region attributes. {@link JsonTree} IS its own scrolling * box, so on the fallback branch the card is a plain frame and the tree names * itself; a `ScrollRegion` around it would take an overflow that never reaches it, * leaving a name announced for a box that cannot move. The {@link SchemaForm} * branch renders no scroller of its own, so there the card IS the region. */ function SchemaPreview({ schema, testId, }: { readonly schema: Record; readonly testId: string; }): ReactNode { const jsonFallback = (
); if (!canRenderWithForm(schema)) return jsonFallback; return ( ); } export function SchemaEditor({ value, onChange, requireTitle, label = 'Schema', description, disabled, idPrefix = 'schema-editor', hideLabel = false, }: SchemaEditorProps): ReactNode { const [text, setText] = useState(() => seedText(value)); // Recomputed for display (inline error + preview) on every render; the change // handler lints the NEXT text independently so `onChange` and the view agree. const result = useMemo(() => lintSchemaText(text, requireTitle), [text, requireTitle]); const emit = (nextText: string): void => { setText(nextText); const next = lintSchemaText(nextText, requireTitle); onChange({ schema: next.valid ? next.schema : null, valid: next.valid }); }; return (