Initialize governed Dataflow module
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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 type {
|
||||
DataflowDiagnostic,
|
||||
NodePreviewDiagnostic,
|
||||
PipelineGraph,
|
||||
PipelineGraphNode
|
||||
} from "../../api/dataflow";
|
||||
import DataflowNode, { type DataflowFlowNode } from "./DataflowNode";
|
||||
import { newNode } from "./model";
|
||||
|
||||
const nodeTypes = { dataflow: DataflowNode };
|
||||
|
||||
type DataflowCanvasProps = {
|
||||
graph: PipelineGraph;
|
||||
diagnostics: DataflowDiagnostic[];
|
||||
nodeDiagnostics: NodePreviewDiagnostic[];
|
||||
selectedNodeId: string | null;
|
||||
readOnly: boolean;
|
||||
onGraphChange: (graph: PipelineGraph) => void;
|
||||
onSelectNode: (nodeId: string | null) => void;
|
||||
};
|
||||
|
||||
export default function DataflowCanvas({
|
||||
graph,
|
||||
diagnostics,
|
||||
nodeDiagnostics,
|
||||
selectedNodeId,
|
||||
readOnly,
|
||||
onGraphChange,
|
||||
onSelectNode
|
||||
}: DataflowCanvasProps) {
|
||||
const [instance, setInstance] = useState<ReactFlowInstance<DataflowFlowNode, Edge> | 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 nodes = useMemo<DataflowFlowNode[]>(
|
||||
() => graph.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "dataflow",
|
||||
position: node.position,
|
||||
selected: node.id === selectedNodeId,
|
||||
data: {
|
||||
label: node.label,
|
||||
transformType: node.type,
|
||||
config: node.config,
|
||||
hasError: errorNodeIds.has(node.id),
|
||||
outputRows: rowCounts.get(node.id)
|
||||
}
|
||||
})),
|
||||
[errorNodeIds, graph.nodes, rowCounts, selectedNodeId]
|
||||
);
|
||||
const edges = useMemo<Edge[]>(
|
||||
() => graph.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: "smoothstep",
|
||||
className: "dataflow-edge"
|
||||
})),
|
||||
[graph.edges]
|
||||
);
|
||||
|
||||
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 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 || connection.source === connection.target) return false;
|
||||
const source = graph.nodes.find((node) => node.id === connection.source);
|
||||
const target = graph.nodes.find((node) => node.id === connection.target);
|
||||
if (!source || !target || source.type === "output" || target.type.startsWith("source.")) return false;
|
||||
const targetAlreadyConnected = graph.edges.some(
|
||||
(edge) => edge.target === connection.target && edge.source !== connection.source
|
||||
);
|
||||
return !targetAlreadyConnected;
|
||||
};
|
||||
|
||||
const onConnect = (connection: Connection) => {
|
||||
if (!isValidConnection(connection)) return;
|
||||
const next = addEdge(
|
||||
{
|
||||
...connection,
|
||||
id: `edge-${connection.source}-${connection.target}-${crypto.randomUUID()}`,
|
||||
type: "smoothstep"
|
||||
},
|
||||
edges
|
||||
);
|
||||
updateEdges(next);
|
||||
};
|
||||
|
||||
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);
|
||||
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
|
||||
onSelectNode(node.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dataflow-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={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onInit={setInstance}
|
||||
onNodesChange={(changes) => {
|
||||
if (readOnly) return;
|
||||
updateNodes(applyNodeChanges(changes, nodes));
|
||||
}}
|
||||
onEdgesChange={(changes) => {
|
||||
if (readOnly) return;
|
||||
updateEdges(applyEdgeChanges(changes, edges));
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeClick={(_event, node) => onSelectNode(node.id)}
|
||||
onPaneClick={() => onSelectNode(null)}
|
||||
nodesDraggable={!readOnly}
|
||||
nodesConnectable={!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 ? (
|
||||
<div className="dataflow-canvas-empty">Drop a source here</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function updateGraphNode(
|
||||
graph: PipelineGraph,
|
||||
updatedNode: PipelineGraphNode
|
||||
): PipelineGraph {
|
||||
return {
|
||||
...graph,
|
||||
nodes: graph.nodes.map((node) => node.id === updatedNode.id ? updatedNode : node)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user