import { useMemo, useState, type DragEvent } from "react"; import { addEdge, applyEdgeChanges, applyNodeChanges, Background, BackgroundVariant, ConnectionLineType, Controls, MiniMap, ReactFlow, type Connection, type Edge, type ReactFlowInstance } from "@xyflow/react"; import { definitionConnectionError } from "@govoplan/core-webui/definition-graph"; import type { WorkflowDiagnostic, WorkflowGraph, WorkflowGraphNode, WorkflowNodeType } from "../../api/workflow"; import { newWorkflowNode } from "./model"; import WorkflowNode, { type WorkflowFlowNode } from "./WorkflowNode"; const nodeTypes = { workflow: WorkflowNode }; export default function WorkflowCanvas({ graph, diagnostics, nodeLibrary, selectedNodeId, readOnly, allowsCycles, onGraphChange, onSelectNode }: { graph: WorkflowGraph; diagnostics: WorkflowDiagnostic[]; nodeLibrary: WorkflowNodeType[]; selectedNodeId: string | null; readOnly: boolean; allowsCycles: boolean; onGraphChange: (graph: WorkflowGraph) => void; onSelectNode: (nodeId: string | null) => void; }) { const [instance, setInstance] = useState< ReactFlowInstance | null >(null); const definitions = useMemo( () => new Map(nodeLibrary.map((item) => [item.type, item])), [nodeLibrary] ); const errorNodeIds = useMemo( () => new Set( diagnostics .filter((item) => item.severity === "error" && item.node_id) .map((item) => item.node_id as string) ), [diagnostics] ); const nodes = useMemo( () => graph.nodes.flatMap((node) => { const definition = definitions.get(node.type); if (!definition) return []; return [{ id: node.id, type: "workflow" as const, position: node.position, initialWidth: 190, initialHeight: 56, selected: node.id === selectedNodeId, data: { label: node.label, workflowType: node.type, definition, hasError: errorNodeIds.has(node.id) } }]; }), [definitions, errorNodeIds, graph.nodes, selectedNodeId] ); const edges = useMemo( () => graph.edges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, sourceHandle: edge.source_port ?? "output", targetHandle: edge.target_port ?? "input", type: "smoothstep", className: "workflow-edge" })), [graph.edges] ); const updateNodes = (nextNodes: WorkflowFlowNode[]) => { const ids = new Set(nextNodes.map((node) => node.id)); onGraphChange({ ...graph, nodes: nextNodes.map((flowNode) => { const current = graph.nodes.find((node) => node.id === flowNode.id); if (!current) throw new Error(`Unknown Workflow node: ${flowNode.id}`); return { ...current, position: flowNode.position }; }), edges: graph.edges.filter( (edge) => ids.has(edge.source) && ids.has(edge.target) ) }); }; const updateEdges = (nextEdges: Edge[]) => { onGraphChange({ ...graph, edges: nextEdges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, source_port: edge.sourceHandle ?? "output", target_port: edge.targetHandle ?? "input" })) }); }; const isValidConnection = (connection: Connection | Edge): boolean => { if (readOnly || !connection.source || !connection.target) return false; return definitionConnectionError( graph, nodeLibrary, { source: connection.source, target: connection.target, sourcePort: connection.sourceHandle, targetPort: connection.targetHandle }, { allowCycles: allowsCycles } ) === null; }; const onDrop = (event: DragEvent) => { event.preventDefault(); if (readOnly || !instance) return; const type = event.dataTransfer.getData( "application/x-govoplan-workflow-node" ); if (!type) return; const node = newWorkflowNode( type, instance.screenToFlowPosition({ x: event.clientX, y: event.clientY }), nodeLibrary ); onGraphChange({ ...graph, nodes: [...graph.nodes, node] }); onSelectNode(node.id); }; return (
{ if ( event.dataTransfer.types.includes( "application/x-govoplan-workflow-node" ) ) { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; } }} onDrop={onDrop} > nodes={nodes} edges={edges} nodeTypes={nodeTypes} onInit={setInstance} onNodesChange={(changes) => { if (readOnly) return; const graphChanges = changes.filter( (change) => change.type !== "dimensions" ); if (graphChanges.length) { updateNodes(applyNodeChanges(graphChanges, nodes)); } }} onEdgesChange={(changes) => { if (!readOnly) updateEdges(applyEdgeChanges(changes, edges)); }} onConnect={(connection) => { if (!isValidConnection(connection)) return; updateEdges(addEdge({ ...connection, id: `edge-${crypto.randomUUID()}`, type: "smoothstep" }, edges)); }} isValidConnection={isValidConnection} onNodeClick={(_event, node) => onSelectNode(node.id)} onPaneClick={() => onSelectNode(null)} nodesDraggable={!readOnly} nodesConnectable={!readOnly} deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]} connectionLineType={ConnectionLineType.SmoothStep} connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }} connectionRadius={32} fitView fitViewOptions={{ padding: 0.22, maxZoom: 1.25 }} minZoom={0.25} maxZoom={1.8} > node.data.hasError ? "var(--danger)" : "var(--accent)" } /> {!graph.nodes.length ? (
Drop a start node here
) : null}
); } export function updateWorkflowGraphNode( graph: WorkflowGraph, updatedNode: WorkflowGraphNode ): WorkflowGraph { return { ...graph, nodes: graph.nodes.map((node) => node.id === updatedNode.id ? updatedNode : node ) }; }