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 { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user