feat(editor): coerce literal number/boolean strings to typed JSON on save
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fromGraph, toGraph } from "./graphAdapter";
|
||||
import type { WorkflowGraph } from "@/api/types";
|
||||
import type { NodeTypeDto, WorkflowGraph } from "@/api/types";
|
||||
|
||||
describe("graphAdapter", () => {
|
||||
const graph: WorkflowGraph = {
|
||||
@@ -22,7 +22,105 @@ describe("graphAdapter", () => {
|
||||
|
||||
it("toGraph is the inverse", () => {
|
||||
const rf = fromGraph(graph);
|
||||
const roundTripped = toGraph(rf.nodes, rf.edges);
|
||||
const roundTripped = toGraph(rf.nodes, rf.edges, []);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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> {
|
||||
typeId: string;
|
||||
@@ -30,13 +30,20 @@ export function fromGraph(g: WorkflowGraph): { nodes: Node<VcNodeData>[]; 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 nt = typeById.get(n.data.typeId);
|
||||
const config = nt ? coerceConfigForSave(n.data.config ?? {}, nt.configSchema) : (n.data.config ?? {});
|
||||
const node: GraphNode = {
|
||||
id: n.id,
|
||||
type_id: n.data.typeId,
|
||||
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.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 };
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (!handle) return 0;
|
||||
const m = new RegExp(`^${prefix}-(\\d+)$`).exec(handle);
|
||||
|
||||
Reference in New Issue
Block a user