16 KiB
VolcanicCore — Node Editor Dialog (N8N-style) Design
Status: Approved for planning Date: 2026-08-30
Purpose
Bring the workflow editor closer to n8n's node-editor UX. When the operator double-clicks a node on the canvas, a modal dialog opens with three columns — Input, Parameters, Output — matching the mental model n8n users already have. All parameter fields are expressions: a string with {input.foo} tokens gets interpolated against upstream data at run time; a bare literal is passed through unchanged. Input and Output columns show the most recent execution's data for the node, or fresh data pulled by a Run button inside the dialog.
The existing right-side NodeConfigPanel goes away. Single-click still selects the node (border highlight is unchanged). Double-click is the only way to edit config.
Non-goals
- Per-field Fixed/Expression toggle (n8n's
fxchip). Every string field is always an expression; a literal with no{is just a literal, and{{/}}still escape to literal braces. - Multi-color / semantic highlighting inside
{...}(function calls, dot-navigation coloring). Single color for the entire{…}run only. - Drag-and-drop insertion from the tree. Click-to-insert at caret only.
- Cross-node expressions (
{nodes.http1.output.body}) — still{input.…}only, single input item on port 0. - Filters / pipes (
{input.foo | upper}). - Pinned data (n8n's "pin this input").
- Webhook "stand by for real data" pause-behavior. Filed for a separate later change.
1. Architecture
Entirely a frontend change plus one small backend coercion pass. No new endpoints, no persistence changes, no DB migrations.
EditorPage
├─ Canvas ← unchanged apart from onNodeDoubleClick wiring
├─ NodePalette ← unchanged
└─ NodeEditorDialog ← NEW; renders when editingId != null
├─ header (name · × close)
├─ column: Input ← JsonTree of last-execution inputs for this node
├─ column: Parameters ← SchemaForm(ExpressionField per leaf) + WebhookUrlHint
└─ column: Output ← JsonTree of last-execution outputs for this node
2. Files
New
frontend/src/expr/templateEngine.ts+.test.ts— client-side port ofVolcanicCore.Core.TemplateEngine.Render. 1:1 behavioral parity, all 17 cases fromTemplateEngineTestsmirrored.frontend/src/expr/ExpressionField.tsx+.test.tsx— textarea with a mirrored highlight overlay + live preview.forwardRefexposesinsertAtCaret(text).frontend/src/expr/JsonTree.tsx+.test.tsx— expand/collapse tree with per-leafonPickPath(path). Uses bracket-form (["weird.key"]) for keys that contain.or-; dot-form otherwise. Includes numeric array indices asarr[0].frontend/src/editor/NodeEditorDialog.tsx+.test.tsx— modal shell, 3-column layout, focus trap, ESC + backdrop-click to close, wiring.frontend/src/editor/nodeIoLookup.ts+.test.ts— pure helper: given anExecutionDetailand anodeId, return{ inputs, outputs } | null.
Changed
frontend/src/editor/Canvas.tsx— wire React Flow'sonNodeDoubleClickto a newonEditNode(id)prop.frontend/src/pages/EditorPage.tsx— remove<NodeConfigPanel>; addeditingIdstate; render<NodeEditorDialog>when set; keeppatchSelected/deleteSelected(renamed topatchNode(id, …)/deleteNode(id)) as callbacks the dialog invokes.frontend/src/forms/SchemaForm.tsx— replace every leaf renderer with<ExpressionField>. Schematype(string/number/integer/boolean) andformat(code/password) are passed through as hints. Enums are rendered as anExpressionFieldaccompanied by a small "Options: a, b, c" caption; no dropdown (dropdowns can't hold expressions).frontend/src/editor/graphAdapter.ts— extendtoGraphwithcoerceConfigForSave(config, schema). For each schema property whose type isinteger/number/boolean, if the saved value is a string with no{, parse it to its typed JSON form ("200"→200,"true"→true). Strings with{in them are saved as strings.src/VolcanicCore.Api/Engine/Executor.cs— afterInterpolateStrings(cfgDoc.RootElement, firstInput), runCoerceToSchema(templatedConfig, typ.ConfigSchema). See §5.frontend/e2e/smoke.spec.ts— extend the smoke test: after dropping a node, double-click, edit label in dialog, close dialog, save, reload, assert label survived.
Deleted
frontend/src/editor/NodeConfigPanel.tsx. ItsWebhookUrlHintblock moves verbatim intoNodeEditorDialog's Parameters column, shown whentypeId === "http.webhook".
3. Modal layout
┌── header: [category] Display Name × ──┐
│ │
│ INPUT │ PARAMETERS │ OUTPUT │
│ ───── │ ────────── │ ────── │
│ Port 0 ▾ │ Node label [__________] │ Port 0 ▾ │
│ Item 0 ▾ │ Connection [dropdown] │ Item 0 ▾ │
│ body ▾ │ Path [/webhook_______] │ status 200 │
│ email │ Body [{input.body}___] │ body "ok" │
│ … │ → "hello alice" │ │
│ │ │ │
│ │ [ Webhook URL block — if type ]│ │
│ │ │ │
│ │ [ Run (uses last trigger) ] │ │
│ │ [ Delete node ] [ Done ] │ │
└───────────────────────────────────────────────────────────────────────────┘
- Sizing:
grid-cols-[minmax(240px,25%)_1fr_minmax(240px,25%)], fixed inset with 5% margin, max 1280px wide, backdropbg-black/60. - Done only closes the dialog. Every keystroke inside the dialog flows into
EditorPagestate viapatchNode(id, patch)(same shape as today'spatchSelected). The user still hits Save in the toolbar to persist to the backend. - Connection dropdown is a plain
<select>— a connection is a reference id, not an expression, so it never becomes an ExpressionField. - Focus trap: on open, focus moves to the first ExpressionField (or the label input if no fields exist). Tab cycles within the dialog. ESC closes.
- Backdrop click closes.
4. ExpressionField internals
<div class="relative">
<div class="overlay"> <!-- absolute; same font metrics -->
hello <span class="expr-token">{input.body.email}</span>!
</div>
<textarea>hello {input.body.email}!</textarea>
<div class="preview">→ hello alice@example.com</div>
</div>
- Textarea
color: transparent; caret-color: rgb(226 232 240);so the mirrored overlay is what the user sees, but the real caret still shows and text still selects normally. - Overlay is
<div aria-hidden>withwhite-space: pre-wrap; word-break: break-word;and identicalfont-family,font-size,line-height,padding,border-widthto the textarea.scrollTop/scrollLeftcopied from textarea'sonScroll. - Tokenizer for the overlay: a small regex scan of the raw value producing
[ { kind: 'text' | 'expr' | 'escape', text } ].{{/}}render as literal{/}in theescapeclass (same style astext). Unclosed{at end-of-string renders astext(backend also passes it through verbatim). - Preview: run
templateEngine.render(rawValue, firstInputItem)on every change (debounced 50 ms). Truncate to 200 chars, add…if longer. IffirstInputItemis undefined, hide the preview line entirely. - Schema-type hint: when
schemaTypeisinteger/number/booleanAND the raw value contains no{AND the value doesn't parse to that type (Number.isInteger(+v)forinteger,!Number.isNaN(+v)fornumber,v === "true" || v === "false"forboolean; empty string counts as "unset" and never warns), replace the preview with⚠ expected <type>in amber. This surfaces bad literals before Save. - Single-line variant: for text fields that are conceptually one line (label, path, subject), pass
singleLine. The textarea getsrows={1}, grows viaauto-resizeup to 5 rows, and Enter is intercepted to blur instead of inserting a newline.format: "code"overrides this and gets a 10-row textarea. forwardRef<ExpressionFieldHandle, Props>exposesinsertAtCaret(text: string): voidandfocus(): void.
5. Backend coercion
Every node reads its config with strict JsonValueKind checks (WebResponseNode.status requires JsonValueKind.Number, etc.). Templated values are always strings post-interpolation, so we coerce back to the schema-declared type before the node sees it.
// After InterpolateStrings(...)
var coerced = CoerceToSchema(templatedConfig, typ.ConfigSchema);
var ctx = new ExecutionContext(executionId, workflowId, node.Id, coerced, ...);
CoerceToSchema walks the top-level properties of the schema. For each property whose type is integer / number / boolean, if the corresponding config leaf is a JsonValueKind.String:
| Schema type | Rule | On failure |
|---|---|---|
integer |
long.TryParse(s, out var i) → JSON number |
throws InvalidOperationException($"config '{name}': expected integer, got '{s}'") |
number |
double.TryParse(s, out var d) → JSON number |
same message with expected number |
boolean |
s == "true" → JSON true, s == "false" → JSON false |
same message with expected boolean |
The thrown exception is caught by RunNodeAsync's existing try/catch and surfaces as a normal node failure (status failed, error message stored on the node execution row). No new failure paths.
Scope: top-level properties only. No shipped schema nests non-string primitives inside objects/arrays. If a future schema does, recurse.
Tests in ExecutorTests:
Template_into_integer_config_field_is_coerced: a WebResponse node withstatus: "{input.code}"and input{code: 201}sends HTTP 201.Non_parseable_integer_config_fails_node:status: "{input.code}"with input{code: "abc"}fails the node with message containingexpected integer.
6. Data flow — Input / Output columns
- Dialog mounts →
useExecutions(workflowId)(already cached from the Executions page and toolbar). - Pick the most recent execution's id →
useExecution(latestId). findLatestIo(execution, nodeId)returns{ inputs, outputs }— the rawinputs/outputsJsonElements on that node execution row (shape:[[items on port 0], [items on port 1], …]).- If no row for this node exists (never ran, or was added since the last run), both columns show the empty state: "No data yet — press Run or activate the workflow."
firstInputItemfor preview evaluation isinputs?.[0]?.[0]?.json(matches executor'sbyPort[0][0].Json). Trigger nodes have an empty inputs array — preview usesundefined, so no preview line is shown for their fields.
Run button:
- Reuses the existing
POST /api/workflows/{id}/executionsmutation (seeEditorPage.testRun). Start-from-node is always the workflow's first trigger node, not the node being edited — the whole point is for upstream nodes to produce the input this node sees. If there are no trigger nodes, the button is disabled with tooltip "Add a trigger node to run". - On success:
invalidate(qk.executions(id)), then set a dialog-localpinnedExecutionId = newEidso the Input/Output columns switch to the fresh run. Falls back to "latest" if the pinned id is cleared. - The endpoint currently awaits the run to completion before returning (
Executor.StartExecutionAsyncawaitsRunAsync), so no polling is needed.
7. Click-to-insert
NodeEditorDialogholdsfocusedFieldRef: RefObject<ExpressionFieldHandle | null>.- Every
ExpressionField.onFocussetsfocusedFieldRef.current = this. JsonTree.onPickPath(path)→ dialog callsfocusedFieldRef.current?.insertAtCaret("{input." + path + "}").- Path assembly in
JsonTreewalks from the tree root. Root of the Input tree's insertable subtree is alwaysinput, corresponding toinputs[0][0].json— that's whatTemplateEngine.Renderevaluates against (executor feeds itbyPort[0][0].Json). Ports beyond 0 and items beyond 0 render in the tree for viewing, but their leaves are non-clickable and dimmed. Leaves in the Output column are non-clickable across the board (no expression syntax for "this node's output" — that's a follow-up). - Path segments: alphanumeric+underscore →
.name; anything else →["name"]. Array indices →[3].
8. Client TemplateEngine parity
Behavior must match VolcanicCore.Core.TemplateEngine.Render exactly, because the dialog's preview shows the user what the executor will produce. Divergence would be a bug.
- Input:
(template: string, input: unknown)whereinputis a plain JS value (already-parsed JSON). {input}→ whole input rendered as compact JSON.{input.foo.bar},{input.arr[0]},{input["weird.key"]}→ nested lookup.- Strings render raw (no JSON quotes). Numbers/booleans → their string form.
null/undefined→ empty string. Objects/arrays → compact JSON. - Missing paths → empty string.
{{/}}→ literal{/}.- Unrecognized placeholders (
{node.foo},{foo}without leadinginput) → pass through verbatim. - Malformed / unclosed braces → pass through verbatim.
- Test file mirrors
TemplateEngineTests.cscase-for-case; if a backend case is added later, the frontend test file gains the same case.
9. Tests
Frontend unit / integration (Vitest):
templateEngine.test.ts— 17 cases, one per backendTemplateEngineTestscase.ExpressionField.test.tsx— insert-at-caret between other text, insert-at-empty, preview updates on input, preview clears when input is undefined, integer-type hint warns on bad literal, overlay scrollTop follows textarea scroll on a tall value.JsonTree.test.tsx— expand/collapse toggles rows, click-leaf emits dot-form path, click-leaf onweird.keyemits bracket-form, click-index emitsarr[3], leaves outside port 0 / item 0 are non-clickable.NodeEditorDialog.test.tsx— opens on double-click, ESC closes, backdrop click closes, edits flow into parent viapatchNode, Run button disabled when no triggers exist, Run success swaps to new execution, Webhook URL block shows only forhttp.webhook, connection dropdown shows only when node type hasconnectionProviderId.graphAdapter.test.ts— new test:toGraphcoerces"200"on anintegerschema field to200; leaves"{input.code}"as string.nodeIoLookup.test.ts— returns null when node not in execution; returns raw json when it is.
Backend (xUnit):
ExecutorTests.Template_into_integer_config_field_is_coercedExecutorTests.Non_parseable_integer_config_fails_node
Playwright E2E:
- Extend
smoke.spec.ts: login → create workflow → drop node → double-click → edit label in dialog → close → save → reload → assert label survived.
10. Follow-ups (add to docs/superpowers/followups.md on close)
- Webhook "stand by" mode: a trigger option where the workflow pauses on the webhook node and only continues when a matching HTTP request arrives, instead of running through on the first-arrived request. Separate spec — needs pause/resume semantics.
- Drag-and-drop from JsonTree to ExpressionField.
- Multi-color / semantic highlighting inside
{…}. - Cross-node expressions (
{nodes.<id>.output.body}). - Pinned input data (n8n's "pin this data" on a node).