feat(forms): SchemaForm renders every leaf as ExpressionField
This commit is contained in:
@@ -5,7 +5,7 @@ import { render, screen } from "@testing-library/react";
|
|||||||
import { SchemaForm } from "./SchemaForm";
|
import { SchemaForm } from "./SchemaForm";
|
||||||
import type { JsonSchemaObject } from "@/api/types";
|
import type { JsonSchemaObject } from "@/api/types";
|
||||||
|
|
||||||
describe("SchemaForm", () => {
|
describe("SchemaForm — ExpressionField-backed", () => {
|
||||||
it("renders string fields from schema.properties with titles as labels", () => {
|
it("renders string fields from schema.properties with titles as labels", () => {
|
||||||
const schema: JsonSchemaObject = {
|
const schema: JsonSchemaObject = {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -32,10 +32,7 @@ describe("SchemaForm", () => {
|
|||||||
<SchemaForm
|
<SchemaForm
|
||||||
schema={schema}
|
schema={schema}
|
||||||
value={v}
|
value={v}
|
||||||
onChange={(next) => {
|
onChange={(next) => { onChange(next); setV(next); }}
|
||||||
onChange(next);
|
|
||||||
setV(next);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,24 +42,39 @@ describe("SchemaForm", () => {
|
|||||||
expect(last).toEqual({ url: "abc" });
|
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 = {
|
const schema: JsonSchemaObject = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { pass: { type: "string", format: "password", title: "Password" } },
|
properties: { status: { type: "integer", title: "Status" } },
|
||||||
};
|
};
|
||||||
render(<SchemaForm schema={schema} value={{}} onChange={() => {}} />);
|
const onChange = vi.fn();
|
||||||
expect(screen.getByLabelText(/password/i)).toHaveAttribute("type", "password");
|
function Wrapper() {
|
||||||
|
const [v, setV] = useState<Record<string, unknown>>({});
|
||||||
|
return (
|
||||||
|
<SchemaForm schema={schema} value={v} onChange={(next) => { onChange(next); setV(next); }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Wrapper />);
|
||||||
|
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 = {
|
const schema: JsonSchemaObject = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { script: { type: "string", format: "code", title: "Script" } },
|
properties: { subject: { type: "string", title: "Subject" } },
|
||||||
};
|
};
|
||||||
render(<SchemaForm schema={schema} value={{ script: "x = 1" }} onChange={() => {}} />);
|
render(
|
||||||
const ta = screen.getByLabelText(/script/i);
|
<SchemaForm
|
||||||
expect(ta.tagName).toBe("TEXTAREA");
|
schema={schema}
|
||||||
expect(ta).toHaveValue("x = 1");
|
value={{ subject: "hi {input.name}" }}
|
||||||
|
onChange={() => {}}
|
||||||
|
previewInput={{ name: "alice" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks required fields with an asterisk", () => {
|
it("marks required fields with an asterisk", () => {
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import type { ChangeEvent } from "react";
|
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||||
import type { JsonSchemaObject } from "@/api/types";
|
import type { JsonSchemaObject } from "@/api/types";
|
||||||
|
import {
|
||||||
|
ExpressionField,
|
||||||
|
type ExpressionFieldHandle,
|
||||||
|
type ExpressionSchemaType,
|
||||||
|
} from "@/expr/ExpressionField";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
schema: JsonSchemaObject;
|
schema: JsonSchemaObject;
|
||||||
value: Record<string, unknown>;
|
value: Record<string, unknown>;
|
||||||
onChange: (v: Record<string, unknown>) => void;
|
onChange: (v: Record<string, unknown>) => 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 required = new Set(schema.required ?? []);
|
||||||
const props = schema.properties ?? {};
|
const props = schema.properties ?? {};
|
||||||
|
|
||||||
function set(name: string, v: unknown) {
|
function set(name: string, v: string) {
|
||||||
onChange({ ...value, [name]: v });
|
onChange({ ...value, [name]: v });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,59 +28,24 @@ export function SchemaForm({ schema, value, onChange }: Props) {
|
|||||||
const label = prop.title ?? name;
|
const label = prop.title ?? name;
|
||||||
const id = `sf-${name}`;
|
const id = `sf-${name}`;
|
||||||
const isRequired = required.has(name);
|
const isRequired = required.has(name);
|
||||||
const current = value[name];
|
const raw = toEditorString(value[name]);
|
||||||
|
const st = mapSchemaType(prop.type);
|
||||||
const control =
|
|
||||||
prop.type === "boolean" ? (
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!current}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => set(name, e.target.checked)}
|
|
||||||
className="rounded border-slate-700 bg-slate-950"
|
|
||||||
/>
|
|
||||||
) : prop.type === "number" || prop.type === "integer" ? (
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="number"
|
|
||||||
value={typeof current === "number" ? current : ""}
|
|
||||||
onChange={(e) => set(name, e.target.value === "" ? undefined : Number(e.target.value))}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
) : prop.format === "code" ? (
|
|
||||||
<textarea
|
|
||||||
id={id}
|
|
||||||
rows={8}
|
|
||||||
value={typeof current === "string" ? current : ""}
|
|
||||||
onChange={(e) => set(name, e.target.value)}
|
|
||||||
className={`${inputCls} font-mono text-xs`}
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
) : prop.format === "password" ? (
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="password"
|
|
||||||
value={typeof current === "string" ? current : ""}
|
|
||||||
onChange={(e) => set(name, e.target.value)}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="text"
|
|
||||||
value={typeof current === "string" ? current : ""}
|
|
||||||
onChange={(e) => set(name, e.target.value)}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label key={name} htmlFor={id} className="block text-sm space-y-1">
|
<label key={name} htmlFor={id} className="block text-sm space-y-1">
|
||||||
<span className="text-slate-300">
|
<span className="text-slate-300">
|
||||||
{label}
|
{label}
|
||||||
{isRequired && <span className="text-red-400 ml-0.5">*</span>}
|
{isRequired && <span className="text-red-400 ml-0.5">*</span>}
|
||||||
</span>
|
</span>
|
||||||
{control}
|
<SchemaExpressionField
|
||||||
|
id={id}
|
||||||
|
value={raw}
|
||||||
|
onChange={(v) => set(name, v)}
|
||||||
|
onFieldFocus={onFieldFocus}
|
||||||
|
previewInput={previewInput}
|
||||||
|
schemaType={st}
|
||||||
|
codeBlock={prop.format === "code"}
|
||||||
|
singleLine={prop.format !== "code"}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -81,5 +53,42 @@ export function SchemaForm({ schema, value, onChange }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
const SchemaExpressionField = forwardRef<ExpressionFieldHandle, {
|
||||||
"w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 outline-none focus:border-brand-500 text-sm";
|
id: string; value: string; onChange: (v: string) => void;
|
||||||
|
previewInput?: unknown; schemaType?: ExpressionSchemaType;
|
||||||
|
singleLine?: boolean; codeBlock?: boolean;
|
||||||
|
onFieldFocus?: (handle: ExpressionFieldHandle) => void;
|
||||||
|
}>(function SchemaExpressionField(props, _outer) {
|
||||||
|
const ref = useRef<ExpressionFieldHandle | null>(null);
|
||||||
|
useImperativeHandle(_outer, () => ref.current ?? { insertAtCaret: () => {}, focus: () => {} });
|
||||||
|
return (
|
||||||
|
<ExpressionField
|
||||||
|
ref={ref}
|
||||||
|
id={props.id}
|
||||||
|
value={props.value}
|
||||||
|
onChange={props.onChange}
|
||||||
|
previewInput={props.previewInput}
|
||||||
|
schemaType={props.schemaType}
|
||||||
|
singleLine={props.singleLine}
|
||||||
|
codeBlock={props.codeBlock}
|
||||||
|
onFocus={() => { if (ref.current) props.onFieldFocus?.(ref.current); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function mapSchemaType(t?: string): ExpressionSchemaType {
|
||||||
|
if (t === "integer") return "integer";
|
||||||
|
if (t === "number") return "number";
|
||||||
|
if (t === "boolean") return "boolean";
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value stored in state may be a number/bool if it round-tripped from the
|
||||||
|
// backend already coerced. Render it as its literal string form so the user
|
||||||
|
// can edit it as an expression.
|
||||||
|
function toEditorString(v: unknown): string {
|
||||||
|
if (v === undefined || v === null) return "";
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user