fix: preserve schema form intent

This commit is contained in:
lda
2026-08-09 05:43:05 +07:00 Verified
parent 2b0b172d04
commit fb66e1ea0b
9 changed files with 700 additions and 54 deletions
@@ -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)