feat: persist and edit versioned workflow definitions
This commit is contained in:
229
webui/src/features/workflow/WorkflowCanvas.tsx
Normal file
229
webui/src/features/workflow/WorkflowCanvas.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
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,
|
||||
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) updateNodes(applyNodeChanges(changes, 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
|
||||
)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user