Files
VolcanicCore/docs/superpowers/specs/2026-08-30-node-editor-dialog-design.md

16 KiB
Raw Blame History

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 fx chip). 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 of VolcanicCore.Core.TemplateEngine.Render. 1:1 behavioral parity, all 17 cases from TemplateEngineTests mirrored.
  • frontend/src/expr/ExpressionField.tsx + .test.tsx — textarea with a mirrored highlight overlay + live preview. forwardRef exposes insertAtCaret(text).
  • frontend/src/expr/JsonTree.tsx + .test.tsx — expand/collapse tree with per-leaf onPickPath(path). Uses bracket-form (["weird.key"]) for keys that contain . or -; dot-form otherwise. Includes numeric array indices as arr[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 an ExecutionDetail and a nodeId, return { inputs, outputs } | null.

Changed

  • frontend/src/editor/Canvas.tsx — wire React Flow's onNodeDoubleClick to a new onEditNode(id) prop.
  • frontend/src/pages/EditorPage.tsx — remove <NodeConfigPanel>; add editingId state; render <NodeEditorDialog> when set; keep patchSelected / deleteSelected (renamed to patchNode(id, …) / deleteNode(id)) as callbacks the dialog invokes.
  • frontend/src/forms/SchemaForm.tsx — replace every leaf renderer with <ExpressionField>. Schema type (string/number/integer/boolean) and format (code/password) are passed through as hints. Enums are rendered as an ExpressionField accompanied by a small "Options: a, b, c" caption; no dropdown (dropdowns can't hold expressions).
  • frontend/src/editor/graphAdapter.ts — extend toGraph with coerceConfigForSave(config, schema). For each schema property whose type is integer/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 — after InterpolateStrings(cfgDoc.RootElement, firstInput), run CoerceToSchema(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. Its WebhookUrlHint block moves verbatim into NodeEditorDialog's Parameters column, shown when typeId === "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, backdrop bg-black/60.
  • Done only closes the dialog. Every keystroke inside the dialog flows into EditorPage state via patchNode(id, patch) (same shape as today's patchSelected). 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> with white-space: pre-wrap; word-break: break-word; and identical font-family, font-size, line-height, padding, border-width to the textarea. scrollTop / scrollLeft copied from textarea's onScroll.
  • Tokenizer for the overlay: a small regex scan of the raw value producing [ { kind: 'text' | 'expr' | 'escape', text } ]. {{ / }} render as literal { / } in the escape class (same style as text). Unclosed { at end-of-string renders as text (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. If firstInputItem is undefined, hide the preview line entirely.
  • Schema-type hint: when schemaType is integer/number/boolean AND the raw value contains no { AND the value doesn't parse to that type (Number.isInteger(+v) for integer, !Number.isNaN(+v) for number, v === "true" || v === "false" for boolean; 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 gets rows={1}, grows via auto-resize up 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> exposes insertAtCaret(text: string): void and focus(): 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 with status: "{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 containing expected integer.

6. Data flow — Input / Output columns

  1. Dialog mounts → useExecutions(workflowId) (already cached from the Executions page and toolbar).
  2. Pick the most recent execution's id → useExecution(latestId).
  3. findLatestIo(execution, nodeId) returns { inputs, outputs } — the raw inputs / outputs JsonElements on that node execution row (shape: [[items on port 0], [items on port 1], …]).
  4. 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."
  5. firstInputItem for preview evaluation is inputs?.[0]?.[0]?.json (matches executor's byPort[0][0].Json). Trigger nodes have an empty inputs array — preview uses undefined, so no preview line is shown for their fields.

Run button:

  • Reuses the existing POST /api/workflows/{id}/executions mutation (see EditorPage.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-local pinnedExecutionId = newEid so 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.StartExecutionAsync awaits RunAsync), so no polling is needed.

7. Click-to-insert

  • NodeEditorDialog holds focusedFieldRef: RefObject<ExpressionFieldHandle | null>.
  • Every ExpressionField.onFocus sets focusedFieldRef.current = this.
  • JsonTree.onPickPath(path) → dialog calls focusedFieldRef.current?.insertAtCaret("{input." + path + "}").
  • Path assembly in JsonTree walks from the tree root. Root of the Input tree's insertable subtree is always input, corresponding to inputs[0][0].json — that's what TemplateEngine.Render evaluates against (executor feeds it byPort[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) where input is 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 leading input) → pass through verbatim.
  • Malformed / unclosed braces → pass through verbatim.
  • Test file mirrors TemplateEngineTests.cs case-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 backend TemplateEngineTests case.
  • 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 on weird.key emits bracket-form, click-index emits arr[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 via patchNode, Run button disabled when no triggers exist, Run success swaps to new execution, Webhook URL block shows only for http.webhook, connection dropdown shows only when node type has connectionProviderId.
  • graphAdapter.test.ts — new test: toGraph coerces "200" on an integer schema field to 200; 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_coerced
  • ExecutorTests.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).