238 lines
6.7 KiB
TypeScript
238 lines
6.7 KiB
TypeScript
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<WorkflowFlowNode, Edge> | 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<WorkflowFlowNode[]>(
|
|
() => 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<Edge[]>(
|
|
() => 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<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
className="workflow-canvas"
|
|
onDragOver={(event) => {
|
|
if (
|
|
event.dataTransfer.types.includes(
|
|
"application/x-govoplan-workflow-node"
|
|
)
|
|
) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "copy";
|
|
}
|
|
}}
|
|
onDrop={onDrop}
|
|
>
|
|
<ReactFlow<WorkflowFlowNode, Edge>
|
|
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}
|
|
>
|
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1.3} />
|
|
<MiniMap
|
|
pannable
|
|
zoomable
|
|
nodeStrokeWidth={2}
|
|
nodeColor={(node) =>
|
|
node.data.hasError ? "var(--danger)" : "var(--accent)"
|
|
}
|
|
/>
|
|
<Controls showInteractive={false} />
|
|
</ReactFlow>
|
|
{!graph.nodes.length ? (
|
|
<div className="workflow-canvas-empty">Drop a start node here</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function updateWorkflowGraphNode(
|
|
graph: WorkflowGraph,
|
|
updatedNode: WorkflowGraphNode
|
|
): WorkflowGraph {
|
|
return {
|
|
...graph,
|
|
nodes: graph.nodes.map((node) =>
|
|
node.id === updatedNode.id ? updatedNode : node
|
|
)
|
|
};
|
|
}
|