docs(spec): node editor dialog (N8N-style, 3-column, expressions)

This commit is contained in:
Claude
2026-08-30 10:27:55 +00:00
parent c187300aa4
commit 744e19e813

View File

@@ -0,0 +1,187 @@
# 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
```html
<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.
```csharp
// 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).