Initialize governed Dataflow module

This commit is contained in:
2026-07-28 01:28:17 +02:00
commit 223c008ff3
44 changed files with 6204 additions and 0 deletions
@@ -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)
};
}