fix: canonicalize schema binding paths

This commit is contained in:
lda
2026-08-09 05:53:25 +07:00 Verified
parent fb66e1ea0b
commit bc634d000a
5 changed files with 81 additions and 7 deletions
@@ -285,8 +285,7 @@ export const SchemaFieldControl = ({
);
}
const legacySourceKey = field.path.length === 0 ? "root" : field.path.map(String).join(".");
const source = sources[pathKey(field)] ?? sources[legacySourceKey] ?? { mode: "literal", value };
const source = sources[pathKey(field)] ?? { mode: "literal", value };
return (
<div className="schema-form__field">
<FieldLabel field={field} id={id} />
@@ -287,4 +287,52 @@ describe("SchemaForm", () => {
const hyphenated = screen.getByRole("textbox", { name: "A-b" });
expect(dotted.id).not.toBe(hyphenated.id);
});
it("edits a canonical literal source without aliasing a nested path", async () => {
const user = userEvent.setup();
const submissions: SchemaSerializationResult[] = [];
render(
<SchemaForm
initialSources={{
"a.b": { mode: "literal", value: "canonical nested" },
}}
initialValue={{ "a.b": "initial dotted", a: { b: "initial nested" } }}
onSubmit={(result) => submissions.push(result)}
schema={{
type: "object",
properties: {
"a.b": { type: "string" },
a: { type: "object", properties: { b: { type: "string" } } },
},
}}
/>,
);
const dotted = screen.getByRole("textbox", { name: "A.b" });
await user.clear(dotted);
await user.type(dotted, "edited dotted");
await user.click(screen.getByRole("button", { name: "Save form" }));
expect(submissions[0]?.value).toEqual({ "a.b": "edited dotted", a: { b: "canonical nested" } });
});
it("updates the canonical literal source when its field is edited", async () => {
const user = userEvent.setup();
const submissions: SchemaSerializationResult[] = [];
render(
<SchemaForm
initialSources={{ '"a.b"': { mode: "literal", value: "canonical dotted" } }}
initialValue={{ "a.b": "initial dotted" }}
onSubmit={(result) => submissions.push(result)}
schema={{ type: "object", properties: { "a.b": { type: "string" } } }}
/>,
);
const dotted = screen.getByRole("textbox", { name: "A.b" });
await user.clear(dotted);
await user.type(dotted, "edited canonical");
await user.click(screen.getByRole("button", { name: "Save form" }));
expect(submissions[0]?.value).toEqual({ "a.b": "edited canonical" });
});
});
@@ -6,7 +6,7 @@ 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);
value.trim().length > 0 && !CONTROL_CHARACTER.test(value);
const parseDoubleQuoted = (raw: string, start: number): { readonly value: string; readonly next: number } | null => {
let escaped = false;
@@ -134,6 +134,36 @@ describe("serializeSchemaValues", () => {
expect(rootResult.bindings).toEqual([{ target: ".", path: "input.payload" }]);
});
it("rejects whitespace-only quoted binding path segments", () => {
const field = normalizeSchema({ type: "string" });
const result = serializeSchemaValues(field, "literal", {
".": { mode: "bind", sourcePath: 'input." "' },
});
expect(result.bindings).toEqual([]);
expect(result.issues).toEqual([
{ path: [], message: "Binding path must start with input, state, or context." },
]);
});
it("does not alias a nested path with a literal dotted property", () => {
const field = normalizeSchema({
type: "object",
properties: {
"a.b": { type: "string" },
a: { type: "object", properties: { b: { type: "string" } } },
},
});
const result = serializeSchemaValues(
field,
{ "a.b": "initial dotted", a: { b: "initial nested" } },
{ "a.b": { mode: "literal", value: "canonical nested" } },
);
expect(result.value).toEqual({ "a.b": "initial dotted", a: { b: "canonical nested" } });
});
it("rebases bindings for the second nested object array item", () => {
const field = normalizeSchema({
type: "object",
@@ -38,13 +38,10 @@ const isRecord = (value: unknown): value is ValueRecord =>
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 sourceForPath = (
sources: FieldSources,
path: ReadonlyArray<string | number>,
): FieldSource | undefined => sources[pathKey(path)] ?? sources[legacyPathKey(path)];
): FieldSource | undefined => sources[pathKey(path)];
const targetPath = (path: ReadonlyArray<string | number>): string => formatTOMLPath(path);