From 744e19e813ddd15c47930efc2385d20f40d085cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:27:55 +0000 Subject: [PATCH] docs(spec): node editor dialog (N8N-style, 3-column, expressions) --- .../2026-08-30-node-editor-dialog-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-30-node-editor-dialog-design.md diff --git a/docs/superpowers/specs/2026-08-30-node-editor-dialog-design.md b/docs/superpowers/specs/2026-08-30-node-editor-dialog-design.md new file mode 100644 index 0000000..da4e010 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-node-editor-dialog-design.md @@ -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 ``; add `editingId` state; render `` 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 ``. 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 ` +
→ hello alice@example.com
+ +``` + +- 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 `
` 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 ` 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` 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`. +- 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..output.body}`). +- Pinned input data (n8n's "pin this data" on a node).