96 KiB
VolcanicCore — Plan 5: Node Editor Dialog (N8N-style)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship an N8N-style node editor. Double-click a node on the canvas → three-column modal (Input / Parameters / Output). All parameter fields are expression editors: {input.foo} tokens are colored inline and previewed live; clicking a leaf in the Input tree inserts a reference at the caret. Number/boolean/integer schema fields survive expressions via a small coercion pass on both save (client) and run (executor).
Architecture: Almost entirely frontend. One backend change: after Executor interpolates config strings, coerce string leaves back to the schema-declared type for integer / number / boolean fields so nodes that check JsonValueKind.Number still work when their value came from a template. Frontend introduces a client-side port of TemplateEngine.Render (behavior parity locked by mirroring all 17 backend tests case-for-case) and three new components (JsonTree, ExpressionField, NodeEditorDialog). SchemaForm is rewritten to use ExpressionField for every leaf. The old right-side NodeConfigPanel is deleted; its WebhookUrlHint block moves into the dialog.
Tech Stack: Same as Plans 3–4. No new runtime deps. Vitest + React Testing Library + userEvent for frontend tests; xUnit for backend tests.
Spec: docs/superpowers/specs/2026-08-30-node-editor-dialog-design.md
Preceding plans: Plans 1–4 (all shipped). Head at plan start: 744e19e.
Follow-ups this plan clears: none (this plan itself files one new follow-up in §10 of the spec — the webhook stand-by mode).
Divergences from the spec: none.
File structure at end of plan
frontend/src/
expr/ # NEW
templateEngine.ts
templateEngine.test.ts
JsonTree.tsx
JsonTree.test.tsx
ExpressionField.tsx
ExpressionField.test.tsx
editor/
nodeIoLookup.ts # NEW
nodeIoLookup.test.ts # NEW
NodeEditorDialog.tsx # NEW
NodeEditorDialog.test.tsx # NEW
Canvas.tsx # CHANGED — onNodeDoubleClick
NodeConfigPanel.tsx # DELETED
graphAdapter.ts # CHANGED — coerceConfigForSave
graphAdapter.test.ts # CHANGED — coercion tests
forms/
SchemaForm.tsx # CHANGED — uses ExpressionField
SchemaForm.test.tsx # CHANGED — expression-aware tests
pages/
EditorPage.tsx # CHANGED — dialog wiring, panel removed
src/VolcanicCore.Api/Engine/
Executor.cs # CHANGED — CoerceToSchema after InterpolateStrings
tests/VolcanicCore.Tests/
ExecutorTests.cs # CHANGED — 2 new coercion tests
frontend/e2e/
smoke.spec.ts # CHANGED — extended with dialog edit
How to run
Backend:
dotnet build
dotnet test
Frontend unit / integration:
pnpm --dir frontend test
Frontend E2E (needs a one-time pnpm exec playwright install chromium):
pnpm --dir frontend test:e2e
Live smoke (optional during development):
DATA_DIRECTORY=$(pwd)/_smoke ADMIN_PASSWORD=hunter2 \
dotnet run --project src/VolcanicCore.Api --urls http://127.0.0.1:5099
# in another terminal:
pnpm --dir frontend dev
# → open http://127.0.0.1:5173
Commit convention: Conventional Commits. Use Claude's identity:
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "..."
Task 1: Client-side TemplateEngine port
Ports VolcanicCore.Core.TemplateEngine.Render to TypeScript with byte-exact behavioral parity. All 17 backend test cases mirrored; if a backend case is added later, this file gains it too.
Files:
-
Create:
frontend/src/expr/templateEngine.ts -
Create:
frontend/src/expr/templateEngine.test.ts -
Step 1: Write the failing tests (all 17 cases at once)
// frontend/src/expr/templateEngine.test.ts
import { describe, it, expect } from "vitest";
import { render } from "./templateEngine";
const J = (json: string) => JSON.parse(json);
describe("templateEngine.render — parity with VolcanicCore.Core.TemplateEngine", () => {
it("no placeholders returns the template unchanged", () => {
expect(render("hello world", J(`{"x":1}`))).toBe("hello world");
});
it("whole input renders as compact json", () => {
expect(render("got: {input}", J(`{"a":1,"b":"x"}`))).toBe(`got: {"a":1,"b":"x"}`);
});
it("top-level string field renders without json quotes", () => {
expect(render("value: {input.test}", J(`{"test":"hello"}`))).toBe("value: hello");
});
it("nested path lookup", () => {
expect(render("{input.a.b.c}", J(`{"a":{"b":{"c":42}}}`))).toBe("42");
});
it("array index lookup", () => {
expect(render("{input.tags[1]}", J(`{"tags":["x","y","z"]}`))).toBe("y");
});
it("missing property renders as empty string", () => {
expect(render("value: {input.missing}", J(`{"other":1}`))).toBe("value: ");
});
it("missing nested path renders as empty string", () => {
expect(render("[{input.a.b.c}]", J(`{"a":{"b":{}}}`))).toBe("[]");
});
it("array index out of range renders as empty string", () => {
expect(render("<{input.tags[9]}>", J(`{"tags":["only"]}`))).toBe("<>");
});
it("numeric and boolean and null leaves render as their string form", () => {
expect(render("{input.n}|{input.f}|{input.b}|{input.z}", J(`{"n":42,"f":1.5,"b":true,"z":null}`)))
.toBe("42|1.5|true|");
});
it("object and array leaves render as compact json", () => {
expect(render("{input.o}/{input.a}", J(`{"o":{"k":1},"a":[1,2]}`))).toBe(`{"k":1}/[1,2]`);
});
it("doubled braces render as literal single braces", () => {
expect(render("{{not a template}}", J(`{}`))).toBe("{not a template}");
});
it("doubled braces around a real placeholder", () => {
expect(render("{{{input.x}}}", J(`{"x":"hello"}`))).toBe("{hello}");
});
it("null input renders placeholders as empty string", () => {
expect(render("hi {input.name}", null)).toBe("hi ");
});
it("whole input placeholder with null input renders empty", () => {
expect(render("{input}", null)).toBe("");
});
it("unrecognised placeholder is left as literal text", () => {
expect(render("{node.foo}", J(`{"foo":1}`))).toBe("{node.foo}");
});
it("unclosed brace is left as literal text", () => {
expect(render("start {input.x and more", J(`{"x":1}`))).toBe("start {input.x and more");
});
it("bracketed string key lookup", () => {
expect(render(`{input["weird.key"]}`, J(`{"weird.key":"v"}`))).toBe("v");
});
});
- Step 2: Run tests to verify they fail
pnpm --dir frontend test --run templateEngine
Expected: FAIL — Cannot find module './templateEngine'.
- Step 3: Implement the engine
// frontend/src/expr/templateEngine.ts
//
// Client-side port of VolcanicCore.Core.TemplateEngine.Render. MUST stay
// byte-exact with the backend — the dialog preview shows the user what the
// executor will produce. Divergence is a bug. Backend tests live in
// tests/VolcanicCore.Tests/TemplateEngineTests.cs; add a matching case here
// when adding one there.
export function render(template: string, input: unknown): string {
if (!template || template.indexOf("{") < 0) return template;
let out = "";
let i = 0;
while (i < template.length) {
const c = template[i];
if (c === "{") {
if (template[i + 1] === "{") {
out += "{";
i += 2;
continue;
}
const end = template.indexOf("}", i + 1);
if (end < 0) {
out += template.slice(i);
break;
}
const expr = template.slice(i + 1, end);
const evald = tryEvaluate(expr, input);
if (evald === undefined) {
out += "{" + expr + "}";
} else {
out += evald;
}
i = end + 1;
continue;
}
if (c === "}" && template[i + 1] === "}") {
out += "}";
i += 2;
continue;
}
out += c;
i++;
}
return out;
}
// Returns undefined if the expression is not one we own (so the caller
// passes it through verbatim), or a string (possibly empty) if it is.
function tryEvaluate(expr: string, input: unknown): string | undefined {
expr = expr.trim();
if (!expr.startsWith("input")) return undefined;
let rest = expr.slice(5);
// input is null/undefined → any well-formed `input`-rooted expression renders empty
if (input === null || input === undefined) {
if (rest.length === 0) return "";
return isWellFormedTail(rest) ? "" : undefined;
}
let walker: unknown = input;
let have = true;
while (rest.length > 0) {
if (rest[0] === ".") {
let j = 1;
while (j < rest.length && isIdentChar(rest[j])) j++;
if (j === 1) return undefined;
const name = rest.slice(1, j);
if (have && isPlainObject(walker) && name in walker) walker = walker[name];
else have = false;
rest = rest.slice(j);
continue;
}
if (rest[0] === "[") {
const close = rest.indexOf("]");
if (close < 0) return undefined;
const inside = rest.slice(1, close).trim();
if (inside.length >= 2 && inside[0] === `"` && inside[inside.length - 1] === `"`) {
const key = inside.slice(1, -1);
if (have && isPlainObject(walker) && key in walker) walker = walker[key];
else have = false;
} else if (/^-?\d+$/.test(inside)) {
const idx = Number(inside);
if (have && Array.isArray(walker) && idx >= 0 && idx < walker.length) walker = walker[idx];
else have = false;
} else {
return undefined;
}
rest = rest.slice(close + 1);
continue;
}
return undefined;
}
return have ? renderValue(walker) : "";
}
function isWellFormedTail(tail: string): boolean {
let i = 0;
while (i < tail.length) {
if (tail[i] === ".") {
let j = i + 1;
while (j < tail.length && isIdentChar(tail[j])) j++;
if (j === i + 1) return false;
i = j;
} else if (tail[i] === "[") {
const close = tail.slice(i).indexOf("]");
if (close < 0) return false;
i += close + 1;
} else {
return false;
}
}
return true;
}
function renderValue(v: unknown): string {
if (v === null || v === undefined) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}
function isIdentChar(c: string): boolean {
return /[A-Za-z0-9_]/.test(c);
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
- Step 4: Run tests to verify they pass
pnpm --dir frontend test --run templateEngine
Expected: PASS — 17/17 tests green.
- Step 5: Commit
git add frontend/src/expr/templateEngine.ts frontend/src/expr/templateEngine.test.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(expr): client-side TemplateEngine port with parity tests"
Task 2: Backend schema coercion in Executor
After InterpolateStrings runs, walk the top-level properties of the node's schema; for each integer/number/boolean property whose value is now a JSON string, parse it to its typed form. Failure throws InvalidOperationException which the existing per-node try/catch surfaces as a normal node failure.
Files:
-
Modify:
src/VolcanicCore.Api/Engine/Executor.cs -
Test:
tests/VolcanicCore.Tests/ExecutorTests.cs -
Step 1: Write the failing tests
Append at the bottom of tests/VolcanicCore.Tests/ExecutorTests.cs, right before the closing } of the ExecutorTests class:
// --- coercion of templated primitives ---------------------------------
// A test node that requires status to be a JSON number, mirroring
// WebResponseNode's real check pattern. We assert on ctx.Config here.
private sealed class NumberFieldNode : IActionNode
{
public List<INodeContext> Calls { get; } = new();
public string Id { get; init; } = "test.numberfield";
public string DisplayName => Id;
public NodeCategory Category => NodeCategory.Terminal;
public int InputCount => 1;
public int OutputCount => 0;
public string? ConnectionProviderId => null;
public JsonSchema ConfigSchema => new(JsonDocument.Parse("""
{
"type": "object",
"properties": { "status": { "type": "integer" } }
}
""").RootElement);
public Task<IReadOnlyList<IReadOnlyList<Item>>> ExecuteAsync(
INodeContext ctx, IReadOnlyList<IReadOnlyList<Item>> _, CancellationToken __)
{
// Strict JsonValueKind.Number check — the coercion pass must produce
// a number here, not a string.
var s = ctx.Config.GetProperty("status");
if (s.ValueKind != JsonValueKind.Number)
throw new InvalidOperationException($"expected number, got {s.ValueKind}");
lock (Calls) Calls.Add(ctx);
return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(Array.Empty<IReadOnlyList<Item>>());
}
}
[Fact]
public async Task Template_into_integer_config_field_is_coerced_to_number()
{
var rec = new NumberFieldNode();
var (er, nr, exec, _) = Build(new TestTrigger(), rec);
var graph = new WorkflowGraph
{
Nodes = new[]
{
new WorkflowNode("t", "test.trigger", null, "{}"),
new WorkflowNode("s", "test.numberfield", null, """{"status":"{input.code}"}"""),
},
Edges = new[] { new WorkflowEdge("t", 0, "s", 0) },
};
var trig = new Core.Item(JsonSerializer.SerializeToElement(new { code = 201 }));
var eid = await exec.StartExecutionAsync("wf", graph, "t", "manual",
new[] { trig }, null, null, CancellationToken.None);
Assert.Equal("completed", er.Get(eid)!.Status);
var got = rec.Calls.Single().Config.GetProperty("status");
Assert.Equal(JsonValueKind.Number, got.ValueKind);
Assert.Equal(201, got.GetInt32());
}
[Fact]
public async Task Non_parseable_integer_config_fails_node_with_clear_message()
{
var rec = new NumberFieldNode();
var (er, nr, exec, _) = Build(new TestTrigger(), rec);
var graph = new WorkflowGraph
{
Nodes = new[]
{
new WorkflowNode("t", "test.trigger", null, "{}"),
new WorkflowNode("s", "test.numberfield", null, """{"status":"{input.code}"}"""),
},
Edges = new[] { new WorkflowEdge("t", 0, "s", 0) },
};
var trig = new Core.Item(JsonSerializer.SerializeToElement(new { code = "abc" }));
var eid = await exec.StartExecutionAsync("wf", graph, "t", "manual",
new[] { trig }, null, null, CancellationToken.None);
Assert.Equal("failed", er.Get(eid)!.Status);
var row = nr.ListByExecution(eid).Single(r => r.NodeId == "s");
Assert.Equal("failed", row.Status);
Assert.NotNull(row.Error);
Assert.Contains("expected integer", row.Error!);
Assert.Contains("status", row.Error!);
Assert.Empty(rec.Calls);
}
- Step 2: Run tests to verify they fail
dotnet test --filter "FullyQualifiedName~ExecutorTests.Template_into_integer_config_field_is_coerced_to_number|FullyQualifiedName~ExecutorTests.Non_parseable_integer_config_fails_node_with_clear_message"
Expected: FAIL — first test throws inside NumberFieldNode.ExecuteAsync because status is still a JsonValueKind.String after InterpolateStrings. The failure bubbles up as run.Status == "failed", not "completed". Second test also fails: row.Error won't contain "expected integer"; it'll say "expected number, got String" from the node's own guard.
- Step 3: Implement
CoerceToSchemain Executor
In src/VolcanicCore.Api/Engine/Executor.cs, change RunNodeAsync to call the new coercion helper right after InterpolateStrings:
private async Task<NodeResult> RunNodeAsync(
string executionId, string workflowId, WorkflowNode node, IActionNode action,
IReadOnlyList<IReadOnlyList<Item>> byPort, Guid? answerChannelId, CancellationToken ct)
{
var inputsJson = SerializeInputs(byPort);
using var cfgDoc = JsonDocument.Parse(node.ConfigJson);
var firstInput = byPort.Count > 0 && byPort[0].Count > 0 ? byPort[0][0].Json : (JsonElement?)null;
var templatedConfig = InterpolateStrings(cfgDoc.RootElement, firstInput);
// After templating, coerce string leaves that the schema declares as
// integer/number/boolean back to their typed JSON form. Nodes check
// ValueKind strictly, so a templated `"{input.code}" -> "201"` string
// would silently miss the ValueKind.Number check without this pass.
var coercedConfig = CoerceToSchema(templatedConfig, _nodes.Get(node.TypeId).ConfigSchema);
var ctx = new ExecutionContext(
executionId, workflowId, node.Id, coercedConfig,
answerChannelId, _lf.CreateLogger($"node.{node.Id}"),
node.ConnectionId, _instances);
var startedAt = DateTimeOffset.UtcNow;
try
{
var outputs = await action.ExecuteAsync(ctx, byPort, ct);
return new NodeResult(true, outputs, inputsJson, startedAt, DateTimeOffset.UtcNow, null);
}
catch (Exception ex)
{
return new NodeResult(false, null, inputsJson, startedAt, DateTimeOffset.UtcNow, ex.Message);
}
}
// Walks the top-level properties of the schema. For each property whose
// declared type is integer/number/boolean, if the config leaf is a JSON
// string (post-interpolation), parse it back to its typed form. Missing
// properties, non-primitive schema types, and string-typed schema fields
// are left untouched. Throws InvalidOperationException with a readable
// message on parse failure — the caller surfaces this as a normal node
// failure with the message stored on the node execution row.
private static JsonElement CoerceToSchema(JsonElement config, JsonSchema schema)
{
if (config.ValueKind != JsonValueKind.Object) return config;
if (schema.Root.ValueKind != JsonValueKind.Object) return config;
if (!schema.Root.TryGetProperty("properties", out var props)
|| props.ValueKind != JsonValueKind.Object) return config;
// Fast path: nothing to coerce → skip the rewrite.
var anyNeedsCoerce = false;
foreach (var prop in props.EnumerateObject())
{
var t = TypeOf(prop.Value);
if (t is null || t == "string") continue;
if (!config.TryGetProperty(prop.Name, out var leaf)) continue;
if (leaf.ValueKind == JsonValueKind.String) { anyNeedsCoerce = true; break; }
}
if (!anyNeedsCoerce) return config;
var buffer = new System.IO.MemoryStream();
using (var w = new Utf8JsonWriter(buffer))
{
w.WriteStartObject();
foreach (var p in config.EnumerateObject())
{
w.WritePropertyName(p.Name);
if (props.TryGetProperty(p.Name, out var propSchema)
&& p.Value.ValueKind == JsonValueKind.String)
{
var t = TypeOf(propSchema);
var s = p.Value.GetString() ?? "";
switch (t)
{
case "integer":
if (long.TryParse(s, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var i))
w.WriteNumberValue(i);
else
throw new InvalidOperationException(
$"config '{p.Name}': expected integer, got '{s}'");
break;
case "number":
if (double.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var d))
w.WriteNumberValue(d);
else
throw new InvalidOperationException(
$"config '{p.Name}': expected number, got '{s}'");
break;
case "boolean":
if (s == "true") w.WriteBooleanValue(true);
else if (s == "false") w.WriteBooleanValue(false);
else
throw new InvalidOperationException(
$"config '{p.Name}': expected boolean, got '{s}'");
break;
default:
p.Value.WriteTo(w);
break;
}
}
else
{
p.Value.WriteTo(w);
}
}
w.WriteEndObject();
}
return JsonDocument.Parse(buffer.ToArray()).RootElement.Clone();
}
private static string? TypeOf(JsonElement schema)
{
if (schema.ValueKind != JsonValueKind.Object) return null;
if (!schema.TryGetProperty("type", out var t) || t.ValueKind != JsonValueKind.String) return null;
return t.GetString();
}
- Step 4: Run tests to verify they pass
dotnet test --filter "FullyQualifiedName~ExecutorTests.Template_into_integer_config_field_is_coerced_to_number|FullyQualifiedName~ExecutorTests.Non_parseable_integer_config_fails_node_with_clear_message"
Expected: PASS — both cases green.
- Step 5: Run full backend suite to confirm no regressions
dotnet test
Expected: PASS — 79/79 (77 existing + 2 new).
- Step 6: Commit
git add src/VolcanicCore.Api/Engine/Executor.cs tests/VolcanicCore.Tests/ExecutorTests.cs
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(executor): coerce templated string leaves to schema-declared primitive types"
Task 3: Client-side coerceConfigForSave in graphAdapter
Mirror the backend coercion at save time: if a value is a plain literal (no {), and the schema says integer/number/boolean, save as native JSON type. If the value contains {, keep it as a string so the executor's runtime pass handles it.
Files:
-
Modify:
frontend/src/editor/graphAdapter.ts -
Test:
frontend/src/editor/graphAdapter.test.ts -
Step 1: Write the failing tests
Append at the bottom of frontend/src/editor/graphAdapter.test.ts:
import type { NodeTypeDto } from "@/api/types";
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);
});
});
Note: The existing toGraph(nodes, edges) two-arg signature needs a third nodeTypes argument. Update the existing test above these new cases to pass [] as the third arg, and update the callers in EditorPage.tsx in Task 9. For this task, also update the top of the test file:
// existing signature test: change `toGraph(nodes, edges)` -> `toGraph(nodes, edges, [])`
Find every existing toGraph(...) call in graphAdapter.test.ts and add , [] as the third argument.
- Step 2: Run tests to verify they fail
pnpm --dir frontend test --run graphAdapter
Expected: FAIL — toGraph doesn't accept a third argument; new assertions fail.
- Step 3: Implement
coerceConfigForSavein graphAdapter
Replace the current toGraph in frontend/src/editor/graphAdapter.ts:
import type { Edge, Node } from "@xyflow/react";
import type { GraphEdge, GraphNode, JsonSchemaObject, NodeTypeDto, WorkflowGraph } from "@/api/types";
export interface VcNodeData extends Record<string, unknown> {
typeId: string;
name?: string;
config: Record<string, unknown>;
connection_id?: string | null;
}
export function fromGraph(g: WorkflowGraph): { nodes: Node<VcNodeData>[]; edges: Edge[] } {
const nodes: Node<VcNodeData>[] = g.nodes.map((n) => ({
id: n.id,
type: "vc",
position: { x: n.position?.x ?? 0, y: n.position?.y ?? 0 },
data: {
typeId: n.type_id,
name: n.name,
config: n.config ?? {},
connection_id: n.connection_id ?? null,
},
}));
const edges: Edge[] = g.edges.map((e) => ({
id: `${e.from_node}:${e.from_port}->${e.to_node}:${e.to_port}`,
source: e.from_node,
sourceHandle: `o-${e.from_port}`,
target: e.to_node,
targetHandle: `i-${e.to_port}`,
}));
return { nodes, edges };
}
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,
};
if (n.data.name !== undefined) node.name = n.data.name;
if (n.data.connection_id != null) node.connection_id = n.data.connection_id;
return node;
});
const gEdges: GraphEdge[] = edges.map((e) => ({
from_node: e.source,
from_port: parseHandlePort(e.sourceHandle, "o"),
to_node: e.target,
to_port: parseHandlePort(e.targetHandle, "i"),
}));
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);
return m ? Number(m[1]) : 0;
}
- Step 4: Run tests to verify they pass
pnpm --dir frontend test --run graphAdapter
Expected: PASS — all cases green (existing + new coercion tests).
- Step 5: Commit
git add frontend/src/editor/graphAdapter.ts frontend/src/editor/graphAdapter.test.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): coerce literal number/boolean strings to typed JSON on save"
Task 4: JsonTree component
Expand/collapse tree that renders arbitrary JSON with clickable leaves. Each leaf click emits a JSON path (body.email, arr[0], ["weird.key"]). Consumer decides what to do with the path (in the dialog: prepend input., wrap in {...}, insert at caret).
Files:
-
Create:
frontend/src/expr/JsonTree.tsx -
Create:
frontend/src/expr/JsonTree.test.tsx -
Step 1: Write the failing tests
// frontend/src/expr/JsonTree.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { JsonTree } from "./JsonTree";
describe("JsonTree", () => {
it("renders top-level object keys collapsed by default", () => {
render(<JsonTree value={{ a: 1, b: { c: 2 } }} />);
expect(screen.getByText("a")).toBeInTheDocument();
expect(screen.getByText("b")).toBeInTheDocument();
expect(screen.queryByText("c")).not.toBeInTheDocument();
});
it("expands a nested object when the row is clicked", async () => {
render(<JsonTree value={{ a: { b: 2 } }} />);
await userEvent.click(screen.getByText("a"));
expect(screen.getByText("b")).toBeInTheDocument();
});
it("emits a dot-form path for a plain leaf on click", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ body: { email: "a@b" } }} onPickPath={onPick} />);
await userEvent.click(screen.getByText("body"));
await userEvent.click(screen.getByText("email"));
expect(onPick).toHaveBeenCalledWith("body.email");
});
it("emits bracket-form for a key containing dots", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ "weird.key": "v" }} onPickPath={onPick} />);
await userEvent.click(screen.getByText("weird.key"));
expect(onPick).toHaveBeenCalledWith(`["weird.key"]`);
});
it("emits bracket-form for a key containing dashes", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ "x-y": 1 }} onPickPath={onPick} />);
await userEvent.click(screen.getByText("x-y"));
expect(onPick).toHaveBeenCalledWith(`["x-y"]`);
});
it("emits numeric index form for array elements", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ tags: ["a", "b", "c"] }} onPickPath={onPick} />);
await userEvent.click(screen.getByText("tags"));
await userEvent.click(screen.getByText("b"));
expect(onPick).toHaveBeenCalledWith("tags[1]");
});
it("does not fire onPickPath when pickable is false", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ a: 1 }} onPickPath={onPick} pickable={false} />);
await userEvent.click(screen.getByText("a"));
expect(onPick).not.toHaveBeenCalled();
});
it("renders empty state for empty object", () => {
render(<JsonTree value={{}} />);
expect(screen.getByText(/no data/i)).toBeInTheDocument();
});
});
- Step 2: Run tests to verify they fail
pnpm --dir frontend test --run JsonTree
Expected: FAIL — Cannot find module './JsonTree'.
- Step 3: Implement
JsonTree
// frontend/src/expr/JsonTree.tsx
import { useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
interface Props {
value: unknown;
onPickPath?: (path: string) => void;
pickable?: boolean; // default true; false renders leaves non-clickable & dimmed
}
export function JsonTree({ value, onPickPath, pickable = true }: Props) {
if (isEmptyRoot(value)) {
return <div className="text-xs text-slate-500 italic">No data.</div>;
}
return <Node label="" value={value} path="" depth={0} pickable={pickable} onPick={onPickPath} />;
}
function isEmptyRoot(v: unknown): boolean {
if (v === null || v === undefined) return true;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === "object") return Object.keys(v as object).length === 0;
return false;
}
interface NodeProps {
label: string;
value: unknown;
path: string;
depth: number;
pickable: boolean;
onPick?: (path: string) => void;
}
function Node({ label, value, path, depth, pickable, onPick }: NodeProps) {
// Root — render children as top-level rows without a wrapping expando.
if (depth === 0) {
return (
<ul className="text-xs font-mono">
{renderChildren(value, path, depth, pickable, onPick)}
</ul>
);
}
const branch = isBranch(value);
const [open, setOpen] = useState(false);
const clickable = pickable && (!branch || true);
const pickHandler = () => {
if (!pickable) return;
if (branch) setOpen((o) => !o);
else onPick?.(path);
};
return (
<li>
<button
type="button"
onClick={pickHandler}
className={
"flex items-center gap-1 w-full text-left py-0.5 px-1 rounded " +
(pickable ? "hover:bg-slate-800/80 " : "opacity-50 cursor-default ") +
(clickable ? "" : "cursor-default")
}
>
{branch ? (open ? <ChevronDown size={12} /> : <ChevronRight size={12} />) : <span className="w-3" />}
<span className="text-sky-300">{label}</span>
{!branch && (
<>
<span className="text-slate-500">:</span>
<span className="text-slate-300 truncate">{leafPreview(value)}</span>
</>
)}
{branch && (
<span className="text-slate-500 ml-1">{summarize(value)}</span>
)}
</button>
{branch && open && (
<ul className="ml-4 border-l border-slate-800 pl-2">
{renderChildren(value, path, depth, pickable, onPick)}
</ul>
)}
</li>
);
}
function renderChildren(
value: unknown, parentPath: string, depth: number,
pickable: boolean, onPick?: (path: string) => void,
) {
if (Array.isArray(value)) {
return value.map((v, i) => {
const childPath = `${parentPath}[${i}]`;
return (
<Node
key={i} label={String(i)} value={v} path={childPath}
depth={depth + 1} pickable={pickable} onPick={onPick}
/>
);
});
}
if (value !== null && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).map(([k, v]) => {
const seg = needsBracketForm(k) ? `["${k.replace(/"/g, `\\"`)}"]` : `.${k}`;
const childPath = parentPath === "" ? (needsBracketForm(k) ? seg : k) : `${parentPath}${seg}`;
return (
<Node
key={k} label={k} value={v} path={childPath}
depth={depth + 1} pickable={pickable} onPick={onPick}
/>
);
});
}
return null;
}
function isBranch(v: unknown): boolean {
return v !== null && typeof v === "object";
}
function leafPreview(v: unknown): string {
if (v === null) return "null";
if (typeof v === "string") return `"${truncate(v, 40)}"`;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n) + "…";
}
function summarize(v: unknown): string {
if (Array.isArray(v)) return `[${v.length}]`;
if (v !== null && typeof v === "object") {
const n = Object.keys(v as object).length;
return `{${n}}`;
}
return "";
}
function needsBracketForm(key: string): boolean {
return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
}
- Step 4: Run tests to verify they pass
pnpm --dir frontend test --run JsonTree
Expected: PASS — all cases green.
- Step 5: Commit
git add frontend/src/expr/JsonTree.tsx frontend/src/expr/JsonTree.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(expr): JsonTree with click-to-emit-path leaves"
Task 5: ExpressionField component
Text field with:
- Monospace textarea + a mirrored overlay
<div>that colors{...}runs in a single accent color. - Live preview line beneath that renders the template against a
previewInput(the first item on port 0). - Schema-type validity hint (
⚠ expected integer) for literals on non-string schema fields. - Imperative
insertAtCaret(text)exposed viaforwardRef. - Single-line variant (auto-grow up to 5 rows, Enter blurs) vs code variant (10 rows).
Files:
-
Create:
frontend/src/expr/ExpressionField.tsx -
Create:
frontend/src/expr/ExpressionField.test.tsx -
Step 1: Write the failing tests
// frontend/src/expr/ExpressionField.test.tsx
import { describe, it, expect, vi } from "vitest";
import { useRef, useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ExpressionField, type ExpressionFieldHandle } from "./ExpressionField";
describe("ExpressionField", () => {
it("emits the raw text on change", async () => {
const onChange = vi.fn();
render(<ExpressionField value="" onChange={onChange} />);
await userEvent.type(screen.getByRole("textbox"), "hi");
expect(onChange).toHaveBeenLastCalledWith("hi");
});
it("shows the live preview against previewInput", () => {
render(
<ExpressionField
value="hi {input.name}!"
onChange={() => {}}
previewInput={{ name: "alice" }}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice!");
});
it("omits the preview line when previewInput is undefined", () => {
render(<ExpressionField value="hi {input.name}!" onChange={() => {}} />);
expect(screen.queryByTestId("expr-preview")).not.toBeInTheDocument();
});
it("shows a schema-validity warning for a bad integer literal", () => {
render(
<ExpressionField
value="abc"
onChange={() => {}}
schemaType="integer"
previewInput={{}}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent(/expected integer/i);
});
it("does not warn on integer literals that are valid", () => {
render(
<ExpressionField
value="200"
onChange={() => {}}
schemaType="integer"
previewInput={{}}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent("200");
});
it("does not warn on integer fields when the value contains a template", () => {
render(
<ExpressionField
value="{input.code}"
onChange={() => {}}
schemaType="integer"
previewInput={{ code: 200 }}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent("200");
});
it("insertAtCaret splices text at the current caret position", async () => {
function Harness() {
const ref = useRef<ExpressionFieldHandle>(null);
const [v, setV] = useState("hello ");
return (
<>
<ExpressionField ref={ref} value={v} onChange={setV} />
<button onClick={() => ref.current?.insertAtCaret("{input.name}")}>insert</button>
</>
);
}
render(<Harness />);
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
await userEvent.click(screen.getByRole("button", { name: /insert/i }));
expect(ta.value).toBe("hello {input.name}");
});
it("single-line variant blurs on Enter instead of inserting a newline", async () => {
const onChange = vi.fn();
render(<ExpressionField value="" onChange={onChange} singleLine />);
const ta = screen.getByRole("textbox");
await userEvent.type(ta, "hi{Enter}");
// The Enter keydown was captured — no newline reached the value.
expect(onChange).toHaveBeenLastCalledWith("hi");
expect(document.activeElement).not.toBe(ta);
});
});
- Step 2: Run tests to verify they fail
pnpm --dir frontend test --run ExpressionField
Expected: FAIL — Cannot find module './ExpressionField'.
- Step 3: Implement
ExpressionField
// frontend/src/expr/ExpressionField.tsx
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { render as renderTemplate } from "./templateEngine";
export interface ExpressionFieldHandle {
insertAtCaret(text: string): void;
focus(): void;
}
export type ExpressionSchemaType = "string" | "integer" | "number" | "boolean";
interface Props {
value: string;
onChange: (v: string) => void;
onFocus?: () => void;
previewInput?: unknown;
schemaType?: ExpressionSchemaType;
singleLine?: boolean;
codeBlock?: boolean; // format:"code" fields — 10-row textarea, no Enter-blur
placeholder?: string;
id?: string;
}
export const ExpressionField = forwardRef<ExpressionFieldHandle, Props>(function ExpressionField(
{ value, onChange, onFocus, previewInput, schemaType = "string", singleLine, codeBlock, placeholder, id },
ref,
) {
const taRef = useRef<HTMLTextAreaElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const [overlayScroll, setOverlayScroll] = useState({ top: 0, left: 0 });
useImperativeHandle(ref, () => ({
focus() {
taRef.current?.focus();
},
insertAtCaret(text: string) {
const ta = taRef.current;
if (!ta) return;
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? ta.value.length;
const next = ta.value.slice(0, start) + text + ta.value.slice(end);
onChange(next);
// Restore caret position after React re-renders the value.
requestAnimationFrame(() => {
ta.focus();
const caret = start + text.length;
ta.setSelectionRange(caret, caret);
});
},
}), [onChange]);
const rows = codeBlock ? 10 : singleLine ? 1 : 3;
const tokens = useMemo(() => tokenize(value), [value]);
const preview = useMemo(() => {
if (previewInput === undefined) return null;
if (schemaType !== "string" && value !== "" && value.indexOf("{") < 0) {
if (!isValidLiteral(value, schemaType)) {
return { kind: "warn" as const, text: `⚠ expected ${schemaType}` };
}
}
const rendered = renderTemplate(value, previewInput);
const truncated = rendered.length > 200 ? rendered.slice(0, 200) + "…" : rendered;
return { kind: "ok" as const, text: truncated };
}, [value, previewInput, schemaType]);
function syncScroll() {
const ta = taRef.current;
if (!ta) return;
setOverlayScroll({ top: ta.scrollTop, left: ta.scrollLeft });
}
useEffect(() => {
if (overlayRef.current) {
overlayRef.current.scrollTop = overlayScroll.top;
overlayRef.current.scrollLeft = overlayScroll.left;
}
}, [overlayScroll]);
return (
<div className="w-full">
<div className="relative">
<div
ref={overlayRef}
aria-hidden
className={
"absolute inset-0 pointer-events-none whitespace-pre-wrap break-words font-mono text-sm " +
"px-3 py-2 leading-5 text-slate-100 overflow-hidden"
}
>
{tokens.map((t, i) =>
t.kind === "expr"
? <span key={i} className="text-amber-300">{t.text}</span>
: <span key={i}>{t.text}</span>,
)}
{/* Trailing newline safeguard: keeps overlay height equal to the textarea when
the value ends in a newline. */}
{value.endsWith("\n") && <span> </span>}
</div>
<textarea
ref={taRef}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onScroll={syncScroll}
onKeyDown={(e) => {
if (singleLine && e.key === "Enter") {
e.preventDefault();
(e.target as HTMLTextAreaElement).blur();
}
}}
rows={rows}
placeholder={placeholder}
spellCheck={false}
className={
"relative w-full rounded bg-slate-950 border border-slate-800 " +
"px-3 py-2 outline-none focus:border-brand-500 " +
"font-mono text-sm leading-5 resize-none " +
"text-transparent caret-slate-100 selection:text-slate-100 selection:bg-brand-600/40"
}
style={{ WebkitTextFillColor: "transparent" }}
/>
</div>
{preview && (
<div
data-testid="expr-preview"
className={
"mt-1 text-xs font-mono truncate " +
(preview.kind === "warn" ? "text-amber-400" : "text-slate-500")
}
>
{preview.kind === "warn" ? preview.text : `→ ${preview.text}`}
</div>
)}
</div>
);
});
type Token = { kind: "text" | "expr"; text: string };
// Tokenizer for the overlay. Matches the executor's parsing shape: `{{` and
// `}}` are literal braces (still rendered as `{` / `}` in the "text" class),
// unclosed `{` is rendered as text, and `{...}` runs are the "expr" class.
function tokenize(s: string): Token[] {
const out: Token[] = [];
let i = 0;
let buf = "";
const flush = () => { if (buf) { out.push({ kind: "text", text: buf }); buf = ""; } };
while (i < s.length) {
const c = s[i];
if (c === "{") {
if (s[i + 1] === "{") { buf += "{"; i += 2; continue; }
const end = s.indexOf("}", i + 1);
if (end < 0) { buf += s.slice(i); break; }
flush();
out.push({ kind: "expr", text: s.slice(i, end + 1) });
i = end + 1;
continue;
}
if (c === "}" && s[i + 1] === "}") { buf += "}"; i += 2; continue; }
buf += c;
i++;
}
flush();
return out;
}
function isValidLiteral(v: string, t: ExpressionSchemaType): boolean {
if (t === "integer") {
if (!/^-?\d+$/.test(v)) return false;
return Number.isInteger(Number(v));
}
if (t === "number") {
const n = Number(v);
return Number.isFinite(n);
}
if (t === "boolean") return v === "true" || v === "false";
return true;
}
- Step 4: Run tests to verify they pass
pnpm --dir frontend test --run ExpressionField
Expected: PASS — all cases green.
- Step 5: Commit
git add frontend/src/expr/ExpressionField.tsx frontend/src/expr/ExpressionField.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(expr): ExpressionField with token highlight overlay and live preview"
Task 6: Rewrite SchemaForm to use ExpressionField
Every schema leaf becomes an ExpressionField. Non-string schema types get schemaType hinted through. The form still stores the value as a string in Record<string, unknown>; the client coerceConfigForSave (Task 3) turns literal number/boolean strings back into typed JSON at save time.
Files:
-
Modify:
frontend/src/forms/SchemaForm.tsx -
Modify:
frontend/src/forms/SchemaForm.test.tsx -
Step 1: Write the failing tests
Replace frontend/src/forms/SchemaForm.test.tsx with:
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { SchemaForm } from "./SchemaForm";
import type { JsonSchemaObject } from "@/api/types";
describe("SchemaForm — ExpressionField-backed", () => {
it("renders string fields from schema.properties with titles as labels", () => {
const schema: JsonSchemaObject = {
type: "object",
required: ["url"],
properties: {
url: { type: "string", title: "Server URL" },
user: { type: "string", title: "User" },
},
};
render(<SchemaForm schema={schema} value={{ url: "nats://x" }} onChange={() => {}} />);
expect(screen.getByLabelText(/server url/i)).toHaveValue("nats://x");
expect(screen.getByLabelText(/user/i)).toHaveValue("");
});
it("emits onChange with the merged object on input", async () => {
const schema: JsonSchemaObject = {
type: "object",
properties: { url: { type: "string" } },
};
const onChange = vi.fn();
function Wrapper() {
const [v, setV] = useState<Record<string, unknown>>({});
return (
<SchemaForm
schema={schema}
value={v}
onChange={(next) => { onChange(next); setV(next); }}
/>
);
}
render(<Wrapper />);
await userEvent.type(screen.getByLabelText(/url/i), "abc");
const last = onChange.mock.calls.at(-1)![0];
expect(last).toEqual({ url: "abc" });
});
it("renders integer fields as expression fields storing string values", async () => {
const schema: JsonSchemaObject = {
type: "object",
properties: { status: { type: "integer", title: "Status" } },
};
const onChange = vi.fn();
function Wrapper() {
const [v, setV] = useState<Record<string, unknown>>({});
return (
<SchemaForm schema={schema} value={v} onChange={(next) => { onChange(next); setV(next); }} />
);
}
render(<Wrapper />);
await userEvent.type(screen.getByLabelText(/status/i), "200");
const last = onChange.mock.calls.at(-1)![0];
// SchemaForm stores strings; the graphAdapter coerces to number at save.
expect(last).toEqual({ status: "200" });
});
it("shows a live preview when previewInput is supplied", () => {
const schema: JsonSchemaObject = {
type: "object",
properties: { subject: { type: "string", title: "Subject" } },
};
render(
<SchemaForm
schema={schema}
value={{ subject: "hi {input.name}" }}
onChange={() => {}}
previewInput={{ name: "alice" }}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice");
});
it("marks required fields with an asterisk", () => {
const schema: JsonSchemaObject = {
type: "object",
required: ["url"],
properties: { url: { type: "string", title: "URL" } },
};
render(<SchemaForm schema={schema} value={{}} onChange={() => {}} />);
expect(screen.getByText("URL")).toBeInTheDocument();
expect(screen.getByText("*")).toBeInTheDocument();
});
});
- Step 2: Run tests to verify they fail
pnpm --dir frontend test --run SchemaForm
Expected: FAIL — old renderer still emits typed inputs; new assertions fail.
- Step 3: Rewrite
SchemaForm
Replace frontend/src/forms/SchemaForm.tsx with:
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { JsonSchemaObject } from "@/api/types";
import {
ExpressionField,
type ExpressionFieldHandle,
type ExpressionSchemaType,
} from "@/expr/ExpressionField";
interface Props {
schema: JsonSchemaObject;
value: Record<string, unknown>;
onChange: (v: Record<string, unknown>) => void;
previewInput?: unknown;
onFieldFocus?: (handle: ExpressionFieldHandle) => void;
}
export function SchemaForm({ schema, value, onChange, previewInput, onFieldFocus }: Props) {
const required = new Set(schema.required ?? []);
const props = schema.properties ?? {};
function set(name: string, v: string) {
onChange({ ...value, [name]: v });
}
return (
<div className="space-y-3">
{Object.entries(props).map(([name, prop]) => {
const label = prop.title ?? name;
const id = `sf-${name}`;
const isRequired = required.has(name);
const raw = toEditorString(value[name]);
const st = mapSchemaType(prop.type);
return (
<label key={name} htmlFor={id} className="block text-sm space-y-1">
<span className="text-slate-300">
{label}
{isRequired && <span className="text-red-400 ml-0.5">*</span>}
</span>
<SchemaExpressionField
id={id}
value={raw}
onChange={(v) => set(name, v)}
onFieldFocus={onFieldFocus}
previewInput={previewInput}
schemaType={st}
codeBlock={prop.format === "code"}
singleLine={prop.format !== "code"}
/>
</label>
);
})}
</div>
);
}
const SchemaExpressionField = forwardRef<ExpressionFieldHandle, {
id: string; value: string; onChange: (v: string) => void;
previewInput?: unknown; schemaType?: ExpressionSchemaType;
singleLine?: boolean; codeBlock?: boolean;
onFieldFocus?: (handle: ExpressionFieldHandle) => void;
}>(function SchemaExpressionField(props, _outer) {
const ref = useRef<ExpressionFieldHandle | null>(null);
useImperativeHandle(_outer, () => ref.current ?? { insertAtCaret: () => {}, focus: () => {} });
return (
<ExpressionField
ref={ref}
id={props.id}
value={props.value}
onChange={props.onChange}
previewInput={props.previewInput}
schemaType={props.schemaType}
singleLine={props.singleLine}
codeBlock={props.codeBlock}
onFocus={() => { if (ref.current) props.onFieldFocus?.(ref.current); }}
/>
);
});
function mapSchemaType(t?: string): ExpressionSchemaType {
if (t === "integer") return "integer";
if (t === "number") return "number";
if (t === "boolean") return "boolean";
return "string";
}
// Value stored in state may be a number/bool if it round-tripped from the
// backend already coerced. Render it as its literal string form so the user
// can edit it as an expression.
function toEditorString(v: unknown): string {
if (v === undefined || v === null) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}
- Step 4: Run tests to verify they pass
pnpm --dir frontend test --run SchemaForm
Expected: PASS — all cases green.
- Step 5: Run the full frontend suite so we catch any consumer that broke
pnpm --dir frontend test
Expected: PASS — no regressions in unrelated tests. If EditorPage or ConnectionsPage tests now break because they assumed a type="number" input, we address them in Task 9 (EditorPage) or here for ConnectionsPage. Fix those tests inline to expect the new expression-field shape.
- Step 6: Commit
git add frontend/src/forms/SchemaForm.tsx frontend/src/forms/SchemaForm.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(forms): SchemaForm renders every leaf as ExpressionField"
Task 7: nodeIoLookup helper
Pure function: given an ExecutionDetail and a node id, return the raw inputs / outputs for that node, or null if the node didn't appear in the execution.
Files:
-
Create:
frontend/src/editor/nodeIoLookup.ts -
Create:
frontend/src/editor/nodeIoLookup.test.ts -
Step 1: Write the failing tests
// frontend/src/editor/nodeIoLookup.test.ts
import { describe, it, expect } from "vitest";
import { findLatestIo } from "./nodeIoLookup";
import type { ExecutionDetail } from "@/api/types";
const detail = (nodes: Array<{ nodeId: string; inputs: unknown; outputs: unknown }>): ExecutionDetail => ({
id: "e1", workflowId: "wf", trigger: "manual", status: "completed",
startedAt: "", endedAt: null, error: null,
nodes: nodes.map(n => ({
nodeId: n.nodeId, status: "completed",
startedAt: "", endedAt: "", error: null,
inputs: n.inputs, outputs: n.outputs,
})),
});
describe("findLatestIo", () => {
it("returns inputs and outputs for a matching node id", () => {
const d = detail([
{ nodeId: "n1", inputs: [[{ json: { a: 1 } }]], outputs: [[{ json: { b: 2 } }]] },
{ nodeId: "n2", inputs: [[{ json: { c: 3 } }]], outputs: [[{ json: { d: 4 } }]] },
]);
const io = findLatestIo(d, "n2");
expect(io).toEqual({
inputs: [[{ json: { c: 3 } }]],
outputs: [[{ json: { d: 4 } }]],
});
});
it("returns null when the node did not appear in the execution", () => {
const d = detail([{ nodeId: "n1", inputs: [], outputs: [] }]);
expect(findLatestIo(d, "missing")).toBeNull();
});
it("returns null when the execution is undefined", () => {
expect(findLatestIo(undefined, "n1")).toBeNull();
});
it("returns inputs and outputs verbatim (does not clone or reshape)", () => {
const inputs = [[{ json: { a: 1 } }]];
const outputs = [[{ json: { b: 2 } }]];
const d = detail([{ nodeId: "n1", inputs, outputs }]);
const io = findLatestIo(d, "n1")!;
expect(io.inputs).toBe(inputs);
expect(io.outputs).toBe(outputs);
});
});
- Step 2: Run tests to verify they fail
pnpm --dir frontend test --run nodeIoLookup
Expected: FAIL — Cannot find module './nodeIoLookup'.
- Step 3: Implement
// frontend/src/editor/nodeIoLookup.ts
import type { ExecutionDetail } from "@/api/types";
export interface NodeIo {
inputs: unknown;
outputs: unknown;
}
export function findLatestIo(execution: ExecutionDetail | undefined, nodeId: string): NodeIo | null {
if (!execution) return null;
const row = execution.nodes.find((n) => n.nodeId === nodeId);
if (!row) return null;
return { inputs: row.inputs, outputs: row.outputs };
}
- Step 4: Run tests to verify they pass
pnpm --dir frontend test --run nodeIoLookup
Expected: PASS — 4/4 tests green.
- Step 5: Commit
git add frontend/src/editor/nodeIoLookup.ts frontend/src/editor/nodeIoLookup.test.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): findLatestIo helper for pulling a node's row out of an ExecutionDetail"
Task 8: NodeEditorDialog component
Three-column modal shell:
- Input (left ~25%) —
JsonTreeof the port-0 inputs; leaves ininputs[0][0].jsonare clickable and insert{input.<path>}at the last-focused parameter caret. - Parameters (middle) — node label, optional connection dropdown,
SchemaForm, optionalWebhookUrlHint,Runbutton,Deletebutton,Donebutton. - Output (right ~25%) —
JsonTreeof the port-0 outputs; non-clickable.
ESC and backdrop-click close. Focus trap on open. Changes flow into parent state via patchNode; parent still owns the toolbar Save.
Files:
-
Create:
frontend/src/editor/NodeEditorDialog.tsx -
Create:
frontend/src/editor/NodeEditorDialog.test.tsx -
Step 1: Write the failing tests
// frontend/src/editor/NodeEditorDialog.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { type ComponentProps, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "@/testing/msw";
import type { Node } from "@xyflow/react";
import type { VcNodeData } from "./graphAdapter";
import { NodeEditorDialog } from "./NodeEditorDialog";
import type { ConnectionDto, ExecutionDetail, ExecutionSummary, NodeTypeDto } from "@/api/types";
const NODE_TYPES: NodeTypeDto[] = [
{
id: "http.webhook", displayName: "Webhook", category: "Trigger",
inputCount: 0, outputCount: 1, connectionProviderId: null,
configSchema: { type: "object", properties: { path: { type: "string", title: "URL path" } } },
},
{
id: "http.response", displayName: "Web Response", category: "Terminal",
inputCount: 1, outputCount: 0, connectionProviderId: null,
configSchema: {
type: "object",
properties: {
status: { type: "integer", title: "HTTP status", default: 200 },
body: { type: "string", title: "Body" },
},
},
},
];
function defaultNode(): Node<VcNodeData> {
return {
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "http.response", name: "Reply", config: {}, connection_id: null },
};
}
// Non-stateful render — for tests that don't mutate node state.
// The mocks it exposes (onPatch, onDelete, onClose) are the props actually passed.
function renderDialog(overrides: Partial<ComponentProps<typeof NodeEditorDialog>> = {}) {
const props: ComponentProps<typeof NodeEditorDialog> = {
node: defaultNode(),
nodeTypes: NODE_TYPES,
connections: [],
workflowId: "wf1",
workflowActive: false,
triggerNodeIds: ["t1"],
onPatch: vi.fn(),
onDelete: vi.fn(),
onClose: vi.fn(),
...overrides,
};
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const utils = render(
<QueryClientProvider client={qc}>
<NodeEditorDialog {...props} />
</QueryClientProvider>,
);
return { ...utils, props };
}
// Stateful render — for tests that type into controlled fields. The wrapper
// applies onPatch to local state so React doesn't fight the DOM value.
function renderControlledDialog(overrides: Partial<ComponentProps<typeof NodeEditorDialog>> = {}) {
function Harness() {
const [n, setN] = useState<Node<VcNodeData>>(overrides.node ?? defaultNode());
return (
<NodeEditorDialog
node={n}
nodeTypes={overrides.nodeTypes ?? NODE_TYPES}
connections={overrides.connections ?? []}
workflowId={overrides.workflowId ?? "wf1"}
workflowActive={overrides.workflowActive ?? false}
triggerNodeIds={overrides.triggerNodeIds ?? ["t1"]}
onPatch={(patch) => setN((prev) => ({ ...prev, data: { ...prev.data, ...patch } }))}
onDelete={overrides.onDelete ?? (() => {})}
onClose={overrides.onClose ?? (() => {})}
/>
);
}
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<Harness />
</QueryClientProvider>,
);
}
beforeEach(() => {
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([] as ExecutionSummary[])),
);
});
describe("NodeEditorDialog", () => {
it("renders the three column headers", () => {
renderDialog();
expect(screen.getByRole("heading", { name: /input/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /parameters/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /output/i })).toBeInTheDocument();
});
it("ESC calls onClose", async () => {
const { props } = renderDialog();
await userEvent.keyboard("{Escape}");
expect(props.onClose).toHaveBeenCalled();
});
it("clicking the backdrop calls onClose", async () => {
const { props } = renderDialog();
await userEvent.click(screen.getByTestId("dialog-backdrop"));
expect(props.onClose).toHaveBeenCalled();
});
it("editing the node label flows into node.data.name", async () => {
renderControlledDialog();
const label = screen.getByLabelText(/node label/i) as HTMLTextAreaElement;
await userEvent.clear(label);
await userEvent.type(label, "Custom");
expect(label.value).toBe("Custom");
});
it("editing a parameter flows into node.data.config", async () => {
renderControlledDialog();
const body = screen.getByLabelText(/^body/i) as HTMLTextAreaElement;
await userEvent.type(body, "hi");
expect(body.value).toBe("hi");
});
it("Run button is disabled when there are no trigger nodes", () => {
renderDialog({ triggerNodeIds: [] });
expect(screen.getByRole("button", { name: /^run/i })).toBeDisabled();
});
it("Run button POSTs an execution and shows the fresh row on success", async () => {
server.use(
http.post("/api/workflows/:id/executions", () =>
HttpResponse.json({ executionId: "e-new" }, { status: 202 })),
http.get("/api/executions/e-new", () =>
HttpResponse.json({
id: "e-new", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null,
nodes: [
{ nodeId: "t1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [], outputs: [[{ json: { code: 200 } }]] },
{ nodeId: "n1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [[{ json: { code: 200 } }]], outputs: [] },
],
} satisfies ExecutionDetail)),
);
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /^run/i }));
await waitFor(() => expect(screen.getByText("code")).toBeInTheDocument());
});
it("Webhook URL block shows for http.webhook", () => {
const node: Node<VcNodeData> = {
id: "w1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "http.webhook", name: "Webhook", config: { path: "hook" }, connection_id: null },
};
renderDialog({ node });
expect(screen.getByText(/webhook url/i)).toBeInTheDocument();
});
it("Webhook URL block is hidden for non-webhook types", () => {
renderDialog();
expect(screen.queryByText(/webhook url/i)).not.toBeInTheDocument();
});
it("connection dropdown shows only when node type has connectionProviderId", () => {
const withConn: NodeTypeDto = {
...NODE_TYPES[1],
id: "nats.publish", connectionProviderId: "nats", displayName: "NATS Publish",
};
const node: Node<VcNodeData> = {
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "nats.publish", name: "Pub", config: {}, connection_id: null },
};
const connections: ConnectionDto[] = [{ id: "c1", providerId: "nats", name: "prod", config: {} }];
renderDialog({ node, nodeTypes: [...NODE_TYPES, withConn], connections });
expect(screen.getByLabelText(/connection/i)).toBeInTheDocument();
});
it("Delete button calls onDelete then onClose", async () => {
const { props } = renderDialog();
await userEvent.click(screen.getByRole("button", { name: /delete node/i }));
expect(props.onDelete).toHaveBeenCalled();
expect(props.onClose).toHaveBeenCalled();
});
it("clicking a leaf in the Input tree inserts {input.<path>} at the focused parameter", async () => {
// Seed an execution so the input tree has a leaf.
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([
{ id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null } satisfies ExecutionSummary,
])),
http.get("/api/executions/e1", () =>
HttpResponse.json({
id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null,
nodes: [
{ nodeId: "n1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [[{ json: { email: "a@b" } }]], outputs: [] },
],
} satisfies ExecutionDetail)),
);
// Controlled — the body textarea is a controlled input and would fight
// the DOM value on each render without a real state owner.
renderControlledDialog();
const body = await screen.findByLabelText(/^body/i) as HTMLTextAreaElement;
body.focus();
await userEvent.click(await screen.findByText("email"));
expect(body.value).toBe("{input.email}");
});
});
- Step 2: Run tests to verify they fail
pnpm --dir frontend test --run NodeEditorDialog
Expected: FAIL — Cannot find module './NodeEditorDialog'.
- Step 3: Implement
NodeEditorDialog
// frontend/src/editor/NodeEditorDialog.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { X, Play, Trash2, Copy, Check } from "lucide-react";
import type { Node } from "@xyflow/react";
import type { ConnectionDto, ExecutionDetail, NodeTypeDto } from "@/api/types";
import { qk, useApiMutation, useExecution, useExecutions, useInvalidate } from "@/api/hooks";
import { apiFetch } from "@/api/client";
import { SchemaForm } from "@/forms/SchemaForm";
import { JsonTree } from "@/expr/JsonTree";
import { findLatestIo } from "./nodeIoLookup";
import type { VcNodeData } from "./graphAdapter";
import type { ExpressionFieldHandle } from "@/expr/ExpressionField";
interface Props {
node: Node<VcNodeData>;
nodeTypes: NodeTypeDto[];
connections: ConnectionDto[];
workflowId: string;
workflowActive: boolean;
triggerNodeIds: string[];
onPatch: (patch: Partial<VcNodeData>) => void;
onDelete: () => void;
onClose: () => void;
}
export function NodeEditorDialog({
node, nodeTypes, connections, workflowId, workflowActive,
triggerNodeIds, onPatch, onDelete, onClose,
}: Props) {
const nt = nodeTypes.find((t) => t.id === node.data.typeId);
const focusedFieldRef = useRef<ExpressionFieldHandle | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.preventDefault(); onClose(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const executions = useExecutions(workflowId);
const [pinnedExecutionId, setPinnedExecutionId] = useState<string | null>(null);
const eid = pinnedExecutionId ?? executions.data?.[0]?.id;
const execution = useExecution(eid);
const io = useMemo(
() => findLatestIo(execution.data, node.id),
[execution.data, node.id],
);
const inputsArray = normalizePorts(io?.inputs);
const outputsArray = normalizePorts(io?.outputs);
const previewInput = inputsArray[0]?.[0]?.json;
const invalidate = useInvalidate();
const qc = useQueryClient();
const runMutation = useApiMutation(
() => {
const startFrom = triggerNodeIds[0];
if (!startFrom) throw new Error("no trigger");
return apiFetch<{ executionId: string }>(
`/api/workflows/${workflowId}/executions`,
{ method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } },
);
},
{
onSuccess: async (res) => {
await invalidate(qk.executions(workflowId));
await qc.invalidateQueries({ queryKey: qk.execution(res.executionId) });
setPinnedExecutionId(res.executionId);
},
},
);
const canRun = triggerNodeIds.length > 0 && !runMutation.isPending;
const registerFocus = useCallback((h: ExpressionFieldHandle) => {
focusedFieldRef.current = h;
}, []);
const insertPath = useCallback((path: string) => {
focusedFieldRef.current?.insertAtCaret(`{input.${path}}`);
}, []);
const filteredConnections = connections.filter((c) => c.providerId === nt?.connectionProviderId);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
data-testid="dialog-backdrop"
className="absolute inset-0 bg-black/60"
onClick={onClose}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`Edit ${nt?.displayName ?? node.data.typeId}`}
className="relative bg-slate-900 border border-slate-800 rounded-lg shadow-2xl w-[95vw] max-w-[1280px] h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-slate-800 px-4 py-3">
<div>
<div className="text-xs uppercase text-slate-500">{nt?.category ?? ""}</div>
<div className="text-lg font-medium">{nt?.displayName ?? node.data.typeId}</div>
</div>
<button
onClick={onClose}
aria-label="Close"
className="ml-auto text-slate-400 hover:text-slate-200"
>
<X size={18} />
</button>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[minmax(240px,25%)_1fr_minmax(240px,25%)] divide-x divide-slate-800">
<PortsColumn title="Input" ports={inputsArray} clickableFirst onPickPath={insertPath} />
<div className="p-4 overflow-y-auto space-y-4">
<label className="block text-sm space-y-1">
<span className="text-slate-300">Node label</span>
<textarea
aria-label="Node label"
value={node.data.name ?? ""}
onChange={(e) => onPatch({ name: e.target.value })}
rows={1}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm font-mono leading-5 resize-none"
/>
</label>
{nt?.connectionProviderId && (
<label className="block text-sm space-y-1">
<span className="text-slate-300">Connection</span>
<select
value={node.data.connection_id ?? ""}
onChange={(e) => onPatch({ connection_id: e.target.value || null })}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
>
<option value="">— none —</option>
{filteredConnections.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</label>
)}
{nt && (
<SchemaForm
schema={nt.configSchema}
value={node.data.config}
onChange={(v) => onPatch({ config: v })}
previewInput={previewInput}
onFieldFocus={registerFocus}
/>
)}
{node.data.typeId === "http.webhook" && (
<WebhookUrlHint
workflowId={workflowId}
workflowActive={workflowActive}
path={typeof node.data.config.path === "string" ? node.data.config.path : ""}
/>
)}
<div className="flex items-center gap-2 pt-2 border-t border-slate-800">
<button
onClick={() => runMutation.mutate()}
disabled={!canRun}
title={triggerNodeIds.length === 0 ? "Add a trigger node to run" : undefined}
className="px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-sm flex items-center gap-1.5 disabled:opacity-50"
>
<Play size={14} /> Run
</button>
{runMutation.isPending && <span className="text-xs text-slate-400">running…</span>}
{runMutation.isError && (
<span className="text-xs text-red-400">
{(runMutation.error as Error)?.message ?? "run failed"}
</span>
)}
<button
onClick={() => { onDelete(); onClose(); }}
className="ml-auto px-3 py-1.5 rounded text-sm text-red-400 hover:text-red-300 flex items-center gap-1"
>
<Trash2 size={14} /> Delete node
</button>
<button
onClick={onClose}
className="px-3 py-1.5 rounded bg-brand-600 hover:bg-brand-700 text-sm"
>
Done
</button>
</div>
</div>
<PortsColumn title="Output" ports={outputsArray} clickableFirst={false} />
</div>
</div>
</div>
);
}
interface Port {
items: { json: unknown }[];
}
function normalizePorts(v: unknown): Port[] {
if (!Array.isArray(v)) return [];
return v.map((port) => ({
items: Array.isArray(port) ? port.map((it) => ({ json: (it as { json: unknown } | null)?.json })) : [],
}));
}
function PortsColumn({
title, ports, clickableFirst, onPickPath,
}: {
title: "Input" | "Output";
ports: Port[];
clickableFirst: boolean;
onPickPath?: (path: string) => void;
}) {
return (
<div className="p-3 overflow-y-auto">
<h3 className="text-xs uppercase tracking-wide text-slate-500 mb-2">{title}</h3>
{ports.length === 0 && (
<div className="text-xs text-slate-500 italic">
No data yet — press <span className="text-slate-300">Run</span> or activate the workflow.
</div>
)}
{ports.map((port, p) => (
<div key={p} className="mb-3">
{ports.length > 1 && <div className="text-xs text-slate-500 mb-1">Port {p}</div>}
{port.items.length === 0 && <div className="text-xs text-slate-500 italic">(no items)</div>}
{port.items.map((it, i) => (
<div key={i} className="mb-2">
{port.items.length > 1 && <div className="text-xs text-slate-500 mb-1">Item {i}</div>}
<JsonTree
value={it.json}
pickable={clickableFirst && p === 0 && i === 0}
onPickPath={onPickPath}
/>
</div>
))}
</div>
))}
</div>
);
}
// Moved verbatim from the deleted NodeConfigPanel — same behavior.
function WebhookUrlHint({
workflowId, workflowActive, path,
}: { workflowId: string; workflowActive: boolean; path: string }) {
const [copied, setCopied] = useState(false);
const cleanPath = path.replace(/^\/+/, "");
const url = `${window.location.origin}/wh/${workflowId}/${cleanPath}`;
return (
<div className="bg-slate-950/60 border border-slate-800 rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs uppercase text-slate-500">Webhook URL</span>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard blocked — leave state alone.
}
}}
className="text-slate-400 hover:text-slate-200 flex items-center gap-1 text-xs"
aria-label="Copy webhook URL"
>
{copied ? <><Check size={12} /> Copied</> : <><Copy size={12} /> Copy</>}
</button>
</div>
<code className="block break-all text-xs text-slate-200 font-mono">{url}</code>
<p className="text-xs text-slate-400">
Accepts <code className="text-slate-200">POST</code> (JSON body) or{" "}
<code className="text-slate-200">GET</code> (query params →{" "}
<code className="text-slate-200">{"{a:'1',…}"}</code>). Click the URL to trigger from the browser.
</p>
{!path && (
<p className="text-xs text-amber-400">Set a URL path above to complete the endpoint.</p>
)}
{!workflowActive && (
<p className="text-xs text-slate-500">
The webhook only responds when the workflow is <strong>active</strong> — activate it
from the toolbar to receive requests.
</p>
)}
</div>
);
}
- Step 4: Run tests to verify they pass
pnpm --dir frontend test --run NodeEditorDialog
Expected: PASS — all cases green.
- Step 5: Commit
git add frontend/src/editor/NodeEditorDialog.tsx frontend/src/editor/NodeEditorDialog.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): NodeEditorDialog — three-column N8N-style modal"
Task 9: Wire double-click on Canvas + rewire EditorPage + delete NodeConfigPanel
Canvas listens for React Flow's onNodeDoubleClick and calls a new onEditNode prop. EditorPage manages editingId state, renders NodeEditorDialog when set, and drops the right-side NodeConfigPanel entirely. toGraph now needs nodeTypes passed in.
Files:
-
Modify:
frontend/src/editor/Canvas.tsx -
Modify:
frontend/src/pages/EditorPage.tsx -
Delete:
frontend/src/editor/NodeConfigPanel.tsx -
Step 1: Wire
onNodeDoubleClickin Canvas
Edit frontend/src/editor/Canvas.tsx. Add onEditNode to the Props interface and pass it through:
interface Props {
nodes: Node<VcNodeData>[];
edges: Edge[];
onNodesChange: (n: Node<VcNodeData>[]) => void;
onEdgesChange: (e: Edge[]) => void;
onSelectNode: (id: string | null) => void;
onEditNode: (id: string) => void;
onDropNodeType: (typeId: string, screenX: number, screenY: number) => void;
}
Inside the Inner component, add:
function Inner({
nodes, edges, onNodesChange, onEdgesChange, onSelectNode, onEditNode, onDropNodeType,
}: Props) {
// ...existing code unchanged...
return (
<div className="h-full" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodes}
onEdgesChange={handleEdges}
onConnect={onConnect}
onSelectionChange={({ nodes: sel }) => onSelectNode(sel[0]?.id ?? null)}
onNodeDoubleClick={(_e, n) => onEditNode(n.id)}
onNodesDelete={(deleted) => {
const gone = new Set(deleted.map((n) => n.id));
onEdgesChange(edges.filter((e) => !gone.has(e.source) && !gone.has(e.target)));
}}
deleteKeyCode={DELETE_KEYS}
nodeTypes={vcNodeTypes}
fitView
>
<Background />
<Controls />
</ReactFlow>
</div>
);
}
- Step 2: Rewrite EditorPage: drop NodeConfigPanel, add dialog
Replace frontend/src/pages/EditorPage.tsx with:
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Edge, Node } from "@xyflow/react";
import { useNodeTypes, useConnections, useWorkflow } from "@/api/hooks";
import { Canvas } from "@/editor/Canvas";
import { NodePalette } from "@/editor/NodePalette";
import { NodeEditorDialog } from "@/editor/NodeEditorDialog";
import { fromGraph, toGraph, type VcNodeData } from "@/editor/graphAdapter";
import { apiFetch } from "@/api/client";
import { qk, useApiMutation, useInvalidate } from "@/api/hooks";
import { Play, Pause, Save, PlayCircle } from "lucide-react";
function newNodeId() {
return `n_${Math.random().toString(36).slice(2, 10)}`;
}
export default function EditorPage() {
const { id } = useParams();
const wf = useWorkflow(id);
const nts = useNodeTypes();
const cs = useConnections();
const [nodes, setNodes] = useState<Node<VcNodeData>[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
useEffect(() => {
if (!wf.data) return;
const { nodes: n, edges: e } = fromGraph(wf.data.graph);
setNodes(n);
setEdges(e);
}, [wf.data]);
const editingNode = useMemo(
() => nodes.find((n) => n.id === editingId) ?? null,
[nodes, editingId],
);
function patchNode(nodeId: string, patch: Partial<VcNodeData>) {
setNodes((ns) => ns.map((n) => n.id === nodeId
? { ...n, data: { ...n.data, ...patch } }
: n));
}
function deleteNode(nodeId: string) {
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
if (selectedId === nodeId) setSelectedId(null);
if (editingId === nodeId) setEditingId(null);
}
function dropNodeType(typeId: string, x: number, y: number) {
const nt = nts.data?.find((t) => t.id === typeId);
if (!nt) return;
setNodes((ns) => ns.concat({
id: newNodeId(),
type: "vc",
position: { x, y },
data: {
typeId,
name: nt.displayName,
config: {},
connection_id: null,
},
}));
}
const invalidate = useInvalidate();
const save = useApiMutation(
() =>
apiFetch<void>(`/api/workflows/${id}`, {
method: "PUT",
body: { name: wf.data!.name, graph: toGraph(nodes, edges, nts.data ?? []) },
}),
{ onSuccess: () => id && invalidate(qk.workflow(id)) },
);
const activate = useApiMutation(
() => apiFetch<void>(`/api/workflows/${id}/activate`, { method: "POST" }),
{ onSuccess: () => id && invalidate(qk.workflow(id)) },
);
const deactivate = useApiMutation(
() => apiFetch<void>(`/api/workflows/${id}/deactivate`, { method: "POST" }),
{ onSuccess: () => id && invalidate(qk.workflow(id)) },
);
const [testStatus, setTestStatus] = useState<
| { kind: "idle" }
| { kind: "running" }
| { kind: "success"; executionId: string }
| { kind: "error"; message: string }
>({ kind: "idle" });
const clearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => { if (clearTimer.current) clearTimeout(clearTimer.current); }, []);
function scheduleClear(ms: number) {
if (clearTimer.current) clearTimeout(clearTimer.current);
clearTimer.current = setTimeout(() => setTestStatus({ kind: "idle" }), ms);
}
const triggerNodeIds = useMemo(
() => nodes
.filter((n) => nts.data?.find((t) => t.id === n.data.typeId)?.category === "Trigger")
.map((n) => n.id),
[nodes, nts.data],
);
const testRun = useApiMutation(
() => {
const startFrom = selectedId || triggerNodeIds[0];
if (!startFrom) throw new Error("Add a trigger node or select a node to start from.");
return apiFetch<{ executionId: string }>(
`/api/workflows/${id}/executions`,
{ method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } },
);
},
{
onMutate: () => setTestStatus({ kind: "running" }),
onSuccess: (res) => {
setTestStatus({ kind: "success", executionId: res.executionId });
scheduleClear(6000);
},
onError: (err) => {
setTestStatus({ kind: "error", message: err instanceof Error ? err.message : String(err) });
scheduleClear(8000);
},
},
);
if (wf.isLoading || !wf.data) {
return <div className="p-6 text-slate-400 text-sm">Loading…</div>;
}
return (
<div className="h-screen flex flex-col">
<div className="border-b border-slate-800 px-4 py-2 flex items-center gap-3">
<span className="font-semibold">{wf.data.name}</span>
<span className="text-xs text-slate-500">Editor</span>
<div className="ml-auto flex items-center gap-2">
<Link
to={`/workflows/${id}/executions`}
className="px-3 py-1.5 rounded text-sm text-slate-300 hover:bg-slate-800"
>
Executions
</Link>
<button
onClick={() => save.mutate()}
disabled={save.isPending}
className="px-3 py-1.5 rounded bg-brand-600 hover:bg-brand-700 text-sm flex items-center gap-1.5"
>
<Save size={14} /> Save
</button>
<button
onClick={() => testRun.mutate()}
disabled={testRun.isPending}
className="px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-sm flex items-center gap-1.5"
>
<PlayCircle size={14} /> Test run
</button>
{wf.data.active ? (
<button
onClick={() => deactivate.mutate()}
disabled={deactivate.isPending}
className="px-3 py-1.5 rounded bg-amber-700/70 hover:bg-amber-700 text-sm flex items-center gap-1.5"
>
<Pause size={14} /> Deactivate
</button>
) : (
<button
onClick={() => activate.mutate()}
disabled={activate.isPending}
className="px-3 py-1.5 rounded bg-emerald-700 hover:bg-emerald-600 text-sm flex items-center gap-1.5"
>
<Play size={14} /> Activate
</button>
)}
</div>
</div>
{testStatus.kind !== "idle" && (
<div
role="status"
className={
"px-4 py-2 text-sm border-b flex items-center gap-2 " +
(testStatus.kind === "success"
? "bg-emerald-950/50 border-emerald-900 text-emerald-200"
: testStatus.kind === "error"
? "bg-red-950/50 border-red-900 text-red-200"
: "bg-slate-900 border-slate-800 text-slate-300")
}
>
{testStatus.kind === "running" && <>Running test execution…</>}
{testStatus.kind === "success" && (
<>
Started execution <code className="font-mono text-xs">{testStatus.executionId}</code>
<Link
to={`/workflows/${id}/executions`}
className="ml-auto underline hover:text-emerald-100"
>
View execution →
</Link>
</>
)}
{testStatus.kind === "error" && <>Test run failed: {testStatus.message}</>}
</div>
)}
<div className="flex-1 flex">
<NodePalette />
<div className="flex-1">
<Canvas
nodes={nodes} edges={edges}
onNodesChange={setNodes} onEdgesChange={setEdges}
onSelectNode={setSelectedId}
onEditNode={setEditingId}
onDropNodeType={dropNodeType}
/>
</div>
</div>
{editingNode && (
<NodeEditorDialog
node={editingNode}
nodeTypes={nts.data ?? []}
connections={cs.data ?? []}
workflowId={id ?? ""}
workflowActive={wf.data.active}
triggerNodeIds={triggerNodeIds}
onPatch={(patch) => patchNode(editingNode.id, patch)}
onDelete={() => deleteNode(editingNode.id)}
onClose={() => setEditingId(null)}
/>
)}
</div>
);
}
- Step 3: Delete
NodeConfigPanel.tsx
rm frontend/src/editor/NodeConfigPanel.tsx
- Step 4: Type-check + run the full frontend suite
pnpm --dir frontend build
pnpm --dir frontend test
Expected: PASS. pnpm build runs tsc -b; a stale import of NodeConfigPanel anywhere fails the build. If a test file references the deleted panel, remove or update it.
- Step 5: Commit
git add -A frontend/src/editor/Canvas.tsx frontend/src/pages/EditorPage.tsx
git rm frontend/src/editor/NodeConfigPanel.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): double-click opens node editor dialog; remove side panel"
Task 10: Extend the Playwright smoke test
Prove the whole flow end-to-end: after dropping a node, double-click opens the dialog, editing the label survives save+reload.
Files:
-
Modify:
frontend/e2e/smoke.spec.ts -
Step 1: Read the current smoke test
cat frontend/e2e/smoke.spec.ts
Familiarize yourself with the shape: login → create workflow → drop a node → save → reload.
- Step 2: Extend the assertion
Add these steps after the node has been dropped and before the final save. Locators use the accessible name where possible; fall back to a data-testid you add if a role/name isn't stable.
// After the drop step, before Save:
// Double-click the just-dropped node's DOM element. React Flow renders
// nodes as `.react-flow__node`; there should be exactly one at this point.
const nodeEl = page.locator(".react-flow__node").first();
await nodeEl.dblclick();
// Dialog appears with the three column headings.
await expect(page.getByRole("heading", { name: /input/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /parameters/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /output/i })).toBeVisible();
// Edit the label to something distinctive.
const label = page.getByLabel("Node label");
await label.fill("Custom smoke node");
// Close the dialog with the Done button.
await page.getByRole("button", { name: /^done$/i }).click();
await expect(page.getByRole("heading", { name: /parameters/i })).not.toBeVisible();
Then, after Save and reload:
// The canvas should show the custom label on the node.
await expect(page.locator(".react-flow__node")).toContainText("Custom smoke node");
- Step 3: Run the smoke test
pnpm --dir frontend test:e2e
Expected: PASS — full smoke green including dialog edit+reload.
- Step 4: Commit
git add frontend/e2e/smoke.spec.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "test(e2e): smoke covers double-click dialog and label persistence"
Wrap-up
- Full test sweep
dotnet build
dotnet test
pnpm --dir frontend test
pnpm --dir frontend test:e2e
Expected: PASS across all four.
- Update
docs/superpowers/followups.md
Append this section (or add if the file doesn't have it):
### From Plan 5 (Node Editor Dialog)
- **Webhook stand-by mode.** Trigger option where an active workflow pauses on
the webhook node and only continues when a matching HTTP request arrives,
instead of running through the first request. Needs pause/resume semantics
in Executor; separate spec.
- **Drag-and-drop insertion** from JsonTree to ExpressionField.
- **Multi-color / semantic highlighting** inside `{…}` (property vs bracket vs
literal). Single color ships in Plan 5.
- **Cross-node expressions** like `{nodes.<id>.output.body}` and
`{nodes.<id>.output[0].json.body}`. Requires the executor to expose a
scoped-view of run state to `TemplateEngine`.
- **Pinned input data** (n8n's "pin this data on a node") — override the input
the dialog uses for previews and the executor uses for that node.
- Update project
CLAUDE.md
In CLAUDE.md, update the "Current status" section to note Plan 5 shipped and the head commit; update the "Follow-ups" pointer if needed. Add these to the "Invariants (do NOT relearn from scratch)" section:
- **Expression preview and executor interpolation MUST stay in parity.** `frontend/src/expr/templateEngine.ts` is a hand-port of `src/VolcanicCore.Core/TemplateEngine.cs`. When adding a case to the backend `TemplateEngineTests`, add the mirror case to `frontend/src/expr/templateEngine.test.ts` too. Divergence is a bug: the preview line under every ExpressionField reads from the frontend port, and users trust it.
- **`toGraph(nodes, edges, nodeTypes)` needs the node types.** `coerceConfigForSave` looks up each node's schema to decide whether to save `"200"` as `200` or as `"200"`. If you call `toGraph` without node types, everything gets saved as strings and every non-string field silently breaks at run time.
- **`Executor.CoerceToSchema` runs AFTER `InterpolateStrings`.** Reorder them and templated numeric fields stop working because nodes check `JsonValueKind.Number`. The pair must always be `InterpolateStrings → CoerceToSchema → node.ExecuteAsync`.
- Commit docs
git add docs/superpowers/followups.md CLAUDE.md
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "docs: plan 5 wrap-up (followups, CLAUDE.md invariants)"
- Deploy (per global CLAUDE.md — fire-and-forget)
git push origin main
APP=$(jq -r '.app.uuid' deploy.json)
curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP" \
-H "Authorization: Bearer $COOLIFY_KEY" \
| jq -r '.deployments[0].deployment_uuid // .deployment_uuid'
Expected: prints a deployment uuid. Do NOT poll. Report the uuid to the user and stop.