corrad-bp/components/process-flow/ProcessFlowCanvas.vue
Afiq fa90c234c5 Enhance Process Builder with New Features and Configuration Options
- Added a new ProcessTemplatesModal component for selecting process flow templates, improving user experience in template management.
- Introduced a ProcessSettingsModal component for comprehensive process configuration, including process info, execution settings, and permissions management.
- Updated BusinessRuleNodeConfiguration and FormNodeConfiguration components to enhance user interaction and streamline configuration processes.
- Implemented new API endpoints for managing form fields and settings, allowing for better integration and data handling.
- Enhanced existing components with improved styling and functionality, including dynamic field conditions and bidirectional data mapping.
- Updated nuxt.config.js to include security settings for API routes, ensuring better protection against XSS and request size limitations.
- Removed the deprecated TaskNodeConfiguration component to streamline the process builder interface.
- Improved documentation to reflect recent changes and enhancements in the process builder features.
2025-05-27 17:00:24 +08:00

679 lines
17 KiB
Vue

<script setup>
import { ref, onMounted, computed, shallowRef, watch, defineExpose } from 'vue';
import { VueFlow, useVueFlow, Panel } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import { nodeTypes as customNodeTypes, nodeStyles } from './ProcessFlowNodes';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/minimap/dist/style.css';
// Add Material Icons import
const materialIconsLink = document.createElement('link');
materialIconsLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
materialIconsLink.rel = 'stylesheet';
document.head.appendChild(materialIconsLink);
const props = defineProps({
initialNodes: {
type: Array,
default: () => []
},
initialEdges: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['nodeClick', 'edgeClick', 'paneClick', 'nodesChange', 'edgesChange', 'nodeSelected', 'edgeSelected']);
// Get the flow instance
const { flowInstance } = useVueFlow();
// Initialize Vue Flow
const {
nodes,
edges,
addNodes,
addEdges,
onNodesChange,
onEdgesChange,
onConnect,
fitView,
project,
removeNodes,
removeEdges,
updateNodeInternals
} = useVueFlow({
defaultEdgeOptions: {
animated: true,
type: 'smoothstep'
},
deleteKeyCode: 'Delete',
selectionKeyCode: 'Shift',
multiSelectionKeyCode: 'Control',
connectionMode: 'loose',
isValidConnection: (connection) => {
// console.log('Validating connection:', connection);
return true;
}
});
// Default nodes if empty
const defaultNodes = [];
// Default edges if empty
const defaultEdges = [];
// Flow configuration
const flowOptions = ref({
defaultZoom: 1,
minZoom: 0.2,
maxZoom: 4,
fitViewOnInit: true,
snapToGrid: true,
snapGrid: [15, 15],
edgeUpdaterRadius: 10,
connectionMode: 'loose',
connectionRadius: 25,
elevateEdgesOnSelect: true,
nodesDraggable: true,
nodesConnectable: true,
elementsSelectable: true,
selectNodesOnDrag: false,
panOnDrag: [0, 2],
panOnScroll: false,
zoomOnScroll: true,
zoomOnPinch: true,
zoomOnDoubleClick: false,
connectOnClick: false
});
// Use shallowRef for selected node to avoid unnecessary reactivity
const selectedNode = shallowRef(null);
// Handle node selection
const onNodeClick = ({ node }) => {
// Check if node exists and has required properties
if (!node || !node.id) {
console.warn('Invalid node clicked - Missing required properties');
return;
}
try {
// Create a plain object copy of the node to avoid reactivity issues
const nodeData = {
id: node.id,
type: node.type,
label: node.label || (node.data && node.data.label) || '',
data: node.data ? JSON.parse(JSON.stringify(node.data)) : {},
position: node.dimensions ? {
x: node.dimensions.x || 0,
y: node.dimensions.y || 0
} : { x: 0, y: 0 }
};
// console.log('Processed node data:', nodeData);
selectedNode.value = nodeData;
emit('nodeSelected', nodeData);
} catch (error) {
console.error('Error processing node data:', error);
}
};
// Handle edge click
const onEdgeClick = (event, edge) => {
// Create a simplified copy of the edge data
const edgeData = {
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label || '',
sourceNode: nodes.value.find(node => node.id === edge.source),
targetNode: nodes.value.find(node => node.id === edge.target)
};
emit('edgeSelected', edgeData);
emit('edgeClick', edge);
};
// Handle pane (background) click
const onPaneClick = () => {
selectedNode.value = null;
emit('paneClick');
};
// Window resize handler
const resizeFlow = () => {
setTimeout(() => {
fitView();
}, 200);
};
onMounted(() => {
// Initialize with provided nodes or default ones
if (props.initialNodes.length) {
addNodes(props.initialNodes);
} else {
addNodes(defaultNodes);
}
// Initialize with provided edges or default ones
if (props.initialEdges.length) {
addEdges(props.initialEdges);
} else {
addEdges(defaultEdges);
}
// Setup window resize handler
window.addEventListener('resize', resizeFlow);
// Initial fit view
setTimeout(() => {
fitView();
}, 100);
});
// Watch for changes to initialNodes prop and update the canvas
watch(() => props.initialNodes, async (newNodes, oldNodes) => {
if (newNodes && Array.isArray(newNodes)) {
// console.log('ProcessFlowCanvas: Updating nodes, count:', newNodes.length);
// Clear existing nodes and add new ones
nodes.value = [];
if (newNodes.length > 0) {
addNodes([...newNodes]); // Create a copy to avoid reactivity issues
// Fit view to show all nodes after both nodes and edges are processed
await nextTick();
setTimeout(() => {
fitView();
}, 100);
}
}
}, { deep: true });
// Watch for changes to initialEdges prop and update the canvas
// This watcher depends on nodes being already present
watch(() => [props.initialEdges, nodes.value.length], async ([newEdges, nodeCount]) => {
if (newEdges && Array.isArray(newEdges) && nodeCount > 0) {
// console.log('ProcessFlowCanvas: Updating edges, count:', newEdges.length, 'nodeCount:', nodeCount);
// Clear existing edges
edges.value = [];
if (newEdges.length > 0) {
// Verify all nodes exist before adding edges
const validEdges = newEdges.filter(edge => {
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
if (!sourceExists || !targetExists) {
console.warn(`Skipping edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
return true;
});
if (validEdges.length > 0) {
// Ensure all edges have proper handle specifications
const edgesWithHandles = validEdges.map(edge => {
// If edge already has sourceHandle and targetHandle, use them
if (edge.sourceHandle && edge.targetHandle) {
return edge;
}
// Otherwise, generate default handles based on node types
const sourceNode = nodes.value.find(n => n.id === edge.source);
const targetNode = nodes.value.find(n => n.id === edge.target);
let sourceHandle = edge.sourceHandle;
let targetHandle = edge.targetHandle;
// Generate default source handle if missing
if (!sourceHandle && sourceNode) {
if (sourceNode.type === 'start') {
sourceHandle = `${edge.source}-bottom`; // Start nodes prefer bottom output
} else if (sourceNode.type === 'gateway') {
sourceHandle = `${edge.source}-right`; // Gateway nodes prefer right output for first connection
} else {
sourceHandle = `${edge.source}-bottom`; // Most nodes prefer bottom output
}
}
// Generate default target handle if missing
if (!targetHandle && targetNode) {
if (targetNode.type === 'end') {
targetHandle = `${edge.target}-top`; // End nodes prefer top input
} else {
targetHandle = `${edge.target}-top`; // Most nodes prefer top input
}
}
return {
...edge,
sourceHandle,
targetHandle
};
});
addEdges([...edgesWithHandles]); // Create a copy to avoid reactivity issues
// console.log('ProcessFlowCanvas: Successfully added edges with handles:', edgesWithHandles.length);
}
}
} else if (newEdges && Array.isArray(newEdges) && newEdges.length > 0 && nodeCount === 0) {
// console.log('ProcessFlowCanvas: Edges provided but no nodes yet, waiting...');
}
}, { deep: true });
// Remove the deep watch as it's causing recursive updates
// Handle node changes
onNodesChange((changes) => {
emit('nodesChange', changes, nodes.value);
});
// Handle edge changes
onEdgesChange((changes) => {
emit('edgesChange', changes, edges.value);
});
// Handle new connections
const handleConnect = (connection) => {
if (!connection.source || !connection.target) return;
console.log('Connection created:', connection);
// Try to determine if this is coming from a gateway
const sourceNode = nodes.value.find(node => node.id === connection.source);
let label = '';
// If the source is a gateway, we should add a label based on conditions
if (sourceNode && sourceNode.type === 'gateway') {
// Check if there's a default path label
if (sourceNode.data && sourceNode.data.defaultPath) {
label = sourceNode.data.defaultPath;
}
// For existing gateway connections, check if we should use a condition's output
const existingEdges = edges.value.filter(edge => edge.source === connection.source);
if (existingEdges.length === 0 && sourceNode.data.conditions && sourceNode.data.conditions.length > 0) {
// If this is the first connection and we have conditions, use the first condition's output
const firstCondition = sourceNode.data.conditions[0];
if (firstCondition && firstCondition.output) {
label = firstCondition.output;
}
} else if (sourceNode.data.conditions) {
// If we already have connections, try to find an unused condition
const usedOutputs = existingEdges.map(edge => edge.label);
const unusedCondition = sourceNode.data.conditions.find(condition =>
condition.output && !usedOutputs.includes(condition.output)
);
if (unusedCondition) {
label = unusedCondition.output;
}
}
}
const newEdge = {
id: `${connection.source}-${connection.target}-${Date.now()}`,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
type: 'smoothstep',
animated: true,
style: { stroke: '#555' },
label: label
};
console.log('Creating edge with handles:', {
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
source: connection.source,
target: connection.target
});
addEdges([newEdge]);
emit('edgesChange', edges.value);
};
// Handle node removal
const onNodeDelete = (event) => {
// Check if we have a node in the event
if (event && event.node) {
removeNodes([event.node]);
emit('nodesChange', nodes.value);
}
};
// Handle edge removal
const onEdgeDelete = (event) => {
// Check if we have an edge in the event
if (event && event.edge) {
removeEdges([event.edge]);
emit('edgesChange', edges.value);
}
};
// Handle delete key press to remove selected elements
const onDeleteKeyPress = () => {
const { getSelectedNodes, getSelectedEdges } = flowInstance.value;
const selectedNodes = getSelectedNodes();
const selectedEdges = getSelectedEdges();
if (selectedNodes.length > 0) {
removeNodes(selectedNodes);
emit('nodesChange', nodes.value);
}
if (selectedEdges.length > 0) {
removeEdges(selectedEdges);
emit('edgesChange', edges.value);
}
};
// Handle drop event
const onDrop = (event) => {
event.preventDefault();
event.stopPropagation();
try {
// Get the dragged component data
const componentData = JSON.parse(event.dataTransfer.getData('text/plain'));
if (!componentData) return;
// Get the Vue Flow wrapper element
const flowWrapper = event.currentTarget;
const bounds = flowWrapper.getBoundingClientRect();
// Calculate the position relative to the wrapper
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
// Create new node
const newNode = {
id: `${componentData.type}-${Date.now()}`,
type: componentData.type,
position,
data: {
...componentData.data,
label: componentData.label
}
};
addNodes([newNode]);
} catch (error) {
console.error('Error handling drop:', error);
}
};
// Handle drag over
const onDragOver = (event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
};
// Define methods to expose to parent components
defineExpose({
updateNode,
addNode,
removeNode,
fitView
});
// Update an existing node
function updateNode(nodeId, newData) {
const nodeToUpdate = nodes.value.find(node => node.id === nodeId);
if (!nodeToUpdate) return;
// Update the node properties
if (newData.label) {
nodeToUpdate.label = newData.label;
}
// Update the node data
if (newData.data) {
nodeToUpdate.data = {
...nodeToUpdate.data,
...newData.data
};
}
// Update node internals to trigger re-render
updateNodeInternals([nodeId]);
return nodeToUpdate;
}
// Add a new node to the canvas
function addNode(node) {
addNodes([node]);
return node;
}
// Remove a node from the canvas
function removeNode(nodeId) {
const nodeToRemove = nodes.value.find(node => node.id === nodeId);
if (!nodeToRemove) return;
removeNodes([nodeToRemove]);
return nodeToRemove;
}
</script>
<template>
<div
class="process-flow-container"
@drop="onDrop"
@dragover="onDragOver"
>
<VueFlow
v-bind="flowOptions"
class="bg-slate-50 process-flow"
:nodeTypes="customNodeTypes"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
@connect="handleConnect"
@nodeDoubleClick="onNodeDelete"
@edgeDoubleClick="onEdgeDelete"
@keyup.delete="onDeleteKeyPress"
>
<Background pattern-color="#aaa" gap="20" />
<Controls />
<MiniMap />
<template #edge-label="{ label }">
<div class="edge-label">{{ label }}</div>
</template>
<Panel position="top-right" class="node-controls">
<div class="p-2 bg-white rounded shadow-sm text-sm">
<div class="mb-1">Controls:</div>
<div> Delete: Remove selected</div>
<div> Shift: Select nodes</div>
<div> Drag between nodes to connect</div>
<div> Double-click to remove</div>
</div>
</Panel>
</VueFlow>
</div>
</template>
<style>
/* Node styles from ProcessFlowNodes.js are imported globally in a plugin */
.process-flow-container {
width: 100%;
height: calc(100vh - 190px); /* Adjust based on new header/footer height */
min-height: 500px;
border: 1px solid #e2e8f0;
border-radius: 0;
overflow: hidden;
position: relative;
flex: 1;
}
/* Make sure VueFlow takes full height */
:deep(.vue-flow) {
height: 100% !important;
}
/* Ensure the flow wrapper fills container */
:deep(.vue-flow__container) {
height: 100% !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.process-flow-container {
height: calc(100vh - 150px);
min-height: 400px;
}
}
@media (max-width: 480px) {
.process-flow-container {
height: calc(100vh - 120px);
min-height: 300px;
}
:deep(.vue-flow__controls) {
transform: scale(0.8);
transform-origin: bottom right;
}
:deep(.vue-flow__minimap) {
transform: scale(0.8);
transform-origin: top right;
}
}
.edge-label {
background-color: white;
padding: 2px 4px;
border-radius: 4px;
font-size: 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Vue Flow custom styles */
:deep(.vue-flow__edge-path) {
stroke: #555;
stroke-width: 2px;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #ff6b6b;
stroke-width: 3px;
}
:deep(.vue-flow__edge.animated .vue-flow__edge-path) {
stroke-dasharray: 5;
animation: flowEdgeAnimation 2s linear infinite;
}
/* Add custom node icon styles */
:deep(.custom-node) {
border-radius: 6px;
padding: 12px;
background: white;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
min-width: 150px;
}
:deep(.custom-node-header) {
display: flex;
align-items: center;
margin-bottom: 8px;
}
:deep(.custom-node-icon) {
margin-right: 8px;
}
:deep(.custom-node-icon .material-icons) {
font-size: 20px;
}
:deep(.node-start .custom-node-icon .material-icons) {
color: #4CAF50;
}
:deep(.node-end .custom-node-icon .material-icons) {
color: #f44336;
}
:deep(.node-task .custom-node-icon .material-icons) {
color: #2196F3;
}
:deep(.node-form .custom-node-icon .material-icons) {
color: #9C27B0;
}
:deep(.node-gateway .custom-node-icon .material-icons) {
color: #FF9800;
font-size: 18px;
}
:deep(.node-script .custom-node-icon .material-icons) {
color: #607D8B;
}
:deep(.custom-node-title) {
font-weight: 500;
flex-grow: 1;
}
:deep(.custom-node-content) {
font-size: 12px;
color: #666;
}
@keyframes flowEdgeAnimation {
from {
stroke-dashoffset: 10;
}
to {
stroke-dashoffset: 0;
}
}
.node-controls {
margin: 10px;
color: #666;
font-size: 12px;
background: white;
border-radius: 4px;
pointer-events: all;
}
:deep(.vue-flow__handle) {
width: 8px;
height: 8px;
background: #555;
border: 2px solid white;
}
:deep(.vue-flow__handle:hover) {
background: #ff6b6b;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #ff6b6b;
stroke-width: 3px;
}
:deep(.vue-flow__node.selected) {
box-shadow: 0 0 0 2px #ff6b6b;
}
</style>