Files
govoplan-dataflow/webui/src/features/dataflow/DataflowCanvas.tsx
T

379 lines
12 KiB
TypeScript

import { useMemo, useRef, useState, type DragEvent } from "react";
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
Background,
BackgroundVariant,
ConnectionLineType,
Controls,
MiniMap,
ReactFlow,
reconnectEdge,
type Connection,
type Edge,
type ReactFlowInstance
} from "@xyflow/react";
import { StatePanel } from "@govoplan/core-webui";
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
import type {
DataflowDiagnostic,
NodeTypeDefinition,
NodePreviewDiagnostic,
PipelineGraph,
PipelineGraphNode
} from "../../api/dataflow";
import DataflowNode, { type DataflowFlowNode } from "./DataflowNode";
import {
closestProximityConnection,
graphEdgesFromFlow,
newGraphEdgeId,
proximityPreviewEdge
} from "./graphInteractions";
import { FALLBACK_NODE_LIBRARY, newNode } from "./model";
const nodeTypes = { dataflow: DataflowNode };
type DataflowCanvasProps = {
graph: PipelineGraph;
diagnostics: DataflowDiagnostic[];
nodeDiagnostics: NodePreviewDiagnostic[];
nodeLibrary: NodeTypeDefinition[];
selectedNodeId: string | null;
readOnly: boolean;
onGraphChange: (graph: PipelineGraph) => void;
onSelectNode: (nodeId: string | null) => void;
};
export default function DataflowCanvas({
graph,
diagnostics,
nodeDiagnostics,
nodeLibrary,
selectedNodeId,
readOnly,
onGraphChange,
onSelectNode
}: DataflowCanvasProps) {
const canvasRef = useRef<HTMLDivElement | null>(null);
const [instance, setInstance] = useState<ReactFlowInstance<DataflowFlowNode, Edge> | null>(null);
const [proximityEdge, setProximityEdge] = useState<Edge | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const reconnectSuccessful = useRef(true);
const reconnectingEdgeId = useRef<string | null>(null);
const errorNodeIds = useMemo(
() => new Set(diagnostics.filter((item) => item.severity === "error" && item.node_id).map((item) => item.node_id)),
[diagnostics]
);
const rowCounts = useMemo(
() => new Map(nodeDiagnostics.map((item) => [item.node_id, item.output_rows])),
[nodeDiagnostics]
);
const definitions = useMemo(
() => new Map(nodeLibrary.map((definition) => [definition.type, definition])),
[nodeLibrary]
);
const nodes = useMemo<DataflowFlowNode[]>(
() => graph.nodes.map((node) => {
const definition = definitions.get(node.type)
?? FALLBACK_NODE_LIBRARY.find((item) => item.type === node.type)
?? FALLBACK_NODE_LIBRARY[0];
return {
id: node.id,
type: "dataflow",
position: node.position,
initialWidth: 180,
initialHeight: node.type.startsWith("combine.") ? 64 : 54,
selected: node.id === selectedNodeId,
data: {
label: node.label,
transformType: node.type,
definition,
config: node.config,
hasError: errorNodeIds.has(node.id),
outputRows: rowCounts.get(node.id)
}
};
}),
[definitions, errorNodeIds, graph.nodes, rowCounts, 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: "dataflow-edge",
selected: edge.id === selectedEdgeId
})),
[graph.edges, selectedEdgeId]
);
const displayedEdges = useMemo(
() => proximityEdge ? [...edges, proximityEdge] : edges,
[edges, proximityEdge]
);
const updateNodes = (nextNodes: DataflowFlowNode[]) => {
const ids = new Set(nextNodes.map((node) => node.id));
const nextGraphNodes = nextNodes.map((flowNode) => {
const current = graph.nodes.find((node) => node.id === flowNode.id);
if (!current) {
throw new Error(`Unknown Dataflow node: ${flowNode.id}`);
}
return { ...current, position: flowNode.position };
});
onGraphChange({
...graph,
nodes: nextGraphNodes,
edges: graph.edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target))
});
};
const restoreFocusAfterRemoval = (removedNodeIds: Set<string>) => {
const focusedNodeId = document.activeElement
?.closest<HTMLElement>(".react-flow__node")
?.dataset.id;
if (!focusedNodeId || !removedNodeIds.has(focusedNodeId)) return;
const removedIndex = graph.nodes.findIndex((node) => node.id === focusedNodeId);
const remainingNodes = graph.nodes.filter((node) => !removedNodeIds.has(node.id));
const nextNode = remainingNodes[Math.min(Math.max(0, removedIndex), remainingNodes.length - 1)];
requestAnimationFrame(() => {
const nextElement = nextNode
? canvasRef.current?.querySelector<HTMLElement>(
`.react-flow__node[data-id="${CSS.escape(nextNode.id)}"]`
)
: null;
(nextElement ?? canvasRef.current)?.focus();
});
};
const updateEdges = (nextEdges: Edge[]) => {
onGraphChange({
...graph,
edges: graphEdgesFromFlow(nextEdges)
});
};
const connectionError = (
connection: Connection | Edge,
ignoredEdgeId: string | null = reconnectingEdgeId.current
): string | null => definitionConnectionError(
ignoredEdgeId
? {
...graph,
edges: graph.edges.filter((edge) => edge.id !== ignoredEdgeId)
}
: graph,
nodeLibrary,
{
source: connection.source,
target: connection.target,
sourcePort: connection.sourceHandle,
targetPort: connection.targetHandle
}
);
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target) return false;
return connectionError(connection) === null;
};
const onConnect = (connection: Connection) => {
if (!isValidConnection(connection)) return;
const next = addEdge(
{
...connection,
id: newGraphEdgeId(),
type: "smoothstep"
},
edges
);
updateEdges(next);
};
const onReconnect = (oldEdge: Edge, connection: Connection) => {
if (readOnly || connectionError(connection, oldEdge.id)) return;
reconnectSuccessful.current = true;
updateEdges(reconnectEdge(
oldEdge,
connection,
edges,
{ shouldReplaceId: false }
));
};
const proximityConnection = (draggedNode: DataflowFlowNode) => (
closestProximityConnection(
draggedNode,
nodes,
graph,
nodeLibrary
)
);
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (readOnly || !instance) return;
const type = event.dataTransfer.getData("application/x-govoplan-dataflow-node");
if (!type) return;
const position = instance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const node = newNode(type, position, nodeLibrary);
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
onSelectNode(node.id);
};
return (
<div
ref={canvasRef}
className="definition-graph-canvas dataflow-canvas"
tabIndex={0}
aria-label="Dataflow graph canvas"
onDragOver={(event) => {
if (event.dataTransfer.types.includes("application/x-govoplan-dataflow-node")) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={onDrop}
>
<ReactFlow<DataflowFlowNode, Edge>
nodes={nodes}
edges={displayedEdges}
nodeTypes={nodeTypes}
onInit={setInstance}
onNodesChange={(changes) => {
if (readOnly) return;
const graphChanges = changes.filter((change) => change.type !== "dimensions");
if (graphChanges.length) {
restoreFocusAfterRemoval(new Set(
graphChanges
.filter((change) => change.type === "remove")
.map((change) => change.id)
));
updateNodes(applyNodeChanges(graphChanges, nodes));
}
}}
onEdgesChange={(changes) => {
if (readOnly) return;
const selectedChange = changes.find(
(change) => change.type === "select" && change.selected
);
if (selectedChange?.type === "select") {
setSelectedEdgeId(selectedChange.id);
} else if (changes.some(
(change) => (
change.type === "select"
&& change.id === selectedEdgeId
&& !change.selected
)
)) {
setSelectedEdgeId(null);
}
const graphChanges = changes.filter((change) => change.type !== "select");
if (graphChanges.length) {
if (graphChanges.some(
(change) => change.type === "remove" && change.id === selectedEdgeId
)) {
setSelectedEdgeId(null);
}
updateEdges(applyEdgeChanges(graphChanges, edges));
}
}}
onConnect={onConnect}
onReconnect={onReconnect}
onReconnectStart={(_event, edge) => {
reconnectSuccessful.current = false;
reconnectingEdgeId.current = edge.id;
setProximityEdge(null);
}}
onReconnectEnd={(_event, edge) => {
if (!reconnectSuccessful.current && !readOnly) {
updateEdges(edges.filter((candidate) => candidate.id !== edge.id));
setSelectedEdgeId(null);
}
reconnectSuccessful.current = true;
reconnectingEdgeId.current = null;
}}
onNodeDrag={(_event, node) => {
if (readOnly) return;
const connection = proximityConnection(node);
setProximityEdge(connection ? proximityPreviewEdge(connection) : null);
}}
onNodeDragStop={(_event, node) => {
setProximityEdge(null);
if (readOnly) return;
const connection = proximityConnection(node);
if (!connection) return;
const nextEdges = addEdge(
{
...connection,
id: newGraphEdgeId(),
type: "smoothstep",
className: "dataflow-edge"
},
edges
);
onGraphChange({
...graph,
nodes: graph.nodes.map((graphNode) => (
graphNode.id === node.id
? { ...graphNode, position: node.position }
: graphNode
)),
edges: graphEdgesFromFlow(nextEdges)
});
}}
isValidConnection={isValidConnection}
onNodeClick={(_event, node) => {
setSelectedEdgeId(null);
onSelectNode(node.id);
}}
onEdgeClick={(_event, edge) => {
setSelectedEdgeId(edge.id);
onSelectNode(null);
}}
onPaneClick={() => {
setSelectedEdgeId(null);
onSelectNode(null);
}}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
edgesReconnectable={!readOnly}
elementsSelectable
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 ? (
<StatePanel className="definition-graph-canvas-empty" size="fill" description="Drop a source here" />
) : null}
</div>
);
}
export function updateGraphNode(
graph: PipelineGraph,
updatedNode: PipelineGraphNode
): PipelineGraph {
return {
...graph,
nodes: graph.nodes.map((node) => node.id === updatedNode.id ? updatedNode : node)
};
}