From 1e5fd42619f7de6277807530c1dd71bbe121788e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:41:28 +0000 Subject: [PATCH] =?UTF-8?q?docs(plan):=20plan=205=20=E2=80=94=20node=20edi?= =?UTF-8?q?tor=20dialog=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-30-plan-5-node-editor-dialog.md | 2775 +++++++++++++++++ 1 file changed, 2775 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-plan-5-node-editor-dialog.md diff --git a/docs/superpowers/plans/2026-08-30-plan-5-node-editor-dialog.md b/docs/superpowers/plans/2026-08-30-plan-5-node-editor-dialog.md new file mode 100644 index 0000000..d05dac2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-plan-5-node-editor-dialog.md @@ -0,0 +1,2775 @@ +# 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: + +```bash +dotnet build +dotnet test +``` + +Frontend unit / integration: + +```bash +pnpm --dir frontend test +``` + +Frontend E2E (needs a one-time `pnpm exec playwright install chromium`): + +```bash +pnpm --dir frontend test:e2e +``` + +Live smoke (optional during development): + +```bash +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: + +```bash +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)** + +```typescript +// 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** + +```bash +pnpm --dir frontend test --run templateEngine +``` + +Expected: FAIL — `Cannot find module './templateEngine'`. + +- [ ] **Step 3: Implement the engine** + +```typescript +// 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 { + return typeof v === "object" && v !== null && !Array.isArray(v); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pnpm --dir frontend test --run templateEngine +``` + +Expected: PASS — 17/17 tests green. + +- [ ] **Step 5: Commit** + +```bash +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: + +```csharp +// --- 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 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>> ExecuteAsync( + INodeContext ctx, IReadOnlyList> _, 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>>(Array.Empty>()); + } +} + +[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** + +```bash +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 `CoerceToSchema` in Executor** + +In `src/VolcanicCore.Api/Engine/Executor.cs`, change `RunNodeAsync` to call the new coercion helper right after `InterpolateStrings`: + +```csharp +private async Task RunNodeAsync( + string executionId, string workflowId, WorkflowNode node, IActionNode action, + IReadOnlyList> 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** + +```bash +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** + +```bash +dotnet test +``` + +Expected: PASS — 79/79 (77 existing + 2 new). + +- [ ] **Step 6: Commit** + +```bash +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`: + +```typescript +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: + +```typescript +// 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** + +```bash +pnpm --dir frontend test --run graphAdapter +``` + +Expected: FAIL — `toGraph` doesn't accept a third argument; new assertions fail. + +- [ ] **Step 3: Implement `coerceConfigForSave` in graphAdapter** + +Replace the current `toGraph` in `frontend/src/editor/graphAdapter.ts`: + +```typescript +import type { Edge, Node } from "@xyflow/react"; +import type { GraphEdge, GraphNode, JsonSchemaObject, NodeTypeDto, WorkflowGraph } from "@/api/types"; + +export interface VcNodeData extends Record { + typeId: string; + name?: string; + config: Record; + connection_id?: string | null; +} + +export function fromGraph(g: WorkflowGraph): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = 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[], + 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, + schema: JsonSchemaObject, +): Record { + const props = schema.properties; + if (!props) return config; + const out: Record = {}; + 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** + +```bash +pnpm --dir frontend test --run graphAdapter +``` + +Expected: PASS — all cases green (existing + new coercion tests). + +- [ ] **Step 5: Commit** + +```bash +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** + +```tsx +// 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await userEvent.click(screen.getByText("a")); + expect(onPick).not.toHaveBeenCalled(); + }); + + it("renders empty state for empty object", () => { + render(); + expect(screen.getByText(/no data/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm --dir frontend test --run JsonTree +``` + +Expected: FAIL — `Cannot find module './JsonTree'`. + +- [ ] **Step 3: Implement `JsonTree`** + +```tsx +// 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
No data.
; + } + return ; +} + +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 ( +
    + {renderChildren(value, path, depth, pickable, onPick)} +
+ ); + } + 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 ( +
  • + + {branch && open && ( +
      + {renderChildren(value, path, depth, pickable, onPick)} +
    + )} +
  • + ); +} + +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 ( + + ); + }); + } + if (value !== null && typeof value === "object") { + return Object.entries(value as Record).map(([k, v]) => { + const seg = needsBracketForm(k) ? `["${k.replace(/"/g, `\\"`)}"]` : `.${k}`; + const childPath = parentPath === "" ? (needsBracketForm(k) ? seg : k) : `${parentPath}${seg}`; + return ( + + ); + }); + } + 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** + +```bash +pnpm --dir frontend test --run JsonTree +``` + +Expected: PASS — all cases green. + +- [ ] **Step 5: Commit** + +```bash +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 `
    ` 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 via `forwardRef`. +- 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** + +```tsx +// 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(); + await userEvent.type(screen.getByRole("textbox"), "hi"); + expect(onChange).toHaveBeenLastCalledWith("hi"); + }); + + it("shows the live preview against previewInput", () => { + render( + {}} + previewInput={{ name: "alice" }} + />, + ); + expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice!"); + }); + + it("omits the preview line when previewInput is undefined", () => { + render( {}} />); + expect(screen.queryByTestId("expr-preview")).not.toBeInTheDocument(); + }); + + it("shows a schema-validity warning for a bad integer literal", () => { + render( + {}} + schemaType="integer" + previewInput={{}} + />, + ); + expect(screen.getByTestId("expr-preview")).toHaveTextContent(/expected integer/i); + }); + + it("does not warn on integer literals that are valid", () => { + render( + {}} + schemaType="integer" + previewInput={{}} + />, + ); + expect(screen.getByTestId("expr-preview")).toHaveTextContent("200"); + }); + + it("does not warn on integer fields when the value contains a template", () => { + render( + {}} + 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(null); + const [v, setV] = useState("hello "); + return ( + <> + + + + ); + } + render(); + 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(); + 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** + +```bash +pnpm --dir frontend test --run ExpressionField +``` + +Expected: FAIL — `Cannot find module './ExpressionField'`. + +- [ ] **Step 3: Implement `ExpressionField`** + +```tsx +// 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(function ExpressionField( + { value, onChange, onFocus, previewInput, schemaType = "string", singleLine, codeBlock, placeholder, id }, + ref, +) { + const taRef = useRef(null); + const overlayRef = useRef(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 ( +
    +
    +
    + {tokens.map((t, i) => + t.kind === "expr" + ? {t.text} + : {t.text}, + )} + {/* Trailing newline safeguard: keeps overlay height equal to the textarea when + the value ends in a newline. */} + {value.endsWith("\n") && } +
    +