feat(editor): double-click opens node editor dialog; remove side panel

This commit is contained in:
Claude
2026-08-30 11:46:42 +00:00
parent abc71e481e
commit 045cdb7b7e
3 changed files with 39 additions and 150 deletions

View File

@@ -15,11 +15,12 @@ interface Props {
onNodesChange: (n: Node<VcNodeData>[]) => void;
onEdgesChange: (e: Edge[]) => void;
onSelectNode: (id: string | null) => void;
onEditNode: (id: string) => void;
onDropNodeType: (typeId: string, screenX: number, screenY: number) => void;
}
function Inner({
nodes, edges, onNodesChange, onEdgesChange, onSelectNode, onDropNodeType,
nodes, edges, onNodesChange, onEdgesChange, onSelectNode, onEditNode, onDropNodeType,
}: Props) {
const { screenToFlowPosition } = useReactFlow();
@@ -53,6 +54,7 @@ function Inner({
onEdgesChange={handleEdges}
onConnect={onConnect}
onSelectionChange={({ nodes: sel }) => onSelectNode(sel[0]?.id ?? null)}
onNodeDoubleClick={(_e, n) => onEditNode(n.id)}
onNodesDelete={(deleted) => {
// React Flow removes the nodes via applyNodeChanges above; it does
// NOT auto-remove edges whose endpoints just vanished. Filter here

View File

@@ -1,125 +0,0 @@
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { useNodeTypes, useConnections } from "@/api/hooks";
import { SchemaForm } from "@/forms/SchemaForm";
import type { Node } from "@xyflow/react";
import type { VcNodeData } from "./graphAdapter";
interface Props {
selected: Node<VcNodeData> | null;
onChange: (patch: Partial<VcNodeData>) => void;
onDelete: () => void;
workflowId: string;
workflowActive: boolean;
}
export function NodeConfigPanel({ selected, onChange, onDelete, workflowId, workflowActive }: Props) {
const nts = useNodeTypes();
const cs = useConnections();
if (!selected) {
return (
<aside className="w-80 border-l border-slate-800 p-4 text-sm text-slate-500">
Select a node to configure it.
</aside>
);
}
const nt = nts.data?.find((t) => t.id === selected.data.typeId);
const filtered = cs.data?.filter((c) => c.providerId === nt?.connectionProviderId) ?? [];
return (
<aside className="w-80 border-l border-slate-800 p-4 space-y-4 overflow-y-auto">
<div>
<div className="text-xs uppercase text-slate-500">{nt?.category}</div>
<div className="text-lg font-medium">{nt?.displayName ?? selected.data.typeId}</div>
</div>
<label className="block text-sm space-y-1">
<span className="text-slate-300">Node label</span>
<input
value={selected.data.name ?? ""}
onChange={(e) => onChange({ name: e.target.value })}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
/>
</label>
{nt?.connectionProviderId && (
<label className="block text-sm space-y-1">
<span className="text-slate-300">Connection</span>
<select
value={selected.data.connection_id ?? ""}
onChange={(e) => onChange({ 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>
{filtered.map((c) => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
</label>
)}
{nt && (
<SchemaForm
schema={nt.configSchema}
value={selected.data.config}
onChange={(v) => onChange({ config: v })}
/>
)}
{selected.data.typeId === "http.webhook" && (
<WebhookUrlHint
workflowId={workflowId}
workflowActive={workflowActive}
path={typeof selected.data.config.path === "string" ? selected.data.config.path : ""}
/>
)}
<button
onClick={onDelete}
className="mt-4 text-sm text-red-400 hover:text-red-300"
>
Delete node
</button>
</aside>
);
}
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 (insecure origin, no permission) — 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

@@ -1,14 +1,13 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Edge, Node } from "@xyflow/react";
import { useNodeTypes, useWorkflow } from "@/api/hooks";
import { useNodeTypes, useConnections, useWorkflow } from "@/api/hooks";
import { Canvas } from "@/editor/Canvas";
import { NodePalette } from "@/editor/NodePalette";
import { NodeConfigPanel } from "@/editor/NodeConfigPanel";
import { fromGraph, type VcNodeData } from "@/editor/graphAdapter";
import { NodeEditorDialog } from "@/editor/NodeEditorDialog";
import { fromGraph, toGraph, type VcNodeData } from "@/editor/graphAdapter";
import { apiFetch } from "@/api/client";
import { qk, useApiMutation, useInvalidate } from "@/api/hooks";
import { toGraph } from "@/editor/graphAdapter";
import { Play, Pause, Save, PlayCircle } from "lucide-react";
function newNodeId() {
@@ -19,10 +18,12 @@ export default function EditorPage() {
const { id } = useParams();
const wf = useWorkflow(id);
const nts = useNodeTypes();
const cs = useConnections();
const [nodes, setNodes] = useState<Node<VcNodeData>[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
useEffect(() => {
if (!wf.data) return;
@@ -31,22 +32,21 @@ export default function EditorPage() {
setEdges(e);
}, [wf.data]);
const selected = useMemo(
() => nodes.find((n) => n.id === selectedId) ?? null,
[nodes, selectedId],
const editingNode = useMemo(
() => nodes.find((n) => n.id === editingId) ?? null,
[nodes, editingId],
);
function patchSelected(patch: Partial<VcNodeData>) {
if (!selected) return;
setNodes((ns) => ns.map((n) => n.id === selected.id
function patchNode(nodeId: string, patch: Partial<VcNodeData>) {
setNodes((ns) => ns.map((n) => n.id === nodeId
? { ...n, data: { ...n.data, ...patch } }
: n));
}
function deleteSelected() {
if (!selected) return;
setNodes((ns) => ns.filter((n) => n.id !== selected.id));
setEdges((es) => es.filter((e) => e.source !== selected.id && e.target !== selected.id));
setSelectedId(null);
function deleteNode(nodeId: string) {
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
if (selectedId === nodeId) setSelectedId(null);
if (editingId === nodeId) setEditingId(null);
}
function dropNodeType(typeId: string, x: number, y: number) {
const nt = nts.data?.find((t) => t.id === typeId);
@@ -65,7 +65,6 @@ export default function EditorPage() {
}
const invalidate = useInvalidate();
const save = useApiMutation(
() =>
apiFetch<void>(`/api/workflows/${id}`, {
@@ -82,6 +81,7 @@ export default function EditorPage() {
() => apiFetch<void>(`/api/workflows/${id}/deactivate`, { method: "POST" }),
{ onSuccess: () => id && invalidate(qk.workflow(id)) },
);
const [testStatus, setTestStatus] = useState<
| { kind: "idle" }
| { kind: "running" }
@@ -95,11 +95,16 @@ export default function EditorPage() {
clearTimer.current = setTimeout(() => setTestStatus({ kind: "idle" }), ms);
}
const triggerNodeIds = useMemo(
() => nodes
.filter((n) => nts.data?.find((t) => t.id === n.data.typeId)?.category === "Trigger")
.map((n) => n.id),
[nodes, nts.data],
);
const testRun = useApiMutation(
() => {
const triggers = nodes.filter((n) =>
nts.data?.find((t) => t.id === n.data.typeId)?.category === "Trigger");
const startFrom = (selected?.id) || triggers[0]?.id;
const startFrom = selectedId || triggerNodeIds[0];
if (!startFrom) throw new Error("Add a trigger node or select a node to start from.");
return apiFetch<{ executionId: string }>(
`/api/workflows/${id}/executions`,
@@ -202,17 +207,24 @@ export default function EditorPage() {
nodes={nodes} edges={edges}
onNodesChange={setNodes} onEdgesChange={setEdges}
onSelectNode={setSelectedId}
onEditNode={setEditingId}
onDropNodeType={dropNodeType}
/>
</div>
<NodeConfigPanel
selected={selected}
onChange={patchSelected}
onDelete={deleteSelected}
</div>
{editingNode && (
<NodeEditorDialog
node={editingNode}
nodeTypes={nts.data ?? []}
connections={cs.data ?? []}
workflowId={id ?? ""}
workflowActive={wf.data.active}
triggerNodeIds={triggerNodeIds}
onPatch={(patch) => patchNode(editingNode.id, patch)}
onDelete={() => deleteNode(editingNode.id)}
onClose={() => setEditingId(null)}
/>
</div>
)}
</div>
);
}