feat: add schema driven workflow forms

This commit is contained in:
lda
2026-08-09 05:19:09 +07:00 Verified
parent a35a32eba4
commit 2b0b172d04
9 changed files with 1450 additions and 0 deletions
@@ -0,0 +1,73 @@
import type { FieldSource, SchemaField } from "./schema-field.js";
export type BindingSourceControlProps = {
readonly field: SchemaField;
readonly source: FieldSource;
readonly onChange: (source: FieldSource) => void;
readonly suggestions?: ReadonlyArray<string>;
};
const fieldKey = (field: SchemaField): string =>
field.path.length === 0 ? "root" : field.path.map(String).join("-");
const displayTitle = (title: string): string =>
title.length === 0 ? "Value" : `${title.slice(0, 1).toUpperCase()}${title.slice(1)}`;
export const BindingSourceControl = ({
field,
source,
onChange,
suggestions = [],
}: BindingSourceControlProps) => {
const key = fieldKey(field);
const literalId = `${key}-literal`;
const bindId = `${key}-bind`;
const sourceId = `${key}-source-path`;
const listId = `${key}-source-suggestions`;
return (
<fieldset className="schema-form__source">
<legend>Value source</legend>
<div className="schema-form__source-options">
<label htmlFor={literalId}>
<input
checked={source.mode === "literal"}
id={literalId}
name={`${key}-source-mode`}
onChange={() => onChange({ mode: "literal", value: source.mode === "literal" ? source.value : "" })}
type="radio"
/>
Literal
</label>
<label htmlFor={bindId}>
<input
checked={source.mode === "bind"}
id={bindId}
name={`${key}-source-mode`}
onChange={() => onChange({ mode: "bind", sourcePath: "input." })}
type="radio"
/>
Bind
</label>
</div>
{source.mode === "bind" && (
<div className="schema-form__source-path">
<label htmlFor={sourceId}>Source path for {displayTitle(field.title)}</label>
<input
aria-describedby={suggestions.length > 0 ? listId : undefined}
id={sourceId}
list={suggestions.length > 0 ? listId : undefined}
onChange={(event) => onChange({ mode: "bind", sourcePath: event.target.value })}
type="text"
value={source.sourcePath}
/>
{suggestions.length > 0 && (
<datalist id={listId}>
{suggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
</datalist>
)}
</div>
)}
</fieldset>
);
};
@@ -0,0 +1,277 @@
import type { FieldSource, SchemaField } from "./schema-field.js";
import type { FieldSources, SchemaValueIssue } from "./schema-values.js";
import { BindingSourceControl } from "./BindingSourceControl.js";
const EMPTY_SUGGESTIONS: ReadonlyArray<string> = [];
export type SchemaFieldControlProps = {
readonly field: SchemaField;
readonly value: unknown;
readonly sources: FieldSources;
readonly diagnostics: ReadonlyArray<SchemaValueIssue>;
readonly onValueChange: (field: SchemaField, value: unknown) => void;
readonly onSourceChange: (field: SchemaField, source: FieldSource) => void;
readonly sourceSuggestions?: ReadonlyArray<string>;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const pathKey = (field: SchemaField): string =>
field.path.length === 0 ? "root" : field.path.map(String).join(".");
const samePath = (
left: ReadonlyArray<string | number>,
right: ReadonlyArray<string | number>,
): boolean => left.length === right.length && left.every((part, index) => part === right[index]);
const fieldId = (field: SchemaField): string =>
`schema-field-${pathKey(field).replace(/[^A-Za-z0-9_-]/g, "-")}`;
const displayTitle = (title: string): string =>
title.length === 0 ? "Value" : `${title.slice(0, 1).toUpperCase()}${title.slice(1)}`;
const jsonText = (value: unknown): string => {
if (typeof value === "string") return value;
const encoded = JSON.stringify(value, null, 2);
return encoded ?? "";
};
const enumValue = (value: string): string => value;
const enumLabel = (value: string | number | boolean | null): string =>
value === null ? "null" : String(value);
const arrayItemTitle = (field: SchemaField, index: number): string => {
const base = displayTitle(field.title);
const singular = base.endsWith("s") ? base.slice(0, -1) : `${base} item`;
return `${singular} ${index + 1}`;
};
const defaultArrayItemValue = (field: SchemaField): unknown => {
if (field.hasDefault) return field.defaultValue;
if (field.kind === "object") return {};
if (field.kind === "array") return [];
if (field.kind === "boolean") return false;
return "";
};
const FieldDiagnostics = ({
field,
diagnostics,
}: {
readonly field: SchemaField;
readonly diagnostics: ReadonlyArray<SchemaValueIssue>;
}) => {
if (diagnostics.length === 0) return null;
return (
<div className="schema-form__diagnostics" id={`${fieldId(field)}-diagnostics`} role="alert">
{diagnostics.map((diagnostic) => (
<p key={`${diagnostic.path.join(".")}-${diagnostic.message}`}>{diagnostic.message}</p>
))}
</div>
);
};
const FieldLabel = ({ field, id }: { readonly field: SchemaField; readonly id: string }) => (
<label htmlFor={id}>
{displayTitle(field.title)}
{field.required && (
<>
<span aria-hidden="true"> *</span>
<span className="visually-hidden"> required</span>
</>
)}
</label>
);
const LeafControl = ({
field,
value,
onValueChange,
describedBy,
invalid,
}: {
readonly field: SchemaField;
readonly value: unknown;
readonly onValueChange: (value: unknown) => void;
readonly describedBy: string | undefined;
readonly invalid: boolean;
}) => {
const id = fieldId(field);
const label = displayTitle(field.title);
const common = {
"aria-describedby": describedBy,
"aria-invalid": invalid,
"aria-required": field.required,
id,
};
if (field.kind === "boolean") {
return (
<input
{...common}
aria-label={label}
checked={value === true}
onChange={(event) => onValueChange(event.target.checked)}
type="checkbox"
/>
);
}
if (field.kind === "enum") {
return (
<select
{...common}
aria-label={label}
onChange={(event) => onValueChange(enumValue(event.target.value))}
value={typeof value === "string" ? value : value === null ? "null" : value === undefined ? "" : String(value)}
>
<option value="">Choose {label.toLowerCase()}</option>
{field.enumValues.map((option) => (
<option key={JSON.stringify(option)} value={enumLabel(option)}>
{enumLabel(option)}
</option>
))}
</select>
);
}
if (field.kind === "number" || field.kind === "integer") {
return (
<input
{...common}
aria-label={label}
onChange={(event) => onValueChange(event.target.value)}
step={field.kind === "integer" ? 1 : "any"}
type="number"
value={typeof value === "number" ? String(value) : typeof value === "string" ? value : ""}
/>
);
}
return (
<textarea
{...common}
aria-label={label}
onChange={(event) => onValueChange(event.target.value)}
value={field.kind === "string" ? (typeof value === "string" ? value : "") : jsonText(value)}
/>
);
};
export const SchemaFieldControl = ({
field,
value,
sources,
diagnostics,
onValueChange,
onSourceChange,
sourceSuggestions = EMPTY_SUGGESTIONS,
}: SchemaFieldControlProps) => {
const id = fieldId(field);
const descriptionId = field.description ? `${id}-description` : undefined;
const fieldDiagnostics = diagnostics.length > 0 ? `${id}-diagnostics` : undefined;
const describedBy = [descriptionId, fieldDiagnostics].filter(Boolean).join(" ") || undefined;
if (field.kind === "object") {
const objectValue = isRecord(value) ? value : {};
return (
<fieldset className="schema-form__group" aria-describedby={describedBy}>
<legend>
{displayTitle(field.title)}
{field.required && <span aria-hidden="true"> *</span>}
</legend>
{field.description && <p id={descriptionId}>{field.description}</p>}
{field.children.map((child) => (
<SchemaFieldControl
diagnostics={diagnostics.filter((diagnostic) => diagnostic.path.join(".").startsWith(`${child.path.join(".")}`))}
field={child}
key={pathKey(child)}
onSourceChange={onSourceChange}
onValueChange={onValueChange}
sourceSuggestions={sourceSuggestions}
sources={sources}
value={objectValue[child.key]}
/>
))}
<FieldDiagnostics
diagnostics={diagnostics.filter((diagnostic) => samePath(diagnostic.path, field.path))}
field={field}
/>
</fieldset>
);
}
if (field.kind === "array") {
const arrayValue = Array.isArray(value) ? value : [];
return (
<fieldset className="schema-form__group schema-form__array" aria-describedby={describedBy}>
<legend>
{displayTitle(field.title)}
{field.required && <span aria-hidden="true"> *</span>}
</legend>
{field.description && <p id={descriptionId}>{field.description}</p>}
{arrayValue.map((itemValue, index) => {
const itemField = field.item ? { ...field.item, path: [...field.path, index], title: arrayItemTitle(field, index) } : null;
if (!itemField) return null;
return (
<div className="schema-form__array-item" key={pathKey(itemField)}>
<SchemaFieldControl
diagnostics={diagnostics.filter((diagnostic) => diagnostic.path.join(".") === itemField.path.join("."))}
field={itemField}
onSourceChange={onSourceChange}
onValueChange={onValueChange}
sourceSuggestions={sourceSuggestions}
sources={sources}
value={itemValue}
/>
<button
aria-label={`Remove ${arrayItemTitle(field, index).toLowerCase()}`}
className="schema-form__secondary-action"
onClick={() => onValueChange(field, arrayValue.filter((_, itemIndex) => itemIndex !== index))}
type="button"
>
Remove
</button>
</div>
);
})}
<button
className="schema-form__secondary-action"
onClick={() => onValueChange(field, [...arrayValue, defaultArrayItemValue(field.item ?? field)])}
type="button"
>
Add {displayTitle(field.title).replace(/s$/, "").toLowerCase()}
</button>
<FieldDiagnostics
diagnostics={diagnostics.filter((diagnostic) => samePath(diagnostic.path, field.path))}
field={field}
/>
</fieldset>
);
}
const source = sources[pathKey(field)] ?? { mode: "literal", value };
return (
<div className="schema-form__field">
<FieldLabel field={field} id={id} />
{field.description && <p id={descriptionId}>{field.description}</p>}
<BindingSourceControl
field={field}
onChange={(nextSource) => onSourceChange(field, nextSource)}
source={source}
suggestions={sourceSuggestions}
/>
{source.mode === "literal" && (
<LeafControl
describedBy={describedBy}
field={field}
invalid={diagnostics.length > 0}
onValueChange={(nextValue) => onValueChange(field, nextValue)}
value={value}
/>
)}
{field.kind === "json" && field.fallbackReason && (
<p className="schema-form__fallback-reason">{field.fallbackReason}</p>
)}
<FieldDiagnostics diagnostics={diagnostics} field={field} />
</div>
);
};
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SchemaForm } from "./SchemaForm.js";
import type { SchemaSerializationResult } from "./schema-values.js";
const schema = {
type: "object",
properties: {
summary: { type: "string", description: "A short report summary." },
amount: { type: "number" },
enabled: { type: "boolean" },
color: { enum: ["red", "blue"] },
profile: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
tags: { type: "array", items: { type: "string" } },
},
required: ["summary"],
};
describe("SchemaForm", () => {
afterEach(() => cleanup());
it("renders accessible native controls and a collapsed raw schema", () => {
render(<SchemaForm schema={schema} />);
expect(screen.getByRole("textbox", { name: "Summary" })).toHaveAttribute(
"aria-required",
"true",
);
expect(screen.getByText("A short report summary.")).toBeInTheDocument();
expect(screen.getByRole("spinbutton", { name: "Amount" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Enabled" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Color" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Profile" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add tag" })).toBeInTheDocument();
expect(screen.getByText("Raw schema").closest("details")).not.toHaveAttribute("open");
});
it("renders unsupported fields as JSON editors with their exact fallback reason", () => {
render(
<SchemaForm
schema={{
type: "object",
properties: {
choice: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}}
/>,
);
expect(screen.getByRole("textbox", { name: "Choice" })).toHaveAttribute(
"aria-label",
"Choice",
);
expect(
screen.getByText("The schema uses oneOf, which the native form cannot represent."),
).toBeInTheDocument();
});
it("exposes literal and bind modes and submits canonical values and bindings", async () => {
const user = userEvent.setup();
const submissions: SchemaSerializationResult[] = [];
render(
<SchemaForm
schema={{ type: "object", properties: { summary: { type: "string" } } }}
initialValue={{ summary: "Report" }}
initialSources={{ summary: { mode: "bind", sourcePath: "input.summary" } }}
onSubmit={(result) => submissions.push(result)}
/>,
);
const summarySource = screen.getAllByRole("group", { name: "Value source" })[0];
expect(within(summarySource!).getByRole("radio", { name: "Bind" })).toBeChecked();
expect(screen.getByRole("textbox", { name: "Source path for Summary" })).toHaveValue(
"input.summary",
);
await user.click(screen.getByRole("button", { name: "Save form" }));
expect(submissions[0]?.value).toEqual({ summary: undefined });
expect(submissions[0]?.bindings).toEqual([
{ target: "summary", path: "input.summary" },
]);
});
it("shows field diagnostics and supports adding array items", async () => {
const user = userEvent.setup();
render(
<SchemaForm
schema={schema}
diagnostics={[{ path: ["summary"], message: "Summary is already used." }]}
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent("Summary is already used.");
expect(screen.queryAllByRole("textbox", { name: "Tag" })).toHaveLength(0);
await user.click(screen.getByRole("button", { name: "Add tag" }));
expect(screen.getByRole("textbox", { name: "Tag 1" })).toBeInTheDocument();
});
});
@@ -0,0 +1,126 @@
import { useState, type FormEvent } from "react";
import { normalizeSchema, type FieldSource, type SchemaField } from "./schema-field.js";
import { SchemaFieldControl } from "./SchemaFieldControl.js";
import {
serializeSchemaValues,
type FieldSources,
type SchemaSerializationResult,
type SchemaValueIssue,
} from "./schema-values.js";
export type SchemaFormProps = {
readonly schema: unknown;
readonly initialValue?: unknown;
readonly initialSources?: FieldSources;
readonly diagnostics?: ReadonlyArray<SchemaValueIssue>;
readonly onSubmit?: (result: SchemaSerializationResult) => void;
readonly submitLabel?: string;
readonly sourceSuggestions?: ReadonlyArray<string>;
};
const EMPTY_SOURCES: FieldSources = {};
const EMPTY_DIAGNOSTICS: ReadonlyArray<SchemaValueIssue> = [];
const EMPTY_SUGGESTIONS: ReadonlyArray<string> = [];
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const emptyValueFor = (field: SchemaField): unknown => {
if (field.hasDefault) return field.defaultValue;
if (field.kind === "object") return {};
if (field.kind === "array") return [];
if (field.kind === "boolean") return false;
return "";
};
const setAtPath = (
current: unknown,
path: ReadonlyArray<string | number>,
value: unknown,
): unknown => {
if (path.length === 0) return value;
const [head, ...tail] = path;
if (typeof head === "number" || Array.isArray(current)) {
const index = Number(head);
const next = Array.isArray(current) ? current : [];
const child = setAtPath(next[index], tail, value);
return Array.from(
{ length: Math.max(next.length, index + 1) },
(_, itemIndex) => (itemIndex === index ? child : next[itemIndex]),
);
}
const next = isRecord(current) ? { ...current } : {};
const key = String(head);
return { ...next, [key]: setAtPath(next[key], tail, value) };
};
const sourceKey = (sourceField: SchemaField): string =>
sourceField.path.length === 0 ? "root" : sourceField.path.map(String).join(".");
const rawSchemaText = (schema: unknown): string => {
try {
const encoded = JSON.stringify(schema, null, 2);
return encoded ?? "";
} catch {
return "The schema could not be displayed as JSON.";
}
};
export const SchemaForm = ({
schema,
initialValue,
initialSources = EMPTY_SOURCES,
diagnostics = EMPTY_DIAGNOSTICS,
onSubmit,
submitLabel = "Save form",
sourceSuggestions = EMPTY_SUGGESTIONS,
}: SchemaFormProps) => {
const field = normalizeSchema(schema);
const [values, setValues] = useState<unknown>(() =>
initialValue !== undefined ? initialValue : emptyValueFor(field),
);
const [sources, setSources] = useState<FieldSources>(() => initialSources);
const [submitIssues, setSubmitIssues] = useState<ReadonlyArray<SchemaValueIssue>>([]);
const allDiagnostics = [...diagnostics, ...submitIssues];
const handleValueChange = (changedField: SchemaField, nextValue: unknown): void => {
setValues((current: unknown) => setAtPath(current, changedField.path, nextValue));
const currentSource = sources[sourceKey(changedField)];
if (currentSource?.mode === "literal") {
setSources((current) => ({
...current,
[sourceKey(changedField)]: { mode: "literal", value: nextValue },
}));
}
};
const handleSourceChange = (changedField: SchemaField, source: FieldSource): void => {
setSources((current) => ({ ...current, [sourceKey(changedField)]: source }));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const result = serializeSchemaValues(field, values, sources);
setSubmitIssues(result.issues);
onSubmit?.(result);
};
return (
<form className="schema-form" onSubmit={handleSubmit}>
<SchemaFieldControl
diagnostics={allDiagnostics}
field={field}
onSourceChange={handleSourceChange}
onValueChange={handleValueChange}
sourceSuggestions={sourceSuggestions}
sources={sources}
value={values}
/>
<button type="submit">{submitLabel}</button>
<details className="schema-form__raw">
<summary>Raw schema</summary>
<pre aria-label="Raw schema JSON" role="region" tabIndex={0}>{rawSchemaText(schema)}</pre>
</details>
</form>
);
};
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { normalizeSchema } from "./schema-field.js";
describe("normalizeSchema", () => {
it("normalizes primitive, multiline, boolean, and enum fields", () => {
const field = normalizeSchema({
type: "object",
properties: {
name: { type: "string", title: "Display name", description: "Shown to users." },
notes: { type: "string", format: "textarea" },
count: { type: "number" },
retries: { type: "integer" },
enabled: { type: "boolean", default: true },
color: { enum: ["red", 2, false, null] },
},
required: ["name", "enabled"],
});
expect(field.kind).toBe("object");
expect(field.children.map((child) => [child.key, child.kind, child.required])).toEqual([
["name", "string", true],
["notes", "string", false],
["count", "number", false],
["retries", "integer", false],
["enabled", "boolean", true],
["color", "enum", false],
]);
expect(field.children[0]).toMatchObject({
path: ["name"],
title: "Display name",
description: "Shown to users.",
});
expect(field.children[4]).toMatchObject({
hasDefault: true,
defaultValue: true,
});
expect(field.children[5]?.enumValues).toEqual(["red", 2, false, null]);
});
it("normalizes nested objects and arrays with item fields", () => {
const field = normalizeSchema({
type: "object",
properties: {
profile: {
type: "object",
properties: {
age: { type: "integer" },
},
required: ["age"],
},
tags: { type: "array", items: { type: "string" } },
},
});
const profile = field.children[0];
const tags = field.children[1];
expect(profile?.children[0]).toMatchObject({
path: ["profile", "age"],
key: "age",
required: true,
kind: "integer",
});
expect(tags).toMatchObject({ path: ["tags"], kind: "array" });
expect(tags?.item).toMatchObject({ path: ["tags", 0], kind: "string" });
});
it("preserves an explicit null default", () => {
const field = normalizeSchema({
type: "object",
properties: { value: { type: "string", default: null } },
});
expect(field.children[0]).toMatchObject({
hasDefault: true,
defaultValue: null,
});
});
it("uses JSON fallback for unconstrained schemas instead of inventing an object", () => {
const field = normalizeSchema({});
expect(field).toMatchObject({ kind: "json", path: [], children: [], item: null });
expect(field.fallbackReason).toBe("The schema is unconstrained; edit JSON directly.");
});
it("uses field-local fallback reasons for unsupported unions and references", () => {
const field = normalizeSchema({
type: "object",
properties: {
choice: { oneOf: [{ type: "string" }, { type: "number" }] },
reference: { $ref: "#/definitions/Missing" },
},
});
expect(field.children[0]).toMatchObject({
kind: "json",
fallbackReason: "The schema uses oneOf, which the native form cannot represent.",
});
expect(field.children[1]).toMatchObject({
kind: "json",
fallbackReason: "The schema contains an unresolved $ref, which the native form cannot represent.",
});
});
});
@@ -0,0 +1,209 @@
export type SchemaField = {
readonly path: ReadonlyArray<string | number>;
readonly key: string;
readonly title: string;
readonly description: string | null;
readonly kind: "string" | "number" | "integer" | "boolean" | "enum" | "object" | "array" | "json";
readonly required: boolean;
readonly hasDefault: boolean;
readonly defaultValue: unknown;
readonly enumValues: ReadonlyArray<string | number | boolean | null>;
readonly children: ReadonlyArray<SchemaField>;
readonly item: SchemaField | null;
readonly fallbackReason: string | null;
};
export type FieldSource =
| { readonly mode: "literal"; readonly value: unknown }
| { readonly mode: "bind"; readonly sourcePath: string };
type SchemaRecord = Record<string, unknown>;
type EnumValue = string | number | boolean | null;
const isRecord = (value: unknown): value is SchemaRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const hasOwn = (value: SchemaRecord, key: string): boolean =>
Object.prototype.hasOwnProperty.call(value, key);
const stringValue = (value: unknown): string | null =>
typeof value === "string" ? value : null;
const isEnumValue = (value: unknown): value is EnumValue =>
value === null ||
typeof value === "string" ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value));
const fallback = (
schema: unknown,
path: ReadonlyArray<string | number>,
key: string,
required: boolean,
title: string,
reason: string,
): SchemaField => ({
path,
key,
title,
description: isRecord(schema) ? stringValue(schema.description) : null,
kind: "json",
required,
hasDefault: isRecord(schema) && hasOwn(schema, "default"),
defaultValue: isRecord(schema) ? schema.default : undefined,
enumValues: [],
children: [],
item: null,
fallbackReason: reason,
});
const unsupportedReason = (schema: SchemaRecord): string | null => {
if (hasOwn(schema, "$ref")) {
return "The schema contains an unresolved $ref, which the native form cannot represent.";
}
if (hasOwn(schema, "oneOf")) {
return "The schema uses oneOf, which the native form cannot represent.";
}
if (hasOwn(schema, "anyOf")) {
return "The schema uses anyOf, which the native form cannot represent.";
}
if (hasOwn(schema, "allOf")) {
return "The schema uses allOf, which the native form cannot represent.";
}
if (hasOwn(schema, "not")) {
return "The schema uses not, which the native form cannot represent.";
}
if (hasOwn(schema, "if") || hasOwn(schema, "then") || hasOwn(schema, "else")) {
return "The schema uses conditional keywords, which the native form cannot represent.";
}
return null;
};
const requiredPropertyNames = (schema: SchemaRecord): ReadonlySet<string> => {
const required = schema.required;
if (!Array.isArray(required)) return new Set();
return new Set(required.filter((value): value is string => typeof value === "string"));
};
const normalizeField = (
schema: unknown,
path: ReadonlyArray<string | number>,
key: string,
required: boolean,
defaultTitle: string,
): SchemaField => {
const title = isRecord(schema) ? stringValue(schema.title) ?? defaultTitle : defaultTitle;
if (!isRecord(schema)) {
return fallback(schema, path, key, required, title, "The schema is not a JSON object; edit JSON directly.");
}
const reason = unsupportedReason(schema);
if (reason) return fallback(schema, path, key, required, title, reason);
const enumValue = schema.enum;
if (Array.isArray(enumValue) && enumValue.every(isEnumValue)) {
return {
path,
key,
title,
description: stringValue(schema.description),
kind: "enum",
required,
hasDefault: hasOwn(schema, "default"),
defaultValue: schema.default,
enumValues: enumValue,
children: [],
item: null,
fallbackReason: null,
};
}
const type = schema.type;
if (type === undefined) {
return fallback(schema, path, key, required, title, "The schema is unconstrained; edit JSON directly.");
}
if (type === "object") {
const properties = schema.properties;
if (properties !== undefined && !isRecord(properties)) {
return fallback(schema, path, key, required, title, "The schema has invalid properties; edit JSON directly.");
}
const requiredNames = requiredPropertyNames(schema);
const children = properties
? Object.entries(properties).map(([propertyKey, propertySchema]) =>
normalizeField(
propertySchema,
[...path, propertyKey],
propertyKey,
requiredNames.has(propertyKey),
stringValue(propertySchema && isRecord(propertySchema) ? propertySchema.title : null) ?? propertyKey,
),
)
: [];
return {
path,
key,
title,
description: stringValue(schema.description),
kind: "object",
required,
hasDefault: hasOwn(schema, "default"),
defaultValue: schema.default,
enumValues: [],
children,
item: null,
fallbackReason: null,
};
}
if (type === "array") {
const itemSchema = schema.items;
if (itemSchema === undefined) {
return fallback(schema, path, key, required, title, "The array has no item schema; edit JSON directly.");
}
const item = normalizeField(itemSchema, [...path, 0], "item", true, `${title} item`);
return {
path,
key,
title,
description: stringValue(schema.description),
kind: "array",
required,
hasDefault: hasOwn(schema, "default"),
defaultValue: schema.default,
enumValues: [],
children: [],
item,
fallbackReason: null,
};
}
if (type === "string" || type === "number" || type === "integer" || type === "boolean") {
return {
path,
key,
title,
description: stringValue(schema.description),
kind: type,
required,
hasDefault: hasOwn(schema, "default"),
defaultValue: schema.default,
enumValues: [],
children: [],
item: null,
fallbackReason: null,
};
}
return fallback(schema, path, key, required, title, "The schema type is unsupported; edit JSON directly.");
};
export const normalizeSchemaField = (
schema: unknown,
path: ReadonlyArray<string | number> = [],
key = "root",
required = true,
): SchemaField => normalizeField(schema, path, key, required, key === "root" ? "Value" : key);
export const normalizeSchema = (schema: unknown): SchemaField =>
normalizeSchemaField(schema);
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { normalizeSchema } from "./schema-field.js";
import { serializeSchemaValues, type FieldSources } from "./schema-values.js";
describe("serializeSchemaValues", () => {
it("omits empty optional values but preserves required incomplete fields and defaults", () => {
const field = normalizeSchema({
type: "object",
properties: {
requiredName: { type: "string" },
optionalNote: { type: "string" },
withDefault: { type: "string", default: null },
},
required: ["requiredName"],
});
const result = serializeSchemaValues(field, {
requiredName: "",
optionalNote: "",
});
expect(result.value).toEqual({ requiredName: "", withDefault: null });
expect(result.issues).toEqual([
{ path: ["requiredName"], message: "Required field is incomplete." },
]);
});
it("parses primitive controls and preserves nested paths", () => {
const field = normalizeSchema({
type: "object",
properties: {
count: { type: "number" },
retries: { type: "integer" },
enabled: { type: "boolean" },
profile: {
type: "object",
properties: { score: { type: "number" } },
},
tags: { type: "array", items: { type: "integer" } },
},
});
const result = serializeSchemaValues(field, {
count: "2.5",
retries: "3",
enabled: "false",
profile: { score: "4.25" },
tags: ["1", "2"],
});
expect(result.value).toEqual({
count: 2.5,
retries: 3,
enabled: false,
profile: { score: 4.25 },
tags: [1, 2],
});
expect(result.issues).toEqual([]);
});
it("serializes valid nested bindings separately from literal values", () => {
const field = normalizeSchema({
type: "object",
properties: {
title: { type: "string" },
profile: {
type: "object",
properties: { email: { type: "string" } },
},
},
});
const sources: FieldSources = {
"profile.email": { mode: "bind", sourcePath: "input.user.email" },
};
const result = serializeSchemaValues(field, { title: "Report" }, sources);
expect(result.value).toEqual({ title: "Report", profile: {} });
expect(result.bindings).toEqual([
{ target: "profile.email", path: "input.user.email" },
]);
expect(result.issues).toEqual([]);
});
it("returns a field-local issue for malformed binding paths without throwing", () => {
const field = normalizeSchema({
type: "object",
properties: { title: { type: "string" } },
required: ["title"],
});
const sources: FieldSources = {
title: { mode: "bind", sourcePath: "not a workflow path" },
};
const result = serializeSchemaValues(field, { title: "Report" }, sources);
expect(result.value).toEqual({ title: undefined });
expect(result.bindings).toEqual([]);
expect(result.issues).toEqual([
{ path: ["title"], message: "Binding path must start with input, state, or context." },
]);
});
});
@@ -0,0 +1,256 @@
import type { FieldSource, SchemaField } from "./schema-field.js";
export type FieldSources = Readonly<Record<string, FieldSource>>;
export type SchemaValueIssue = {
readonly path: ReadonlyArray<string | number>;
readonly message: string;
};
export type SchemaBinding = {
readonly target: string;
readonly path: string;
};
export type SchemaSerializationResult = {
readonly value: unknown;
readonly bindings: ReadonlyArray<SchemaBinding>;
readonly issues: ReadonlyArray<SchemaValueIssue>;
};
type ValueRecord = Record<string, unknown>;
type SerializedField = {
readonly present: boolean;
readonly value: unknown;
readonly bindings: ReadonlyArray<SchemaBinding>;
readonly issues: ReadonlyArray<SchemaValueIssue>;
};
const isRecord = (value: unknown): value is ValueRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const pathKey = (path: ReadonlyArray<string | number>): string =>
path.length === 0 ? "root" : path.map(String).join(".");
const targetPath = (path: ReadonlyArray<string | number>): string => path.map(String).join(".");
const hasDescendantSource = (field: SchemaField, sources: FieldSources): boolean => {
const prefix = targetPath(field.path);
return Object.keys(sources).some((key) => key.startsWith(`${prefix}.`));
};
const isEmptyValue = (value: unknown): boolean =>
value === undefined ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
(isRecord(value) && Object.keys(value).length === 0);
const validBindingPath = (value: string): boolean => {
const parts = value.split(".");
return (
parts.length > 0 &&
(parts[0] === "input" || parts[0] === "state" || parts[0] === "context") &&
parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part))
);
};
const issue = (
path: ReadonlyArray<string | number>,
message: string,
): SchemaValueIssue => ({ path, message });
const parseNumber = (
raw: unknown,
integer: boolean,
): { readonly value: unknown; readonly message: string | null } => {
if (typeof raw === "number") {
return Number.isFinite(raw) && (!integer || Number.isInteger(raw))
? { value: raw, message: null }
: { value: raw, message: integer ? "Enter a whole number." : "Enter a number." };
}
if (typeof raw !== "string" || raw.trim() === "") {
return { value: raw, message: integer ? "Enter a whole number." : "Enter a number." };
}
const value = Number(raw);
return Number.isFinite(value) && (!integer || Number.isInteger(value))
? { value, message: null }
: { value: raw, message: integer ? "Enter a whole number." : "Enter a number." };
};
const parseBoolean = (
raw: unknown,
): { readonly value: unknown; readonly message: string | null } => {
if (typeof raw === "boolean") return { value: raw, message: null };
if (raw === "true") return { value: true, message: null };
if (raw === "false") return { value: false, message: null };
return { value: raw, message: "Choose true or false." };
};
const parseEnum = (
raw: unknown,
values: ReadonlyArray<string | number | boolean | null>,
): { readonly value: unknown; readonly message: string | null } => {
const match = values.find((candidate) =>
candidate === raw ||
(typeof raw === "string" &&
(raw === String(candidate) || raw === JSON.stringify(candidate))),
);
return match !== undefined || values.some((candidate) => candidate === null && raw === "null")
? { value: match === undefined ? null : match, message: null }
: { value: raw, message: "Choose one of the listed values." };
};
const parseJson = (
raw: unknown,
): { readonly value: unknown; readonly message: string | null } => {
if (typeof raw !== "string") return { value: raw, message: null };
try {
const value: unknown = JSON.parse(raw);
return { value, message: null };
} catch {
return { value: raw, message: "Enter valid JSON." };
}
};
const serializeField = (
field: SchemaField,
rawValue: unknown,
sources: FieldSources,
): SerializedField => {
const source = sources[pathKey(field.path)];
if (source?.mode === "bind") {
if (!validBindingPath(source.sourcePath)) {
return {
present: field.required,
value: undefined,
bindings: [],
issues: [issue(field.path, "Binding path must start with input, state, or context.")],
};
}
return {
present: true,
value: undefined,
bindings: [{ target: targetPath(field.path), path: source.sourcePath }],
issues: [],
};
}
const usingDefault = rawValue === undefined && field.hasDefault;
const sourceValue = source?.mode === "literal" ? source.value : usingDefault ? field.defaultValue : rawValue;
const hasNestedSource = hasDescendantSource(field, sources);
// Traverse an omitted container when a descendant is bound; otherwise the binding would disappear.
const raw =
sourceValue === undefined && hasNestedSource
? field.kind === "array"
? []
: field.kind === "object"
? {}
: sourceValue
: sourceValue;
if (!usingDefault && raw === undefined && !field.required && !hasNestedSource) {
return { present: false, value: undefined, bindings: [], issues: [] };
}
if (
!usingDefault &&
field.kind !== "object" &&
field.kind !== "array" &&
isEmptyValue(raw) &&
!field.required
) {
return { present: false, value: undefined, bindings: [], issues: [] };
}
if (field.kind === "object") {
if (!isRecord(raw)) {
return {
present: field.required,
value: raw,
bindings: [],
issues: [issue(field.path, "Enter an object value.")],
};
}
const value: ValueRecord = {};
const bindings: SchemaBinding[] = [];
const issues: SchemaValueIssue[] = [];
for (const child of field.children) {
const childValue = serializeField(child, raw[child.key], sources);
if (childValue.present) value[child.key] = childValue.value;
bindings.push(...childValue.bindings);
issues.push(...childValue.issues);
}
if (Object.keys(value).length === 0 && !field.required && bindings.length === 0) {
return { present: false, value: undefined, bindings, issues };
}
return { present: true, value, bindings, issues };
}
if (field.kind === "array") {
if (!Array.isArray(raw)) {
return {
present: field.required,
value: raw,
bindings: [],
issues: [issue(field.path, "Enter an array value.")],
};
}
const value: unknown[] = [];
const bindings: SchemaBinding[] = [];
const issues: SchemaValueIssue[] = [];
const item = field.item;
if (item) {
raw.forEach((itemValue, index) => {
const itemField: SchemaField = { ...item, path: [...field.path, index] };
const serialized = serializeField(itemField, itemValue, sources);
if (serialized.present) value.push(serialized.value);
bindings.push(...serialized.bindings);
issues.push(...serialized.issues);
});
}
if (value.length === 0 && bindings.length === 0 && !field.required && !usingDefault) {
return { present: false, value: undefined, bindings, issues };
}
return { present: true, value, bindings, issues };
}
if (field.kind === "string") {
return {
present: true,
value: raw,
bindings: [],
issues: field.required && raw === "" ? [issue(field.path, "Required field is incomplete.")] : [],
};
}
const parsed =
field.kind === "number"
? parseNumber(raw, false)
: field.kind === "integer"
? parseNumber(raw, true)
: field.kind === "boolean"
? parseBoolean(raw)
: field.kind === "enum"
? parseEnum(raw, field.enumValues)
: parseJson(raw);
return {
present: true,
value: parsed.value,
bindings: [],
issues: parsed.message ? [issue(field.path, parsed.message)] : [],
};
};
export const serializeSchemaValues = (
field: SchemaField,
values: unknown,
sources: FieldSources = {},
): SchemaSerializationResult => {
const serialized = serializeField(field, values, sources);
return {
value: serialized.value,
bindings: serialized.bindings,
issues: serialized.issues,
};
};
export type { FieldSource } from "./schema-field.js";