feat(editor): double-click opens node editor dialog; remove side panel
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user