fix: preserve canonical authoring state

This commit is contained in:
lda
2026-08-09 07:52:31 +07:00 Verified
parent 71f997f221
commit a485ca8a6b
17 changed files with 830 additions and 113 deletions
@@ -17,6 +17,7 @@ export type SchemaFormProps = {
readonly initialSources?: FieldSources;
readonly diagnostics?: ReadonlyArray<SchemaValueIssue>;
readonly onSubmit?: (result: SchemaSerializationResult) => void;
readonly onValueChange?: (result: SchemaSerializationResult) => void;
readonly onDirtyChange?: (dirty: boolean) => void;
readonly renderBeforeFields?: ReactNode;
readonly submitLabel?: string;
@@ -97,6 +98,7 @@ export const SchemaForm = ({
initialSources = EMPTY_SOURCES,
diagnostics = EMPTY_DIAGNOSTICS,
onSubmit,
onValueChange,
onDirtyChange,
renderBeforeFields,
submitLabel = "Save form",
@@ -111,42 +113,52 @@ export const SchemaForm = ({
const allDiagnostics = [...diagnostics, ...submitIssues];
const handleValueChange = (changedField: SchemaField, nextValue: unknown): void => {
setValues((current: unknown) => setAtPath(current, changedField.path, nextValue));
const nextValues = setAtPath(values, changedField.path, nextValue);
let nextSources = sources;
setValues(nextValues);
onDirtyChange?.(true);
const currentSource = sources[sourceKey(changedField)];
if (currentSource?.mode === "literal") {
setSources((current) => ({
...current,
nextSources = {
...sources,
[sourceKey(changedField)]: { mode: "literal", value: nextValue },
};
setSources(() => ({
...sources,
[sourceKey(changedField)]: { mode: "literal", value: nextValue },
}));
}
onValueChange?.(serializeSchemaValues(field, nextValues, nextSources));
};
const handleSourceChange = (changedField: SchemaField, source: FieldSource): void => {
onDirtyChange?.(true);
const nextValues = source.mode === "literal"
? setAtPath(values, changedField.path, source.value)
: values;
const nextSources = { ...sources, [sourceKey(changedField)]: source };
if (source.mode === "literal") {
setValues((current: unknown) => setAtPath(current, changedField.path, source.value));
setValues(nextValues);
}
setSources((current) => ({ ...current, [sourceKey(changedField)]: source }));
setSources(nextSources);
onValueChange?.(serializeSchemaValues(field, nextValues, nextSources));
};
const handleArrayItemRemove = (arrayField: SchemaField, index: number): void => {
onDirtyChange?.(true);
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 arrayValue = readAtPath(values, arrayField.path);
if (!Array.isArray(arrayValue)) return;
const nextValues = setAtPath(
values,
arrayField.path,
arrayValue.filter((_, itemIndex) => itemIndex !== index),
);
const nextSources = rebaseFieldSourcesAfterArrayRemoval(sources, arrayField.path, index);
const nextIssues = rebaseSchemaIssuesAfterArrayRemoval(submitIssues, arrayField.path, index);
setValues(nextValues);
setSources(nextSources);
setSubmitIssues(nextIssues);
onValueChange?.(serializeSchemaValues(field, nextValues, nextSources));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
@@ -79,9 +79,38 @@ describe("serializeSchemaValues", () => {
expect(result.bindings).toEqual([
{ target: "profile.email", path: "input.user.email" },
]);
expect(result.literalBindings).toEqual([
{ target: "title", value: "Report" },
]);
expect(result.issues).toEqual([]);
});
it("lowers root, nested, array, and null literals without wrappers", () => {
const objectResult = serializeSchemaValues(
normalizeSchema({
type: "object",
properties: {
profile: {
type: "object",
properties: { display: { type: "string" } },
},
tags: { type: "array", items: { type: "string" } },
note: { type: "string" },
},
}),
{ profile: { display: "Ada" }, tags: ["one", "two"], note: null },
);
const rootResult = serializeSchemaValues(normalizeSchema({ type: "array", items: { type: "string" } }), ["one"]);
expect(objectResult.literalBindings).toEqual([
{ target: "profile.display", value: "Ada" },
{ target: "tags.0", value: "one" },
{ target: "tags.1", value: "two" },
{ target: "note", value: null },
]);
expect(rootResult.literalBindings).toEqual([{ target: "0", value: "one" }]);
});
it("returns a field-local issue for malformed binding paths without throwing", () => {
const field = normalizeSchema({
type: "object",
@@ -18,9 +18,15 @@ export type SchemaBinding = {
readonly path: string;
};
export type SchemaLiteralBinding = {
readonly target: string;
readonly value: unknown;
};
export type SchemaSerializationResult = {
readonly value: unknown;
readonly bindings: ReadonlyArray<SchemaBinding>;
readonly literalBindings: ReadonlyArray<SchemaLiteralBinding>;
readonly issues: ReadonlyArray<SchemaValueIssue>;
};
@@ -30,6 +36,7 @@ type SerializedField = {
readonly present: boolean;
readonly value: unknown;
readonly bindings: ReadonlyArray<SchemaBinding>;
readonly literalBindings: ReadonlyArray<SchemaLiteralBinding>;
readonly issues: ReadonlyArray<SchemaValueIssue>;
};
@@ -199,6 +206,7 @@ const serializeField = (
present: field.required,
value: undefined,
bindings: [],
literalBindings: [],
issues: [issue(field.path, "Binding path must start with input, state, or context.")],
};
}
@@ -206,6 +214,7 @@ const serializeField = (
present: true,
value: undefined,
bindings: [{ target: targetPath(field.path), path: source.sourcePath }],
literalBindings: [],
issues: [],
};
}
@@ -223,7 +232,7 @@ const serializeField = (
: sourceValue
: sourceValue;
if (!usingDefault && raw === undefined && !field.required && !hasNestedSource) {
return { present: false, value: undefined, bindings: [], issues: [] };
return { present: false, value: undefined, bindings: [], literalBindings: [], issues: [] };
}
if (
!usingDefault &&
@@ -232,7 +241,7 @@ const serializeField = (
isEmptyValue(raw) &&
!field.required
) {
return { present: false, value: undefined, bindings: [], issues: [] };
return { present: false, value: undefined, bindings: [], literalBindings: [], issues: [] };
}
if (field.kind === "object") {
@@ -241,22 +250,28 @@ const serializeField = (
present: field.required,
value: raw,
bindings: [],
literalBindings: [],
issues: [issue(field.path, "Enter an object value.")],
};
}
const value: ValueRecord = {};
const bindings: SchemaBinding[] = [];
const literalBindings: SchemaLiteralBinding[] = [];
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);
literalBindings.push(...childValue.literalBindings);
issues.push(...childValue.issues);
}
if (Object.keys(value).length === 0 && !field.required && bindings.length === 0 && !usingDefault) {
return { present: false, value: undefined, bindings, issues };
return { present: false, value: undefined, bindings, literalBindings, issues };
}
return { present: true, value, bindings, issues };
if (field.children.length === 0) {
literalBindings.push({ target: targetPath(field.path), value });
}
return { present: true, value, bindings, literalBindings, issues };
}
if (field.kind === "array") {
@@ -265,11 +280,13 @@ const serializeField = (
present: field.required,
value: raw,
bindings: [],
literalBindings: [],
issues: [issue(field.path, "Enter an array value.")],
};
}
const value: unknown[] = [];
const bindings: SchemaBinding[] = [];
const literalBindings: SchemaLiteralBinding[] = [];
const issues: SchemaValueIssue[] = [];
const item = field.item;
if (item) {
@@ -278,13 +295,17 @@ const serializeField = (
const serialized = serializeField(itemField, itemValue, sources);
if (serialized.present) value.push(serialized.value);
bindings.push(...serialized.bindings);
literalBindings.push(...serialized.literalBindings);
issues.push(...serialized.issues);
});
}
if (value.length === 0 && bindings.length === 0 && !field.required && !usingDefault) {
return { present: false, value: undefined, bindings, issues };
return { present: false, value: undefined, bindings, literalBindings, issues };
}
return { present: true, value, bindings, issues };
if (item === null) {
literalBindings.push({ target: targetPath(field.path), value });
}
return { present: true, value, bindings, literalBindings, issues };
}
if (field.kind === "string") {
@@ -293,6 +314,7 @@ const serializeField = (
present: true,
value: "",
bindings: [],
literalBindings: [{ target: targetPath(field.path), value: "" }],
issues: [issue(field.path, "Required field is incomplete.")],
};
}
@@ -300,6 +322,7 @@ const serializeField = (
present: true,
value: raw,
bindings: [],
literalBindings: [{ target: targetPath(field.path), value: raw }],
issues: field.required && raw === "" ? [issue(field.path, "Required field is incomplete.")] : [],
};
}
@@ -309,6 +332,7 @@ const serializeField = (
present: true,
value: "",
bindings: [],
literalBindings: [{ target: targetPath(field.path), value: "" }],
issues: [issue(field.path, "Required field is incomplete.")],
};
}
@@ -327,6 +351,10 @@ const serializeField = (
present: true,
value: parsed.value,
bindings: [],
literalBindings:
parsed.value === undefined
? []
: [{ target: targetPath(field.path), value: parsed.value }],
issues: parsed.message ? [issue(field.path, parsed.message)] : [],
};
};
@@ -340,6 +368,7 @@ export const serializeSchemaValues = (
return {
value: serialized.value,
bindings: serialized.bindings,
literalBindings: serialized.literalBindings,
issues: serialized.issues,
};
};