wacrm/src/components/flows/flow-canvas.tsx

681 lines
24 KiB
TypeScript

"use client";
/**
* Canvas / mind-map view of a flow. Editable, in parity with the
* list view for everything except the trigger / header / fallback
* panels (those are list-only — they don't fit visually inside a
* node graph and the user can switch to List for them).
*
* What this view does:
* - Renders every flow_node as a draggable tile, pan + zoom +
* minimap. Drag positions persist via the editor context
* (writing on dragStop, not every frame).
* - Renders edges between nodes, labeled per slot (button title,
* "true" / "false", list row title) so a branching flow reads
* as a real decision tree.
* - Click a node → side-sheet opens with the same per-node form
* the list view uses, plus "Set as entry" / "Delete".
* - Drag from a source handle on one node to a target handle on
* another → wires that slot's `next_node_key`. Per-slot handles
* for multi-outgoing types (condition, send_buttons, send_list)
* so the user picks which branch they're wiring.
* - Backspace / Delete on a selected node → removes it AND clears
* every inbound `next_node_key` reference (no dangling arrows).
* - Delete on a selected edge → clears just that slot.
* - "+ Add node" floating button drops a new node at the visible
* viewport center.
* - Runs dagre auto-layout once on mount for flows whose
* `position_x` / `position_y` are all zero (pre-canvas flows
* and brand-new flows) — otherwise everything would pile at
* the origin.
*
* The toggle in `flow-editor-shell.tsx` swaps this in for
* `<FlowBuilder>` on the same page. Both views share the same
* `BuilderState` via `useFlowEditor()` — toggling never resets
* unsaved edits, and a drag here updates the same nodes array the
* list view reads.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
applyNodeChanges,
Background,
Controls,
Handle,
MiniMap,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Connection,
type Node as RfNode,
type Edge as RfEdge,
type NodeChange,
type NodeProps,
type OnNodeDrag,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
applyEdgeConnection,
deriveCanvasEdges,
outgoingSlots,
} from "@/lib/flows/edges";
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
import {
NODE_META,
summarizeNode,
type BuilderNode,
type NodeType,
} from "./shared";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFlowEditor } from "./flow-editor-state";
import { NodeConfigForm } from "./forms/node-config-form";
// React-Flow node `data` payload — the bits our custom renderer needs.
interface NodeData extends Record<string, unknown> {
node: BuilderNode;
isEntry: boolean;
/** Validator's "look here" pulse — flashes the card border for
* ~1.6s. Drives a CSS animation, doesn't change layout. */
isFlashed: boolean;
}
const NODE_WIDTH = 240;
// Best-effort default; actual height varies by summary length but
// dagre needs SOMETHING to compute rank spacing. Underestimating is
// safer than over (tighter layout that still doesn't overlap).
const NODE_HEIGHT = 90;
// ============================================================
// Custom node — one card per flow node, styled to match the list
// view's collapsed card so the two views feel like the same product.
// ============================================================
function FlowNodeCard({ data, selected }: NodeProps) {
const { node, isEntry, isFlashed } = data as NodeData;
const meta = NODE_META[node.node_type];
const summary = summarizeNode(node);
const Icon = meta.icon;
const slots = outgoingSlots(node);
// Start nodes are entry-only; nothing ever targets them, so they
// don't need an incoming Handle. Every other node type accepts
// incoming edges (including terminal handoff / end — they're the
// common targets).
const hasTarget = node.node_type !== "start";
// Single-slot nodes get a single source handle floated on the right
// edge of the card. Multi-slot nodes (condition, send_buttons,
// send_list) render slot rows inline so each handle visually sits
// next to the slot it represents.
const isMultiSlot = slots.length > 1;
return (
<div
className={cn(
"relative min-w-[220px] max-w-[260px] rounded-lg border bg-slate-900/95 px-3 py-2 text-left shadow-lg backdrop-blur transition-colors",
selected
? "border-primary ring-1 ring-primary/40"
: "border-slate-700 hover:border-slate-600",
// Flash overrides hover/selected colors briefly. Tailwind's
// built-in `animate-pulse` is too gentle; a ring with the
// amber accent matches the list view's flash semantics.
isFlashed && "!border-amber-400 ring-2 ring-amber-400/60",
)}
>
{hasTarget && (
<Handle
type="target"
position={Position.Left}
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
/>
)}
<div className="flex items-center gap-2">
<Icon className={cn("h-3.5 w-3.5 shrink-0", meta.color)} />
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400">
{meta.label}
</span>
{isEntry && (
<span className="ml-auto rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
Entry
</span>
)}
</div>
<div className="mt-1 truncate font-mono text-[11px] text-slate-300">
{node.node_key}
</div>
{summary && (
<div className="mt-1 line-clamp-2 text-xs text-slate-400">
{summary}
</div>
)}
{isMultiSlot && (
<div className="mt-2 flex flex-col gap-1 border-t border-slate-800 pt-2">
{slots.map((slot) => (
<div
key={slot.id}
className="relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px] text-slate-300"
>
<span className="truncate" title={slot.label}>
{slot.label}
</span>
<Handle
type="source"
id={slot.id}
position={Position.Right}
// Override default absolute positioning so the handle
// sits flush with the right edge of the card instead
// of floating at vertical center. The negative offset
// matches the card's px-3 + the handle's own radius.
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[12px] !transform-none !border-slate-600 !bg-slate-700"
/>
</div>
))}
</div>
)}
{!isMultiSlot && slots.length === 1 && (
<Handle
type="source"
id={slots[0].id}
position={Position.Right}
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
/>
)}
</div>
);
}
const NODE_TYPES = { flow: FlowNodeCard };
// ============================================================
// Root canvas
// ============================================================
/**
* Outer wrapper provides the React-Flow context to the inner body,
* so `useReactFlow()` works from anywhere in `FlowCanvasInner`
* (notably, the pan-to-flash effect). The split is required because
* useReactFlow() must be called inside a ReactFlowProvider.
*/
export function FlowCanvas() {
return (
<ReactFlowProvider>
<FlowCanvasInner />
</ReactFlowProvider>
);
}
function FlowCanvasInner() {
const {
state,
setState,
updateNodeConfig,
updateNodePosition,
updateNodePositions,
removeNode,
flashKey,
} = useFlowEditor();
const reactFlow = useReactFlow();
const builderNodes = state.nodes;
const entryNodeId = state.entry_node_id;
// Side-panel state — which node's form is open. Canvas-only UI; the
// list view's analogue is the per-card expanded set in
// flow-builder.tsx.
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
const selectedNode = useMemo(
() =>
selectedNodeKey
? builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null
: null,
[selectedNodeKey, builderNodes],
);
const autoLayoutPositions = useMemo(() => {
const canvasEdges = deriveCanvasEdges(builderNodes);
return shouldAutoLayout(builderNodes)
? autoLayout(
builderNodes.map((n) => ({
id: n.node_key,
width: NODE_WIDTH,
height: NODE_HEIGHT,
})),
canvasEdges.map((e) => ({ source: e.source, target: e.target })),
{ direction: "TB" },
)
: null;
}, [builderNodes]);
// If dagre had to place an all-zero flow, persist the generated
// positions into editor state once. Otherwise the next drag would
// save only the dragged node and every other node would fall back
// to (0,0), which feels like nodes teleporting around the canvas.
const persistedAutoLayoutRef = useRef(false);
useEffect(() => {
if (!autoLayoutPositions || persistedAutoLayoutRef.current) return;
persistedAutoLayoutRef.current = true;
updateNodePositions(
Object.fromEntries(
[...autoLayoutPositions].map(([key, pos]) => [key, pos]),
),
);
}, [autoLayoutPositions, updateNodePositions]);
const derivedRfNodes = useMemo(() => {
const nodes: RfNode<NodeData>[] = builderNodes.map((n) => {
const fallback = autoLayoutPositions?.get(n.node_key);
return {
id: n.node_key,
type: "flow",
position: {
x: fallback?.x ?? n.position_x ?? 0,
y: fallback?.y ?? n.position_y ?? 0,
},
data: {
node: n,
isEntry: n.node_key === entryNodeId,
isFlashed: n.node_key === flashKey,
},
};
});
return nodes;
}, [builderNodes, entryNodeId, flashKey, autoLayoutPositions]);
const [rfNodes, setRfNodes] = useState<RfNode<NodeData>[]>(derivedRfNodes);
useEffect(() => {
setRfNodes(derivedRfNodes);
}, [derivedRfNodes]);
const rfEdges = useMemo(() => {
const canvasEdges = deriveCanvasEdges(builderNodes);
// sourceHandle is now wired up — the FlowNodeCard renders a Handle
// per slot whose id matches the scheme in edges.ts, so React-Flow
// can hang the arrow off the right place on each card.
const rfEdges: RfEdge[] = canvasEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
label: e.label,
labelStyle: { fill: "#cbd5e1", fontSize: 11 },
labelBgStyle: { fill: "#0f172a" },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
style: { stroke: "#475569", strokeWidth: 1.5 },
}));
return rfEdges;
}, [builderNodes]);
const handleNodesChange = useCallback(
(changes: NodeChange<RfNode<NodeData>>[]) => {
setRfNodes((nodes) => applyNodeChanges(changes, nodes));
},
[],
);
// Drag-to-position: React-Flow tracks the visual drag internally and
// fires this once on release. We write the final coordinate back to
// the editor context (which flips `dirty`); save then ships the new
// positions in the existing PUT /api/flows/[id] body (the route
// already destructures position_x / position_y per migration 010).
// Writing only on dragStop (not on every position-change tick during
// the drag) keeps state updates cheap on long drags.
const handleNodeDragStop = useCallback<OnNodeDrag<RfNode<NodeData>>>(
(_event, node) => {
updateNodePosition(node.id, node.position.x, node.position.y);
},
[updateNodePosition],
);
// Pan to the flashed node when the validator panel requests one.
// Animate over 400ms; landing zoom is whatever the user already has
// (don't force a zoom reset — that would be jarring mid-edit).
useEffect(() => {
if (!flashKey) return;
const node = builderNodes.find((n) => n.node_key === flashKey);
if (!node) return;
const x = (node.position_x ?? 0) + NODE_WIDTH / 2;
const y = (node.position_y ?? 0) + NODE_HEIGHT / 2;
reactFlow.setCenter(x, y, {
zoom: reactFlow.getZoom(),
duration: 400,
});
}, [flashKey, builderNodes, reactFlow]);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
setSelectedNodeKey(node.id);
},
[],
);
// Drag-to-connect: React-Flow fires onConnect when the user drops a
// handle drag onto a target handle. We look up the source node,
// compute the right config patch via applyEdgeConnection (matches
// the same slot scheme as deriveCanvasEdges), and dispatch via
// updateNodeConfig. The resulting state change re-derives edges on
// the next render — no need to maintain a separate edge list.
const handleConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target || !connection.sourceHandle) {
return;
}
const sourceNode = builderNodes.find(
(n) => n.node_key === connection.source,
);
if (!sourceNode) return;
// Self-loops are a footgun (a button whose target is its own
// node = infinite reprompt). Reject silently — the user can
// still wire one via the per-node dropdown if they really want.
if (connection.source === connection.target) return;
const patch = applyEdgeConnection(
sourceNode,
connection.sourceHandle,
connection.target,
);
if (patch) updateNodeConfig(connection.source, patch);
},
[builderNodes, updateNodeConfig],
);
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
// fires this with the set of deleted-node objects; we route each
// through the editor context's removeNode (which now also unlinks
// inbound references so no dangling arrows survive). Closing the
// side panel on delete keeps the UI honest if the user deleted the
// node currently being edited.
const handleNodesDelete = useCallback(
(deleted: RfNode<NodeData>[]) => {
for (const n of deleted) {
removeNode(n.id);
if (selectedNodeKey === n.id) setSelectedNodeKey(null);
}
},
[removeNode, selectedNodeKey],
);
// Edge delete: clear the source node's slot rather than removing
// anything. Edges are derived from configs, so the only way to
// "delete" one is to null out its underlying next_node_key.
const handleEdgesDelete = useCallback(
(deleted: RfEdge[]) => {
for (const e of deleted) {
if (!e.sourceHandle) continue;
const sourceNode = builderNodes.find((n) => n.node_key === e.source);
if (!sourceNode) continue;
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, "");
if (patch) updateNodeConfig(e.source, patch);
}
},
[builderNodes, updateNodeConfig],
);
// Wrapped mutators that target the currently-selected node — pass to
// the form so each keystroke goes through the editor context (which
// flips `dirty` and feeds the validator).
const onSelectedUpdateConfig = useCallback(
(patch: Record<string, unknown>) => {
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
},
[selectedNodeKey, updateNodeConfig],
);
const handleDeleteSelected = useCallback(() => {
if (!selectedNodeKey) return;
removeNode(selectedNodeKey);
setSelectedNodeKey(null);
}, [selectedNodeKey, removeNode]);
const handleSetEntry = useCallback(() => {
if (!selectedNodeKey) return;
setState((s) => ({ ...s, entry_node_id: selectedNodeKey }));
}, [selectedNodeKey, setState]);
if (rfNodes.length === 0) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-950 text-sm text-slate-500">
<p>No nodes yet.</p>
<CanvasAddNodeButton />
</div>
);
}
return (
<>
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
proOptions={{ hideAttribution: true }}
onNodesChange={handleNodesChange}
onNodeDragStop={handleNodeDragStop}
onNodeClick={handleNodeClick}
onConnect={handleConnect}
onNodesDelete={handleNodesDelete}
onEdgesDelete={handleEdgesDelete}
// Default is "Backspace" only — accept both so Mac users
// hitting Delete (Fn+Backspace) get the same behavior.
deleteKeyCode={["Backspace", "Delete"]}
nodesConnectable={true}
edgesFocusable={true}
elementsSelectable={true}
// Lower default min/max zoom than the lib's defaults; the
// tiles already truncate their summary at a reasonable
// size, so we don't need to zoom past 1.5x.
minZoom={0.2}
maxZoom={1.5}
>
<Background gap={24} size={1} color="#1e293b" />
<Controls
className="!border-slate-700 !bg-slate-900 [&_button]:!border-slate-700 [&_button]:!bg-slate-900 [&_button:hover]:!bg-slate-800"
showInteractive={false}
/>
<MiniMap
pannable
zoomable
nodeColor="#334155"
maskColor="rgba(15, 23, 42, 0.7)"
className="!border !border-slate-700 !bg-slate-900"
/>
<Panel position="bottom-right" className="!bottom-4 !right-4">
<CanvasAddNodeButton />
</Panel>
</ReactFlow>
</div>
<NodeEditSheet
node={selectedNode}
isEntry={selectedNode?.node_key === entryNodeId}
allNodes={builderNodes}
onClose={() => setSelectedNodeKey(null)}
onUpdateConfig={onSelectedUpdateConfig}
onDelete={handleDeleteSelected}
onSetEntry={handleSetEntry}
/>
</>
);
}
// ============================================================
// Side panel — opens when a canvas node is clicked. Mounts the
// shared NodeConfigForm dispatcher so edits made here behave
// identically to the list view's per-card editor.
// ============================================================
function NodeEditSheet({
node,
isEntry,
allNodes,
onClose,
onUpdateConfig,
onDelete,
onSetEntry,
}: {
node: BuilderNode | null;
isEntry: boolean;
allNodes: BuilderNode[];
onClose: () => void;
onUpdateConfig: (patch: Record<string, unknown>) => void;
onDelete: () => void;
onSetEntry: () => void;
}) {
// Sheet is controlled — opens when a node is selected, closes via
// Esc / overlay / close button (all delegated to onClose).
const open = node !== null;
if (!node) {
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent side="right" className="w-full sm:max-w-md" />
</Sheet>
);
}
const meta = NODE_META[node.node_type];
const Icon = meta.icon;
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-l border-slate-800 bg-slate-950 p-0 sm:max-w-md"
>
<SheetHeader className="border-b border-slate-800 px-5 py-4">
<SheetTitle className="flex items-center gap-2 text-slate-100">
<Icon className={cn("h-4 w-4 shrink-0", meta.color)} />
<span>{meta.label}</span>
{isEntry && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
Entry
</span>
)}
</SheetTitle>
<SheetDescription className="font-mono text-[11px] text-slate-400">
{node.node_key}
</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4">
<NodeConfigForm
node={node}
allNodes={allNodes}
showAdvanced={false}
onUpdateConfig={onUpdateConfig}
/>
</div>
<SheetFooter className="border-t border-slate-800 px-5 py-3 sm:flex-row sm:justify-between">
{!isEntry ? (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry
</Button>
) : (
<span />
)}
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete node
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
// ============================================================
// Floating add-node button — bottom-right of the canvas. Mirrors
// the list view's AddNodeButton (same dropdown menu, same NodeType
// list, same icons via NODE_META) but drops the new node into the
// center of the visible viewport rather than appending to a list.
// ============================================================
const ADD_NODE_TYPES: NodeType[] = [
"start",
"send_buttons",
"send_list",
"send_message",
"send_media",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
];
function CanvasAddNodeButton() {
const reactFlow = useReactFlow();
const { addNode, updateNodePosition } = useFlowEditor();
const handleAdd = (type: NodeType) => {
const key = addNode(type);
// Place the new node at the visible canvas center. The Panel's
// own DOM lives inside ReactFlow so we can climb up to find the
// .react-flow root and read its bounding rect. If we can't find
// it (test envs, etc.), addNode's default (0, 0) is the fallback
// and the user can drag the node into view.
const root = document.querySelector(".react-flow") as HTMLElement | null;
if (!root) return;
const rect = root.getBoundingClientRect();
const center = reactFlow.screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
// so the card sits visually centered rather than top-left at the
// viewport center.
updateNodePosition(key, center.x - NODE_WIDTH / 2, center.y - NODE_HEIGHT / 2);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-200 shadow-lg transition-colors hover:bg-slate-800"
aria-label="Add node"
>
<Plus className="h-3.5 w-3.5" />
Add node
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-slate-700 bg-slate-900">
{ADD_NODE_TYPES.map((t) => {
const meta = NODE_META[t];
const Icon = meta.icon;
return (
<DropdownMenuItem key={t} onClick={() => handleAdd(t)}>
<Icon className={cn("h-3.5 w-3.5", meta.color)} />
{meta.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}