feat: add schema driven workflow forms
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user