From df5a34ba33fe7ce6bd8dd8c1ba6928f69b4d681b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:23:21 +0000 Subject: [PATCH] feat(forms): SchemaForm renders every leaf as ExpressionField --- frontend/src/forms/SchemaForm.test.tsx | 42 +++++---- frontend/src/forms/SchemaForm.tsx | 113 +++++++++++++------------ 2 files changed, 88 insertions(+), 67 deletions(-) diff --git a/frontend/src/forms/SchemaForm.test.tsx b/frontend/src/forms/SchemaForm.test.tsx index b089b6a..acac116 100644 --- a/frontend/src/forms/SchemaForm.test.tsx +++ b/frontend/src/forms/SchemaForm.test.tsx @@ -5,7 +5,7 @@ import { render, screen } from "@testing-library/react"; import { SchemaForm } from "./SchemaForm"; import type { JsonSchemaObject } from "@/api/types"; -describe("SchemaForm", () => { +describe("SchemaForm — ExpressionField-backed", () => { it("renders string fields from schema.properties with titles as labels", () => { const schema: JsonSchemaObject = { type: "object", @@ -32,10 +32,7 @@ describe("SchemaForm", () => { { - onChange(next); - setV(next); - }} + onChange={(next) => { onChange(next); setV(next); }} /> ); } @@ -45,24 +42,39 @@ describe("SchemaForm", () => { expect(last).toEqual({ url: "abc" }); }); - it("renders format:password as a password input", () => { + it("renders integer fields as expression fields storing string values", async () => { const schema: JsonSchemaObject = { type: "object", - properties: { pass: { type: "string", format: "password", title: "Password" } }, + properties: { status: { type: "integer", title: "Status" } }, }; - render( {}} />); - expect(screen.getByLabelText(/password/i)).toHaveAttribute("type", "password"); + const onChange = vi.fn(); + function Wrapper() { + const [v, setV] = useState>({}); + return ( + { onChange(next); setV(next); }} /> + ); + } + render(); + await userEvent.type(screen.getByLabelText(/status/i), "200"); + const last = onChange.mock.calls.at(-1)![0]; + // SchemaForm stores strings; the graphAdapter coerces to number at save. + expect(last).toEqual({ status: "200" }); }); - it("renders format:code as a textarea", () => { + it("shows a live preview when previewInput is supplied", () => { const schema: JsonSchemaObject = { type: "object", - properties: { script: { type: "string", format: "code", title: "Script" } }, + properties: { subject: { type: "string", title: "Subject" } }, }; - render( {}} />); - const ta = screen.getByLabelText(/script/i); - expect(ta.tagName).toBe("TEXTAREA"); - expect(ta).toHaveValue("x = 1"); + render( + {}} + previewInput={{ name: "alice" }} + />, + ); + expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice"); }); it("marks required fields with an asterisk", () => { diff --git a/frontend/src/forms/SchemaForm.tsx b/frontend/src/forms/SchemaForm.tsx index 29ce211..7828c12 100644 --- a/frontend/src/forms/SchemaForm.tsx +++ b/frontend/src/forms/SchemaForm.tsx @@ -1,17 +1,24 @@ -import type { ChangeEvent } from "react"; +import { forwardRef, useImperativeHandle, useRef } from "react"; import type { JsonSchemaObject } from "@/api/types"; +import { + ExpressionField, + type ExpressionFieldHandle, + type ExpressionSchemaType, +} from "@/expr/ExpressionField"; interface Props { schema: JsonSchemaObject; value: Record; onChange: (v: Record) => void; + previewInput?: unknown; + onFieldFocus?: (handle: ExpressionFieldHandle) => void; } -export function SchemaForm({ schema, value, onChange }: Props) { +export function SchemaForm({ schema, value, onChange, previewInput, onFieldFocus }: Props) { const required = new Set(schema.required ?? []); const props = schema.properties ?? {}; - function set(name: string, v: unknown) { + function set(name: string, v: string) { onChange({ ...value, [name]: v }); } @@ -21,59 +28,24 @@ export function SchemaForm({ schema, value, onChange }: Props) { const label = prop.title ?? name; const id = `sf-${name}`; const isRequired = required.has(name); - const current = value[name]; - - const control = - prop.type === "boolean" ? ( - ) => set(name, e.target.checked)} - className="rounded border-slate-700 bg-slate-950" - /> - ) : prop.type === "number" || prop.type === "integer" ? ( - set(name, e.target.value === "" ? undefined : Number(e.target.value))} - className={inputCls} - /> - ) : prop.format === "code" ? ( -