feat(frontend): SchemaForm — schema-driven form generator
This commit is contained in:
78
frontend/src/forms/SchemaForm.test.tsx
Normal file
78
frontend/src/forms/SchemaForm.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
85
frontend/src/forms/SchemaForm.tsx
Normal file
85
frontend/src/forms/SchemaForm.tsx
Normal 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";
|
||||
Reference in New Issue
Block a user