fix: preserve schema form intent
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
import type { FieldSource, SchemaField } from "./schema-field.js";
|
||||
import { encodeSchemaPath } from "./schema-paths.js";
|
||||
|
||||
const EMPTY_SUGGESTIONS: ReadonlyArray<string> = [];
|
||||
|
||||
export type BindingSourceControlProps = {
|
||||
readonly field: SchemaField;
|
||||
readonly source: FieldSource;
|
||||
readonly literalValue: unknown;
|
||||
readonly onChange: (source: FieldSource) => void;
|
||||
readonly suggestions?: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
const fieldKey = (field: SchemaField): string =>
|
||||
field.path.length === 0 ? "root" : field.path.map(String).join("-");
|
||||
encodeSchemaPath(field.path);
|
||||
|
||||
const displayTitle = (title: string): string =>
|
||||
title.length === 0 ? "Value" : `${title.slice(0, 1).toUpperCase()}${title.slice(1)}`;
|
||||
@@ -16,8 +20,9 @@ const displayTitle = (title: string): string =>
|
||||
export const BindingSourceControl = ({
|
||||
field,
|
||||
source,
|
||||
literalValue,
|
||||
onChange,
|
||||
suggestions = [],
|
||||
suggestions = EMPTY_SUGGESTIONS,
|
||||
}: BindingSourceControlProps) => {
|
||||
const key = fieldKey(field);
|
||||
const literalId = `${key}-literal`;
|
||||
@@ -34,7 +39,7 @@ export const BindingSourceControl = ({
|
||||
checked={source.mode === "literal"}
|
||||
id={literalId}
|
||||
name={`${key}-source-mode`}
|
||||
onChange={() => onChange({ mode: "literal", value: source.mode === "literal" ? source.value : "" })}
|
||||
onChange={() => onChange({ mode: "literal", value: literalValue })}
|
||||
type="radio"
|
||||
/>
|
||||
Literal
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { FieldSource, SchemaField } from "./schema-field.js";
|
||||
import type { FieldSources, SchemaValueIssue } from "./schema-values.js";
|
||||
import { rebaseSchemaField, type FieldSource, type SchemaField } from "./schema-field.js";
|
||||
import {
|
||||
enumOptionId,
|
||||
type FieldSources,
|
||||
type SchemaValueIssue,
|
||||
} from "./schema-values.js";
|
||||
import { BindingSourceControl } from "./BindingSourceControl.js";
|
||||
import { encodeSchemaPath, formatTOMLPath } from "./schema-paths.js";
|
||||
|
||||
const EMPTY_SUGGESTIONS: ReadonlyArray<string> = [];
|
||||
|
||||
@@ -11,6 +16,7 @@ export type SchemaFieldControlProps = {
|
||||
readonly diagnostics: ReadonlyArray<SchemaValueIssue>;
|
||||
readonly onValueChange: (field: SchemaField, value: unknown) => void;
|
||||
readonly onSourceChange: (field: SchemaField, source: FieldSource) => void;
|
||||
readonly onArrayItemRemove: (field: SchemaField, index: number) => void;
|
||||
readonly sourceSuggestions?: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
@@ -18,7 +24,7 @@ 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(".");
|
||||
formatTOMLPath(field.path);
|
||||
|
||||
const samePath = (
|
||||
left: ReadonlyArray<string | number>,
|
||||
@@ -26,7 +32,7 @@ const samePath = (
|
||||
): 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, "-")}`;
|
||||
`schema-field-${encodeSchemaPath(field.path)}`;
|
||||
|
||||
const displayTitle = (title: string): string =>
|
||||
title.length === 0 ? "Value" : `${title.slice(0, 1).toUpperCase()}${title.slice(1)}`;
|
||||
@@ -37,8 +43,6 @@ const jsonText = (value: unknown): string => {
|
||||
return encoded ?? "";
|
||||
};
|
||||
|
||||
const enumValue = (value: string): string => value;
|
||||
|
||||
const enumLabel = (value: string | number | boolean | null): string =>
|
||||
value === null ? "null" : String(value);
|
||||
|
||||
@@ -67,7 +71,9 @@ const FieldDiagnostics = ({
|
||||
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>
|
||||
<p key={`${formatTOMLPath(diagnostic.path)}-${diagnostic.message}`}>
|
||||
{diagnostic.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -85,6 +91,19 @@ const FieldLabel = ({ field, id }: { readonly field: SchemaField; readonly id: s
|
||||
</label>
|
||||
);
|
||||
|
||||
const isPathPrefix = (
|
||||
prefix: ReadonlyArray<string | number>,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): boolean =>
|
||||
prefix.length <= path.length && prefix.every((part, index) => part === path[index]);
|
||||
|
||||
const RequiredMarker = () => (
|
||||
<>
|
||||
<span aria-hidden="true"> *</span>
|
||||
<span className="visually-hidden"> required</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const LeafControl = ({
|
||||
field,
|
||||
value,
|
||||
@@ -118,16 +137,27 @@ const LeafControl = ({
|
||||
);
|
||||
}
|
||||
if (field.kind === "enum") {
|
||||
const selectedIndex = field.enumValues.findIndex((option) => option === value);
|
||||
const selectedOption = selectedIndex >= 0 ? field.enumValues[selectedIndex] : undefined;
|
||||
const selectedValue = selectedOption === undefined
|
||||
? ""
|
||||
: enumOptionId(selectedOption, selectedIndex);
|
||||
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)}
|
||||
onChange={(event) => {
|
||||
const optionIndex = field.enumValues.findIndex(
|
||||
(option, index) => enumOptionId(option, index) === event.target.value,
|
||||
);
|
||||
const option = optionIndex >= 0 ? field.enumValues[optionIndex] : undefined;
|
||||
onValueChange(option);
|
||||
}}
|
||||
value={selectedValue}
|
||||
>
|
||||
<option value="">Choose {label.toLowerCase()}</option>
|
||||
{field.enumValues.map((option) => (
|
||||
<option key={JSON.stringify(option)} value={enumLabel(option)}>
|
||||
{field.enumValues.map((option, index) => (
|
||||
<option key={enumOptionId(option, index)} value={enumOptionId(option, index)}>
|
||||
{enumLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
@@ -163,27 +193,31 @@ export const SchemaFieldControl = ({
|
||||
diagnostics,
|
||||
onValueChange,
|
||||
onSourceChange,
|
||||
onArrayItemRemove,
|
||||
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;
|
||||
const ownDiagnostics = diagnostics.filter((diagnostic) => samePath(diagnostic.path, field.path));
|
||||
const diagnosticsId = ownDiagnostics.length > 0 ? `${id}-diagnostics` : undefined;
|
||||
const fallbackReasonId = field.kind === "json" && field.fallbackReason ? `${id}-fallback` : undefined;
|
||||
const describedBy = [descriptionId, fallbackReasonId, diagnosticsId].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
if (field.kind === "object") {
|
||||
const objectValue = isRecord(value) ? value : {};
|
||||
return (
|
||||
<fieldset className="schema-form__group" aria-describedby={describedBy}>
|
||||
<fieldset aria-describedby={describedBy} aria-required={field.required} className="schema-form__group">
|
||||
<legend>
|
||||
{displayTitle(field.title)}
|
||||
{field.required && <span aria-hidden="true"> *</span>}
|
||||
{field.required && <RequiredMarker />}
|
||||
</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(".")}`))}
|
||||
diagnostics={diagnostics.filter((diagnostic) => isPathPrefix(child.path, diagnostic.path))}
|
||||
field={child}
|
||||
key={pathKey(child)}
|
||||
onArrayItemRemove={onArrayItemRemove}
|
||||
onSourceChange={onSourceChange}
|
||||
onValueChange={onValueChange}
|
||||
sourceSuggestions={sourceSuggestions}
|
||||
@@ -192,7 +226,7 @@ export const SchemaFieldControl = ({
|
||||
/>
|
||||
))}
|
||||
<FieldDiagnostics
|
||||
diagnostics={diagnostics.filter((diagnostic) => samePath(diagnostic.path, field.path))}
|
||||
diagnostics={ownDiagnostics}
|
||||
field={field}
|
||||
/>
|
||||
</fieldset>
|
||||
@@ -202,21 +236,24 @@ export const SchemaFieldControl = ({
|
||||
if (field.kind === "array") {
|
||||
const arrayValue = Array.isArray(value) ? value : [];
|
||||
return (
|
||||
<fieldset className="schema-form__group schema-form__array" aria-describedby={describedBy}>
|
||||
<fieldset aria-describedby={describedBy} aria-required={field.required} className="schema-form__group schema-form__array">
|
||||
<legend>
|
||||
{displayTitle(field.title)}
|
||||
{field.required && <span aria-hidden="true"> *</span>}
|
||||
{field.required && <RequiredMarker />}
|
||||
</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;
|
||||
const itemField = field.item
|
||||
? { ...rebaseSchemaField(field.item, [...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("."))}
|
||||
diagnostics={diagnostics.filter((diagnostic) => isPathPrefix(itemField.path, diagnostic.path))}
|
||||
field={itemField}
|
||||
onSourceChange={onSourceChange}
|
||||
onArrayItemRemove={onArrayItemRemove}
|
||||
onValueChange={onValueChange}
|
||||
sourceSuggestions={sourceSuggestions}
|
||||
sources={sources}
|
||||
@@ -225,7 +262,7 @@ export const SchemaFieldControl = ({
|
||||
<button
|
||||
aria-label={`Remove ${arrayItemTitle(field, index).toLowerCase()}`}
|
||||
className="schema-form__secondary-action"
|
||||
onClick={() => onValueChange(field, arrayValue.filter((_, itemIndex) => itemIndex !== index))}
|
||||
onClick={() => onArrayItemRemove(field, index)}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
@@ -241,20 +278,22 @@ export const SchemaFieldControl = ({
|
||||
Add {displayTitle(field.title).replace(/s$/, "").toLowerCase()}
|
||||
</button>
|
||||
<FieldDiagnostics
|
||||
diagnostics={diagnostics.filter((diagnostic) => samePath(diagnostic.path, field.path))}
|
||||
diagnostics={ownDiagnostics}
|
||||
field={field}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const source = sources[pathKey(field)] ?? { mode: "literal", value };
|
||||
const legacySourceKey = field.path.length === 0 ? "root" : field.path.map(String).join(".");
|
||||
const source = sources[pathKey(field)] ?? sources[legacySourceKey] ?? { 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}
|
||||
literalValue={value}
|
||||
onChange={(nextSource) => onSourceChange(field, nextSource)}
|
||||
source={source}
|
||||
suggestions={sourceSuggestions}
|
||||
@@ -263,15 +302,17 @@ export const SchemaFieldControl = ({
|
||||
<LeafControl
|
||||
describedBy={describedBy}
|
||||
field={field}
|
||||
invalid={diagnostics.length > 0}
|
||||
invalid={ownDiagnostics.length > 0}
|
||||
onValueChange={(nextValue) => onValueChange(field, nextValue)}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
{field.kind === "json" && field.fallbackReason && (
|
||||
<p className="schema-form__fallback-reason">{field.fallbackReason}</p>
|
||||
<p className="schema-form__fallback-reason" id={fallbackReasonId}>
|
||||
{field.fallbackReason}
|
||||
</p>
|
||||
)}
|
||||
<FieldDiagnostics diagnostics={diagnostics} field={field} />
|
||||
<FieldDiagnostics diagnostics={ownDiagnostics} field={field} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,4 +100,191 @@ describe("SchemaForm", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Add tag" }));
|
||||
expect(screen.getByRole("textbox", { name: "Tag 1" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reindexes bindings when removing the first array item", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: SchemaSerializationResult[] = [];
|
||||
render(
|
||||
<SchemaForm
|
||||
initialSources={{
|
||||
"items.0.name": { mode: "bind", sourcePath: "input.first" },
|
||||
"items.1.name": { mode: "bind", sourcePath: "input.second" },
|
||||
}}
|
||||
initialValue={{ items: [{ name: "first" }, { name: "second" }] }}
|
||||
onSubmit={(result) => submissions.push(result)}
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove item 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save form" }));
|
||||
|
||||
expect(submissions[0]?.bindings).toEqual([
|
||||
{ target: "items.0.name", path: "input.second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits an untouched optional boolean but preserves explicit false", async () => {
|
||||
const user = userEvent.setup();
|
||||
const untouched: SchemaSerializationResult[] = [];
|
||||
const { unmount } = render(
|
||||
<SchemaForm
|
||||
onSubmit={(result) => untouched.push(result)}
|
||||
schema={{ type: "object", properties: { enabled: { type: "boolean" } } }}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save form" }));
|
||||
expect(untouched[0]?.value).toEqual({});
|
||||
unmount();
|
||||
|
||||
const explicit: SchemaSerializationResult[] = [];
|
||||
render(
|
||||
<SchemaForm
|
||||
initialValue={{ enabled: false }}
|
||||
onSubmit={(result) => explicit.push(result)}
|
||||
schema={{ type: "object", properties: { enabled: { type: "boolean" } } }}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save form" }));
|
||||
expect(explicit[0]?.value).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("routes nested diagnostics only to their owning field", () => {
|
||||
render(
|
||||
<SchemaForm
|
||||
diagnostics={[{ path: ["profile", "name2"], message: "Second name is invalid." }]}
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, name2: { type: "string" } },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const name = screen.getByRole("textbox", { name: "Name" });
|
||||
const name2 = screen.getByRole("textbox", { name: "Name2" });
|
||||
expect(name).not.toHaveAttribute("aria-describedby", expect.stringContaining("diagnostics"));
|
||||
expect(name2).toHaveAttribute("aria-describedby", expect.stringContaining("diagnostics"));
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("routes diagnostics into nested array object items", () => {
|
||||
render(
|
||||
<SchemaForm
|
||||
diagnostics={[{ path: ["items", 1, "name"], message: "Second item is invalid." }]}
|
||||
initialValue={{ items: [{ name: "first" }, { name: "second" }] }}
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const names = screen.getAllByRole("textbox", { name: "Name" });
|
||||
expect(names[0]).not.toHaveAttribute("aria-describedby", expect.stringContaining("diagnostics"));
|
||||
expect(names[1]).toHaveAttribute("aria-describedby", expect.stringContaining("diagnostics"));
|
||||
});
|
||||
|
||||
it("preserves the literal value when toggling from Bind back to Literal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: SchemaSerializationResult[] = [];
|
||||
render(
|
||||
<SchemaForm
|
||||
initialSources={{ summary: { mode: "bind", sourcePath: "input.summary" } }}
|
||||
initialValue={{ summary: "keep this literal" }}
|
||||
onSubmit={(result) => submissions.push(result)}
|
||||
schema={{ type: "object", properties: { summary: { type: "string" } } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: "Literal" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save form" }));
|
||||
|
||||
expect(submissions[0]?.value).toEqual({ summary: "keep this literal" });
|
||||
expect(submissions[0]?.bindings).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips colliding enum display values through distinct option identities", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: SchemaSerializationResult[] = [];
|
||||
render(
|
||||
<SchemaForm
|
||||
onSubmit={(result) => submissions.push(result)}
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: { choice: { enum: ["1", 1, "true", true] } },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole("combobox", { name: "Choice" });
|
||||
const options = screen.getAllByRole("option");
|
||||
const numberOption = options[2];
|
||||
expect(numberOption).toBeDefined();
|
||||
if (!numberOption) return;
|
||||
await user.selectOptions(select, numberOption);
|
||||
await user.click(screen.getByRole("button", { name: "Save form" }));
|
||||
|
||||
expect(submissions[0]?.value).toEqual({ choice: 1 });
|
||||
});
|
||||
|
||||
it("associates required group help and fallback reasons with controls", () => {
|
||||
render(
|
||||
<SchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
settings: { type: "object", properties: {} },
|
||||
choice: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
required: ["settings", "choice"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("group", { name: /Settings.*required/ })).toBeInTheDocument();
|
||||
const choice = screen.getByRole("textbox", { name: "Choice" });
|
||||
expect(choice).toHaveAttribute("aria-describedby", expect.stringContaining("fallback"));
|
||||
expect(screen.getByText("The schema uses oneOf, which the native form cannot represent.")).toHaveAttribute(
|
||||
"id",
|
||||
expect.stringContaining("fallback"),
|
||||
);
|
||||
});
|
||||
|
||||
it("generates distinct ids for dotted and hyphenated property names", () => {
|
||||
render(
|
||||
<SchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: { "a.b": { type: "string" }, "a-b": { type: "string" } },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dotted = screen.getByRole("textbox", { name: "A.b" });
|
||||
const hyphenated = screen.getByRole("textbox", { name: "A-b" });
|
||||
expect(dotted.id).not.toBe(hyphenated.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,14 @@ import { useState, type FormEvent } from "react";
|
||||
import { normalizeSchema, type FieldSource, type SchemaField } from "./schema-field.js";
|
||||
import { SchemaFieldControl } from "./SchemaFieldControl.js";
|
||||
import {
|
||||
rebaseFieldSourcesAfterArrayRemoval,
|
||||
rebaseSchemaIssuesAfterArrayRemoval,
|
||||
serializeSchemaValues,
|
||||
type FieldSources,
|
||||
type SchemaSerializationResult,
|
||||
type SchemaValueIssue,
|
||||
} from "./schema-values.js";
|
||||
import { formatTOMLPath } from "./schema-paths.js";
|
||||
|
||||
export type SchemaFormProps = {
|
||||
readonly schema: unknown;
|
||||
@@ -29,10 +32,30 @@ 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;
|
||||
if (field.kind === "boolean") return undefined;
|
||||
if (field.kind === "json") return undefined;
|
||||
return "";
|
||||
};
|
||||
|
||||
const readAtPath = (
|
||||
current: unknown,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): unknown => {
|
||||
let value = current;
|
||||
for (const part of path) {
|
||||
if (Array.isArray(value)) {
|
||||
const index = typeof part === "number" ? part : Number(part);
|
||||
if (!Number.isInteger(index)) return undefined;
|
||||
value = value[index];
|
||||
} else if (isRecord(value) && typeof part === "string") {
|
||||
value = value[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const setAtPath = (
|
||||
current: unknown,
|
||||
path: ReadonlyArray<string | number>,
|
||||
@@ -55,7 +78,7 @@ const setAtPath = (
|
||||
};
|
||||
|
||||
const sourceKey = (sourceField: SchemaField): string =>
|
||||
sourceField.path.length === 0 ? "root" : sourceField.path.map(String).join(".");
|
||||
formatTOMLPath(sourceField.path);
|
||||
|
||||
const rawSchemaText = (schema: unknown): string => {
|
||||
try {
|
||||
@@ -95,9 +118,30 @@ export const SchemaForm = ({
|
||||
};
|
||||
|
||||
const handleSourceChange = (changedField: SchemaField, source: FieldSource): void => {
|
||||
if (source.mode === "literal") {
|
||||
setValues((current: unknown) => setAtPath(current, changedField.path, source.value));
|
||||
}
|
||||
setSources((current) => ({ ...current, [sourceKey(changedField)]: source }));
|
||||
};
|
||||
|
||||
const handleArrayItemRemove = (arrayField: SchemaField, index: number): void => {
|
||||
setValues((current: unknown) => {
|
||||
const arrayValue = readAtPath(current, arrayField.path);
|
||||
if (!Array.isArray(arrayValue)) return current;
|
||||
return setAtPath(
|
||||
current,
|
||||
arrayField.path,
|
||||
arrayValue.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
});
|
||||
setSources((current) =>
|
||||
rebaseFieldSourcesAfterArrayRemoval(current, arrayField.path, index),
|
||||
);
|
||||
setSubmitIssues((current) =>
|
||||
rebaseSchemaIssuesAfterArrayRemoval(current, arrayField.path, index),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const result = serializeSchemaValues(field, values, sources);
|
||||
@@ -110,6 +154,7 @@ export const SchemaForm = ({
|
||||
<SchemaFieldControl
|
||||
diagnostics={allDiagnostics}
|
||||
field={field}
|
||||
onArrayItemRemove={handleArrayItemRemove}
|
||||
onSourceChange={handleSourceChange}
|
||||
onValueChange={handleValueChange}
|
||||
sourceSuggestions={sourceSuggestions}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSchema } from "./schema-field.js";
|
||||
import { normalizeSchema, rebaseSchemaField } from "./schema-field.js";
|
||||
|
||||
describe("normalizeSchema", () => {
|
||||
it("normalizes primitive, multiline, boolean, and enum fields", () => {
|
||||
@@ -101,4 +101,37 @@ describe("normalizeSchema", () => {
|
||||
fallbackReason: "The schema contains an unresolved $ref, which the native form cannot represent.",
|
||||
});
|
||||
});
|
||||
|
||||
it("recursively rebases nested object and array item paths", () => {
|
||||
const field = normalizeSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const item = field.children[0]?.item;
|
||||
expect(item).not.toBeNull();
|
||||
if (!item) return;
|
||||
|
||||
const rebased = rebaseSchemaField(item, ["items", 1]);
|
||||
expect(rebased.path).toEqual(["items", 1]);
|
||||
expect(rebased.children[0]?.path).toEqual(["items", 1, "profile"]);
|
||||
expect(rebased.children[0]?.children[0]?.path).toEqual([
|
||||
"items",
|
||||
1,
|
||||
"profile",
|
||||
"name",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,21 @@ export type FieldSource =
|
||||
| { readonly mode: "literal"; readonly value: unknown }
|
||||
| { readonly mode: "bind"; readonly sourcePath: string };
|
||||
|
||||
export const rebaseSchemaField = (
|
||||
field: SchemaField,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): SchemaField => {
|
||||
const relativePath = (childPath: ReadonlyArray<string | number>): ReadonlyArray<string | number> =>
|
||||
childPath.slice(field.path.length);
|
||||
const children = field.children.map((child) =>
|
||||
rebaseSchemaField(child, [...path, ...relativePath(child.path)]),
|
||||
);
|
||||
const item = field.item
|
||||
? rebaseSchemaField(field.item, [...path, ...relativePath(field.item.path)])
|
||||
: null;
|
||||
return { ...field, path, children, item };
|
||||
};
|
||||
|
||||
type SchemaRecord = Record<string, unknown>;
|
||||
type EnumValue = string | number | boolean | null;
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
export type SchemaPathPart = string | number;
|
||||
|
||||
const BARE_TOML_KEY = /^[A-Za-z0-9_-]+$/;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
const isBareSegment = (value: string): boolean => BARE_TOML_KEY.test(value);
|
||||
|
||||
const isValidSegment = (value: string): boolean =>
|
||||
value.length > 0 && !CONTROL_CHARACTER.test(value);
|
||||
|
||||
const parseDoubleQuoted = (raw: string, start: number): { readonly value: string; readonly next: number } | null => {
|
||||
let escaped = false;
|
||||
for (let index = start + 1; index < raw.length; index++) {
|
||||
const character = raw[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
const encoded = raw.slice(start, index + 1);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(encoded);
|
||||
return typeof parsed === "string" && isValidSegment(parsed)
|
||||
? { value: parsed, next: index + 1 }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseSingleQuoted = (raw: string, start: number): { readonly value: string; readonly next: number } | null => {
|
||||
let value = "";
|
||||
for (let index = start + 1; index < raw.length; index++) {
|
||||
const character = raw[index];
|
||||
if (character === "'") {
|
||||
if (raw[index + 1] === "'") {
|
||||
value += "'";
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
return isValidSegment(value) ? { value, next: index + 1 } : null;
|
||||
}
|
||||
value += character;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Parse the canonical TOML-key path grammar used by workflow paths. */
|
||||
export const parseTOMLPath = (raw: string): ReadonlyArray<string> | null => {
|
||||
if (raw === ".") return [];
|
||||
if (raw.length === 0 || raw.trim() !== raw) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
let index = 0;
|
||||
while (index < raw.length) {
|
||||
const character = raw[index];
|
||||
let parsed: { readonly value: string; readonly next: number } | null;
|
||||
if (character === '"') parsed = parseDoubleQuoted(raw, index);
|
||||
else if (character === "'") parsed = parseSingleQuoted(raw, index);
|
||||
else {
|
||||
const start = index;
|
||||
while (index < raw.length && raw[index] !== ".") index++;
|
||||
const value = raw.slice(start, index);
|
||||
parsed = isBareSegment(value) ? { value, next: index } : null;
|
||||
}
|
||||
if (!parsed) return null;
|
||||
parts.push(parsed.value);
|
||||
index = parsed.next;
|
||||
if (index === raw.length) return parts;
|
||||
if (raw[index] !== ".") return null;
|
||||
index++;
|
||||
if (index === raw.length) return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Format literal schema path segments using the canonical TOML-key syntax. */
|
||||
export const formatTOMLPath = (parts: ReadonlyArray<SchemaPathPart>): string => {
|
||||
if (parts.length === 0) return ".";
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (typeof part === "number") return String(part);
|
||||
if (isBareSegment(part)) return part;
|
||||
const encoded = JSON.stringify(part);
|
||||
return encoded ?? '""';
|
||||
})
|
||||
.join(".");
|
||||
};
|
||||
|
||||
export const parseGraphSourcePath = (raw: string): ReadonlyArray<string> | null => {
|
||||
const parts = parseTOMLPath(raw);
|
||||
return parts && parts.length > 0 && (parts[0] === "input" || parts[0] === "state" || parts[0] === "context")
|
||||
? parts
|
||||
: null;
|
||||
};
|
||||
|
||||
/** Encode path segment type and contents so valid schema paths cannot collide. */
|
||||
export const encodeSchemaPath = (parts: ReadonlyArray<SchemaPathPart>): string => {
|
||||
if (parts.length === 0) return "root";
|
||||
return parts
|
||||
.map((part) => {
|
||||
const text = String(part);
|
||||
const codePoints = Array.from(text).map((character) => character.codePointAt(0)?.toString(16) ?? "0");
|
||||
return `${typeof part === "number" ? "n" : "s"}${text.length}_${codePoints.join("-")}`;
|
||||
})
|
||||
.join("__");
|
||||
};
|
||||
@@ -100,4 +100,116 @@ describe("serializeSchemaValues", () => {
|
||||
{ path: ["title"], message: "Binding path must start with input, state, or context." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats and validates quoted TOML-key paths, including the root marker", () => {
|
||||
const nestedField = normalizeSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "object",
|
||||
properties: { "display.name": { type: "string" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const nestedResult = serializeSchemaValues(
|
||||
nestedField,
|
||||
{ profile: {} },
|
||||
{
|
||||
'profile."display.name"': {
|
||||
mode: "bind",
|
||||
sourcePath: 'input."user.name"',
|
||||
},
|
||||
},
|
||||
);
|
||||
const rootResult = serializeSchemaValues(
|
||||
normalizeSchema({ type: "string" }),
|
||||
"literal",
|
||||
{ ".": { mode: "bind", sourcePath: "input.payload" } },
|
||||
);
|
||||
|
||||
expect(nestedResult.bindings).toEqual([
|
||||
{ target: 'profile."display.name"', path: 'input."user.name"' },
|
||||
]);
|
||||
expect(nestedResult.issues).toEqual([]);
|
||||
expect(rootResult.bindings).toEqual([{ target: ".", path: "input.payload" }]);
|
||||
});
|
||||
|
||||
it("rebases bindings for the second nested object array item", () => {
|
||||
const field = normalizeSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = serializeSchemaValues(
|
||||
field,
|
||||
{ items: [{ profile: {} }, { profile: {} }] },
|
||||
{
|
||||
"items.1.profile.name": { mode: "bind", sourcePath: "input.second" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.bindings).toEqual([
|
||||
{ target: "items.1.profile.name", path: "input.second" },
|
||||
]);
|
||||
expect(result.issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves absence versus explicit false for optional booleans", () => {
|
||||
const field = normalizeSchema({
|
||||
type: "object",
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
});
|
||||
|
||||
expect(serializeSchemaValues(field, {}).value).toEqual({});
|
||||
expect(serializeSchemaValues(field, { enabled: false }).value).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("reports and preserves missing required string and JSON values", () => {
|
||||
const field = normalizeSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
payload: {},
|
||||
},
|
||||
required: ["name", "payload"],
|
||||
});
|
||||
const result = serializeSchemaValues(field, {});
|
||||
|
||||
expect(result.value).toEqual({ name: "", payload: "" });
|
||||
expect(result.issues).toEqual([
|
||||
{ path: ["name"], message: "Required field is incomplete." },
|
||||
{ path: ["payload"], message: "Required field is incomplete." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves an explicit empty object default", () => {
|
||||
const field = normalizeSchema({
|
||||
type: "object",
|
||||
properties: { options: { type: "object", default: {}, properties: {} } },
|
||||
});
|
||||
|
||||
expect(serializeSchemaValues(field, {}).value).toEqual({ options: {} });
|
||||
});
|
||||
|
||||
it("keeps enum values distinct when their display text collides", () => {
|
||||
const field = normalizeSchema({
|
||||
type: "object",
|
||||
properties: { choice: { enum: ["true", true, "1", 1] } },
|
||||
});
|
||||
|
||||
expect(serializeSchemaValues(field, { choice: true }).value).toEqual({ choice: true });
|
||||
expect(serializeSchemaValues(field, { choice: 1 }).value).toEqual({ choice: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { FieldSource, SchemaField } from "./schema-field.js";
|
||||
import { rebaseSchemaField } from "./schema-field.js";
|
||||
import {
|
||||
formatTOMLPath,
|
||||
parseGraphSourcePath,
|
||||
parseTOMLPath,
|
||||
} from "./schema-paths.js";
|
||||
|
||||
export type FieldSources = Readonly<Record<string, FieldSource>>;
|
||||
|
||||
@@ -30,14 +36,78 @@ type SerializedField = {
|
||||
const isRecord = (value: unknown): value is ValueRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const pathKey = (path: ReadonlyArray<string | number>): string =>
|
||||
const pathKey = (path: ReadonlyArray<string | number>): string => formatTOMLPath(path);
|
||||
|
||||
const legacyPathKey = (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 sourceForPath = (
|
||||
sources: FieldSources,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): FieldSource | undefined => sources[pathKey(path)] ?? sources[legacyPathKey(path)];
|
||||
|
||||
const targetPath = (path: ReadonlyArray<string | number>): string => formatTOMLPath(path);
|
||||
|
||||
const hasDescendantSource = (field: SchemaField, sources: FieldSources): boolean => {
|
||||
const prefix = targetPath(field.path);
|
||||
return Object.keys(sources).some((key) => key.startsWith(`${prefix}.`));
|
||||
const fieldParts = field.path.map(String);
|
||||
return Object.keys(sources).some((key) => {
|
||||
const sourceParts = parseTOMLPath(key);
|
||||
return (
|
||||
sourceParts !== null &&
|
||||
sourceParts.length > fieldParts.length &&
|
||||
fieldParts.every((part, index) => sourceParts[index] === part)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const rebaseFieldSourcesAfterArrayRemoval = (
|
||||
sources: FieldSources,
|
||||
arrayPath: ReadonlyArray<string | number>,
|
||||
removedIndex: number,
|
||||
): FieldSources => {
|
||||
const arrayParts = arrayPath.map(String);
|
||||
const next: Record<string, FieldSource> = {};
|
||||
for (const [rawPath, source] of Object.entries(sources)) {
|
||||
const parsedPath = parseTOMLPath(rawPath);
|
||||
const matchesArray =
|
||||
parsedPath !== null &&
|
||||
parsedPath.length > arrayParts.length &&
|
||||
arrayParts.every((part, index) => parsedPath[index] === part);
|
||||
if (!matchesArray || parsedPath === null) {
|
||||
next[rawPath] = source;
|
||||
continue;
|
||||
}
|
||||
const itemIndex = Number(parsedPath[arrayParts.length]);
|
||||
if (!Number.isInteger(itemIndex) || String(itemIndex) !== parsedPath[arrayParts.length]) {
|
||||
next[rawPath] = source;
|
||||
continue;
|
||||
}
|
||||
if (itemIndex === removedIndex) continue;
|
||||
const rebased = [...parsedPath];
|
||||
if (itemIndex > removedIndex) rebased[arrayParts.length] = String(itemIndex - 1);
|
||||
next[formatTOMLPath(rebased)] = source;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const rebaseSchemaIssuesAfterArrayRemoval = (
|
||||
issues: ReadonlyArray<SchemaValueIssue>,
|
||||
arrayPath: ReadonlyArray<string | number>,
|
||||
removedIndex: number,
|
||||
): ReadonlyArray<SchemaValueIssue> => {
|
||||
const matchesPrefix = (path: ReadonlyArray<string | number>): boolean =>
|
||||
arrayPath.length < path.length &&
|
||||
arrayPath.every((part, index) => String(path[index]) === String(part));
|
||||
return issues.flatMap((currentIssue) => {
|
||||
if (!matchesPrefix(currentIssue.path)) return [currentIssue];
|
||||
const itemIndex = currentIssue.path[arrayPath.length];
|
||||
if (typeof itemIndex !== "number") return [currentIssue];
|
||||
if (itemIndex === removedIndex) return [];
|
||||
if (itemIndex < removedIndex) return [currentIssue];
|
||||
const rebasedPath = [...currentIssue.path];
|
||||
rebasedPath[arrayPath.length] = itemIndex - 1;
|
||||
return [{ ...currentIssue, path: rebasedPath }];
|
||||
});
|
||||
};
|
||||
|
||||
const isEmptyValue = (value: unknown): boolean =>
|
||||
@@ -47,12 +117,7 @@ const isEmptyValue = (value: unknown): boolean =>
|
||||
(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))
|
||||
);
|
||||
return parseGraphSourcePath(value) !== null;
|
||||
};
|
||||
|
||||
const issue = (
|
||||
@@ -91,16 +156,28 @@ 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 directIndex = values.findIndex((candidate) => candidate === raw);
|
||||
if (directIndex >= 0) return { value: values[directIndex], message: null };
|
||||
if (typeof raw === "string") {
|
||||
const encodedIndex = /^([0-9]+):/.exec(raw)?.[1];
|
||||
if (encodedIndex !== undefined) {
|
||||
const index = Number(encodedIndex);
|
||||
if (Number.isInteger(index) && index >= 0 && index < values.length) {
|
||||
return { value: values[index], message: null };
|
||||
}
|
||||
}
|
||||
if (raw === "null" && values.some((candidate) => candidate === null)) {
|
||||
return { value: null, message: null };
|
||||
}
|
||||
}
|
||||
return { value: raw, message: "Choose one of the listed values." };
|
||||
};
|
||||
|
||||
export const enumOptionId = (
|
||||
value: string | number | boolean | null,
|
||||
index: number,
|
||||
): string => `${index}:${JSON.stringify(value)}`;
|
||||
|
||||
const parseJson = (
|
||||
raw: unknown,
|
||||
): { readonly value: unknown; readonly message: string | null } => {
|
||||
@@ -118,7 +195,7 @@ const serializeField = (
|
||||
rawValue: unknown,
|
||||
sources: FieldSources,
|
||||
): SerializedField => {
|
||||
const source = sources[pathKey(field.path)];
|
||||
const source = sourceForPath(sources, field.path);
|
||||
if (source?.mode === "bind") {
|
||||
if (!validBindingPath(source.sourcePath)) {
|
||||
return {
|
||||
@@ -179,7 +256,7 @@ const serializeField = (
|
||||
bindings.push(...childValue.bindings);
|
||||
issues.push(...childValue.issues);
|
||||
}
|
||||
if (Object.keys(value).length === 0 && !field.required && bindings.length === 0) {
|
||||
if (Object.keys(value).length === 0 && !field.required && bindings.length === 0 && !usingDefault) {
|
||||
return { present: false, value: undefined, bindings, issues };
|
||||
}
|
||||
return { present: true, value, bindings, issues };
|
||||
@@ -200,7 +277,7 @@ const serializeField = (
|
||||
const item = field.item;
|
||||
if (item) {
|
||||
raw.forEach((itemValue, index) => {
|
||||
const itemField: SchemaField = { ...item, path: [...field.path, index] };
|
||||
const itemField = rebaseSchemaField(item, [...field.path, index]);
|
||||
const serialized = serializeField(itemField, itemValue, sources);
|
||||
if (serialized.present) value.push(serialized.value);
|
||||
bindings.push(...serialized.bindings);
|
||||
@@ -214,6 +291,14 @@ const serializeField = (
|
||||
}
|
||||
|
||||
if (field.kind === "string") {
|
||||
if (raw === undefined) {
|
||||
return {
|
||||
present: true,
|
||||
value: "",
|
||||
bindings: [],
|
||||
issues: [issue(field.path, "Required field is incomplete.")],
|
||||
};
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
value: raw,
|
||||
@@ -222,6 +307,15 @@ const serializeField = (
|
||||
};
|
||||
}
|
||||
|
||||
if (field.kind === "json" && raw === undefined) {
|
||||
return {
|
||||
present: true,
|
||||
value: "",
|
||||
bindings: [],
|
||||
issues: [issue(field.path, "Required field is incomplete.")],
|
||||
};
|
||||
}
|
||||
|
||||
const parsed =
|
||||
field.kind === "number"
|
||||
? parseNumber(raw, false)
|
||||
|
||||
Reference in New Issue
Block a user