feat(editor): NodeEditorDialog — three-column N8N-style modal

Left column shows the port-0 inputs as a clickable JsonTree that inserts
{input.<path>} at the last-focused parameter caret; middle column carries
the node label, optional connection dropdown, SchemaForm, WebhookUrlHint
(for http.webhook), Run/Delete/Done buttons; right column shows the
port-0 outputs read-only. ESC and backdrop-click both call onClose;
changes flow through onPatch so the parent still owns the toolbar Save.

Adds a `server` alias export to src/testing/msw so the dialog test file
can import the shared MSW server under the shorter name used by newer
tests without breaking the existing `mswServer` import sites.
This commit is contained in:
Claude
2026-08-30 11:36:37 +00:00
parent 1153e56cc5
commit 2649e09da9
3 changed files with 510 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { type ComponentProps, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "@/testing/msw";
import type { Node } from "@xyflow/react";
import type { VcNodeData } from "./graphAdapter";
import { NodeEditorDialog } from "./NodeEditorDialog";
import type { ConnectionDto, ExecutionDetail, ExecutionSummary, NodeTypeDto } from "@/api/types";
const NODE_TYPES: NodeTypeDto[] = [
{
id: "http.webhook", displayName: "Webhook", category: "Trigger",
inputCount: 0, outputCount: 1, connectionProviderId: null,
configSchema: { type: "object", properties: { path: { type: "string", title: "URL path" } } },
},
{
id: "http.response", displayName: "Web Response", category: "Terminal",
inputCount: 1, outputCount: 0, connectionProviderId: null,
configSchema: {
type: "object",
properties: {
status: { type: "integer", title: "HTTP status", default: 200 },
body: { type: "string", title: "Body" },
},
},
},
];
function defaultNode(): Node<VcNodeData> {
return {
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "http.response", name: "Reply", config: {}, connection_id: null },
};
}
// Non-stateful render — for tests that don't mutate node state.
function renderDialog(overrides: Partial<ComponentProps<typeof NodeEditorDialog>> = {}) {
const props: ComponentProps<typeof NodeEditorDialog> = {
node: defaultNode(),
nodeTypes: NODE_TYPES,
connections: [],
workflowId: "wf1",
workflowActive: false,
triggerNodeIds: ["t1"],
onPatch: vi.fn(),
onDelete: vi.fn(),
onClose: vi.fn(),
...overrides,
};
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const utils = render(
<QueryClientProvider client={qc}>
<NodeEditorDialog {...props} />
</QueryClientProvider>,
);
return { ...utils, props };
}
// Stateful render — for tests that type into controlled fields. The wrapper
// applies onPatch to local state so React doesn't fight the DOM value.
function renderControlledDialog(overrides: Partial<ComponentProps<typeof NodeEditorDialog>> = {}) {
function Harness() {
const [n, setN] = useState<Node<VcNodeData>>(overrides.node ?? defaultNode());
return (
<NodeEditorDialog
node={n}
nodeTypes={overrides.nodeTypes ?? NODE_TYPES}
connections={overrides.connections ?? []}
workflowId={overrides.workflowId ?? "wf1"}
workflowActive={overrides.workflowActive ?? false}
triggerNodeIds={overrides.triggerNodeIds ?? ["t1"]}
onPatch={(patch) => setN((prev) => ({ ...prev, data: { ...prev.data, ...patch } }))}
onDelete={overrides.onDelete ?? (() => {})}
onClose={overrides.onClose ?? (() => {})}
/>
);
}
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<Harness />
</QueryClientProvider>,
);
}
beforeEach(() => {
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([] as ExecutionSummary[])),
);
});
describe("NodeEditorDialog", () => {
it("renders the three column headers", () => {
renderDialog();
expect(screen.getByRole("heading", { name: /input/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /parameters/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /output/i })).toBeInTheDocument();
});
it("ESC calls onClose", async () => {
const { props } = renderDialog();
await userEvent.keyboard("{Escape}");
expect(props.onClose).toHaveBeenCalled();
});
it("clicking the backdrop calls onClose", async () => {
const { props } = renderDialog();
await userEvent.click(screen.getByTestId("dialog-backdrop"));
expect(props.onClose).toHaveBeenCalled();
});
it("editing the node label flows into node.data.name", async () => {
renderControlledDialog();
const label = screen.getByLabelText(/node label/i) as HTMLTextAreaElement;
await userEvent.clear(label);
await userEvent.type(label, "Custom");
expect(label.value).toBe("Custom");
});
it("editing a parameter flows into node.data.config", async () => {
renderControlledDialog();
const body = screen.getByLabelText(/^body/i) as HTMLTextAreaElement;
await userEvent.type(body, "hi");
expect(body.value).toBe("hi");
});
it("Run button is disabled when there are no trigger nodes", () => {
renderDialog({ triggerNodeIds: [] });
expect(screen.getByRole("button", { name: /^run/i })).toBeDisabled();
});
it("Run button POSTs an execution and shows the fresh row on success", async () => {
server.use(
http.post("/api/workflows/:id/executions", () =>
HttpResponse.json({ executionId: "e-new" }, { status: 202 })),
http.get("/api/executions/e-new", () =>
HttpResponse.json({
id: "e-new", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null,
nodes: [
{ nodeId: "t1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [], outputs: [[{ json: { code: 200 } }]] },
{ nodeId: "n1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [[{ json: { code: 200 } }]], outputs: [] },
],
} satisfies ExecutionDetail)),
);
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /^run/i }));
await waitFor(() => expect(screen.getByText("code")).toBeInTheDocument());
});
it("Webhook URL block shows for http.webhook", () => {
const node: Node<VcNodeData> = {
id: "w1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "http.webhook", name: "Webhook", config: { path: "hook" }, connection_id: null },
};
renderDialog({ node });
expect(screen.getByText(/webhook url/i)).toBeInTheDocument();
});
it("Webhook URL block is hidden for non-webhook types", () => {
renderDialog();
expect(screen.queryByText(/webhook url/i)).not.toBeInTheDocument();
});
it("connection dropdown shows only when node type has connectionProviderId", () => {
const withConn: NodeTypeDto = {
...NODE_TYPES[1],
id: "nats.publish", connectionProviderId: "nats", displayName: "NATS Publish",
};
const node: Node<VcNodeData> = {
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "nats.publish", name: "Pub", config: {}, connection_id: null },
};
const connections: ConnectionDto[] = [{ id: "c1", providerId: "nats", name: "prod", config: {} }];
renderDialog({ node, nodeTypes: [...NODE_TYPES, withConn], connections });
expect(screen.getByLabelText(/connection/i)).toBeInTheDocument();
});
it("Delete button calls onDelete then onClose", async () => {
const { props } = renderDialog();
await userEvent.click(screen.getByRole("button", { name: /delete node/i }));
expect(props.onDelete).toHaveBeenCalled();
expect(props.onClose).toHaveBeenCalled();
});
it("clicking a leaf in the Input tree inserts {input.<path>} at the focused parameter", async () => {
// Seed an execution so the input tree has a leaf.
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([
{ id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null } satisfies ExecutionSummary,
])),
http.get("/api/executions/e1", () =>
HttpResponse.json({
id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null,
nodes: [
{ nodeId: "n1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [[{ json: { email: "a@b" } }]], outputs: [] },
],
} satisfies ExecutionDetail)),
);
// Controlled — the body textarea is a controlled input and would fight
// the DOM value on each render without a real state owner.
renderControlledDialog();
const body = await screen.findByLabelText(/^body/i) as HTMLTextAreaElement;
body.focus();
await userEvent.click(await screen.findByText("email"));
expect(body.value).toBe("{input.email}");
});
});

View File

@@ -0,0 +1,286 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { X, Play, Trash2, Copy, Check } from "lucide-react";
import type { Node } from "@xyflow/react";
import type { ConnectionDto, NodeTypeDto } from "@/api/types";
import { qk, useApiMutation, useExecution, useExecutions, useInvalidate } from "@/api/hooks";
import { apiFetch } from "@/api/client";
import { SchemaForm } from "@/forms/SchemaForm";
import { JsonTree } from "@/expr/JsonTree";
import { findLatestIo } from "./nodeIoLookup";
import type { VcNodeData } from "./graphAdapter";
import type { ExpressionFieldHandle } from "@/expr/ExpressionField";
interface Props {
node: Node<VcNodeData>;
nodeTypes: NodeTypeDto[];
connections: ConnectionDto[];
workflowId: string;
workflowActive: boolean;
triggerNodeIds: string[];
onPatch: (patch: Partial<VcNodeData>) => void;
onDelete: () => void;
onClose: () => void;
}
export function NodeEditorDialog({
node, nodeTypes, connections, workflowId, workflowActive,
triggerNodeIds, onPatch, onDelete, onClose,
}: Props) {
const nt = nodeTypes.find((t) => t.id === node.data.typeId);
const focusedFieldRef = useRef<ExpressionFieldHandle | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.preventDefault(); onClose(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const executions = useExecutions(workflowId);
const [pinnedExecutionId, setPinnedExecutionId] = useState<string | null>(null);
const eid = pinnedExecutionId ?? executions.data?.[0]?.id;
const execution = useExecution(eid);
const io = useMemo(
() => findLatestIo(execution.data, node.id),
[execution.data, node.id],
);
const inputsArray = normalizePorts(io?.inputs);
const outputsArray = normalizePorts(io?.outputs);
const previewInput = inputsArray[0]?.items[0]?.json;
const invalidate = useInvalidate();
const qc = useQueryClient();
const runMutation = useApiMutation(
() => {
const startFrom = triggerNodeIds[0];
if (!startFrom) throw new Error("no trigger");
return apiFetch<{ executionId: string }>(
`/api/workflows/${workflowId}/executions`,
{ method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } },
);
},
{
onSuccess: async (res) => {
await invalidate(qk.executions(workflowId));
await qc.invalidateQueries({ queryKey: qk.execution(res.executionId) });
setPinnedExecutionId(res.executionId);
},
},
);
const canRun = triggerNodeIds.length > 0 && !runMutation.isPending;
const registerFocus = useCallback((h: ExpressionFieldHandle) => {
focusedFieldRef.current = h;
}, []);
const insertPath = useCallback((path: string) => {
focusedFieldRef.current?.insertAtCaret(`{input.${path}}`);
}, []);
const filteredConnections = connections.filter((c) => c.providerId === nt?.connectionProviderId);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
data-testid="dialog-backdrop"
className="absolute inset-0 bg-black/60"
onClick={onClose}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`Edit ${nt?.displayName ?? node.data.typeId}`}
className="relative bg-slate-900 border border-slate-800 rounded-lg shadow-2xl w-[95vw] max-w-[1280px] h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-slate-800 px-4 py-3">
<div>
<div className="text-xs uppercase text-slate-500">{nt?.category ?? ""}</div>
<div className="text-lg font-medium">{nt?.displayName ?? node.data.typeId}</div>
</div>
<button
onClick={onClose}
aria-label="Close"
className="ml-auto text-slate-400 hover:text-slate-200"
>
<X size={18} />
</button>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[minmax(240px,25%)_1fr_minmax(240px,25%)] divide-x divide-slate-800">
<PortsColumn title="Input" ports={inputsArray} clickableFirst onPickPath={insertPath} />
<div className="p-4 overflow-y-auto space-y-4">
<h3 className="text-xs uppercase tracking-wide text-slate-500">Parameters</h3>
<label className="block text-sm space-y-1">
<span className="text-slate-300">Node label</span>
<textarea
aria-label="Node label"
value={node.data.name ?? ""}
onChange={(e) => onPatch({ name: e.target.value })}
rows={1}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm font-mono leading-5 resize-none"
/>
</label>
{nt?.connectionProviderId && (
<label className="block text-sm space-y-1">
<span className="text-slate-300">Connection</span>
<select
value={node.data.connection_id ?? ""}
onChange={(e) => onPatch({ connection_id: e.target.value || null })}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
>
<option value="">— none —</option>
{filteredConnections.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</label>
)}
{nt && (
<SchemaForm
schema={nt.configSchema}
value={node.data.config}
onChange={(v) => onPatch({ config: v })}
previewInput={previewInput}
onFieldFocus={registerFocus}
/>
)}
{node.data.typeId === "http.webhook" && (
<WebhookUrlHint
workflowId={workflowId}
workflowActive={workflowActive}
path={typeof node.data.config.path === "string" ? node.data.config.path : ""}
/>
)}
<div className="flex items-center gap-2 pt-2 border-t border-slate-800">
<button
onClick={() => runMutation.mutate()}
disabled={!canRun}
title={triggerNodeIds.length === 0 ? "Add a trigger node to run" : undefined}
className="px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-sm flex items-center gap-1.5 disabled:opacity-50"
>
<Play size={14} /> Run
</button>
{runMutation.isPending && <span className="text-xs text-slate-400">running…</span>}
{runMutation.isError && (
<span className="text-xs text-red-400">
{(runMutation.error as Error)?.message ?? "run failed"}
</span>
)}
<button
onClick={() => { onDelete(); onClose(); }}
className="ml-auto px-3 py-1.5 rounded text-sm text-red-400 hover:text-red-300 flex items-center gap-1"
>
<Trash2 size={14} /> Delete node
</button>
<button
onClick={onClose}
className="px-3 py-1.5 rounded bg-brand-600 hover:bg-brand-700 text-sm"
>
Done
</button>
</div>
</div>
<PortsColumn title="Output" ports={outputsArray} clickableFirst={false} />
</div>
</div>
</div>
);
}
interface Port {
items: { json: unknown }[];
}
function normalizePorts(v: unknown): Port[] {
if (!Array.isArray(v)) return [];
return v.map((port) => ({
items: Array.isArray(port) ? port.map((it) => ({ json: (it as { json: unknown } | null)?.json })) : [],
}));
}
function PortsColumn({
title, ports, clickableFirst, onPickPath,
}: {
title: "Input" | "Output";
ports: Port[];
clickableFirst: boolean;
onPickPath?: (path: string) => void;
}) {
return (
<div className="p-3 overflow-y-auto">
<h3 className="text-xs uppercase tracking-wide text-slate-500 mb-2">{title}</h3>
{ports.length === 0 && (
<div className="text-xs text-slate-500 italic">
No data yet — press <span className="text-slate-300">Run</span> or activate the workflow.
</div>
)}
{ports.map((port, p) => (
<div key={p} className="mb-3">
{ports.length > 1 && <div className="text-xs text-slate-500 mb-1">Port {p}</div>}
{port.items.length === 0 && <div className="text-xs text-slate-500 italic">(no items)</div>}
{port.items.map((it, i) => (
<div key={i} className="mb-2">
{port.items.length > 1 && <div className="text-xs text-slate-500 mb-1">Item {i}</div>}
<JsonTree
value={it.json}
pickable={clickableFirst && p === 0 && i === 0}
onPickPath={onPickPath}
/>
</div>
))}
</div>
))}
</div>
);
}
// Moved verbatim from the deleted NodeConfigPanel (Task 9 deletes it) — same behavior.
function WebhookUrlHint({
workflowId, workflowActive, path,
}: { workflowId: string; workflowActive: boolean; path: string }) {
const [copied, setCopied] = useState(false);
const cleanPath = path.replace(/^\/+/, "");
const url = `${window.location.origin}/wh/${workflowId}/${cleanPath}`;
return (
<div className="bg-slate-950/60 border border-slate-800 rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs uppercase text-slate-500">Webhook URL</span>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard blocked — leave state alone.
}
}}
className="text-slate-400 hover:text-slate-200 flex items-center gap-1 text-xs"
aria-label="Copy webhook URL"
>
{copied ? <><Check size={12} /> Copied</> : <><Copy size={12} /> Copy</>}
</button>
</div>
<code className="block break-all text-xs text-slate-200 font-mono">{url}</code>
<p className="text-xs text-slate-400">
Accepts <code className="text-slate-200">POST</code> (JSON body) or{" "}
<code className="text-slate-200">GET</code> (query params →{" "}
<code className="text-slate-200">{"{a:'1',…}"}</code>). Click the URL to trigger from the browser.
</p>
{!path && (
<p className="text-xs text-amber-400">Set a URL path above to complete the endpoint.</p>
)}
{!workflowActive && (
<p className="text-xs text-slate-500">
The webhook only responds when the workflow is <strong>active</strong> — activate it
from the toolbar to receive requests.
</p>
)}
</div>
);
}

View File

@@ -9,4 +9,7 @@ const defaultHandlers = [
]; ];
export const mswServer = setupServer(...defaultHandlers); export const mswServer = setupServer(...defaultHandlers);
// Alias so newer test files can `import { server } from "@/testing/msw"`
// without breaking the older `mswServer` import sites.
export const server = mswServer;
export { http, HttpResponse }; export { http, HttpResponse };