feat(editor): coerce literal number/boolean strings to typed JSON on save

This commit is contained in:
Claude
2026-08-30 11:01:26 +00:00
parent 85e2339c1d
commit b1dae49171
2 changed files with 150 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { fromGraph, toGraph } from "./graphAdapter"; import { fromGraph, toGraph } from "./graphAdapter";
import type { WorkflowGraph } from "@/api/types"; import type { NodeTypeDto, WorkflowGraph } from "@/api/types";
describe("graphAdapter", () => { describe("graphAdapter", () => {
const graph: WorkflowGraph = { const graph: WorkflowGraph = {
@@ -22,7 +22,105 @@ describe("graphAdapter", () => {
it("toGraph is the inverse", () => { it("toGraph is the inverse", () => {
const rf = fromGraph(graph); const rf = fromGraph(graph);
const roundTripped = toGraph(rf.nodes, rf.edges); const roundTripped = toGraph(rf.nodes, rf.edges, []);
expect(roundTripped).toEqual(graph); expect(roundTripped).toEqual(graph);
}); });
}); });
describe("toGraph — schema-typed coercion on save", () => {
const numberNodeType: NodeTypeDto = {
id: "test.numberfield",
displayName: "Number Field",
category: "Action",
inputCount: 1,
outputCount: 0,
connectionProviderId: null,
configSchema: {
type: "object",
properties: {
status: { type: "integer" },
ratio: { type: "number" },
active: { type: "boolean" },
note: { type: "string" },
},
},
};
it("literal number strings are coerced to typed numbers", () => {
const g = toGraph(
[{
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "test.numberfield", config: {
status: "200", ratio: "0.5", active: "true", note: "hi",
} },
}],
[],
[numberNodeType],
);
expect(g.nodes[0].config).toEqual({
status: 200, ratio: 0.5, active: true, note: "hi",
});
});
it("expression strings are kept as strings so the executor's runtime coercion handles them", () => {
const g = toGraph(
[{
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "test.numberfield", config: {
status: "{input.code}", ratio: "0.5",
} },
}],
[],
[numberNodeType],
);
expect(g.nodes[0].config).toEqual({
status: "{input.code}", // still string — has `{`
ratio: 0.5, // literal → coerced
});
});
it("empty string on a numeric field is preserved as empty string", () => {
// Undefined-ish literal — user hasn't typed anything. Passing "" keeps
// the persisted graph honest; the node will hit its own default handling.
const g = toGraph(
[{
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "test.numberfield", config: { status: "" } },
}],
[],
[numberNodeType],
);
expect(g.nodes[0].config.status).toBe("");
});
it("nodes with no matching schema entry pass config through unchanged", () => {
const g = toGraph(
[{
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "unknown.type", config: { anything: "raw" } },
}],
[],
[numberNodeType],
);
expect(g.nodes[0].config).toEqual({ anything: "raw" });
});
it("non-string values pass through untouched", () => {
// A numeric leaf that's already a number (e.g. round-tripped) shouldn't
// be double-processed.
const g = toGraph(
[{
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "test.numberfield", config: { status: 200 } },
}],
[],
[numberNodeType],
);
expect(g.nodes[0].config.status).toBe(200);
});
});

View File

@@ -1,5 +1,5 @@
import type { Edge, Node } from "@xyflow/react"; import type { Edge, Node } from "@xyflow/react";
import type { GraphEdge, GraphNode, WorkflowGraph } from "@/api/types"; import type { GraphEdge, GraphNode, JsonSchemaObject, NodeTypeDto, WorkflowGraph } from "@/api/types";
export interface VcNodeData extends Record<string, unknown> { export interface VcNodeData extends Record<string, unknown> {
typeId: string; typeId: string;
@@ -30,13 +30,20 @@ export function fromGraph(g: WorkflowGraph): { nodes: Node<VcNodeData>[]; edges:
return { nodes, edges }; return { nodes, edges };
} }
export function toGraph(nodes: Node<VcNodeData>[], edges: Edge[]): WorkflowGraph { export function toGraph(
nodes: Node<VcNodeData>[],
edges: Edge[],
nodeTypes: NodeTypeDto[],
): WorkflowGraph {
const typeById = new Map(nodeTypes.map((t) => [t.id, t] as const));
const gNodes: GraphNode[] = nodes.map((n) => { const gNodes: GraphNode[] = nodes.map((n) => {
const nt = typeById.get(n.data.typeId);
const config = nt ? coerceConfigForSave(n.data.config ?? {}, nt.configSchema) : (n.data.config ?? {});
const node: GraphNode = { const node: GraphNode = {
id: n.id, id: n.id,
type_id: n.data.typeId, type_id: n.data.typeId,
position: { x: n.position.x, y: n.position.y }, position: { x: n.position.x, y: n.position.y },
config: n.data.config ?? {}, config,
}; };
if (n.data.name !== undefined) node.name = n.data.name; if (n.data.name !== undefined) node.name = n.data.name;
if (n.data.connection_id != null) node.connection_id = n.data.connection_id; if (n.data.connection_id != null) node.connection_id = n.data.connection_id;
@@ -51,6 +58,46 @@ export function toGraph(nodes: Node<VcNodeData>[], edges: Edge[]): WorkflowGraph
return { nodes: gNodes, edges: gEdges }; return { nodes: gNodes, edges: gEdges };
} }
// Per-property coercion. Fields whose schema type is integer/number/boolean:
// - literal string with no `{` → parse to native JSON type
// - string containing `{` → keep as string (executor coerces at run time)
// - already-typed value → passed through untouched
// - empty string → passed through untouched (user hasn't filled it in)
// Fields whose schema type is string or missing pass through untouched.
export function coerceConfigForSave(
config: Record<string, unknown>,
schema: JsonSchemaObject,
): Record<string, unknown> {
const props = schema.properties;
if (!props) return config;
const out: Record<string, unknown> = {};
for (const [name, value] of Object.entries(config)) {
const prop = props[name];
if (!prop || typeof value !== "string" || value === "" || value.indexOf("{") >= 0) {
out[name] = value;
continue;
}
switch (prop.type) {
case "integer": {
const n = Number(value);
out[name] = Number.isInteger(n) ? n : value;
break;
}
case "number": {
const n = Number(value);
out[name] = Number.isFinite(n) ? n : value;
break;
}
case "boolean":
out[name] = value === "true" ? true : value === "false" ? false : value;
break;
default:
out[name] = value;
}
}
return out;
}
function parseHandlePort(handle: string | null | undefined, prefix: "i" | "o"): number { function parseHandlePort(handle: string | null | undefined, prefix: "i" | "o"): number {
if (!handle) return 0; if (!handle) return 0;
const m = new RegExp(`^${prefix}-(\\d+)$`).exec(handle); const m = new RegExp(`^${prefix}-(\\d+)$`).exec(handle);