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:
221
frontend/src/editor/NodeEditorDialog.test.tsx
Normal file
221
frontend/src/editor/NodeEditorDialog.test.tsx
Normal 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}");
|
||||||
|
});
|
||||||
|
});
|
||||||
286
frontend/src/editor/NodeEditorDialog.tsx
Normal file
286
frontend/src/editor/NodeEditorDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user