feat(frontend): SchemaForm — schema-driven form generator

This commit is contained in:
Claude
2026-08-28 16:15:59 +00:00
parent 942d001c1f
commit 03cb34d009
2 changed files with 163 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { SchemaForm } from "./SchemaForm";
import type { JsonSchemaObject } from "@/api/types";
describe("SchemaForm", () => {
it("renders string fields from schema.properties with titles as labels", () => {
const schema: JsonSchemaObject = {
type: "object",
required: ["url"],
properties: {
url: { type: "string", title: "Server URL" },
user: { type: "string", title: "User" },
},
};
render(<SchemaForm schema={schema} value={{ url: "nats://x" }} onChange={() => {}} />);
expect(screen.getByLabelText(/server url/i)).toHaveValue("nats://x");
expect(screen.getByLabelText(/user/i)).toHaveValue("");
});
it("emits onChange with the merged object on input", async () => {
const schema: JsonSchemaObject = {
type: "object",
properties: { url: { type: "string" } },
};
const onChange = vi.fn();
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(/url/i), "abc");
const last = onChange.mock.calls.at(-1)![0];
expect(last).toEqual({ url: "abc" });
});
it("renders format:password as a password input", () => {
const schema: JsonSchemaObject = {
type: "object",
properties: { pass: { type: "string", format: "password", title: "Password" } },
};
render(<SchemaForm schema={schema} value={{}} onChange={() => {}} />);
expect(screen.getByLabelText(/password/i)).toHaveAttribute("type", "password");
});
it("renders format:code as a textarea", () => {
const schema: JsonSchemaObject = {
type: "object",
properties: { script: { type: "string", format: "code", title: "Script" } },
};
render(<SchemaForm schema={schema} value={{ script: "x = 1" }} onChange={() => {}} />);
const ta = screen.getByLabelText(/script/i);
expect(ta.tagName).toBe("TEXTAREA");
expect(ta).toHaveValue("x = 1");
});
it("marks required fields with an asterisk", () => {
const schema: JsonSchemaObject = {
type: "object",
required: ["url"],
properties: { url: { type: "string", title: "URL" } },
};
render(<SchemaForm schema={schema} value={{}} onChange={() => {}} />);
expect(screen.getByText("URL")).toBeInTheDocument();
expect(screen.getByText("*")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,85 @@
import type { ChangeEvent } from "react";
import type { JsonSchemaObject } from "@/api/types";
interface Props {
schema: JsonSchemaObject;
value: Record<string, unknown>;
onChange: (v: Record<string, unknown>) => void;
}
export function SchemaForm({ schema, value, onChange }: Props) {
const required = new Set(schema.required ?? []);
const props = schema.properties ?? {};
function set(name: string, v: unknown) {
onChange({ ...value, [name]: v });
}
return (
<div className="space-y-3">
{Object.entries(props).map(([name, prop]) => {
const label = prop.title ?? name;
const id = `sf-${name}`;
const isRequired = required.has(name);
const current = value[name];
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 (
<label key={name} htmlFor={id} className="block text-sm space-y-1">
<span className="text-slate-300">
{label}
{isRequired && <span className="text-red-400 ml-0.5">*</span>}
</span>
{control}
</label>
);
})}
</div>
);
}
const inputCls =
"w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 outline-none focus:border-brand-500 text-sm";