diff --git a/frontend/src/editor/NodeEditorDialog.test.tsx b/frontend/src/editor/NodeEditorDialog.test.tsx new file mode 100644 index 0000000..7feda98 --- /dev/null +++ b/frontend/src/editor/NodeEditorDialog.test.tsx @@ -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 { + 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> = {}) { + const props: ComponentProps = { + 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( + + + , + ); + 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> = {}) { + function Harness() { + const [n, setN] = useState>(overrides.node ?? defaultNode()); + return ( + setN((prev) => ({ ...prev, data: { ...prev.data, ...patch } }))} + onDelete={overrides.onDelete ?? (() => {})} + onClose={overrides.onClose ?? (() => {})} + /> + ); + } + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +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 = { + 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 = { + 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.} 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}"); + }); +}); diff --git a/frontend/src/editor/NodeEditorDialog.tsx b/frontend/src/editor/NodeEditorDialog.tsx new file mode 100644 index 0000000..2e24edf --- /dev/null +++ b/frontend/src/editor/NodeEditorDialog.tsx @@ -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; + nodeTypes: NodeTypeDto[]; + connections: ConnectionDto[]; + workflowId: string; + workflowActive: boolean; + triggerNodeIds: string[]; + onPatch: (patch: Partial) => 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(null); + const dialogRef = useRef(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(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 ( +
+
+
e.stopPropagation()} + > +
+
+
{nt?.category ?? ""}
+
{nt?.displayName ?? node.data.typeId}
+
+ +
+ +
+ +
+

Parameters

+