- Updated HtmlNodeConfiguration.vue and ScriptNodeConfiguration.vue to prevent recursive updates when props change by introducing a flag and utilizing nextTick for state management. - Improved keyboard shortcut handling in index.vue to block shortcuts when modals are open or when input elements are focused, enhancing user experience during process building.
4769 lines
159 KiB
Vue
4769 lines
159 KiB
Vue
<script setup>
|
|
import { ref, onMounted, computed, shallowRef, onUnmounted, nextTick, watch } from 'vue';
|
|
import { useProcessBuilderStore } from '~/stores/processBuilder';
|
|
import { useRouter, useRoute } from 'vue-router';
|
|
import ProcessFlowCanvas from '~/components/process-flow/ProcessFlowCanvas.vue';
|
|
import ProcessBuilderComponents from '~/components/process-flow/ProcessBuilderComponents.vue';
|
|
import FormSelector from '~/components/process-flow/FormSelector.vue';
|
|
import GatewayConditionManager from '~/components/process-flow/GatewayConditionManager.vue';
|
|
import GatewayConditionManagerModal from '~/components/process-flow/GatewayConditionManagerModal.vue';
|
|
import ApiNodeConfiguration from '~/components/process-flow/ApiNodeConfiguration.vue';
|
|
import ApiNodeConfigurationModal from '~/components/process-flow/ApiNodeConfigurationModal.vue';
|
|
import VariableManager from '~/components/process-flow/VariableManager.vue';
|
|
import { onBeforeRouteLeave } from 'vue-router';
|
|
import FormNodeConfiguration from '~/components/process-flow/FormNodeConfiguration.vue';
|
|
import FormNodeConfigurationModal from '~/components/process-flow/FormNodeConfigurationModal.vue';
|
|
|
|
import BusinessRuleNodeConfiguration from '~/components/process-flow/BusinessRuleNodeConfiguration.vue';
|
|
import BusinessRuleNodeConfigurationModal from '~/components/process-flow/BusinessRuleNodeConfigurationModal.vue';
|
|
import NotificationNodeConfigurationModal from '~/components/process-flow/NotificationNodeConfigurationModal.vue';
|
|
import ScriptNodeConfigurationModal from '~/components/process-flow/ScriptNodeConfigurationModal.vue';
|
|
import HtmlNodeConfigurationModal from '~/components/process-flow/HtmlNodeConfigurationModal.vue';
|
|
import SubprocessNodeConfigurationModal from '~/components/process-flow/SubprocessNodeConfigurationModal.vue';
|
|
import ProcessTemplatesModal from '~/components/ProcessTemplatesModal.vue';
|
|
import ProcessSettingsModal from '~/components/process-flow/ProcessSettingsModal.vue';
|
|
import ProcessHistoryModal from '~/components/ProcessHistoryModal.vue';
|
|
import ExportCanvasModal from '~/components/process-flow/ExportCanvasModal.vue';
|
|
|
|
// Define page meta
|
|
definePageMeta({
|
|
title: "Process Builder",
|
|
description: "Create business processes with drag and drop",
|
|
layout: "empty",
|
|
middleware: ["auth"],
|
|
requiresAuth: true,
|
|
});
|
|
|
|
// Initialize the store and router
|
|
const processStore = useProcessBuilderStore();
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
|
|
// Initialize toast with fallback
|
|
let toast;
|
|
try {
|
|
toast = useToast();
|
|
} catch (error) {
|
|
// Create a simple toast object if composable is not available
|
|
toast = {
|
|
success: (msg) => {}, // Silent success
|
|
error: (msg) => console.error('Error:', msg),
|
|
info: (msg) => console.info('Info:', msg),
|
|
warning: (msg) => console.warn('Warning:', msg)
|
|
};
|
|
}
|
|
|
|
// Track selected node local state (syncs with store)
|
|
// Using shallowRef to avoid making Vue components reactive
|
|
const selectedNode = shallowRef(null);
|
|
|
|
// Track if we're creating a new process
|
|
const isCreatingProcess = ref(false);
|
|
const newProcessName = ref('');
|
|
const newProcessDescription = ref('');
|
|
|
|
// Track selected node data
|
|
const selectedNodeData = ref(null);
|
|
|
|
// Track drag data
|
|
const draggedComponent = ref(null);
|
|
|
|
// Add a variable to track selected edge
|
|
const selectedEdgeData = ref(null);
|
|
|
|
// Add variables for navigation handling
|
|
const showUnsavedChangesModal = ref(false);
|
|
const pendingNavigation = ref(null);
|
|
const navigationTarget = ref(null);
|
|
const navigationConfirmed = ref(false);
|
|
const isSaving = ref(false);
|
|
const isAddingComponent = ref(false); // Flag to prevent canvas reactivity issues during component addition
|
|
|
|
// Add clipboard state for copy/paste functionality
|
|
const clipboard = ref({
|
|
nodes: [],
|
|
edges: [],
|
|
type: null, // 'nodes', 'edges', or 'mixed'
|
|
timestamp: null
|
|
});
|
|
|
|
// Load clipboard from localStorage on mount
|
|
const loadClipboardFromStorage = () => {
|
|
try {
|
|
const stored = localStorage.getItem('processBuilderClipboard');
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
// Only restore if it's recent (within 24 hours)
|
|
const isRecent = parsed.timestamp && (Date.now() - parsed.timestamp) < 24 * 60 * 60 * 1000;
|
|
if (isRecent) {
|
|
clipboard.value = parsed;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load clipboard from storage:', error);
|
|
}
|
|
};
|
|
|
|
// Save clipboard to localStorage
|
|
const saveClipboardToStorage = () => {
|
|
try {
|
|
localStorage.setItem('processBuilderClipboard', JSON.stringify(clipboard.value));
|
|
} catch (error) {
|
|
console.warn('Failed to save clipboard to storage:', error);
|
|
}
|
|
};
|
|
|
|
// Storage event handler for cross-tab clipboard sync
|
|
const handleStorageEvent = (event) => {
|
|
if (event.key === 'processBuilderClipboard' && event.newValue) {
|
|
try {
|
|
const parsed = JSON.parse(event.newValue);
|
|
clipboard.value = parsed;
|
|
} catch (error) {
|
|
console.warn('Failed to parse clipboard from storage event:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Add a ref for the ProcessFlowCanvas component
|
|
const processFlowCanvas = ref(null);
|
|
|
|
// Add state for node configuration modals
|
|
const showFormConfigModal = ref(false);
|
|
const showApiConfigModal = ref(false);
|
|
const showGatewayConfigModal = ref(false);
|
|
const showBusinessRuleConfigModal = ref(false);
|
|
const showNotificationConfigModal = ref(false);
|
|
const showScriptConfigModal = ref(false);
|
|
const showHtmlConfigModal = ref(false);
|
|
const showSubprocessConfigModal = ref(false);
|
|
const showTemplatesModal = ref(false);
|
|
const showProcessSettings = ref(false);
|
|
const showDropdown = ref(false);
|
|
const showProcessHistoryModal = ref(false);
|
|
const showExportModal = ref(false);
|
|
|
|
// Add mobile responsive state
|
|
const showLeftPanel = ref(true);
|
|
const showRightPanel = ref(true);
|
|
const isMobile = ref(false);
|
|
const isTablet = ref(false);
|
|
|
|
// Add node highlighting state
|
|
const highlightedNodeId = ref(null);
|
|
const highlightTimeout = ref(null);
|
|
|
|
// Mobile breakpoint detection
|
|
const checkScreenSize = () => {
|
|
const width = window.innerWidth;
|
|
isMobile.value = width < 768; // Mobile: < 768px
|
|
isTablet.value = width >= 768 && width < 1024; // Tablet: 768px - 1024px
|
|
|
|
// Auto-hide panels on mobile
|
|
if (isMobile.value) {
|
|
showLeftPanel.value = false;
|
|
showRightPanel.value = false;
|
|
} else if (isTablet.value) {
|
|
// On tablet, show only one panel at a time if both were closed
|
|
if (!showLeftPanel.value && !showRightPanel.value) {
|
|
showRightPanel.value = true;
|
|
}
|
|
} else {
|
|
// Desktop: show both panels by default only on first load
|
|
// Don't override user preferences on screen size changes
|
|
}
|
|
};
|
|
|
|
// Toggle panel visibility
|
|
const toggleLeftPanel = () => {
|
|
showLeftPanel.value = !showLeftPanel.value;
|
|
|
|
// On mobile/tablet, close the other panel when opening one
|
|
if ((isMobile.value || isTablet.value) && showLeftPanel.value) {
|
|
showRightPanel.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleRightPanel = () => {
|
|
showRightPanel.value = !showRightPanel.value;
|
|
|
|
// On mobile/tablet, close the other panel when opening one
|
|
if ((isMobile.value || isTablet.value) && showRightPanel.value) {
|
|
showLeftPanel.value = false;
|
|
}
|
|
};
|
|
|
|
// Toggle both panels at once
|
|
const toggleBothPanels = () => {
|
|
const bothVisible = showLeftPanel.value && showRightPanel.value;
|
|
showLeftPanel.value = !bothVisible;
|
|
showRightPanel.value = !bothVisible;
|
|
};
|
|
|
|
// Close panels when clicking on canvas (mobile only)
|
|
const onPaneClickMobile = () => {
|
|
selectedNode.value = null;
|
|
selectedNodeData.value = null;
|
|
selectedEdgeData.value = null;
|
|
processStore.clearSelection();
|
|
|
|
// Close panels on mobile when clicking canvas
|
|
if (isMobile.value) {
|
|
showLeftPanel.value = false;
|
|
showRightPanel.value = false;
|
|
}
|
|
};
|
|
|
|
// Copy/paste functions
|
|
const copySelectedElements = () => {
|
|
const copiedNodes = [];
|
|
const copiedEdges = [];
|
|
|
|
// Get all selected elements from Vue Flow
|
|
let selectedNodes = [];
|
|
let selectedEdges = [];
|
|
|
|
if (processFlowCanvas.value) {
|
|
try {
|
|
// Try the new getSelectedElements method first
|
|
if (processFlowCanvas.value.getSelectedElements) {
|
|
const selection = processFlowCanvas.value.getSelectedElements();
|
|
selectedNodes = selection.nodes || [];
|
|
selectedEdges = selection.edges || [];
|
|
}
|
|
|
|
// If no selection found, try direct access to nodes and edges
|
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
|
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
|
|
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected === true);
|
|
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected === true);
|
|
}
|
|
}
|
|
|
|
// If still no selection found, try DOM-based detection as fallback
|
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
|
// Look for selected nodes in the DOM
|
|
const selectedNodeElements = document.querySelectorAll('.vue-flow__node.selected');
|
|
const selectedEdgeElements = document.querySelectorAll('.vue-flow__edge.selected');
|
|
|
|
// Convert DOM elements to node/edge data
|
|
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
|
|
selectedNodeElements.forEach(element => {
|
|
const nodeId = element.getAttribute('data-id');
|
|
if (nodeId) {
|
|
const node = processFlowCanvas.value.nodes.find(n => n.id === nodeId);
|
|
if (node) {
|
|
selectedNodes.push(node);
|
|
}
|
|
}
|
|
});
|
|
|
|
selectedEdgeElements.forEach(element => {
|
|
const edgeId = element.getAttribute('data-id');
|
|
if (edgeId) {
|
|
const edge = processFlowCanvas.value.edges.find(e => e.id === edgeId);
|
|
if (edge) {
|
|
selectedEdges.push(edge);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to get selected elements from Vue Flow:', error);
|
|
}
|
|
}
|
|
|
|
// If no multiple selection, fall back to single selection
|
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
|
// Copy selected nodes
|
|
if (selectedNodeData.value) {
|
|
const nodeCopy = JSON.parse(JSON.stringify(selectedNodeData.value));
|
|
// Remove any temporary properties
|
|
delete nodeCopy.selected;
|
|
delete nodeCopy.dragging;
|
|
copiedNodes.push(nodeCopy);
|
|
}
|
|
|
|
// Copy selected edges
|
|
if (selectedEdgeData.value) {
|
|
const edgeCopy = JSON.parse(JSON.stringify(selectedEdgeData.value));
|
|
// Remove any temporary properties
|
|
delete edgeCopy.selected;
|
|
copiedEdges.push(edgeCopy);
|
|
}
|
|
} else {
|
|
// Handle multiple selection
|
|
for (const node of selectedNodes) {
|
|
const nodeCopy = JSON.parse(JSON.stringify(node));
|
|
// Remove any temporary properties
|
|
delete nodeCopy.selected;
|
|
delete nodeCopy.dragging;
|
|
copiedNodes.push(nodeCopy);
|
|
}
|
|
|
|
for (const edge of selectedEdges) {
|
|
const edgeCopy = JSON.parse(JSON.stringify(edge));
|
|
// Remove any temporary properties
|
|
delete edgeCopy.selected;
|
|
copiedEdges.push(edgeCopy);
|
|
}
|
|
}
|
|
|
|
// Store in clipboard
|
|
clipboard.value = {
|
|
nodes: copiedNodes,
|
|
edges: copiedEdges,
|
|
type: copiedNodes.length > 0 && copiedEdges.length > 0 ? 'mixed' :
|
|
copiedNodes.length > 0 ? 'nodes' :
|
|
copiedEdges.length > 0 ? 'edges' : null,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
// Save to localStorage
|
|
saveClipboardToStorage();
|
|
|
|
// Show feedback
|
|
const totalElements = copiedNodes.length + copiedEdges.length;
|
|
if (totalElements > 0) {
|
|
toast.success(`Copied ${totalElements} element${totalElements > 1 ? 's' : ''} to clipboard`);
|
|
} else {
|
|
toast.warning('No elements selected to copy');
|
|
}
|
|
};
|
|
|
|
const clearClipboard = () => {
|
|
clipboard.value = {
|
|
nodes: [],
|
|
edges: [],
|
|
type: null,
|
|
timestamp: null
|
|
};
|
|
// Clear from localStorage
|
|
try {
|
|
localStorage.removeItem('processBuilderClipboard');
|
|
} catch (error) {
|
|
console.warn('Failed to clear clipboard from storage:', error);
|
|
}
|
|
toast.info('Clipboard cleared');
|
|
};
|
|
|
|
// Debug function to help understand selection state
|
|
const debugSelection = () => {
|
|
console.log('=== DEBUG SELECTION STATE ===');
|
|
|
|
if (!processFlowCanvas.value) {
|
|
console.log('ProcessFlowCanvas not available');
|
|
return;
|
|
}
|
|
|
|
console.log('ProcessFlowCanvas available, checking properties...');
|
|
console.log('Available methods:', Object.keys(processFlowCanvas.value));
|
|
|
|
if (!processFlowCanvas.value.flowInstance) {
|
|
console.log('Flow instance not available');
|
|
console.log('Trying to access flowInstance directly...');
|
|
|
|
// Try different ways to access the flow instance
|
|
try {
|
|
const flowInstance = processFlowCanvas.value.flowInstance;
|
|
console.log('Direct access result:', flowInstance);
|
|
} catch (error) {
|
|
console.log('Direct access error:', error);
|
|
}
|
|
|
|
// Check if we can access nodes and edges directly
|
|
try {
|
|
const nodes = processFlowCanvas.value.nodes;
|
|
const edges = processFlowCanvas.value.edges;
|
|
console.log('Direct nodes access:', nodes);
|
|
console.log('Direct edges access:', edges);
|
|
} catch (error) {
|
|
console.log('Direct nodes/edges access error:', error);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const flowInstance = processFlowCanvas.value.flowInstance;
|
|
|
|
// Check Vue Flow selection
|
|
try {
|
|
const selection = flowInstance.getSelection();
|
|
console.log('Vue Flow selection:', selection);
|
|
} catch (error) {
|
|
console.log('Error getting Vue Flow selection:', error);
|
|
}
|
|
|
|
// Check all nodes and edges
|
|
const allNodes = flowInstance.getNodes();
|
|
const allEdges = flowInstance.getEdges();
|
|
|
|
console.log('All nodes:', allNodes.length);
|
|
console.log('All edges:', allEdges.length);
|
|
|
|
// Check for selected property
|
|
const selectedNodes = allNodes.filter(node => node.selected);
|
|
const selectedEdges = allEdges.filter(edge => edge.selected);
|
|
|
|
console.log('Nodes with selected=true:', selectedNodes.length);
|
|
console.log('Edges with selected=true:', selectedEdges.length);
|
|
|
|
// Check DOM
|
|
const domSelectedNodes = document.querySelectorAll('.vue-flow__node.selected');
|
|
const domSelectedEdges = document.querySelectorAll('.vue-flow__edge.selected');
|
|
|
|
console.log('DOM selected nodes:', domSelectedNodes.length);
|
|
console.log('DOM selected edges:', domSelectedEdges.length);
|
|
|
|
// Show details of selected elements
|
|
if (selectedNodes.length > 0) {
|
|
console.log('Selected nodes:', selectedNodes.map(n => ({ id: n.id, type: n.type, selected: n.selected })));
|
|
}
|
|
|
|
if (selectedEdges.length > 0) {
|
|
console.log('Selected edges:', selectedEdges.map(e => ({ id: e.id, source: e.source, target: e.target, selected: e.selected })));
|
|
}
|
|
|
|
console.log('=== END DEBUG ===');
|
|
};
|
|
|
|
const pasteElements = async () => {
|
|
if (!clipboard.value.type || !processStore.currentProcess) {
|
|
toast.warning('No elements in clipboard to paste');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const pastedNodes = [];
|
|
const pastedEdges = [];
|
|
const nodeIdMap = new Map(); // Map old IDs to new IDs
|
|
|
|
// Process nodes first
|
|
if (clipboard.value.nodes.length > 0) {
|
|
for (const node of clipboard.value.nodes) {
|
|
const newNode = JSON.parse(JSON.stringify(node));
|
|
|
|
// Generate new ID
|
|
const oldId = newNode.id;
|
|
const newId = `${newNode.type}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
|
newNode.id = newId;
|
|
|
|
// Offset position to avoid overlap - use a more sophisticated offset
|
|
const baseX = newNode.position?.x || 100;
|
|
const baseY = newNode.position?.y || 100;
|
|
const offsetX = 100 + (pastedNodes.length * 80); // Larger offset for better visibility
|
|
const offsetY = 100 + (pastedNodes.length * 60); // Different Y offset to create a diagonal pattern
|
|
|
|
newNode.position = {
|
|
x: baseX + offsetX,
|
|
y: baseY + offsetY
|
|
};
|
|
|
|
// Update label to indicate it's a copy
|
|
if (newNode.label && !newNode.label.includes(' (Copy)')) {
|
|
newNode.label = `${newNode.label} (Copy)`;
|
|
}
|
|
if (newNode.data?.label && !newNode.data.label.includes(' (Copy)')) {
|
|
newNode.data.label = `${newNode.data.label} (Copy)`;
|
|
}
|
|
|
|
// Store ID mapping for edge updates
|
|
nodeIdMap.set(oldId, newId);
|
|
|
|
// Add to store
|
|
await processStore.addNode(newNode);
|
|
pastedNodes.push(newNode);
|
|
}
|
|
}
|
|
|
|
// Process edges after nodes
|
|
if (clipboard.value.edges.length > 0) {
|
|
for (const edge of clipboard.value.edges) {
|
|
const newEdge = JSON.parse(JSON.stringify(edge));
|
|
|
|
// Generate new ID
|
|
newEdge.id = `edge_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
|
|
|
// Update source and target IDs if they were copied nodes
|
|
if (nodeIdMap.has(newEdge.source)) {
|
|
newEdge.source = nodeIdMap.get(newEdge.source);
|
|
}
|
|
if (nodeIdMap.has(newEdge.target)) {
|
|
newEdge.target = nodeIdMap.get(newEdge.target);
|
|
}
|
|
|
|
// Only add edge if both source and target nodes exist
|
|
const sourceExists = processStore.currentProcess.nodes.some(n => n.id === newEdge.source);
|
|
const targetExists = processStore.currentProcess.nodes.some(n => n.id === newEdge.target);
|
|
|
|
if (sourceExists && targetExists) {
|
|
await processStore.addEdge(newEdge);
|
|
pastedEdges.push(newEdge);
|
|
} else {
|
|
// Show warning for orphaned edges
|
|
const missingNodes = [];
|
|
if (!sourceExists) missingNodes.push(newEdge.source);
|
|
if (!targetExists) missingNodes.push(newEdge.target);
|
|
console.warn(`Skipping edge ${newEdge.id}: missing nodes ${missingNodes.join(', ')}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show success message
|
|
const totalPasted = pastedNodes.length + pastedEdges.length;
|
|
const skippedEdges = clipboard.value.edges.length - pastedEdges.length;
|
|
|
|
if (totalPasted > 0) {
|
|
let message = `Pasted ${totalPasted} element${totalPasted > 1 ? 's' : ''} successfully`;
|
|
if (skippedEdges > 0) {
|
|
message += ` (${skippedEdges} edge${skippedEdges > 1 ? 's' : ''} skipped - missing nodes)`;
|
|
}
|
|
toast.success(message);
|
|
|
|
// Select the first pasted node if any
|
|
if (pastedNodes.length > 0) {
|
|
onNodeSelected(pastedNodes[0]);
|
|
|
|
// Fit view to show all pasted nodes
|
|
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
|
|
nextTick(() => {
|
|
try {
|
|
processFlowCanvas.value.fitView({
|
|
padding: 0.1,
|
|
includeHiddenNodes: false,
|
|
minZoom: 0.5,
|
|
maxZoom: 1.5
|
|
});
|
|
} catch (error) {
|
|
console.warn('Failed to fit view:', error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} else if (skippedEdges > 0) {
|
|
toast.warning(`${skippedEdges} edge${skippedEdges > 1 ? 's' : ''} could not be pasted - missing connected nodes`);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error pasting elements:', error);
|
|
toast.error('Failed to paste elements');
|
|
}
|
|
};
|
|
|
|
// Keyboard shortcuts for panel toggles and copy/paste
|
|
const handleKeyboardShortcuts = (event) => {
|
|
// Check if any modal is open - if so, don't handle process builder shortcuts
|
|
const isModalOpen = showFormConfigModal.value ||
|
|
showApiConfigModal.value ||
|
|
showGatewayConfigModal.value ||
|
|
showBusinessRuleConfigModal.value ||
|
|
showNotificationConfigModal.value ||
|
|
showScriptConfigModal.value ||
|
|
showHtmlConfigModal.value ||
|
|
showSubprocessConfigModal.value ||
|
|
showTemplatesModal.value ||
|
|
showProcessSettings.value ||
|
|
showProcessHistoryModal.value ||
|
|
showExportModal.value ||
|
|
showUnsavedChangesModal.value;
|
|
|
|
if (isModalOpen) {
|
|
// Debug logging (can be removed in production)
|
|
if (process.env.NODE_ENV === 'development' && (event.ctrlKey || event.metaKey) && (event.key === 'c' || event.key === 'v')) {
|
|
console.log('🔒 Process builder shortcuts blocked - modal is open');
|
|
}
|
|
return; // Let the modal handle its own keyboard shortcuts
|
|
}
|
|
|
|
// Check if the target is an input element or code editor
|
|
const target = event.target;
|
|
const isInputElement = target.tagName === 'INPUT' ||
|
|
target.tagName === 'TEXTAREA' ||
|
|
target.tagName === 'SELECT' ||
|
|
target.contentEditable === 'true' ||
|
|
target.closest('.CodeMirror') !== null ||
|
|
target.closest('.cm-editor') !== null ||
|
|
target.closest('.cm-content') !== null ||
|
|
target.closest('.cm-cursor') !== null ||
|
|
target.closest('.cm-line') !== null ||
|
|
target.closest('.formkit-input') !== null ||
|
|
target.closest('.formkit-textarea') !== null ||
|
|
target.closest('[contenteditable="true"]') !== null ||
|
|
target.closest('.rs-code-mirror') !== null ||
|
|
target.closest('[data-cm-editor]') !== null;
|
|
|
|
if (isInputElement) {
|
|
// Debug logging (can be removed in production)
|
|
if (process.env.NODE_ENV === 'development' && (event.ctrlKey || event.metaKey) && (event.key === 'c' || event.key === 'v')) {
|
|
console.log('🔒 Process builder shortcuts blocked - input element detected:', target.tagName, target.className);
|
|
}
|
|
return; // Let the input element handle its own keyboard shortcuts
|
|
}
|
|
|
|
// Additional check: if the target is inside any modal, don't handle process builder shortcuts
|
|
const isInsideModal = target.closest('.rs-modal') !== null ||
|
|
target.closest('.modal') !== null ||
|
|
target.closest('[role="dialog"]') !== null ||
|
|
target.closest('.vue-flow__modal') !== null;
|
|
|
|
if (isInsideModal) {
|
|
// Debug logging (can be removed in production)
|
|
if (process.env.NODE_ENV === 'development' && (event.ctrlKey || event.metaKey) && (event.key === 'c' || event.key === 'v')) {
|
|
console.log('🔒 Process builder shortcuts blocked - inside modal');
|
|
}
|
|
return; // Let the modal handle its own keyboard shortcuts
|
|
}
|
|
|
|
// Ctrl/Cmd + 1: Toggle left panel
|
|
if ((event.ctrlKey || event.metaKey) && event.key === '1') {
|
|
event.preventDefault();
|
|
toggleLeftPanel();
|
|
}
|
|
|
|
// Ctrl/Cmd + 2: Toggle right panel
|
|
if ((event.ctrlKey || event.metaKey) && event.key === '2') {
|
|
event.preventDefault();
|
|
toggleRightPanel();
|
|
}
|
|
|
|
// Ctrl/Cmd + 3: Toggle both panels
|
|
if ((event.ctrlKey || event.metaKey) && event.key === '3') {
|
|
event.preventDefault();
|
|
toggleBothPanels();
|
|
}
|
|
|
|
// Ctrl/Cmd + C: Copy selected elements
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
|
|
event.preventDefault();
|
|
// Debug logging (can be removed in production)
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log('📋 Process builder copy shortcut triggered');
|
|
}
|
|
copySelectedElements();
|
|
}
|
|
|
|
// Ctrl/Cmd + V: Paste elements
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
|
event.preventDefault();
|
|
// Debug logging (can be removed in production)
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log('📋 Process builder paste shortcut triggered');
|
|
}
|
|
pasteElements();
|
|
}
|
|
};
|
|
|
|
// Handle node highlighting from variable usage
|
|
const handleNodeHighlight = (event) => {
|
|
|
|
const { nodeId, node } = event.detail;
|
|
|
|
// Clear any existing highlight timeout
|
|
if (highlightTimeout.value) {
|
|
clearTimeout(highlightTimeout.value);
|
|
}
|
|
|
|
// Set the highlighted node
|
|
highlightedNodeId.value = nodeId;
|
|
|
|
|
|
// Center the view on the highlighted node
|
|
if (processFlowCanvas.value && node) {
|
|
nextTick(() => {
|
|
try {
|
|
|
|
|
|
// Call the exposed methods from ProcessFlowCanvas
|
|
if (processFlowCanvas.value.centerOnNode) {
|
|
processFlowCanvas.value.centerOnNode(nodeId, node.position);
|
|
} else {
|
|
console.warn('centerOnNode method not available on ProcessFlowCanvas');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error centering view on node:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Show success toast
|
|
if (toast) {
|
|
toast.success(`Navigated to: ${node.data?.label || node.type} node`);
|
|
}
|
|
|
|
// Auto-remove highlight after 5 seconds
|
|
highlightTimeout.value = setTimeout(() => {
|
|
highlightedNodeId.value = null;
|
|
highlightTimeout.value = null;
|
|
}, 5000);
|
|
};
|
|
|
|
// Component definitions
|
|
const components = [
|
|
{
|
|
type: 'start',
|
|
label: 'Start Point',
|
|
icon: 'play-circle-rounded',
|
|
iconColor: 'text-green-500',
|
|
data: { description: 'Process starts here' }
|
|
},
|
|
{
|
|
type: 'form',
|
|
label: 'Form',
|
|
icon: 'description',
|
|
iconColor: 'text-purple-500',
|
|
data: { description: 'Form submission', formName: '', shape: 'rectangle', backgroundColor: '#faf5ff', borderColor: '#9333ea', textColor: '#6b21a8' }
|
|
},
|
|
{
|
|
type: 'api',
|
|
label: 'API Call',
|
|
icon: 'api',
|
|
iconColor: 'text-indigo-500',
|
|
data: {
|
|
description: 'External API call',
|
|
apiMethod: 'GET',
|
|
apiUrl: '',
|
|
requestBody: '',
|
|
headers: '{ "Content-Type": "application/json" }',
|
|
outputVariable: 'apiResponse',
|
|
continueOnError: false,
|
|
errorVariable: 'apiError',
|
|
shape: 'rectangle',
|
|
backgroundColor: '#eff6ff',
|
|
borderColor: '#3b82f6',
|
|
textColor: '#1e40af'
|
|
}
|
|
},
|
|
{
|
|
type: 'gateway',
|
|
label: 'Decision Point',
|
|
icon: 'call-split',
|
|
iconColor: 'text-orange-500',
|
|
data: { description: 'Decision point for branching the workflow', conditions: [], shape: 'diamond', backgroundColor: '#fff7ed', borderColor: '#f97316', textColor: '#c2410c' }
|
|
},
|
|
{
|
|
type: 'script',
|
|
label: 'Script',
|
|
icon: 'code',
|
|
iconColor: 'text-gray-500',
|
|
data: { description: 'Script execution', language: 'JavaScript', shape: 'rectangle', backgroundColor: '#f9fafb', borderColor: '#6b7280', textColor: '#374151' }
|
|
},
|
|
{
|
|
type: 'html',
|
|
label: 'HTML',
|
|
icon: 'html',
|
|
iconColor: 'text-blue-500',
|
|
data: {
|
|
description: 'Custom HTML content',
|
|
htmlCode: '<!-- Enter your HTML code here -->\n<div class="custom-html-content">\n <h2>Custom HTML Content</h2>\n <p>This is a custom HTML node that can display rich content.</p>\n</div>',
|
|
cssCode: '',
|
|
jsCode: '',
|
|
shape: 'rectangle',
|
|
backgroundColor: '#e0f2fe',
|
|
borderColor: '#0ea5e9',
|
|
textColor: '#0c4a6e'
|
|
}
|
|
},
|
|
{
|
|
type: 'business-rule',
|
|
label: 'Business Rule',
|
|
icon: 'rule',
|
|
iconColor: 'text-purple-500',
|
|
data: { description: 'Business logic evaluation', shape: 'rectangle', backgroundColor: '#fdf4ff', borderColor: '#a855f7', textColor: '#7c3aed' }
|
|
},
|
|
{
|
|
type: 'notification',
|
|
label: 'Notification',
|
|
icon: 'notifications',
|
|
iconColor: 'text-blue-500',
|
|
data: { description: 'Send notifications', shape: 'rectangle', backgroundColor: '#f0f9ff', borderColor: '#0ea5e9', textColor: '#0284c7' }
|
|
},
|
|
{
|
|
type: 'subprocess',
|
|
label: 'Sub Process',
|
|
icon: 'hub',
|
|
iconColor: 'text-teal-500',
|
|
data: {
|
|
description: 'Executes another process',
|
|
subprocessId: null,
|
|
subprocessName: '',
|
|
shape: 'rectangle',
|
|
backgroundColor: '#f0fdfa',
|
|
borderColor: '#14b8a6',
|
|
textColor: '#134e4a'
|
|
}
|
|
},
|
|
{
|
|
type: 'end',
|
|
label: 'End Point',
|
|
icon: 'stop-circle',
|
|
iconColor: 'text-red-500',
|
|
data: { description: 'Process completes here' }
|
|
}
|
|
];
|
|
|
|
// Computed to check if we have a current process
|
|
const hasCurrentProcess = computed(() => {
|
|
return !!processStore.currentProcess;
|
|
});
|
|
|
|
// Create computed properties for canvas props to control reactivity
|
|
const canvasNodes = computed(() => {
|
|
if (!processStore.currentProcess) return [];
|
|
// Return a deep copy to ensure proper reactivity for nested objects like data
|
|
return processStore.currentProcess.nodes.map(node => {
|
|
const isShape = node.data?.isShape || false;
|
|
const style = isShape ? { zIndex: node.data.zIndex ?? 0 } : {};
|
|
|
|
return {
|
|
...node,
|
|
style: { ...(node.style || {}), ...style },
|
|
data: { ...node.data },
|
|
// Ensure labels are synchronized both ways
|
|
label: node.label || node.data?.label || node.type
|
|
};
|
|
});
|
|
});
|
|
|
|
const canvasEdges = computed(() => {
|
|
if (!processStore.currentProcess) return [];
|
|
// Return a shallow copy to avoid direct reactivity
|
|
return processStore.currentProcess.edges.map(edge => ({ ...edge }));
|
|
});
|
|
|
|
// Computed properties for node data
|
|
const nodeLabel = computed({
|
|
get: () => selectedNodeData.value?.label || '',
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.label = value;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
const nodeDescription = computed({
|
|
get: () => selectedNodeData.value?.data?.description || '',
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data.description = value;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
const nodeAssignee = computed({
|
|
get: () => selectedNodeData.value?.data?.assignee || '',
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data.assignee = value;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
const nodeFormName = computed({
|
|
get: () => selectedNodeData.value?.data?.formName || '',
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data.formName = value;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
const nodeLanguage = computed({
|
|
get: () => selectedNodeData.value?.data?.language || 'JavaScript',
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data.language = value;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add computed property for node shape
|
|
const nodeShape = computed({
|
|
get: () => {
|
|
if (!selectedNodeData.value?.data) return 'rectangle';
|
|
// Don't allow shape changes for start and end nodes
|
|
if (selectedNodeData.value.type === 'start' || selectedNodeData.value.type === 'end') {
|
|
return selectedNodeData.value.type === 'start' || selectedNodeData.value.type === 'end' ? 'circle' : 'rectangle';
|
|
}
|
|
return selectedNodeData.value.data.shape || 'rectangle';
|
|
},
|
|
set: (value) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type !== 'start' && selectedNodeData.value.type !== 'end') {
|
|
selectedNodeData.value.data.shape = value;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Shape options for the dropdown
|
|
const shapeOptions = [
|
|
{ value: 'rectangle', label: 'Rectangle', icon: 'crop-landscape' },
|
|
{ value: 'rounded-rectangle', label: 'Rounded Rectangle', icon: 'rounded-corner' },
|
|
{ value: 'circle', label: 'Circle', icon: 'radio-button-unchecked' },
|
|
{ value: 'diamond', label: 'Diamond', icon: 'change-history' },
|
|
{ value: 'hexagon', label: 'Hexagon', icon: 'hexagon' },
|
|
{ value: 'parallelogram', label: 'Parallelogram', icon: 'details' },
|
|
{ value: 'trapezoid', label: 'Trapezoid', icon: 'architecture' }
|
|
];
|
|
|
|
// Helper function to get icon for node type
|
|
const getNodeIcon = (nodeType) => {
|
|
const iconMap = {
|
|
'form': 'description',
|
|
'api': 'api',
|
|
'gateway': 'call-split',
|
|
'script': 'code',
|
|
'business-rule': 'rule',
|
|
'notification': 'notifications',
|
|
'subprocess': 'hub',
|
|
'start': 'play-circle-rounded',
|
|
'end': 'stop-circle',
|
|
'swimlane-horizontal': 'view-stream',
|
|
'swimlane-vertical': 'view-column',
|
|
'rectangle-shape': 'rectangle',
|
|
'text-annotation': 'text-fields',
|
|
'process-group': 'group-work'
|
|
};
|
|
return iconMap[nodeType] || 'account_tree';
|
|
};
|
|
|
|
// Helper function to validate and normalize hex color
|
|
const normalizeHexColor = (value, fallback = '#ffffff') => {
|
|
if (!value || typeof value !== 'string') return fallback;
|
|
|
|
// Remove # if present and trim whitespace
|
|
let hex = value.toString().replace('#', '').trim();
|
|
|
|
// If empty after processing, return fallback
|
|
if (!hex) return fallback;
|
|
|
|
// If it's a 3-character hex, expand it to 6
|
|
if (hex.length === 3 && /^[0-9a-fA-F]{3}$/.test(hex)) {
|
|
hex = hex.split('').map(char => char + char).join('');
|
|
}
|
|
|
|
// Validate hex format (6 characters, valid hex digits)
|
|
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
|
|
// Return fallback color if invalid
|
|
console.warn(`Invalid hex color: ${value}, using fallback: ${fallback}`);
|
|
return fallback;
|
|
}
|
|
|
|
return `#${hex.toLowerCase()}`;
|
|
};
|
|
|
|
// Add computed properties for node colors
|
|
const nodeBackgroundColor = computed({
|
|
get: () => {
|
|
if (!selectedNodeData.value?.data) return '#ffffff';
|
|
const color = selectedNodeData.value.data.backgroundColor || '#ffffff';
|
|
return normalizeHexColor(color, '#ffffff');
|
|
},
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
const normalizedColor = normalizeHexColor(value, '#ffffff');
|
|
selectedNodeData.value.data.backgroundColor = normalizedColor;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
const nodeBorderColor = computed({
|
|
get: () => {
|
|
if (!selectedNodeData.value?.data) return '#dddddd';
|
|
const color = selectedNodeData.value.data.borderColor || '#dddddd';
|
|
return normalizeHexColor(color, '#dddddd');
|
|
},
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
const normalizedColor = normalizeHexColor(value, '#dddddd');
|
|
selectedNodeData.value.data.borderColor = normalizedColor;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
const nodeTextColor = computed({
|
|
get: () => {
|
|
if (!selectedNodeData.value?.data) return '#333333';
|
|
const color = selectedNodeData.value.data.textColor || '#333333';
|
|
return normalizeHexColor(color, '#333333');
|
|
},
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
const normalizedColor = normalizeHexColor(value, '#333333');
|
|
selectedNodeData.value.data.textColor = normalizedColor;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add validation function for color inputs
|
|
const validateColorInput = (event) => {
|
|
const input = event.target;
|
|
const value = input.value;
|
|
|
|
// Remove invalid class first
|
|
input.classList.remove('border-red-500');
|
|
|
|
// If empty, that's ok (will use default)
|
|
if (!value.trim()) {
|
|
return;
|
|
}
|
|
|
|
// Check if valid hex color
|
|
const hex = value.replace('#', '').trim();
|
|
const isValid = /^[0-9a-fA-F]{3}$/.test(hex) || /^[0-9a-fA-F]{6}$/.test(hex);
|
|
|
|
if (!isValid) {
|
|
input.classList.add('border-red-500');
|
|
}
|
|
};
|
|
|
|
// Add a computed property for gateway conditions
|
|
const nodeConditions = computed({
|
|
get: () => selectedNodeData.value?.data?.conditions || [],
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data.conditions = value;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add a computed property for gateway default path
|
|
const nodeDefaultPath = computed({
|
|
get: () => selectedNodeData.value?.data?.defaultPath || 'Default',
|
|
set: (value) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data.defaultPath = value;
|
|
updateNodeInStore();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Computed for gateway available variables
|
|
const gatewayAvailableVariables = computed(() => {
|
|
if (!processStore.currentProcess || !processStore.currentProcess.variables) {
|
|
return [];
|
|
}
|
|
|
|
// Get variables from the current process instead of variable store
|
|
const processVariables = Object.values(processStore.currentProcess.variables);
|
|
|
|
const allVars = processVariables.map(v => ({
|
|
name: v.name || 'unnamed',
|
|
label: `${v.name || 'unnamed'} (${v.type || 'string'})`,
|
|
type: v.type || 'string',
|
|
scope: v.scope || 'global',
|
|
description: v.description || ''
|
|
}));
|
|
|
|
return allVars;
|
|
});
|
|
|
|
// Computed key that changes when variables are updated to force re-render of configuration modals
|
|
const variablesUpdateKey = computed(() => {
|
|
if (!processStore.currentProcess || !processStore.currentProcess.variables) {
|
|
return 'no-variables';
|
|
}
|
|
|
|
// Create a hash of variable names to detect changes
|
|
const variableNames = Object.keys(processStore.currentProcess.variables).sort().join(',');
|
|
return `vars-${variableNames.length}-${variableNames.split('').reduce((a, b) => {
|
|
a = ((a << 5) - a) + b.charCodeAt(0);
|
|
return a & a;
|
|
}, 0)}`;
|
|
});
|
|
|
|
// Handle node selection
|
|
const onNodeSelected = (node) => {
|
|
// console.log('Node selected:', node);
|
|
|
|
// Create a deep copy of the node to avoid reactivity issues
|
|
const nodeCopy = JSON.parse(JSON.stringify(node));
|
|
|
|
// Always ensure label is present in both places for consistency
|
|
if (!nodeCopy.label && nodeCopy.data && nodeCopy.data.label) {
|
|
// If label is missing but exists in data, use it
|
|
nodeCopy.label = nodeCopy.data.label;
|
|
} else if (nodeCopy.label && nodeCopy.data) {
|
|
// If label exists, make sure it's also in data
|
|
nodeCopy.data.label = nodeCopy.label;
|
|
}
|
|
|
|
// Set the selected node data first to avoid causing canvas to disappear
|
|
selectedNodeData.value = nodeCopy;
|
|
selectedNode.value = node;
|
|
selectedEdgeData.value = null;
|
|
processStore.selectNode(node.id);
|
|
|
|
// Then handle defaults asynchronously to avoid disrupting the canvas
|
|
if (nodeCopy.data && nodeCopy.type !== 'start' && nodeCopy.type !== 'end') {
|
|
let shouldUpdate = false;
|
|
|
|
// Set default shape
|
|
if (!nodeCopy.data.shape) {
|
|
let defaultShape = 'rectangle';
|
|
if (nodeCopy.type === 'gateway') {
|
|
defaultShape = 'diamond';
|
|
}
|
|
nodeCopy.data.shape = defaultShape;
|
|
shouldUpdate = true;
|
|
}
|
|
|
|
// Set default colors based on node type
|
|
if (!nodeCopy.data.backgroundColor || !nodeCopy.data.borderColor || !nodeCopy.data.textColor) {
|
|
let defaultColors = { backgroundColor: '#ffffff', borderColor: '#dddddd', textColor: '#333333' };
|
|
|
|
switch (nodeCopy.type) {
|
|
case 'form':
|
|
defaultColors = { backgroundColor: '#faf5ff', borderColor: '#9333ea', textColor: '#6b21a8' };
|
|
break;
|
|
case 'api':
|
|
defaultColors = { backgroundColor: '#eff6ff', borderColor: '#3b82f6', textColor: '#1e40af' };
|
|
break;
|
|
case 'gateway':
|
|
defaultColors = { backgroundColor: '#fff7ed', borderColor: '#f97316', textColor: '#c2410c' };
|
|
break;
|
|
case 'script':
|
|
defaultColors = { backgroundColor: '#f9fafb', borderColor: '#6b7280', textColor: '#374151' };
|
|
break;
|
|
case 'business-rule':
|
|
defaultColors = { backgroundColor: '#fdf4ff', borderColor: '#a855f7', textColor: '#7c3aed' };
|
|
break;
|
|
case 'notification':
|
|
defaultColors = { backgroundColor: '#f0f9ff', borderColor: '#0ea5e9', textColor: '#0284c7' };
|
|
break;
|
|
case 'subprocess':
|
|
defaultColors = { backgroundColor: '#f0fdfa', borderColor: '#14b8a6', textColor: '#134e4a' };
|
|
break;
|
|
}
|
|
|
|
if (!nodeCopy.data.backgroundColor) {
|
|
nodeCopy.data.backgroundColor = defaultColors.backgroundColor;
|
|
selectedNodeData.value.data.backgroundColor = defaultColors.backgroundColor;
|
|
shouldUpdate = true;
|
|
}
|
|
|
|
if (!nodeCopy.data.borderColor) {
|
|
nodeCopy.data.borderColor = defaultColors.borderColor;
|
|
selectedNodeData.value.data.borderColor = defaultColors.borderColor;
|
|
shouldUpdate = true;
|
|
}
|
|
|
|
if (!nodeCopy.data.textColor) {
|
|
nodeCopy.data.textColor = defaultColors.textColor;
|
|
selectedNodeData.value.data.textColor = defaultColors.textColor;
|
|
shouldUpdate = true;
|
|
}
|
|
}
|
|
|
|
// Update the original node in store asynchronously if any properties were added
|
|
if (shouldUpdate) {
|
|
nextTick(() => {
|
|
processStore.updateNode(nodeCopy.id, {
|
|
data: { ...nodeCopy.data }
|
|
});
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle edge selection
|
|
const onEdgeSelected = (edge) => {
|
|
selectedEdgeData.value = edge;
|
|
selectedNode.value = null;
|
|
selectedNodeData.value = null;
|
|
processStore.selectEdge(edge.id);
|
|
};
|
|
|
|
// Update edge label
|
|
const updateEdgeLabel = (value) => {
|
|
if (selectedEdgeData.value) {
|
|
processStore.updateEdge(selectedEdgeData.value.id, { label: value });
|
|
selectedEdgeData.value.label = value;
|
|
}
|
|
};
|
|
|
|
// Update the pane click handler to clear edge selection too
|
|
const onPaneClick = () => {
|
|
onPaneClickMobile();
|
|
};
|
|
|
|
// Enhanced updateNodeInStore with automatic refresh and canvas sync
|
|
const updateNodeInStore = async () => {
|
|
if (selectedNodeData.value) {
|
|
const nodeId = selectedNodeData.value.id;
|
|
const newLabel = selectedNodeData.value.label;
|
|
const newData = { ...selectedNodeData.value.data };
|
|
|
|
// Update in store first
|
|
processStore.updateNode(nodeId, {
|
|
label: newLabel,
|
|
data: newData
|
|
});
|
|
|
|
// Wait for store update to complete
|
|
await nextTick();
|
|
|
|
// Force canvas update directly for immediate feedback
|
|
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
|
|
// Include z-index in style if this is a shape
|
|
const isShape = newData?.isShape || false;
|
|
const style = isShape ? { zIndex: newData.zIndex ?? 0 } : {};
|
|
|
|
processFlowCanvas.value.updateNode(nodeId, {
|
|
label: newLabel,
|
|
data: newData,
|
|
style: style
|
|
});
|
|
}
|
|
|
|
// Refresh selected node from store to maintain consistency
|
|
refreshSelectedNodeFromStore();
|
|
}
|
|
};
|
|
|
|
// Enhanced refresh function with change detection to prevent loops
|
|
const refreshSelectedNodeFromStore = () => {
|
|
if (selectedNodeData.value && processStore.currentProcess) {
|
|
const storeNode = processStore.currentProcess.nodes.find(node => node.id === selectedNodeData.value.id);
|
|
if (storeNode) {
|
|
// Use a more sophisticated comparison to detect actual changes
|
|
const hasChanges = (
|
|
selectedNodeData.value.label !== storeNode.label ||
|
|
JSON.stringify(selectedNodeData.value.data) !== JSON.stringify(storeNode.data) ||
|
|
selectedNodeData.value.position?.x !== storeNode.position?.x ||
|
|
selectedNodeData.value.position?.y !== storeNode.position?.y
|
|
);
|
|
|
|
if (hasChanges) {
|
|
selectedNodeData.value = { ...storeNode, data: { ...storeNode.data } };
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Special handler for shape size updates to force reactivity
|
|
const updateShapeSize = async () => {
|
|
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
|
|
// Get the z-index before updating
|
|
const zIndex = selectedNodeData.value.data.zIndex || 0;
|
|
|
|
// Force update the node in store with new size
|
|
updateNodeInStore();
|
|
|
|
// Also force update the canvas to ensure immediate visual feedback
|
|
await nextTick();
|
|
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
|
|
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
|
|
data: { ...selectedNodeData.value.data },
|
|
style: { zIndex }
|
|
});
|
|
|
|
// Also directly manipulate DOM for immediate feedback
|
|
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
|
|
if (nodeElement) {
|
|
nodeElement.style.zIndex = zIndex;
|
|
|
|
// Also update custom-node if it exists
|
|
const customNode = nodeElement.querySelector('.custom-node');
|
|
if (customNode) {
|
|
customNode.style.zIndex = zIndex;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Reset node colors to default based on node type
|
|
const resetNodeColors = () => {
|
|
if (selectedNodeData.value) {
|
|
const nodeType = selectedNodeData.value.type;
|
|
let defaultColors = { backgroundColor: '#ffffff', borderColor: '#dddddd', textColor: '#333333' };
|
|
|
|
// Set type-specific default colors
|
|
switch (nodeType) {
|
|
case 'form':
|
|
defaultColors = { backgroundColor: '#faf5ff', borderColor: '#9333ea', textColor: '#6b21a8' };
|
|
break;
|
|
case 'api':
|
|
defaultColors = { backgroundColor: '#eff6ff', borderColor: '#3b82f6', textColor: '#1e40af' };
|
|
break;
|
|
case 'gateway':
|
|
defaultColors = { backgroundColor: '#fff7ed', borderColor: '#f97316', textColor: '#c2410c' };
|
|
break;
|
|
case 'script':
|
|
defaultColors = { backgroundColor: '#f9fafb', borderColor: '#6b7280', textColor: '#374151' };
|
|
break;
|
|
case 'business-rule':
|
|
defaultColors = { backgroundColor: '#fdf4ff', borderColor: '#a855f7', textColor: '#7c3aed' };
|
|
break;
|
|
case 'notification':
|
|
defaultColors = { backgroundColor: '#f0f9ff', borderColor: '#0ea5e9', textColor: '#0284c7' };
|
|
break;
|
|
case 'subprocess':
|
|
defaultColors = { backgroundColor: '#f0fdfa', borderColor: '#14b8a6', textColor: '#134e4a' };
|
|
break;
|
|
}
|
|
|
|
selectedNodeData.value.data.backgroundColor = defaultColors.backgroundColor;
|
|
selectedNodeData.value.data.borderColor = defaultColors.borderColor;
|
|
selectedNodeData.value.data.textColor = defaultColors.textColor;
|
|
updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Handle condition update
|
|
const handleConditionUpdate = async (conditions) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
|
|
// Update conditions in the node data
|
|
selectedNodeData.value.data = {
|
|
...selectedNodeData.value.data,
|
|
conditions: conditions
|
|
};
|
|
|
|
// Update edges with new condition outputs
|
|
if (processStore.currentProcess?.edges) {
|
|
const updatedEdges = processStore.currentProcess.edges.map(edge => {
|
|
if (edge.source === selectedNodeData.value.id) {
|
|
// Find matching condition group
|
|
const matchingGroup = conditions.find(group => group.output === edge.label);
|
|
|
|
// If no matching group found, and this isn't the default path, update edge label
|
|
if (!matchingGroup && edge.label !== selectedNodeData.value.data.defaultPath) {
|
|
return {
|
|
...edge,
|
|
label: selectedNodeData.value.data.defaultPath || 'Default'
|
|
};
|
|
}
|
|
}
|
|
return edge;
|
|
});
|
|
|
|
// Update edges in store
|
|
processStore.currentProcess.edges = updatedEdges;
|
|
}
|
|
|
|
// Update the node in store and refresh
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Handle updates from the canvas when nodes change
|
|
const onNodesChange = (changes, currentNodes) => {
|
|
if (!changes || !currentNodes) return;
|
|
|
|
// Skip processing during component addition to avoid conflicts
|
|
if (isAddingComponent.value) {
|
|
return;
|
|
}
|
|
|
|
// Handle position changes (only when dragging is complete)
|
|
const positionChanges = {};
|
|
const positionChangesList = changes
|
|
.filter(change => change.type === 'position' && change.position && !change.dragging);
|
|
|
|
positionChangesList.forEach(change => {
|
|
positionChanges[change.id] = change.position;
|
|
});
|
|
|
|
if (Object.keys(positionChanges).length > 0) {
|
|
processStore.updateNodePositions(positionChanges);
|
|
}
|
|
|
|
// Handle node removals (from delete key or other removal actions)
|
|
const removedNodes = changes
|
|
.filter(change => change.type === 'remove')
|
|
.map(change => change.id);
|
|
|
|
if (removedNodes.length > 0) {
|
|
removedNodes.forEach(nodeId => {
|
|
processStore.deleteNode(nodeId);
|
|
});
|
|
|
|
// Clear selection if the selected node was deleted
|
|
if (selectedNodeData.value && removedNodes.includes(selectedNodeData.value.id)) {
|
|
selectedNodeData.value = null;
|
|
}
|
|
}
|
|
|
|
// Handle node additions (this should be rare since we add nodes through the store first)
|
|
const addedNodes = changes
|
|
.filter(change => change.type === 'add')
|
|
.map(change => change.id);
|
|
|
|
if (addedNodes.length > 0) {
|
|
// These nodes are already in the canvas, so we don't need to add them to the store
|
|
// unless they're not already there
|
|
}
|
|
|
|
// REMOVED: Don't overwrite selectedNodeData from canvas changes to preserve local edits
|
|
// This was causing property panel changes to be lost when the canvas updated
|
|
// The canvasNodes computed and its watcher will handle synchronization properly
|
|
};
|
|
|
|
// Handle updates from the canvas when edges change
|
|
const onEdgesChange = (changes, currentEdges) => {
|
|
if (!changes || !currentEdges) return;
|
|
|
|
// Skip processing during component addition to avoid conflicts
|
|
if (isAddingComponent.value) {
|
|
return;
|
|
}
|
|
|
|
// Handle edge removals
|
|
const removedEdges = changes
|
|
.filter(change => change.type === 'remove')
|
|
.map(change => change.id);
|
|
|
|
if (removedEdges.length > 0) {
|
|
removedEdges.forEach(edgeId => {
|
|
processStore.deleteEdge(edgeId);
|
|
});
|
|
|
|
// Clear selection if the selected edge was deleted
|
|
if (selectedEdgeData.value && removedEdges.includes(selectedEdgeData.value.id)) {
|
|
selectedEdgeData.value = null;
|
|
}
|
|
}
|
|
|
|
// Handle edge additions (new connections)
|
|
const addedEdges = changes.filter(change => change.type === 'add');
|
|
|
|
if (addedEdges.length > 0) {
|
|
addedEdges.forEach(change => {
|
|
const edge = currentEdges.find(e => e.id === change.id);
|
|
if (edge) {
|
|
processStore.addEdge({
|
|
id: edge.id,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
label: edge.label || '',
|
|
type: edge.type || 'custom',
|
|
animated: edge.animated !== undefined ? edge.animated : true,
|
|
data: edge.data || {},
|
|
sourceHandle: edge.sourceHandle,
|
|
targetHandle: edge.targetHandle
|
|
});
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Handle creating a new process
|
|
const createNewProcess = async () => {
|
|
if (!newProcessName.value.trim()) {
|
|
toast.error('Please enter a process name');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const newProcess = await processStore.createProcess(
|
|
newProcessName.value.trim(),
|
|
newProcessDescription.value.trim()
|
|
);
|
|
|
|
if (newProcess && newProcess.id) {
|
|
// Update the URL to include the new process ID
|
|
router.replace(`/process-builder?id=${newProcess.id}`);
|
|
|
|
// Show success message
|
|
toast.success(`Process "${newProcess.name}" created successfully`);
|
|
|
|
// Reset form
|
|
isCreatingProcess.value = false;
|
|
newProcessName.value = '';
|
|
newProcessDescription.value = '';
|
|
|
|
// Reset navigation confirmation
|
|
navigationConfirmed.value = false;
|
|
} else {
|
|
toast.error('Failed to create process. Please try again.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating process:', error);
|
|
toast.error(`Failed to create process: ${error.message || 'Unknown error'}`);
|
|
}
|
|
};
|
|
|
|
// Add navigation guard
|
|
onBeforeRouteLeave((to, from, next) => {
|
|
// If navigation was already confirmed or there are no unsaved changes, proceed
|
|
if (navigationConfirmed.value || !processStore.hasUnsavedChanges) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Otherwise show the confirmation modal
|
|
showUnsavedChangesModal.value = true;
|
|
pendingNavigation.value = () => {
|
|
navigationConfirmed.value = true;
|
|
next();
|
|
};
|
|
next(false);
|
|
});
|
|
|
|
// Navigation handlers
|
|
const cancelNavigation = () => {
|
|
showUnsavedChangesModal.value = false;
|
|
pendingNavigation.value = null;
|
|
navigationTarget.value = null;
|
|
navigationConfirmed.value = false;
|
|
};
|
|
|
|
// Update the confirmNavigation function to handle targets
|
|
const confirmNavigation = (target) => {
|
|
// Force check unsaved changes by calling the getter
|
|
const hasChanges = processStore.hasUnsavedChanges;
|
|
|
|
// If already confirmed or no unsaved changes, navigate directly
|
|
if (navigationConfirmed.value || !hasChanges) {
|
|
// Clear the current process when navigating away
|
|
if (target !== '/process-builder') {
|
|
processStore.clearCurrentProcess();
|
|
}
|
|
// Reset navigation confirmation
|
|
navigationConfirmed.value = false;
|
|
router.push(target);
|
|
return;
|
|
}
|
|
|
|
// Otherwise show confirmation modal
|
|
showUnsavedChangesModal.value = true;
|
|
navigationTarget.value = target;
|
|
};
|
|
|
|
// Add proceeding with navigation
|
|
const proceedWithNavigation = () => {
|
|
showUnsavedChangesModal.value = false;
|
|
|
|
if (pendingNavigation.value) {
|
|
pendingNavigation.value();
|
|
} else if (navigationTarget.value) {
|
|
// Clear the current process when navigating away
|
|
if (navigationTarget.value !== '/process-builder') {
|
|
processStore.clearCurrentProcess();
|
|
}
|
|
navigationConfirmed.value = true; // Mark as confirmed before navigating
|
|
router.push(navigationTarget.value);
|
|
}
|
|
};
|
|
|
|
// Update the goToManage function to use the navigation system
|
|
const goToManage = () => {
|
|
// If already confirmed or no unsaved changes, navigate directly
|
|
if (navigationConfirmed.value || !processStore.hasUnsavedChanges) {
|
|
router.push('/process-builder/manage');
|
|
return;
|
|
}
|
|
|
|
// Otherwise show confirmation modal
|
|
showUnsavedChangesModal.value = true;
|
|
navigationTarget.value = "/process-builder/manage";
|
|
};
|
|
|
|
|
|
|
|
// Add events for beforeunload
|
|
onMounted(() => {
|
|
// Load clipboard from localStorage
|
|
loadClipboardFromStorage();
|
|
|
|
// Check if there's a process ID in the URL query parameters
|
|
const processId = route.query.id;
|
|
|
|
if (processId) {
|
|
// Load the specific process
|
|
processStore.loadProcess(processId).then((result) => {
|
|
if (!result.success) {
|
|
console.error('Failed to load process:', processId, result.error);
|
|
// Could show an error notification here
|
|
// For now, just redirect back to manage page
|
|
router.push('/process-builder/manage');
|
|
}
|
|
}).catch((error) => {
|
|
console.error('Error loading process:', error);
|
|
router.push('/process-builder/manage');
|
|
});
|
|
}
|
|
|
|
// Add the beforeunload event listener
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
// Add click outside listener for dropdown
|
|
document.addEventListener('click', handleClickOutside);
|
|
|
|
// Add resize listener for responsive behavior
|
|
window.addEventListener('resize', checkScreenSize);
|
|
|
|
// Add node highlight listener for variable navigation
|
|
window.addEventListener('highlightNode', handleNodeHighlight);
|
|
|
|
// Add keyboard shortcuts listener
|
|
window.addEventListener('keydown', handleKeyboardShortcuts);
|
|
|
|
// Add storage event listener for cross-tab clipboard sync
|
|
window.addEventListener('storage', handleStorageEvent);
|
|
|
|
// Initial screen size check
|
|
checkScreenSize();
|
|
});
|
|
|
|
// Handle click outside dropdown
|
|
const handleClickOutside = (event) => {
|
|
if (!event.target.closest('.dropdown')) {
|
|
showDropdown.value = false;
|
|
}
|
|
};
|
|
|
|
onUnmounted(() => {
|
|
// Remove event listeners
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
document.removeEventListener('click', handleClickOutside);
|
|
window.removeEventListener('resize', checkScreenSize);
|
|
window.removeEventListener('highlightNode', handleNodeHighlight);
|
|
window.removeEventListener('keydown', handleKeyboardShortcuts);
|
|
window.removeEventListener('storage', handleStorageEvent);
|
|
|
|
// Clear highlight timeout if it exists
|
|
if (highlightTimeout.value) {
|
|
clearTimeout(highlightTimeout.value);
|
|
}
|
|
});
|
|
|
|
// Show warning if there are unsaved changes
|
|
const handleBeforeUnload = (event) => {
|
|
if (processStore.hasUnsavedChanges) {
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
return '';
|
|
}
|
|
};
|
|
|
|
// Handle drag start
|
|
const onDragStart = (event, component) => {
|
|
try {
|
|
const dragData = {
|
|
type: component.type,
|
|
label: component.label,
|
|
data: {
|
|
...component.data,
|
|
label: component.label
|
|
}
|
|
};
|
|
|
|
draggedComponent.value = dragData;
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
|
|
|
// Add visual feedback
|
|
event.target.classList.add('dragging');
|
|
} catch (error) {
|
|
console.error('Error starting drag:', error);
|
|
}
|
|
};
|
|
|
|
// Handle drag end
|
|
const onDragEnd = (event) => {
|
|
event.target.classList.remove('dragging');
|
|
draggedComponent.value = null;
|
|
};
|
|
|
|
// Add these functions to handle form selection
|
|
const handleFormSelection = (form) => {
|
|
if (selectedNodeData.value) {
|
|
// Update all form-related data
|
|
selectedNodeData.value.data = {
|
|
...selectedNodeData.value.data,
|
|
formId: form.formID,
|
|
formName: form.formName,
|
|
formUuid: form.formUUID,
|
|
label: form.formName,
|
|
description: `Form: ${form.formName}`
|
|
};
|
|
|
|
// Also update the node's root label
|
|
selectedNodeData.value.label = form.formName;
|
|
|
|
// Update the node in store to trigger reactivity
|
|
updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
const clearFormSelection = () => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data = {
|
|
...selectedNodeData.value.data,
|
|
formId: null,
|
|
formName: '',
|
|
formUuid: null,
|
|
label: 'Form Task',
|
|
description: 'Form submission task'
|
|
};
|
|
|
|
// Reset the node's root label
|
|
selectedNodeData.value.label = 'Form Task';
|
|
|
|
// Update the node in store
|
|
updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Delete current node
|
|
const deleteNode = () => {
|
|
if (selectedNodeData.value) {
|
|
// Store the node ID before clearing the selection
|
|
const nodeId = selectedNodeData.value.id;
|
|
|
|
// Clear selection first to avoid references to deleted node
|
|
selectedNodeData.value = null;
|
|
selectedNode.value = null;
|
|
|
|
// Delete the node
|
|
processStore.deleteNode(nodeId);
|
|
}
|
|
};
|
|
|
|
// Delete current edge
|
|
const deleteEdge = () => {
|
|
if (selectedEdgeData.value) {
|
|
processStore.deleteEdge(selectedEdgeData.value.id);
|
|
selectedEdgeData.value = null;
|
|
}
|
|
};
|
|
|
|
// Save current process
|
|
const saveProcess = async () => {
|
|
if (!processStore.currentProcess) {
|
|
toast.error('No process to save');
|
|
return;
|
|
}
|
|
|
|
if (!processStore.currentProcess.name.trim()) {
|
|
toast.error('Please enter a process name before saving');
|
|
return;
|
|
}
|
|
|
|
if (isSaving.value) {
|
|
return; // Prevent multiple simultaneous save operations
|
|
}
|
|
|
|
try {
|
|
isSaving.value = true;
|
|
|
|
// Capture complete Vue Flow state before saving
|
|
if (processFlowCanvas.value && processFlowCanvas.value.toObject) {
|
|
try {
|
|
const flowState = processFlowCanvas.value.toObject();
|
|
|
|
if (flowState && flowState.nodes) {
|
|
// Set the captured flow state in the store for saving
|
|
processStore.setFlowStateForSave(flowState);
|
|
} else {
|
|
console.warn('⚠️ Vue Flow state capture returned invalid data, proceeding with store data');
|
|
}
|
|
} catch (captureError) {
|
|
console.error('❌ Error capturing Vue Flow state:', captureError);
|
|
console.warn('⚠️ Proceeding with store data as fallback');
|
|
}
|
|
} else {
|
|
console.warn('⚠️ Vue Flow canvas not available for state capture');
|
|
}
|
|
|
|
const success = await processStore.saveProcess();
|
|
|
|
if (success) {
|
|
toast.success(`Process "${processStore.currentProcess.name}" saved successfully`);
|
|
|
|
// Reset navigation confirmation since changes are now saved
|
|
navigationConfirmed.value = false;
|
|
|
|
// Force a reactivity update to ensure unsavedChanges is properly reflected
|
|
await nextTick();
|
|
} else {
|
|
toast.error('Failed to save process. Please try again.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving process:', error);
|
|
toast.error(`Failed to save process: ${error.message || 'Unknown error'}`);
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
};
|
|
|
|
// Add a component handler to add components from the component panel
|
|
const onAddComponent = async (component) => {
|
|
if (isAddingComponent.value || isProcessLoading.value) return; // Prevent concurrent additions or interference with process loading
|
|
|
|
try {
|
|
isAddingComponent.value = true;
|
|
console.log('🎯 Adding component:', component.type);
|
|
|
|
// Create a new node from the component definition
|
|
// The component structure from ProcessBuilderComponents is:
|
|
// { type, label, data: { ...defaultProps.data } }
|
|
|
|
// Calculate a better position for the new node to avoid overlap
|
|
let nodePosition = { x: 100, y: 100 };
|
|
if (processStore.currentProcess && processStore.currentProcess.nodes.length > 0) {
|
|
// Find the rightmost and bottommost positions
|
|
const maxX = Math.max(...processStore.currentProcess.nodes.map(n => n.position?.x || 0));
|
|
const maxY = Math.max(...processStore.currentProcess.nodes.map(n => n.position?.y || 0));
|
|
|
|
// Place new node to the right with some spacing
|
|
nodePosition = {
|
|
x: maxX + 200, // 200px spacing from rightmost node
|
|
y: Math.max(100, maxY) // At least 100px from top, or at the level of the bottommost node
|
|
};
|
|
}
|
|
|
|
const newNode = {
|
|
id: `${component.type}_${Date.now()}`,
|
|
type: component.type,
|
|
position: nodePosition,
|
|
label: component.label,
|
|
data: {
|
|
...component.data
|
|
}
|
|
};
|
|
|
|
// If this is a shape node, set an initial z-index
|
|
if (component.data?.isShape) {
|
|
// Find the highest z-index among existing shapes
|
|
let highestZ = 0;
|
|
if (processStore.currentProcess && processStore.currentProcess.nodes) {
|
|
processStore.currentProcess.nodes.forEach(node => {
|
|
if (node.data?.isShape) {
|
|
const nodeZ = parseInt(node.data.zIndex || 0, 10);
|
|
highestZ = Math.max(highestZ, nodeZ);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Set new shape's z-index to be on top of existing shapes
|
|
// Add 10 to ensure it's clearly above other shapes
|
|
const newZ = highestZ + 10;
|
|
newNode.data.zIndex = newZ;
|
|
|
|
// Add style with z-index
|
|
newNode.style = { zIndex: newZ };
|
|
|
|
// After the node is added, directly manipulate the DOM to ensure z-index is applied
|
|
setTimeout(() => {
|
|
const nodeElement = document.querySelector(`[data-id="${newNode.id}"]`);
|
|
if (nodeElement) {
|
|
nodeElement.style.zIndex = newZ;
|
|
|
|
// Also update custom-node if it exists
|
|
const customNode = nodeElement.querySelector('.custom-node');
|
|
if (customNode) {
|
|
customNode.style.zIndex = newZ;
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
// Add the node to the process store
|
|
console.log('📝 Adding node to store:', newNode.id);
|
|
const addedNode = await processStore.addNode(newNode);
|
|
|
|
if (!addedNode) {
|
|
console.error('❌ Failed to add node to store');
|
|
toast.error('Failed to add component to store. Please try again.');
|
|
return;
|
|
}
|
|
|
|
// Wait for store update and next render cycle
|
|
await nextTick();
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Add the node directly to the canvas using Vue Flow's addNode method
|
|
if (processFlowCanvas.value) {
|
|
try {
|
|
console.log('🎨 Adding node to canvas:', addedNode.id);
|
|
|
|
// Use Vue Flow's addNode method directly to avoid any sync issues
|
|
if (processFlowCanvas.value.addNode) {
|
|
processFlowCanvas.value.addNode(addedNode);
|
|
}
|
|
|
|
// Wait for the node to be added to the canvas
|
|
await nextTick();
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Select the newly added node after it's stable
|
|
onNodeSelected(addedNode);
|
|
|
|
console.log('✅ Successfully added new node:', addedNode.id, 'at position:', addedNode.position);
|
|
} catch (error) {
|
|
console.error('❌ Error adding node to canvas:', error);
|
|
toast.error('Failed to add component to canvas. Please try again.');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error adding component:', error);
|
|
toast.error('Failed to add component. Please try again.');
|
|
} finally {
|
|
// Reset the flag after a longer delay to ensure canvas is stable
|
|
setTimeout(() => {
|
|
console.log('🏁 Component addition completed, resetting flag');
|
|
isAddingComponent.value = false;
|
|
}, 300);
|
|
}
|
|
};
|
|
|
|
// Handle template application
|
|
const applyProcessTemplate = async (template) => {
|
|
try {
|
|
|
|
|
|
// Create a new process if one doesn't exist
|
|
if (!processStore.currentProcess) {
|
|
|
|
processStore.createProcess(template.name, template.description || 'Process created from template');
|
|
} else {
|
|
// Confirm if there's already content in the existing process
|
|
if (processStore.currentProcess.nodes.length > 0 || processStore.currentProcess.edges.length > 0) {
|
|
if (!confirm("This will replace your current process content. Continue?")) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Update process name if user allows
|
|
if (processStore.currentProcess.name === 'New Process' || confirm("Update the process name to match the template?")) {
|
|
processStore.currentProcess.name = template.name;
|
|
processStore.currentProcess.description = template.description;
|
|
}
|
|
}
|
|
|
|
// Clear current process nodes and edges
|
|
if (processStore.currentProcess) {
|
|
|
|
processStore.currentProcess.nodes = [];
|
|
processStore.currentProcess.edges = [];
|
|
}
|
|
|
|
// Prepare template data
|
|
const templateNodes = template.nodes || [];
|
|
const templateEdges = template.edges || [];
|
|
|
|
|
|
|
|
// Process nodes first and wait for them to be fully added
|
|
for (const node of templateNodes) {
|
|
// Get default styling for the node type
|
|
const defaultStyling = getDefaultNodeStyling(node.type);
|
|
|
|
const newNode = {
|
|
...node,
|
|
id: node.id, // Keep original ID for edge references
|
|
label: node.data?.label || node.label || `${node.type} node`, // Set label at root level
|
|
position: node.position || { x: 100, y: 100 },
|
|
data: {
|
|
...node.data,
|
|
label: node.data?.label || node.label || `${node.type} node`,
|
|
// Preserve styling properties from template or use defaults
|
|
backgroundColor: node.data?.backgroundColor || defaultStyling.backgroundColor,
|
|
borderColor: node.data?.borderColor || defaultStyling.borderColor,
|
|
textColor: node.data?.textColor || defaultStyling.textColor,
|
|
shape: node.data?.shape || defaultStyling.shape
|
|
}
|
|
};
|
|
|
|
processStore.addNode(newNode);
|
|
}
|
|
|
|
// Wait for nodes to be processed by the canvas
|
|
await nextTick();
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Process edges after nodes are fully loaded
|
|
for (const edge of templateEdges) {
|
|
const newEdge = {
|
|
...edge,
|
|
id: edge.id, // Keep original ID
|
|
type: edge.type || 'custom',
|
|
animated: edge.animated !== undefined ? edge.animated : true
|
|
};
|
|
|
|
processStore.addEdge(newEdge);
|
|
}
|
|
|
|
// Wait for edges to be added to store
|
|
await nextTick();
|
|
|
|
// Explicitly sync the canvas to ensure everything is displayed
|
|
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
|
|
|
|
processFlowCanvas.value.syncCanvas(
|
|
processStore.currentProcess.nodes,
|
|
processStore.currentProcess.edges
|
|
);
|
|
}
|
|
|
|
// Add template variables to the variable store
|
|
if (template.variables && template.variables.length > 0) {
|
|
|
|
|
|
// Clear existing process variables first (they'll be loaded from the process)
|
|
// Process variables are now managed directly in the process store
|
|
|
|
template.variables.forEach((variable) => {
|
|
|
|
processStore.addProcessVariable({
|
|
...variable,
|
|
id: crypto.randomUUID() // Generate unique ID for the variable
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
|
|
// Mark the process as having unsaved changes
|
|
processStore.unsavedChanges = true;
|
|
|
|
// Fit the view to show all nodes after a short delay
|
|
await nextTick();
|
|
setTimeout(() => {
|
|
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
|
|
processFlowCanvas.value.fitView();
|
|
}
|
|
}, 200);
|
|
|
|
// Show success message
|
|
toast.success(`Template "${template.name}" applied successfully`);
|
|
|
|
} catch (error) {
|
|
console.error('Error applying process template:', error);
|
|
toast.error('Failed to apply template: ' + error.message);
|
|
}
|
|
};
|
|
|
|
// Get default styling for node types
|
|
const getDefaultNodeStyling = (nodeType) => {
|
|
const stylingMap = {
|
|
'start': {
|
|
backgroundColor: '#dcfce7',
|
|
borderColor: '#10b981',
|
|
textColor: '#065f46',
|
|
shape: 'circle'
|
|
},
|
|
'end': {
|
|
backgroundColor: '#fee2e2',
|
|
borderColor: '#dc2626',
|
|
textColor: '#991b1b',
|
|
shape: 'circle'
|
|
},
|
|
'form': {
|
|
backgroundColor: '#faf5ff',
|
|
borderColor: '#9333ea',
|
|
textColor: '#6b21a8',
|
|
shape: 'rectangle'
|
|
},
|
|
'api': {
|
|
backgroundColor: '#eff6ff',
|
|
borderColor: '#3b82f6',
|
|
textColor: '#1e40af',
|
|
shape: 'rectangle'
|
|
},
|
|
'gateway': {
|
|
backgroundColor: '#fff7ed',
|
|
borderColor: '#f97316',
|
|
textColor: '#c2410c',
|
|
shape: 'diamond'
|
|
},
|
|
'notification': {
|
|
backgroundColor: '#f0f9ff',
|
|
borderColor: '#0ea5e9',
|
|
textColor: '#0284c7',
|
|
shape: 'rectangle'
|
|
},
|
|
'business-rule': {
|
|
backgroundColor: '#fdf4ff',
|
|
borderColor: '#a855f7',
|
|
textColor: '#7c3aed',
|
|
shape: 'rectangle'
|
|
},
|
|
'script': {
|
|
backgroundColor: '#f9fafb',
|
|
borderColor: '#6b7280',
|
|
textColor: '#374151',
|
|
shape: 'rectangle'
|
|
},
|
|
'html': {
|
|
backgroundColor: '#e0f2fe',
|
|
borderColor: '#0ea5e9',
|
|
textColor: '#0c4a6e',
|
|
shape: 'rectangle'
|
|
},
|
|
'subprocess': {
|
|
backgroundColor: '#f0fdfa',
|
|
borderColor: '#14b8a6',
|
|
textColor: '#134e4a',
|
|
shape: 'rectangle'
|
|
}
|
|
};
|
|
|
|
return stylingMap[nodeType] || {
|
|
backgroundColor: '#ffffff',
|
|
borderColor: '#e2e8f0',
|
|
textColor: '#374151',
|
|
shape: 'rectangle'
|
|
};
|
|
};
|
|
// Fix references to functions
|
|
const onFormSelected = (formData) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'form') {
|
|
selectedNodeData.value.data.formId = formData.id;
|
|
selectedNodeData.value.data.formName = formData.name;
|
|
updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
const onConditionsUpdated = (conditions) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
|
|
selectedNodeData.value.data.conditions = conditions;
|
|
updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Handle API node update
|
|
const handleApiNodeUpdate = async (updatedData) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'api') {
|
|
// Make sure to update the label both in data and at the root level
|
|
const newLabel = updatedData.label || 'API Call';
|
|
|
|
// Update the data
|
|
selectedNodeData.value.data = {
|
|
...updatedData,
|
|
label: newLabel // Ensure label is in data
|
|
};
|
|
|
|
// Also update the root label
|
|
selectedNodeData.value.label = newLabel;
|
|
|
|
// Update the node in store and refresh
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Add this function to handle form node updates
|
|
const handleFormNodeUpdate = async (updatedData) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'form') {
|
|
// Make sure to update the label both in data and at the root level
|
|
const newLabel = updatedData.label || 'Form Task';
|
|
|
|
// Update the data
|
|
selectedNodeData.value.data = {
|
|
...updatedData,
|
|
label: newLabel // Ensure label is in data
|
|
};
|
|
|
|
// Also update the root label
|
|
selectedNodeData.value.label = newLabel;
|
|
|
|
// Update the node in store and refresh
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Update handler for business rule node
|
|
const handleBusinessRuleUpdate = async (data) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data = {
|
|
...selectedNodeData.value.data,
|
|
...data
|
|
};
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Add a method to handle default path updates
|
|
const handleDefaultPathUpdate = async (path) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
|
|
selectedNodeData.value.data.defaultPath = path;
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Handle notification node update
|
|
const handleNotificationNodeUpdate = async (updatedData) => {
|
|
if (selectedNodeData.value) {
|
|
selectedNodeData.value.data = { ...updatedData };
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Handle script node update
|
|
const handleScriptNodeUpdate = async (updatedData) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'script') {
|
|
// Make sure to update the label both in data and at the root level
|
|
const newLabel = updatedData.label || 'Script Task';
|
|
|
|
// Update the data
|
|
selectedNodeData.value.data = {
|
|
...updatedData,
|
|
label: newLabel // Ensure label is in data
|
|
};
|
|
|
|
// Also update the root label
|
|
selectedNodeData.value.label = newLabel;
|
|
|
|
// Add output variables to the process
|
|
if (updatedData.outputVariables && Array.isArray(updatedData.outputVariables)) {
|
|
updatedData.outputVariables.forEach(output => {
|
|
if (output.name && output.name.trim()) {
|
|
processStore.addProcessVariable({
|
|
name: output.name,
|
|
type: output.type || 'string',
|
|
scope: 'global',
|
|
value: null,
|
|
description: output.description || `Output from ${newLabel}`
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update the node in store and refresh
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Handle HTML node update
|
|
const handleHtmlNodeUpdate = async (updatedData) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'html') {
|
|
// Make sure to update the label both in data and at the root level
|
|
const newLabel = updatedData.label || 'HTML Content';
|
|
|
|
// Update the data
|
|
selectedNodeData.value.data = {
|
|
...updatedData,
|
|
label: newLabel // Ensure label is in data
|
|
};
|
|
|
|
// Also update the root label
|
|
selectedNodeData.value.label = newLabel;
|
|
|
|
// Add output variables to the process
|
|
if (updatedData.outputVariables && Array.isArray(updatedData.outputVariables)) {
|
|
updatedData.outputVariables.forEach(output => {
|
|
if (output.name && output.name.trim()) {
|
|
processStore.addProcessVariable({
|
|
name: output.name,
|
|
type: output.type || 'string',
|
|
scope: 'global',
|
|
value: null,
|
|
description: output.description || `Output from ${newLabel}`
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update the node in store and refresh
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Handle Sub-process node update
|
|
const handleSubprocessNodeUpdate = async (updatedData) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.type === 'subprocess') {
|
|
// Make sure to update the label both in data and at the root level
|
|
const newLabel = updatedData.label || 'Sub Process';
|
|
|
|
// Update the data
|
|
selectedNodeData.value.data = {
|
|
...updatedData,
|
|
label: newLabel // Ensure label is in data
|
|
};
|
|
|
|
// Also update the root label
|
|
selectedNodeData.value.label = newLabel;
|
|
|
|
// Update the node in store and refresh
|
|
await updateNodeInStore();
|
|
}
|
|
};
|
|
|
|
// Handle process restoration from history
|
|
const handleProcessRestored = (restoredProcess) => {
|
|
// The process has been restored in the backend, so we need to reload it
|
|
|
|
// The current process will be automatically updated by the store
|
|
toast.success('Process has been restored successfully');
|
|
};
|
|
|
|
// Handle canvas export
|
|
const handleExportCanvas = async (options) => {
|
|
try {
|
|
if (!processFlowCanvas.value) {
|
|
throw new Error('Canvas not available for export');
|
|
}
|
|
|
|
console.log('Starting export with options:', options);
|
|
|
|
if (options.format === 'png') {
|
|
await processFlowCanvas.value.exportToPNG(options);
|
|
toast.success('Canvas exported as PNG successfully');
|
|
} else if (options.format === 'pdf') {
|
|
await processFlowCanvas.value.exportToPDF(options);
|
|
toast.success('Canvas exported as PDF successfully');
|
|
}
|
|
} catch (error) {
|
|
console.error('Export error:', error);
|
|
toast.error(`Export failed: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Navigate to variables page
|
|
const navigateToVariables = () => {
|
|
confirmNavigation('/variables');
|
|
};
|
|
|
|
// Watch for route changes to handle process ID changes
|
|
watch(() => route.query.id, (newProcessId, oldProcessId) => {
|
|
// Only react if the process ID actually changed and it's not empty
|
|
if (newProcessId && newProcessId !== oldProcessId) {
|
|
processStore.loadProcess(newProcessId).then((result) => {
|
|
if (!result.success) {
|
|
console.error('Failed to load process:', newProcessId, result.error);
|
|
router.push('/process-builder/manage');
|
|
}
|
|
}).catch((error) => {
|
|
console.error('Error loading process:', error);
|
|
router.push('/process-builder/manage');
|
|
});
|
|
} else if (!newProcessId && oldProcessId) {
|
|
// If the ID was removed from the URL, clear the current process
|
|
processStore.clearCurrentProcess();
|
|
}
|
|
});
|
|
|
|
// Watch for unsaved changes to reset navigation confirmation
|
|
watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
|
|
// If there are no more unsaved changes, reset navigation confirmation
|
|
if (!hasChanges) {
|
|
navigationConfirmed.value = false;
|
|
}
|
|
});
|
|
|
|
// Watch for canvas nodes changes to ensure selected node stays in sync
|
|
watch(() => canvasNodes.value, (newNodes) => {
|
|
// If we have a selected node, make sure it's kept in sync with the canvas
|
|
if (selectedNodeData.value && newNodes.length > 0) {
|
|
const updatedNode = newNodes.find(node => node.id === selectedNodeData.value.id);
|
|
if (updatedNode) {
|
|
// Use the same change detection logic as refreshSelectedNodeFromStore
|
|
const hasChanges = (
|
|
selectedNodeData.value.label !== updatedNode.label ||
|
|
JSON.stringify(selectedNodeData.value.data) !== JSON.stringify(updatedNode.data) ||
|
|
selectedNodeData.value.position?.x !== updatedNode.position?.x ||
|
|
selectedNodeData.value.position?.y !== updatedNode.position?.y
|
|
);
|
|
|
|
if (hasChanges) {
|
|
selectedNodeData.value = { ...updatedNode, data: { ...updatedNode.data } };
|
|
}
|
|
}
|
|
}
|
|
}, { deep: true });
|
|
|
|
// Track if we're in the middle of adding a component to prevent interference
|
|
const isProcessLoading = ref(false);
|
|
|
|
// Watch for process changes to restore Vue Flow state
|
|
// Only trigger when the entire process object changes (different process loaded)
|
|
watch(() => processStore.currentProcess?.id, async (newProcessId, oldProcessId) => {
|
|
if (!newProcessId) return;
|
|
|
|
// Only restore when a different process is loaded (not on updates to the same process)
|
|
if (oldProcessId && newProcessId === oldProcessId) return;
|
|
|
|
// Don't interfere if we're in the middle of adding a component or modifying nodes
|
|
if (isAddingComponent.value || processStore.isModifyingNodes) {
|
|
console.log('🚫 Skipping process restoration during component/node modification');
|
|
return;
|
|
}
|
|
|
|
const newProcess = processStore.currentProcess;
|
|
console.log('🔄 Loading process:', newProcessId, 'Nodes:', newProcess.nodes?.length || 0);
|
|
isProcessLoading.value = true;
|
|
|
|
// Wait for the canvas to be ready
|
|
await nextTick();
|
|
|
|
// Give the canvas component a moment to initialize
|
|
setTimeout(async () => {
|
|
try {
|
|
if (processFlowCanvas.value && processFlowCanvas.value.fromObject) {
|
|
try {
|
|
// Prepare the flow object for restoration
|
|
const rawFlowObject = {
|
|
nodes: newProcess.nodes || [],
|
|
edges: newProcess.edges || [],
|
|
viewport: newProcess.viewport || { x: 0, y: 0, zoom: 1 }
|
|
};
|
|
|
|
// Clean the data to remove any Vue Flow internal properties
|
|
const flowObject = processStore.cleanFlowData(rawFlowObject);
|
|
|
|
console.log('🔄 Restoring Vue Flow state with', flowObject.nodes.length, 'nodes');
|
|
|
|
// Use Vue Flow's proper restoration method (now returns a Promise)
|
|
await processFlowCanvas.value.fromObject(flowObject);
|
|
|
|
console.log('✅ Vue Flow state restored successfully');
|
|
|
|
// Only fit view if nodes don't have meaningful positions (all at 0,0)
|
|
// This prevents repositioning nodes that were carefully positioned
|
|
const hasPositionedNodes = flowObject.nodes.some(node =>
|
|
node.position && (node.position.x !== 0 || node.position.y !== 0)
|
|
);
|
|
|
|
if (!hasPositionedNodes && flowObject.nodes.length > 0) {
|
|
setTimeout(() => {
|
|
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
|
|
processFlowCanvas.value.fitView();
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('❌ Error restoring Vue Flow state:', error);
|
|
|
|
// Fallback to manual sync if Vue Flow restoration fails
|
|
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
|
|
console.log('🔄 Falling back to manual sync');
|
|
processFlowCanvas.value.syncCanvas(newProcess.nodes, newProcess.edges);
|
|
}
|
|
}
|
|
} else {
|
|
console.warn('⚠️ Vue Flow canvas not available for restoration, using sync fallback');
|
|
|
|
// Fallback sync method
|
|
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
|
|
processFlowCanvas.value.syncCanvas(newProcess.nodes, newProcess.edges);
|
|
}
|
|
}
|
|
} finally {
|
|
isProcessLoading.value = false;
|
|
}
|
|
}, 200); // Allow time for canvas to initialize
|
|
}, { immediate: false });
|
|
|
|
// Duplicate Node logic
|
|
const duplicateNode = async () => {
|
|
if (!selectedNodeData.value) return;
|
|
const node = selectedNodeData.value;
|
|
// Prevent duplication for start/end nodes and shapes
|
|
if (node.type === 'start' || node.type === 'end' || node.data?.isShape) return;
|
|
|
|
// Deep copy node
|
|
const newNode = JSON.parse(JSON.stringify(node));
|
|
newNode.id = `${node.type}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
|
// Offset position
|
|
newNode.position = {
|
|
x: (node.position?.x || 100) + 40,
|
|
y: (node.position?.y || 100) + 40
|
|
};
|
|
// Update label
|
|
if (newNode.label) {
|
|
newNode.label = newNode.label.endsWith(' (Copy)') ? newNode.label : `${newNode.label} (Copy)`;
|
|
}
|
|
if (newNode.data && newNode.data.label) {
|
|
newNode.data.label = newNode.data.label.endsWith(' (Copy)') ? newNode.data.label : `${newNode.data.label} (Copy)`;
|
|
}
|
|
// Remove any edge/connection-specific data if present
|
|
delete newNode.selected;
|
|
delete newNode.dragging;
|
|
|
|
// Add to store only
|
|
await processStore.addNode(newNode);
|
|
await nextTick();
|
|
// Select the new node from the store
|
|
const freshNode = processStore.currentProcess?.nodes.find(n => n.id === newNode.id);
|
|
if (freshNode) {
|
|
onNodeSelected(freshNode);
|
|
}
|
|
};
|
|
|
|
// Add computed property for action visibility
|
|
const canShowNodeActions = computed(() => {
|
|
return (
|
|
selectedNodeData.value &&
|
|
selectedNodeData.value.type !== 'start' &&
|
|
selectedNodeData.value.type !== 'end'
|
|
);
|
|
});
|
|
|
|
// Add computed properties for copy/paste functionality
|
|
const canCopy = computed(() => {
|
|
// Check for multiple selections first
|
|
if (processFlowCanvas.value) {
|
|
try {
|
|
let selectedNodes = [];
|
|
let selectedEdges = [];
|
|
|
|
// Try the new getSelectedElements method first
|
|
if (processFlowCanvas.value.getSelectedElements) {
|
|
const selection = processFlowCanvas.value.getSelectedElements();
|
|
selectedNodes = selection.nodes || [];
|
|
selectedEdges = selection.edges || [];
|
|
}
|
|
|
|
// If no selection found, try direct access
|
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
|
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
|
|
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected);
|
|
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected);
|
|
}
|
|
}
|
|
|
|
// If there are multiple selections, any selection is copyable
|
|
if (selectedNodes.length > 0 || selectedEdges.length > 0) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to check multiple selections:', error);
|
|
}
|
|
}
|
|
|
|
// Fall back to single selection check
|
|
// Can copy edges
|
|
if (selectedEdgeData.value) return true;
|
|
|
|
// Can copy any nodes (including start/end)
|
|
if (selectedNodeData.value) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
const canPaste = computed(() => {
|
|
return clipboard.value.type && processStore.currentProcess;
|
|
});
|
|
|
|
const clipboardInfo = computed(() => {
|
|
if (!clipboard.value.type) return null;
|
|
|
|
const nodeCount = clipboard.value.nodes.length;
|
|
const edgeCount = clipboard.value.edges.length;
|
|
|
|
if (clipboard.value.type === 'mixed') {
|
|
return `${nodeCount} node${nodeCount > 1 ? 's' : ''}, ${edgeCount} edge${edgeCount > 1 ? 's' : ''}`;
|
|
} else if (clipboard.value.type === 'nodes') {
|
|
return `${nodeCount} node${nodeCount > 1 ? 's' : ''}`;
|
|
} else if (clipboard.value.type === 'edges') {
|
|
return `${edgeCount} edge${edgeCount > 1 ? 's' : ''}`;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
// Add computed property to show current selection info
|
|
const currentSelectionInfo = computed(() => {
|
|
if (!processFlowCanvas.value || !processFlowCanvas.value.flowInstance) return null;
|
|
|
|
try {
|
|
let selectedNodes = [];
|
|
let selectedEdges = [];
|
|
|
|
// Try the new getSelectedElements method first
|
|
if (processFlowCanvas.value.getSelectedElements) {
|
|
const selection = processFlowCanvas.value.getSelectedElements();
|
|
selectedNodes = selection.nodes || [];
|
|
selectedEdges = selection.edges || [];
|
|
}
|
|
|
|
// If no selection found, try direct access
|
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
|
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
|
|
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected);
|
|
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected);
|
|
}
|
|
}
|
|
|
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) return null;
|
|
|
|
let info = '';
|
|
if (selectedNodes.length > 0) {
|
|
info += `${selectedNodes.length} node${selectedNodes.length > 1 ? 's' : ''}`;
|
|
}
|
|
if (selectedEdges.length > 0) {
|
|
if (info) info += ', ';
|
|
info += `${selectedEdges.length} edge${selectedEdges.length > 1 ? 's' : ''}`;
|
|
}
|
|
|
|
return info;
|
|
} catch (error) {
|
|
console.warn('Failed to get current selection info:', error);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const nodeZIndex = computed({
|
|
get: () => {
|
|
return selectedNodeData.value?.data?.isShape ? selectedNodeData.value.data.zIndex || 0 : 0;
|
|
},
|
|
set: (value) => {
|
|
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
|
|
const z = parseInt(value, 10);
|
|
const newZ = isNaN(z) ? 0 : z;
|
|
selectedNodeData.value.data.zIndex = newZ;
|
|
updateNodeInStore();
|
|
|
|
// Force update the node style in the canvas
|
|
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
|
|
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
|
|
style: { zIndex: newZ }
|
|
});
|
|
|
|
// Also directly manipulate DOM for immediate feedback
|
|
nextTick(() => {
|
|
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
|
|
if (nodeElement) {
|
|
nodeElement.style.zIndex = newZ;
|
|
|
|
// Also update custom-node if it exists
|
|
const customNode = nodeElement.querySelector('.custom-node');
|
|
if (customNode) {
|
|
customNode.style.zIndex = newZ;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add functions to bring forward and send backward shapes
|
|
const bringForward = () => {
|
|
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
|
|
// Increment z-index by 1
|
|
const currentZ = parseInt(selectedNodeData.value.data.zIndex || 0, 10);
|
|
const newZ = currentZ + 1;
|
|
selectedNodeData.value.data.zIndex = newZ;
|
|
|
|
// Update in store
|
|
updateNodeInStore();
|
|
|
|
// Force update the node style in the canvas
|
|
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
|
|
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
|
|
style: { zIndex: newZ }
|
|
});
|
|
|
|
// Also directly manipulate DOM for immediate feedback
|
|
nextTick(() => {
|
|
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
|
|
if (nodeElement) {
|
|
nodeElement.style.zIndex = newZ;
|
|
|
|
// Also update custom-node if it exists
|
|
const customNode = nodeElement.querySelector('.custom-node');
|
|
if (customNode) {
|
|
customNode.style.zIndex = newZ;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const sendBackward = () => {
|
|
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
|
|
// Decrement z-index by 1, but not below 0
|
|
const currentZ = parseInt(selectedNodeData.value.data.zIndex || 0, 10);
|
|
const newZ = Math.max(0, currentZ - 1);
|
|
selectedNodeData.value.data.zIndex = newZ;
|
|
|
|
// Update in store
|
|
updateNodeInStore();
|
|
|
|
// Force update the node style in the canvas
|
|
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
|
|
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
|
|
style: { zIndex: newZ }
|
|
});
|
|
|
|
// Also directly manipulate DOM for immediate feedback
|
|
nextTick(() => {
|
|
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
|
|
if (nodeElement) {
|
|
nodeElement.style.zIndex = newZ;
|
|
|
|
// Also update custom-node if it exists
|
|
const customNode = nodeElement.querySelector('.custom-node');
|
|
if (customNode) {
|
|
customNode.style.zIndex = newZ;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const bringToFront = () => {
|
|
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
|
|
// Find the highest z-index in the current shapes
|
|
let highestZ = 0;
|
|
if (processStore.currentProcess && processStore.currentProcess.nodes) {
|
|
processStore.currentProcess.nodes.forEach(node => {
|
|
if (node.data?.isShape) {
|
|
const nodeZ = parseInt(node.data.zIndex || 0, 10);
|
|
highestZ = Math.max(highestZ, nodeZ);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Set to higher than the highest found (add 10 to ensure it's clearly on top)
|
|
const newZ = highestZ + 10;
|
|
selectedNodeData.value.data.zIndex = newZ;
|
|
|
|
// Update in store
|
|
updateNodeInStore();
|
|
|
|
// Force update the node style in the canvas
|
|
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
|
|
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
|
|
style: { zIndex: newZ }
|
|
});
|
|
|
|
// Also directly manipulate DOM for immediate feedback
|
|
nextTick(() => {
|
|
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
|
|
if (nodeElement) {
|
|
nodeElement.style.zIndex = newZ;
|
|
|
|
// Also update custom-node if it exists
|
|
const customNode = nodeElement.querySelector('.custom-node');
|
|
if (customNode) {
|
|
customNode.style.zIndex = newZ;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const sendToBack = () => {
|
|
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
|
|
// Find the lowest z-index in the current shapes
|
|
let lowestZ = Number.MAX_SAFE_INTEGER;
|
|
if (processStore.currentProcess && processStore.currentProcess.nodes) {
|
|
processStore.currentProcess.nodes.forEach(node => {
|
|
if (node.data?.isShape && node.id !== selectedNodeData.value.id) {
|
|
const nodeZ = parseInt(node.data.zIndex || 0, 10);
|
|
lowestZ = Math.min(lowestZ, nodeZ);
|
|
}
|
|
});
|
|
}
|
|
|
|
// If no other shapes were found, set to 0
|
|
if (lowestZ === Number.MAX_SAFE_INTEGER) {
|
|
lowestZ = 0;
|
|
}
|
|
|
|
// Set to lower than the lowest found (subtract 10 to ensure it's clearly below)
|
|
const newZ = Math.max(0, lowestZ - 10);
|
|
selectedNodeData.value.data.zIndex = newZ;
|
|
|
|
// Update in store
|
|
updateNodeInStore();
|
|
|
|
// Force update the node style in the canvas
|
|
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
|
|
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
|
|
style: { zIndex: newZ }
|
|
});
|
|
|
|
// Also directly manipulate DOM for immediate feedback
|
|
nextTick(() => {
|
|
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
|
|
if (nodeElement) {
|
|
nodeElement.style.zIndex = newZ;
|
|
|
|
// Also update custom-node if it exists
|
|
const customNode = nodeElement.querySelector('.custom-node');
|
|
if (customNode) {
|
|
customNode.style.zIndex = newZ;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<client-only>
|
|
<div class="process-builder flex flex-col h-screen bg-white">
|
|
<!-- Header Bar -->
|
|
<header
|
|
class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm"
|
|
>
|
|
<!-- Left section - Logo and navigation -->
|
|
<div class="flex items-center gap-4">
|
|
<Icon
|
|
@click="confirmNavigation('/process-builder/manage')"
|
|
name="ph:arrow-circle-left-duotone"
|
|
class="cursor-pointer w-6 h-6 hover:text-gray-600 text-gray-500"
|
|
/>
|
|
<div class="flex items-center gap-3">
|
|
<img
|
|
src="@/assets/img/logo/logo-word-black.svg"
|
|
alt="Corrad Logo"
|
|
class="h-8"
|
|
/>
|
|
<div class="border-l border-gray-300 pl-3">
|
|
<h1 class="text-xl font-semibold text-gray-900">Process Builder</h1>
|
|
<p class="text-sm text-gray-500">Create business processes with drag and drop</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Middle section - Process name and Panel Controls -->
|
|
<div class="flex-1 flex justify-center items-center mx-2 md:mx-4 gap-4">
|
|
<!-- Panel toggle controls - Always visible when process is loaded -->
|
|
<div v-if="hasCurrentProcess" class="flex items-center gap-1">
|
|
<RsButton
|
|
@click="toggleLeftPanel"
|
|
variant="tertiary"
|
|
size="sm"
|
|
:class="{
|
|
'bg-blue-100 text-blue-600 border-blue-200': showLeftPanel,
|
|
'bg-gray-100 text-gray-600 border-gray-200': !showLeftPanel
|
|
}"
|
|
class="p-2 border transition-all duration-200 hover:scale-105 relative"
|
|
title="Toggle Components Panel (Ctrl+1)"
|
|
>
|
|
<Icon name="material-symbols:widgets" class="w-4 h-4" />
|
|
<span v-if="!showLeftPanel" class="absolute -top-1 -right-1 w-2 h-2 bg-orange-400 rounded-full"></span>
|
|
</RsButton>
|
|
<RsButton
|
|
@click="toggleRightPanel"
|
|
variant="tertiary"
|
|
size="sm"
|
|
:class="{
|
|
'bg-blue-100 text-blue-600 border-blue-200': showRightPanel,
|
|
'bg-gray-100 text-gray-600 border-gray-200': !showRightPanel
|
|
}"
|
|
class="p-2 border transition-all duration-200 hover:scale-105 relative"
|
|
title="Toggle Properties Panel (Ctrl+2)"
|
|
>
|
|
<Icon name="material-symbols:tune" class="w-4 h-4" />
|
|
<span v-if="!showRightPanel" class="absolute -top-1 -right-1 w-2 h-2 bg-orange-400 rounded-full"></span>
|
|
</RsButton>
|
|
<RsButton
|
|
@click="toggleBothPanels"
|
|
variant="tertiary"
|
|
size="sm"
|
|
:class="{
|
|
'bg-blue-100 text-blue-600 border-blue-200': showLeftPanel && showRightPanel,
|
|
'bg-gray-100 text-gray-600 border-gray-200': !(showLeftPanel && showRightPanel)
|
|
}"
|
|
class="p-2 hidden md:block border transition-all duration-200 hover:scale-105 relative"
|
|
title="Toggle Both Panels (Ctrl+3)"
|
|
>
|
|
<Icon name="material-symbols:view-sidebar" class="w-4 h-4" />
|
|
<span v-if="!showLeftPanel || !showRightPanel" class="absolute -top-1 -right-1 w-2 h-2 bg-orange-400 rounded-full"></span>
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Process name input -->
|
|
<FormKit
|
|
v-if="hasCurrentProcess"
|
|
v-model="processStore.currentProcess.name"
|
|
type="text"
|
|
placeholder="Process Name"
|
|
validation="required"
|
|
validation-visibility="live"
|
|
:validation-messages="{ required: 'Please enter a process name' }"
|
|
class="process-name-input max-w-xs md:max-w-md"
|
|
:classes="{
|
|
outer: 'mb-0 w-full',
|
|
}"
|
|
/>
|
|
<div v-else class="text-sm md:text-lg font-medium text-gray-400 hidden sm:block">No Process Selected</div>
|
|
</div>
|
|
|
|
<!-- Right section - Actions -->
|
|
<div class="flex items-center">
|
|
<!-- Primary actions -->
|
|
<div class="flex items-center mr-1 md:mr-2 border-r border-gray-200 pr-1 md:pr-2">
|
|
<RsButton @click="saveProcess" variant="primary" size="sm" class="mr-1 md:mr-2" :disabled="!hasCurrentProcess || isSaving">
|
|
<Icon v-if="isSaving" name="material-symbols:progress-activity" class="mr-0 md:mr-1 animate-spin" />
|
|
<Icon v-else name="material-symbols:save" class="mr-0 md:mr-1" />
|
|
<span class="hidden md:inline">{{ isSaving ? 'Saving...' : 'Save' }}</span>
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Copy/Paste actions -->
|
|
<div class="flex items-center mr-1 md:mr-2 border-r border-gray-200 pr-1 md:pr-2">
|
|
<RsButton
|
|
@click="copySelectedElements"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-1"
|
|
:disabled="!canCopy"
|
|
:title="`Copy selected elements (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
|
|
>
|
|
<Icon name="material-symbols:content-copy" class="mr-0 md:mr-1" />
|
|
<span class="hidden md:inline">Copy</span>
|
|
</RsButton>
|
|
<RsButton
|
|
@click="pasteElements"
|
|
variant="secondary"
|
|
size="sm"
|
|
:disabled="!canPaste"
|
|
:title="`Paste elements (Ctrl+V)${clipboardInfo ? ' - ' + clipboardInfo : ''}`"
|
|
>
|
|
<Icon name="material-symbols:content-paste" class="mr-0 md:mr-1" />
|
|
<span class="hidden md:inline">Paste</span>
|
|
</RsButton>
|
|
|
|
<!-- Debug button (temporary) -->
|
|
<RsButton
|
|
@click="debugSelection"
|
|
variant="tertiary"
|
|
size="sm"
|
|
class="ml-1"
|
|
title="Debug selection state"
|
|
>
|
|
<Icon name="material-symbols:bug-report" class="w-4 h-4" />
|
|
</RsButton>
|
|
|
|
<!-- Selection indicator -->
|
|
<div v-if="currentSelectionInfo && !clipboardInfo" class="ml-2 px-2 py-1 bg-green-100 text-green-700 text-xs rounded-md border border-green-200 hidden sm:block" :title="`Selected: ${currentSelectionInfo}. Press Ctrl+C to copy.`">
|
|
<Icon name="material-symbols:select-all" class="w-3 h-3 mr-1" />
|
|
{{ currentSelectionInfo }}
|
|
</div>
|
|
|
|
<!-- Clipboard indicator -->
|
|
<div v-if="clipboardInfo" class="ml-2 px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-md border border-blue-200 hidden sm:block" :title="`Clipboard contains: ${clipboardInfo}. You can paste this in any process tab.`">
|
|
<Icon name="material-symbols:content-paste" class="w-3 h-3 mr-1" />
|
|
{{ clipboardInfo }}
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Secondary actions -->
|
|
<div class="flex items-center">
|
|
<div class="dropdown relative">
|
|
<RsButton @click="showDropdown = !showDropdown" variant="tertiary" size="sm" class="flex items-center">
|
|
<Icon name="material-symbols:more-vert" class="w-4 h-4 md:w-5 md:h-5" />
|
|
</RsButton>
|
|
|
|
<div v-if="showDropdown" class="dropdown-menu absolute right-0 mt-2 bg-white rounded shadow-lg py-1 z-10 w-48 text-gray-800">
|
|
<!-- Process Actions -->
|
|
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
Process Actions
|
|
</div>
|
|
<button @click="showTemplatesModal = true; showDropdown = false" :disabled="!hasCurrentProcess" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center disabled:opacity-50">
|
|
<Icon name="material-symbols:description-outline" class="mr-2 w-4 h-4" />
|
|
<span>Templates</span>
|
|
</button>
|
|
<button v-if="processStore.currentProcess && processStore.currentProcess.id" @click="showProcessHistoryModal = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
|
|
<Icon name="material-symbols:history" class="mr-2 w-4 h-4" />
|
|
<span>History</span>
|
|
</button>
|
|
<button @click="showExportModal = true; showDropdown = false" :disabled="!hasCurrentProcess" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center disabled:opacity-50">
|
|
<Icon name="material-symbols:download" class="mr-2 w-4 h-4" />
|
|
<span>Export</span>
|
|
</button>
|
|
|
|
<!-- Clipboard Actions -->
|
|
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
|
|
Clipboard
|
|
</div>
|
|
<div v-if="clipboardInfo" class="px-4 py-2 text-xs text-gray-600 bg-gray-50 border-l-2 border-blue-400">
|
|
<div class="flex items-center">
|
|
<Icon name="material-symbols:content-paste" class="w-3 h-3 mr-1" />
|
|
<span>{{ clipboardInfo }} ready to paste</span>
|
|
</div>
|
|
</div>
|
|
<button @click="clearClipboard(); showDropdown = false" :disabled="!clipboardInfo" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center disabled:opacity-50">
|
|
<Icon name="mdi:clipboard-remove-outline" class="mr-2 w-4 h-4" />
|
|
<span>Clear Clipboard</span>
|
|
</button>
|
|
|
|
<!-- Settings -->
|
|
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
|
|
Settings
|
|
</div>
|
|
<button @click="showProcessSettings = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
|
|
<Icon name="material-symbols:tune" class="mr-2 w-4 h-4" />
|
|
<span>Process Settings</span>
|
|
</button>
|
|
<button @click="confirmNavigation('/process-builder/manage'); showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
|
|
<Icon name="material-symbols:settings" class="mr-2 w-4 h-4" />
|
|
<span>Manage Processes</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Content Area -->
|
|
<div class="flex-1 flex overflow-hidden relative h-full" v-if="hasCurrentProcess">
|
|
<!-- Left Panel - Components -->
|
|
<div
|
|
v-show="showLeftPanel"
|
|
:class="{
|
|
'absolute inset-y-0 left-0 z-20 bg-white shadow-lg': isMobile,
|
|
'absolute inset-y-0 left-0 z-10 bg-white shadow-md': isTablet,
|
|
'relative w-60': !isMobile && !isTablet,
|
|
'w-72': isMobile,
|
|
'w-80': isTablet
|
|
}"
|
|
class="border-r border-gray-200 flex flex-col overflow-hidden transition-transform duration-300 ease-in-out"
|
|
>
|
|
<div class="bg-gray-100 p-3 flex items-center justify-between border-b border-gray-200">
|
|
<h2 class="text-sm font-medium text-gray-700">Process Components</h2>
|
|
<!-- Close button for all devices -->
|
|
<button
|
|
@click="showLeftPanel = false"
|
|
class="p-1 hover:bg-gray-200 rounded transition-colors"
|
|
title="Close Panel (Ctrl+1)"
|
|
>
|
|
<Icon name="material-symbols:close" class="w-4 h-4 text-gray-500" />
|
|
</button>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto">
|
|
<ProcessBuilderComponents @add-component="onAddComponent" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Center Panel - Process Canvas -->
|
|
<div class="flex-1 relative h-full" :class="{ 'bg-gray-50': !showLeftPanel && !showRightPanel }">
|
|
<!-- Canvas State Indicator -->
|
|
<div v-if="!showLeftPanel && !showRightPanel" class="absolute top-4 left-1/2 transform -translate-x-1/2 z-10 bg-white px-4 py-2 rounded-lg shadow-md border border-gray-200">
|
|
<div class="flex items-center gap-2 text-sm text-gray-600">
|
|
<Icon name="material-symbols:fullscreen" class="w-4 h-4 text-green-600" />
|
|
<span>Full Canvas Mode</span>
|
|
<span class="text-xs text-gray-400">Press Ctrl+3 to restore panels</span>
|
|
</div>
|
|
</div>
|
|
|
|
<ProcessFlowCanvas
|
|
ref="processFlowCanvas"
|
|
:initial-nodes="canvasNodes"
|
|
:initial-edges="canvasEdges"
|
|
:highlighted-node-id="highlightedNodeId"
|
|
@node-selected="onNodeSelected"
|
|
@edge-selected="onEdgeSelected"
|
|
@pane-click="onPaneClick"
|
|
@nodes-change="onNodesChange"
|
|
@edges-change="onEdgesChange"
|
|
class="w-full h-full"
|
|
/>
|
|
|
|
<!-- Mobile floating action buttons -->
|
|
<div v-if="isMobile" class="absolute bottom-4 right-4 flex flex-col gap-2">
|
|
<RsButton
|
|
@click="toggleLeftPanel"
|
|
variant="primary"
|
|
class="rounded-full w-12 h-12 p-0 shadow-lg"
|
|
:class="{ 'bg-blue-600': showLeftPanel }"
|
|
>
|
|
<Icon name="material-symbols:widgets" class="w-6 h-6" />
|
|
</RsButton>
|
|
<RsButton
|
|
@click="toggleRightPanel"
|
|
variant="primary"
|
|
class="rounded-full w-12 h-12 p-0 shadow-lg"
|
|
:class="{ 'bg-blue-600': showRightPanel }"
|
|
>
|
|
<Icon name="material-symbols:tune" class="w-6 h-6" />
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Panel - Properties -->
|
|
<div
|
|
v-show="showRightPanel"
|
|
:class="{
|
|
'absolute inset-y-0 right-0 z-20 bg-white shadow-lg': isMobile,
|
|
'absolute inset-y-0 right-0 z-10 bg-white shadow-md': isTablet,
|
|
'relative w-64': !isMobile && !isTablet,
|
|
'w-72': isMobile,
|
|
'w-80': isTablet
|
|
}"
|
|
class="border-l border-gray-200 flex flex-col overflow-hidden transition-transform duration-300 ease-in-out"
|
|
>
|
|
<div class="bg-gray-100 p-3 flex items-center justify-between border-b border-gray-200">
|
|
<h2 class="text-sm font-medium text-gray-700">Properties</h2>
|
|
<!-- Close button for all devices -->
|
|
<button
|
|
@click="showRightPanel = false"
|
|
class="p-1 hover:bg-gray-200 rounded transition-colors"
|
|
title="Close Panel (Ctrl+2)"
|
|
>
|
|
<Icon name="material-symbols:close" class="w-4 h-4 text-gray-500" />
|
|
</button>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto">
|
|
<!-- Show variable manager when no node is selected -->
|
|
<VariableManager v-if="!selectedNodeData && !selectedEdgeData" :key="`variables-${variablesUpdateKey}`" />
|
|
|
|
<!-- Show edge properties when an edge is selected -->
|
|
<div v-else-if="selectedEdgeData" class="flex flex-col h-full">
|
|
<!-- Edge Header -->
|
|
<div class="p-4 border-b border-gray-200 bg-gray-50">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center text-white text-sm font-medium bg-blue-500">
|
|
<Icon name="material-symbols:timeline" class="w-4 h-4" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-900">Connection</h3>
|
|
<p class="text-xs text-gray-500">{{ selectedEdgeData.id }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<RsButton
|
|
@click="copySelectedElements"
|
|
variant="secondary-text"
|
|
size="sm"
|
|
icon
|
|
:title="`Copy Edge (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
|
|
class="modern-icon-btn"
|
|
>
|
|
<Icon name="material-symbols:content-copy" />
|
|
</RsButton>
|
|
<RsButton
|
|
@click="deleteEdge"
|
|
variant="danger-text"
|
|
size="sm"
|
|
icon
|
|
:title="'Delete Edge'"
|
|
class="modern-icon-btn"
|
|
>
|
|
<Icon name="material-symbols:delete" />
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edge Properties -->
|
|
<div class="flex-1 overflow-y-auto">
|
|
<div class="p-4 space-y-4">
|
|
<div>
|
|
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
|
<Icon name="material-symbols:edit" class="w-4 h-4 mr-2" />
|
|
Edge Properties
|
|
</h4>
|
|
|
|
<!-- Edge Label -->
|
|
<div class="mb-3">
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Label</label>
|
|
<input
|
|
v-model="selectedEdgeData.label"
|
|
type="text"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="Enter edge label"
|
|
@input="updateEdgeLabel(selectedEdgeData.label)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Source Node -->
|
|
<div class="mb-3">
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Source Node</label>
|
|
<div class="px-3 py-2 text-sm bg-gray-100 border border-gray-300 rounded-md">
|
|
{{ selectedEdgeData.sourceNode?.label || selectedEdgeData.source }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Target Node -->
|
|
<div class="mb-3">
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Target Node</label>
|
|
<div class="px-3 py-2 text-sm bg-gray-100 border border-gray-300 rounded-md">
|
|
{{ selectedEdgeData.targetNode?.label || selectedEdgeData.target }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Show node properties when a node is selected -->
|
|
<div v-else class="flex flex-col h-full">
|
|
<!-- Node/Shape Header -->
|
|
<div class="p-4 border-b border-gray-200 bg-gray-50">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center text-white text-sm font-medium"
|
|
:style="{ backgroundColor: nodeBorderColor }">
|
|
<Icon :name="`material-symbols:${getNodeIcon(selectedNodeData.type)}`"
|
|
class="w-4 h-4" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-900 capitalize">
|
|
{{ selectedNodeData.data?.isShape ? 'Shape' : 'Node' }}: {{ selectedNodeData.type.replace('-', ' ') }}
|
|
</h3>
|
|
<p class="text-xs text-gray-500">{{ selectedNodeData.id }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="canShowNodeActions" class="flex items-center gap-2">
|
|
<RsButton
|
|
@click="copySelectedElements"
|
|
variant="secondary-text"
|
|
size="sm"
|
|
icon
|
|
:disabled="!canCopy"
|
|
:title="`Copy Node (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
|
|
class="modern-icon-btn"
|
|
>
|
|
<Icon name="material-symbols:content-copy" />
|
|
</RsButton>
|
|
<RsButton
|
|
@click="duplicateNode"
|
|
variant="secondary-text"
|
|
size="sm"
|
|
icon
|
|
:title="'Duplicate Node'"
|
|
class="modern-icon-btn"
|
|
>
|
|
<Icon name="material-symbols:content-copy-outline" />
|
|
</RsButton>
|
|
<RsButton
|
|
@click="deleteNode"
|
|
variant="danger-text"
|
|
size="sm"
|
|
icon
|
|
:title="'Delete Node'"
|
|
class="modern-icon-btn"
|
|
>
|
|
<Icon name="material-symbols:delete" />
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scrollable Content -->
|
|
<div class="flex-1 overflow-y-auto">
|
|
<!-- Basic Properties Section -->
|
|
<div class="p-4 space-y-4">
|
|
<div>
|
|
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
|
<Icon name="material-symbols:edit" class="w-4 h-4 mr-2" />
|
|
Basic Properties
|
|
</h4>
|
|
|
|
<!-- Node Label -->
|
|
<div class="mb-3">
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Label</label>
|
|
<input
|
|
v-model="nodeLabel"
|
|
type="text"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="Enter node label"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Node Description -->
|
|
<div class="mb-3">
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Description</label>
|
|
<textarea
|
|
v-model="nodeDescription"
|
|
rows="2"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="Enter node description"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Node Shape Selector (only for non-start/end nodes and non-shapes) -->
|
|
<div v-if="selectedNodeData.type !== 'start' && selectedNodeData.type !== 'end' && !selectedNodeData.data?.isShape">
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Shape</label>
|
|
<select
|
|
v-model="nodeShape"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option v-for="shape in shapeOptions" :key="shape.value" :value="shape.value">
|
|
{{ shape.label }}
|
|
</option>
|
|
</select>
|
|
<div class="mt-1 text-xs text-gray-500 flex items-center">
|
|
<Icon :name="`material-symbols:${shapeOptions.find(s => s.value === nodeShape)?.icon || 'crop_din'}`" class="w-3 h-3 mr-1" />
|
|
{{ shapeOptions.find(s => s.value === nodeShape)?.label || 'Rectangle' }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Size Controls for Shapes -->
|
|
<div v-if="selectedNodeData.data?.isShape" class="space-y-4">
|
|
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
|
<Icon name="material-symbols:straighten" class="w-4 h-4 mr-2" />
|
|
Size
|
|
</h4>
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Width</label>
|
|
<input
|
|
v-model.number="selectedNodeData.data.width"
|
|
type="number"
|
|
min="50"
|
|
max="2000"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
@input="updateShapeSize"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Height</label>
|
|
<input
|
|
v-model.number="selectedNodeData.data.height"
|
|
type="number"
|
|
min="50"
|
|
max="2000"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
@input="updateShapeSize"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Z-Index control with heading -->
|
|
<div class="mt-6 pt-4 border-t border-gray-100">
|
|
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
|
<Icon name="material-symbols:layers" class="w-4 h-4 mr-2" />
|
|
Layer Position
|
|
</h4>
|
|
|
|
<div class="mb-3">
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Stack Order (Z-Index)</label>
|
|
<input
|
|
v-model.number="nodeZIndex"
|
|
type="number"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="0"
|
|
/>
|
|
<div class="flex justify-center space-x-2 mt-2">
|
|
<button
|
|
@click="sendToBack"
|
|
title="Send to Back"
|
|
class="p-2 bg-gray-100 hover:bg-gray-200 rounded border border-gray-300"
|
|
>
|
|
<Icon name="material-symbols:vertical-align-bottom" class="w-4 h-4 text-gray-600" />
|
|
</button>
|
|
<button
|
|
@click="sendBackward"
|
|
title="Send Backward"
|
|
class="p-2 bg-gray-100 hover:bg-gray-200 rounded border border-gray-300"
|
|
>
|
|
<Icon name="material-symbols:arrow-downward" class="w-4 h-4 text-gray-600" />
|
|
</button>
|
|
<button
|
|
@click="bringForward"
|
|
title="Bring Forward"
|
|
class="p-2 bg-gray-100 hover:bg-gray-200 rounded border border-gray-300"
|
|
>
|
|
<Icon name="material-symbols:arrow-upward" class="w-4 h-4 text-gray-600" />
|
|
</button>
|
|
<button
|
|
@click="bringToFront"
|
|
title="Bring to Front"
|
|
class="p-2 bg-gray-100 hover:bg-gray-200 rounded border border-gray-300"
|
|
>
|
|
<Icon name="material-symbols:vertical-align-top" class="w-4 h-4 text-gray-600" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-blue-50 p-2 rounded-md">
|
|
<p class="text-xs text-blue-700">
|
|
<strong>Tip:</strong> Use these controls to arrange the stacking order of shapes. Higher values appear on top of other shapes.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Appearance Section -->
|
|
<div class="p-4 border-t border-gray-200">
|
|
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
|
<Icon name="material-symbols:palette" class="w-4 h-4 mr-2" />
|
|
Appearance
|
|
</h4>
|
|
|
|
<div class="space-y-3">
|
|
<!-- Background Color -->
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-2">Background</label>
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
v-model="nodeBackgroundColor"
|
|
type="color"
|
|
class="w-8 h-8 border border-gray-300 rounded cursor-pointer flex-shrink-0"
|
|
/>
|
|
<input
|
|
v-model="nodeBackgroundColor"
|
|
type="text"
|
|
pattern="^#[0-9a-fA-F]{6}$"
|
|
title="Enter a valid hex color (e.g., #ffffff)"
|
|
maxlength="7"
|
|
@input="validateColorInput"
|
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
placeholder="#ffffff"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Border Color -->
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-2">Border</label>
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
v-model="nodeBorderColor"
|
|
type="color"
|
|
class="w-8 h-8 border border-gray-300 rounded cursor-pointer flex-shrink-0"
|
|
/>
|
|
<input
|
|
v-model="nodeBorderColor"
|
|
type="text"
|
|
pattern="^#[0-9a-fA-F]{6}$"
|
|
title="Enter a valid hex color (e.g., #dddddd)"
|
|
maxlength="7"
|
|
@input="validateColorInput"
|
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
placeholder="#dddddd"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Text Color -->
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-2">Text</label>
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
v-model="nodeTextColor"
|
|
type="color"
|
|
class="w-8 h-8 border border-gray-300 rounded cursor-pointer flex-shrink-0"
|
|
/>
|
|
<input
|
|
v-model="nodeTextColor"
|
|
type="text"
|
|
pattern="^#[0-9a-fA-F]{6}$"
|
|
title="Enter a valid hex color (e.g., #333333)"
|
|
maxlength="7"
|
|
@input="validateColorInput"
|
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
placeholder="#333333"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reset Colors Button -->
|
|
<button
|
|
@click="resetNodeColors"
|
|
class="w-full mt-3 py-2 px-3 text-xs bg-gray-100 text-gray-700 border border-gray-300 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
|
>
|
|
<Icon name="material-symbols:refresh" class="w-3 h-3 mr-1" />
|
|
Reset to Default
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Configuration Section (only for process nodes, not shapes) -->
|
|
<div v-if="selectedNodeData.type !== 'start' && selectedNodeData.type !== 'end' && !selectedNodeData.data?.isShape" class="p-4 border-t border-gray-200">
|
|
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
|
<Icon name="material-symbols:settings" class="w-4 h-4 mr-2" />
|
|
Configuration
|
|
</h4>
|
|
|
|
<!-- Form Selection for Form Nodes -->
|
|
<div v-if="selectedNodeData.type === 'form'">
|
|
<p class="text-xs text-gray-600 mb-3">Configure form submission behavior and data mapping.</p>
|
|
<RsButton @click="showFormConfigModal = true" variant="primary" class="w-full">
|
|
<Icon name="material-symbols:description" class="w-4 h-4 mr-2" />
|
|
Configure Form Task
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- API Configuration for API Nodes -->
|
|
<div v-if="selectedNodeData.type === 'api'">
|
|
<p class="text-xs text-gray-600 mb-3">Set up API endpoint, request parameters, and response handling.</p>
|
|
<RsButton @click="showApiConfigModal = true" variant="primary" class="w-full">
|
|
<Icon name="material-symbols:api" class="w-4 h-4 mr-2" />
|
|
Configure API Call
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Gateway Conditions -->
|
|
<div v-if="selectedNodeData.type === 'gateway'">
|
|
<p class="text-xs text-gray-600 mb-3">Define decision conditions and workflow paths.</p>
|
|
<RsButton @click="showGatewayConfigModal = true" variant="primary" class="w-full">
|
|
<Icon name="material-symbols:call-split" class="w-4 h-4 mr-2" />
|
|
Configure Decision Paths
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Business Rule Configuration -->
|
|
<div v-if="selectedNodeData.type === 'business-rule'">
|
|
<p class="text-xs text-gray-600 mb-3">Set up business logic conditions and actions.</p>
|
|
<RsButton @click="showBusinessRuleConfigModal = true" variant="primary" class="w-full">
|
|
<Icon name="material-symbols:rule" class="w-4 h-4 mr-2" />
|
|
Configure Business Rule
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Notification Configuration -->
|
|
<div v-if="selectedNodeData.type === 'notification'">
|
|
<p class="text-xs text-gray-600 mb-3">Configure notification recipients, content, and delivery method.</p>
|
|
<RsButton @click="showNotificationConfigModal = true" variant="primary" class="w-full">
|
|
<Icon name="material-symbols:notifications" class="w-4 h-4 mr-2" />
|
|
Configure Notification
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Script Configuration -->
|
|
<div v-if="selectedNodeData.type === 'script'">
|
|
<p class="text-xs text-gray-600 mb-3">Write custom scripts and define execution parameters.</p>
|
|
<RsButton @click="showScriptConfigModal = true" variant="primary" class="w-full">
|
|
<Icon name="material-symbols:code" class="w-4 h-4 mr-2" />
|
|
Configure Script Task
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- HTML Configuration -->
|
|
<div v-if="selectedNodeData.type === 'html'">
|
|
<p class="text-xs text-gray-600 mb-3">Configure HTML content and output variables.</p>
|
|
<RsButton @click="showHtmlConfigModal = true" variant="primary" class="w-full">
|
|
<Icon name="material-symbols:html" class="w-4 h-4 mr-2" />
|
|
Configure HTML Node
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Sub-process Configuration -->
|
|
<div v-if="selectedNodeData.type === 'subprocess'">
|
|
<p class="text-xs text-gray-600 mb-3">Select a process to execute as a sub-process.</p>
|
|
<RsButton @click="showSubprocessConfigModal = true" variant="primary" class="w-full">
|
|
<Icon name="material-symbols:hub" class="w-4 h-4 mr-2" />
|
|
Configure Sub-process
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Shape Information -->
|
|
<div v-if="selectedNodeData.data?.isShape" class="p-4 border-t border-gray-200">
|
|
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
|
<Icon name="material-symbols:info" class="w-4 h-4 mr-2" />
|
|
Shape Information
|
|
</h4>
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<p class="text-sm text-blue-700">
|
|
<strong>Design Element:</strong> This is a decorative shape used for visual organization and layout.
|
|
It doesn't participate in the process flow and cannot be connected to other nodes.
|
|
</p>
|
|
<div class="mt-2 text-xs text-blue-600">
|
|
<div class="flex items-center">
|
|
<Icon name="material-symbols:palette" class="w-3 h-3 mr-1" />
|
|
Use the appearance section above to customize colors and styling
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Backdrop for mobile panels -->
|
|
<div
|
|
v-if="(isMobile || isTablet) && (showLeftPanel || showRightPanel)"
|
|
@click="showLeftPanel = false; showRightPanel = false"
|
|
class="absolute inset-0 bg-black bg-opacity-50 z-5"
|
|
></div>
|
|
</div>
|
|
|
|
<!-- Empty state - No process selected -->
|
|
<div v-else class="flex-1 flex items-center justify-center bg-gray-50">
|
|
<div class="text-center p-8 max-w-md">
|
|
<Icon name="material-symbols:flowchart" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
|
<h2 class="text-xl font-semibold text-gray-800 mb-2">Create a New Process</h2>
|
|
<p class="text-gray-600 mb-6">Get started by creating a new process or navigate back to manage your existing processes.</p>
|
|
|
|
<div class="space-y-3">
|
|
<div class="mb-4">
|
|
<FormKit
|
|
v-model="newProcessName"
|
|
type="text"
|
|
label="Process Name"
|
|
placeholder="Enter a name for your new process"
|
|
validation="required"
|
|
/>
|
|
|
|
<FormKit
|
|
v-model="newProcessDescription"
|
|
type="textarea"
|
|
label="Description (Optional)"
|
|
placeholder="Enter a description"
|
|
:rows="3"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex justify-center gap-4">
|
|
<RsButton @click="createNewProcess" variant="primary" :disabled="!newProcessName.trim()">
|
|
<Icon name="material-symbols:add" class="mr-1" />
|
|
Create Process
|
|
</RsButton>
|
|
|
|
<RsButton @click="confirmNavigation('/process-builder/manage')" variant="tertiary">
|
|
<Icon name="material-symbols:arrow-back" class="mr-1" />
|
|
Back to Processes
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unsaved changes dialog -->
|
|
<RsModal v-model="showUnsavedChangesModal" title="Unsaved Changes" size="md" position="center">
|
|
<div class="p-4">
|
|
<div class="flex items-center mb-4">
|
|
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3" />
|
|
<div>
|
|
<p class="text-gray-600">You have unsaved changes that will be lost if you leave the page.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<RsButton @click="cancelNavigation" variant="tertiary">
|
|
Stay on this Page
|
|
</RsButton>
|
|
<RsButton @click="proceedWithNavigation" variant="danger">
|
|
Discard Changes
|
|
</RsButton>
|
|
</div>
|
|
</template>
|
|
</RsModal>
|
|
|
|
<!-- Form Task Configuration Modal -->
|
|
<FormNodeConfigurationModal
|
|
v-if="selectedNodeData && selectedNodeData.type === 'form'"
|
|
v-model="showFormConfigModal"
|
|
:key="`form-${selectedNodeData.id}-${variablesUpdateKey}`"
|
|
:nodeData="selectedNodeData.data"
|
|
:availableVariables="gatewayAvailableVariables"
|
|
@update="handleFormNodeUpdate"
|
|
/>
|
|
|
|
<!-- API Call Configuration Modal -->
|
|
<ApiNodeConfigurationModal
|
|
v-if="selectedNodeData && selectedNodeData.type === 'api'"
|
|
v-model="showApiConfigModal"
|
|
:key="`api-${selectedNodeData.id}-${variablesUpdateKey}`"
|
|
:nodeData="selectedNodeData.data"
|
|
:availableVariables="gatewayAvailableVariables"
|
|
@update="handleApiNodeUpdate"
|
|
/>
|
|
|
|
<!-- Gateway/Decision Point Configuration Modal -->
|
|
<GatewayConditionManagerModal
|
|
v-if="selectedNodeData && selectedNodeData.type === 'gateway'"
|
|
v-model="showGatewayConfigModal"
|
|
:key="`gateway-${selectedNodeData.id}-${variablesUpdateKey}`"
|
|
:conditions="selectedNodeData.data.conditions || []"
|
|
:availableVariables="gatewayAvailableVariables"
|
|
:defaultPath="selectedNodeData.data.defaultPath || 'Default'"
|
|
@update:conditions="handleConditionUpdate"
|
|
@update:defaultPath="handleDefaultPathUpdate"
|
|
/>
|
|
|
|
<!-- Business Rule Configuration Modal -->
|
|
<BusinessRuleNodeConfigurationModal
|
|
v-if="selectedNodeData && selectedNodeData.type === 'business-rule'"
|
|
v-model="showBusinessRuleConfigModal"
|
|
:key="`business-rule-${selectedNodeData.id}-${variablesUpdateKey}`"
|
|
:nodeId="selectedNodeData.id"
|
|
:nodeData="selectedNodeData.data"
|
|
:availableVariables="gatewayAvailableVariables"
|
|
@update="handleBusinessRuleUpdate"
|
|
/>
|
|
|
|
<!-- Notification Configuration Modal -->
|
|
<NotificationNodeConfigurationModal
|
|
v-if="selectedNodeData && selectedNodeData.type === 'notification'"
|
|
v-model="showNotificationConfigModal"
|
|
:key="`notification-${selectedNodeData.id}-${variablesUpdateKey}`"
|
|
:nodeData="selectedNodeData.data"
|
|
:availableVariables="gatewayAvailableVariables"
|
|
@update="handleNotificationNodeUpdate"
|
|
/>
|
|
|
|
<!-- Script Configuration Modal -->
|
|
<ScriptNodeConfigurationModal
|
|
v-if="selectedNodeData && selectedNodeData.type === 'script'"
|
|
v-model="showScriptConfigModal"
|
|
:key="`script-${selectedNodeData.id}-${variablesUpdateKey}`"
|
|
:nodeData="selectedNodeData.data"
|
|
:availableVariables="gatewayAvailableVariables"
|
|
@update="handleScriptNodeUpdate"
|
|
/>
|
|
|
|
<!-- HTML Configuration Modal -->
|
|
<HtmlNodeConfigurationModal
|
|
v-if="selectedNodeData && selectedNodeData.type === 'html'"
|
|
v-model="showHtmlConfigModal"
|
|
:key="`html-${selectedNodeData.id}-${variablesUpdateKey}`"
|
|
:nodeData="selectedNodeData.data"
|
|
:availableVariables="gatewayAvailableVariables"
|
|
@update="handleHtmlNodeUpdate"
|
|
/>
|
|
|
|
<!-- Sub-process Configuration Modal -->
|
|
<SubprocessNodeConfigurationModal
|
|
v-if="selectedNodeData && selectedNodeData.type === 'subprocess'"
|
|
v-model="showSubprocessConfigModal"
|
|
:key="`subprocess-${selectedNodeData.id}-${variablesUpdateKey}`"
|
|
:nodeData="selectedNodeData.data"
|
|
@update="handleSubprocessNodeUpdate"
|
|
/>
|
|
|
|
<!-- Process Templates Modal -->
|
|
<ProcessTemplatesModal
|
|
v-model="showTemplatesModal"
|
|
@select-template="applyProcessTemplate"
|
|
/>
|
|
|
|
<!-- Process Settings Modal -->
|
|
<ProcessSettingsModal
|
|
v-model="showProcessSettings"
|
|
/>
|
|
|
|
<!-- Process History Modal -->
|
|
<ProcessHistoryModal
|
|
:is-open="showProcessHistoryModal"
|
|
:process-id="processStore.currentProcess?.id || ''"
|
|
@close="showProcessHistoryModal = false"
|
|
@restored="handleProcessRestored"
|
|
/>
|
|
|
|
<!-- Export Canvas Modal -->
|
|
<ExportCanvasModal
|
|
:show="showExportModal"
|
|
:process-name="processStore.currentProcess?.name || 'process'"
|
|
@close="showExportModal = false"
|
|
@export="handleExportCanvas"
|
|
/>
|
|
</div>
|
|
</client-only>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.process-builder {
|
|
--flow-node-selected-color: theme('colors.blue.500');
|
|
--flow-background-color: theme('colors.gray.100');
|
|
--flow-connection-path-color: theme('colors.gray.400');
|
|
--flow-connection-path-hover-color: theme('colors.blue.400');
|
|
}
|
|
|
|
:deep(.custom-node) {
|
|
border-radius: 4px;
|
|
padding: 10px;
|
|
}
|
|
|
|
/* Shape classes for flowchart nodes */
|
|
:deep(.custom-node.shape-rectangle) {
|
|
border-radius: 4px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-rounded-rectangle) {
|
|
border-radius: 12px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-circle) {
|
|
border-radius: 50%;
|
|
min-width: 120px;
|
|
min-height: 120px;
|
|
width: 120px;
|
|
height: 120px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
:deep(.custom-node.shape-circle .custom-node-content) {
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond) {
|
|
transform: rotate(45deg);
|
|
border-radius: 8px;
|
|
min-width: 120px;
|
|
min-height: 120px;
|
|
width: 120px;
|
|
height: 120px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond .custom-node-content) {
|
|
transform: rotate(-45deg);
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
:deep(.custom-node.shape-hexagon) {
|
|
position: relative;
|
|
border-radius: 0;
|
|
background: none;
|
|
border: none;
|
|
min-width: 140px;
|
|
min-height: 80px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-hexagon::before) {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: var(--node-bg-color, white);
|
|
border: 1px solid var(--node-border-color, #ddd);
|
|
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
|
z-index: 0;
|
|
}
|
|
|
|
/* Special backgrounds for business-rule, notification, and api hexagon shapes */
|
|
:deep(.node-business-rule.shape-hexagon::before) {
|
|
border-left: 4px solid var(--node-border-color, #9333ea);
|
|
}
|
|
|
|
:deep(.node-notification.shape-hexagon::before) {
|
|
border-left: 4px solid var(--node-border-color, #3b82f6);
|
|
}
|
|
|
|
:deep(.node-api.shape-hexagon::before) {
|
|
border-left: 4px solid var(--node-border-color, #10b981);
|
|
}
|
|
|
|
:deep(.node-gateway.shape-hexagon::before) {
|
|
border: 2px solid var(--node-border-color, #FF9800);
|
|
}
|
|
|
|
:deep(.custom-node.shape-hexagon .custom-node-content) {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
:deep(.custom-node.shape-parallelogram) {
|
|
transform: skew(-20deg);
|
|
border-radius: 4px;
|
|
min-width: 140px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-parallelogram .custom-node-content) {
|
|
transform: skew(20deg);
|
|
}
|
|
|
|
:deep(.custom-node.shape-trapezoid) {
|
|
position: relative;
|
|
border-radius: 0;
|
|
background: none;
|
|
border: none;
|
|
min-width: 140px;
|
|
min-height: 80px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-trapezoid::before) {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: var(--node-bg-color, white);
|
|
border: 1px solid var(--node-border-color, #ddd);
|
|
clip-path: polygon(30% 0%, 70% 0%, 100% 100%, 0% 100%);
|
|
z-index: 0;
|
|
}
|
|
|
|
/* Special backgrounds for business-rule, notification, and api trapezoid shapes */
|
|
:deep(.node-business-rule.shape-trapezoid::before) {
|
|
border-left: 4px solid var(--node-border-color, #9333ea);
|
|
}
|
|
|
|
:deep(.node-notification.shape-trapezoid::before) {
|
|
border-left: 4px solid var(--node-border-color, #3b82f6);
|
|
}
|
|
|
|
:deep(.node-api.shape-trapezoid::before) {
|
|
border-left: 4px solid var(--node-border-color, #10b981);
|
|
}
|
|
|
|
:deep(.node-gateway.shape-trapezoid::before) {
|
|
border: 2px solid var(--node-border-color, #FF9800);
|
|
}
|
|
|
|
:deep(.custom-node.shape-trapezoid .custom-node-content) {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Handle positioning adjustments for different shapes */
|
|
|
|
/* Circle shape handles */
|
|
:deep(.custom-node.shape-circle .vue-flow__handle) {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-circle .handle-top) {
|
|
top: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-circle .handle-bottom) {
|
|
bottom: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-circle .handle-left) {
|
|
left: -5px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-circle .handle-right) {
|
|
right: -5px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
/* Text overflow and positioning for all shapes */
|
|
|
|
/* Base text styling for all nodes */
|
|
:deep(.custom-node .custom-node-content) {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
overflow: hidden;
|
|
word-wrap: break-word;
|
|
hyphens: auto;
|
|
color: var(--node-text-color, #333333);
|
|
}
|
|
|
|
/* Ensure all text elements in nodes inherit the custom text color */
|
|
:deep(.custom-node *) {
|
|
color: inherit !important;
|
|
}
|
|
|
|
/* Override for icons to inherit text color but maintain visibility */
|
|
:deep(.custom-node .material-icons) {
|
|
color: inherit;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* Ensure icons in start and end nodes maintain their original colors */
|
|
:deep(.node-start .material-icons) {
|
|
color: #16a34a !important;
|
|
opacity: 1;
|
|
}
|
|
|
|
:deep(.node-end .material-icons) {
|
|
color: #dc2626 !important;
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Special handling for colored status indicators - make them more visible */
|
|
:deep(.custom-node .node-rule-detail-value) {
|
|
color: inherit;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Make labels slightly dimmed for hierarchy */
|
|
:deep(.custom-node .node-rule-detail-label) {
|
|
color: inherit;
|
|
opacity: 0.7;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
:deep(.custom-node .custom-node-content .custom-node-label) {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
line-height: 1.2;
|
|
max-width: 100%;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
padding: 2px 4px;
|
|
}
|
|
|
|
/* Rectangle and rounded rectangle - allow more text */
|
|
:deep(.custom-node.shape-rectangle .custom-node-content .custom-node-label),
|
|
:deep(.custom-node.shape-rounded-rectangle .custom-node-content .custom-node-label) {
|
|
-webkit-line-clamp: 4;
|
|
font-size: 13px;
|
|
max-width: calc(100% - 8px);
|
|
}
|
|
|
|
/* Circle - more constrained text */
|
|
:deep(.custom-node.shape-circle .custom-node-content) {
|
|
padding: 15px 10px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-circle .custom-node-content .custom-node-label) {
|
|
-webkit-line-clamp: 2;
|
|
font-size: 11px;
|
|
max-width: 80px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
/* Diamond - very constrained text due to angled shape */
|
|
:deep(.custom-node.shape-diamond .custom-node-content) {
|
|
padding: 20px 15px;
|
|
/* DO NOT rotate the content - keep text readable */
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond .custom-node-content .custom-node-label) {
|
|
-webkit-line-clamp: 2;
|
|
font-size: 10px;
|
|
max-width: 70px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
/* Hexagon - moderate text space */
|
|
:deep(.custom-node.shape-hexagon .custom-node-content .custom-node-label) {
|
|
-webkit-line-clamp: 3;
|
|
font-size: 11px;
|
|
max-width: calc(100% - 20px);
|
|
}
|
|
|
|
/* Parallelogram - slight skew consideration */
|
|
:deep(.custom-node.shape-parallelogram .custom-node-content .custom-node-label) {
|
|
-webkit-line-clamp: 3;
|
|
font-size: 12px;
|
|
max-width: calc(100% - 16px);
|
|
}
|
|
|
|
/* Trapezoid - consider the angled sides */
|
|
:deep(.custom-node.shape-trapezoid .custom-node-content .custom-node-label) {
|
|
-webkit-line-clamp: 3;
|
|
font-size: 11px;
|
|
max-width: calc(100% - 20px);
|
|
}
|
|
|
|
/* Responsive text sizing for smaller nodes */
|
|
:deep(.custom-node) {
|
|
min-width: 100px;
|
|
min-height: 60px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-circle) {
|
|
min-width: 120px;
|
|
min-height: 120px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond) {
|
|
min-width: 120px;
|
|
min-height: 120px;
|
|
}
|
|
|
|
/* Handle text overflow gracefully with tooltips */
|
|
:deep(.custom-node .custom-node-content .custom-node-label[title]) {
|
|
cursor: help;
|
|
}
|
|
|
|
/* Improve readability on hover for overflowed text */
|
|
:deep(.custom-node:hover .custom-node-content .custom-node-label) {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
/* Additional text styling for node details */
|
|
:deep(.custom-node .node-description) {
|
|
font-size: 10px;
|
|
color: var(--node-text-color, #6b7280);
|
|
text-align: center;
|
|
line-height: 1.2;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
margin: 2px 0;
|
|
}
|
|
|
|
:deep(.custom-node .node-rule-detail) {
|
|
font-size: 9px;
|
|
margin: 1px 0;
|
|
}
|
|
|
|
:deep(.custom-node .node-rule-detail-value) {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 60px;
|
|
}
|
|
|
|
/* Shape-specific adjustments for detailed content */
|
|
:deep(.custom-node.shape-circle .node-description),
|
|
:deep(.custom-node.shape-circle .node-rule-detail) {
|
|
font-size: 8px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond .node-description),
|
|
:deep(.custom-node.shape-diamond .node-rule-detail) {
|
|
font-size: 7px;
|
|
/* Keep text readable - do not rotate */
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond .node-rule-detail-value) {
|
|
max-width: 40px;
|
|
}
|
|
|
|
/* Better spacing for different node types */
|
|
:deep(.custom-node .gateway-details),
|
|
:deep(.custom-node .node-details) {
|
|
width: 100%;
|
|
padding: 2px 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Responsive font sizing based on node size */
|
|
@media (max-width: 768px) {
|
|
:deep(.custom-node .custom-node-label) {
|
|
font-size: 10px;
|
|
}
|
|
|
|
:deep(.custom-node .node-description) {
|
|
font-size: 8px;
|
|
}
|
|
|
|
:deep(.custom-node .node-rule-detail) {
|
|
font-size: 7px;
|
|
}
|
|
}
|
|
|
|
/* Diamond shape handles */
|
|
:deep(.custom-node.shape-diamond .vue-flow__handle) {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond .handle-top) {
|
|
top: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond .handle-bottom) {
|
|
bottom: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond .handle-left) {
|
|
left: -5px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond .handle-right) {
|
|
right: -5px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
/* Hexagon shape handles */
|
|
:deep(.custom-node.shape-hexagon .vue-flow__handle) {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-hexagon .handle-top) {
|
|
top: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-hexagon .handle-bottom) {
|
|
bottom: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-hexagon .handle-left) {
|
|
left: 5px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-hexagon .handle-right) {
|
|
right: 5px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
/* Parallelogram shape handles */
|
|
:deep(.custom-node.shape-parallelogram .vue-flow__handle) {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
/* Trapezoid shape handles */
|
|
:deep(.custom-node.shape-trapezoid .vue-flow__handle) {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
:deep(.custom-node.shape-trapezoid .handle-top) {
|
|
top: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-trapezoid .handle-bottom) {
|
|
bottom: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-trapezoid .handle-left) {
|
|
left: 10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
:deep(.custom-node.shape-trapezoid .handle-right) {
|
|
right: 10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
/* Ensure handles have higher z-index for all shapes */
|
|
:deep(.custom-node .vue-flow__handle) {
|
|
z-index: 1001 !important;
|
|
border: 2px solid white;
|
|
background: #555;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
:deep(.custom-node .vue-flow__handle:hover) {
|
|
background: #3b82f6;
|
|
border-color: white;
|
|
transform: scale(1.2);
|
|
}
|
|
|
|
:deep(.custom-node.selected) {
|
|
border-color: var(--flow-node-selected-color);
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
/* Selection highlighting for shaped nodes */
|
|
:deep(.custom-node.shape-hexagon.selected),
|
|
:deep(.custom-node.shape-trapezoid.selected) {
|
|
border: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
:deep(.custom-node.shape-hexagon.selected::before) {
|
|
border-color: var(--flow-node-selected-color);
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
:deep(.custom-node.shape-trapezoid.selected::before) {
|
|
border-color: var(--flow-node-selected-color);
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
/* Hover effects for shaped nodes */
|
|
:deep(.custom-node.shape-hexagon:hover::before),
|
|
:deep(.custom-node.shape-trapezoid:hover::before) {
|
|
border-color: #999;
|
|
}
|
|
|
|
/* Override selection highlighting for other shapes that use transforms */
|
|
:deep(.custom-node.shape-circle.selected) {
|
|
border-color: var(--flow-node-selected-color);
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
:deep(.custom-node.shape-diamond.selected) {
|
|
border-color: var(--flow-node-selected-color);
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
:deep(.custom-node.shape-parallelogram.selected) {
|
|
border-color: var(--flow-node-selected-color);
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
:deep(.custom-node-content) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
:deep(.custom-node-title) {
|
|
margin-top: 4px;
|
|
text-align: center;
|
|
font-weight: 500;
|
|
}
|
|
|
|
:deep(.node-gateway) {
|
|
transform: rotate(45deg);
|
|
background: var(--node-bg-color, white);
|
|
border: 2px solid var(--node-border-color, #FF9800);
|
|
color: var(--node-text-color, #333333);
|
|
}
|
|
|
|
/* Override borders for gateway shaped nodes */
|
|
:deep(.node-gateway.shape-hexagon),
|
|
:deep(.node-gateway.shape-trapezoid) {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
|
|
:deep(.node-gateway .custom-node-content) {
|
|
transform: rotate(-45deg);
|
|
}
|
|
|
|
:deep(.node-start), :deep(.node-end) {
|
|
border-radius: 50%;
|
|
width: 60px;
|
|
height: 60px;
|
|
}
|
|
|
|
:deep(.node-task), :deep(.node-form), :deep(.node-script) {
|
|
min-width: 160px;
|
|
background: var(--node-bg-color, white);
|
|
border: 1px solid var(--node-border-color, #ddd);
|
|
color: var(--node-text-color, #333333);
|
|
}
|
|
|
|
/* Override borders for shaped nodes */
|
|
:deep(.node-task.shape-hexagon),
|
|
:deep(.node-form.shape-hexagon),
|
|
:deep(.node-script.shape-hexagon),
|
|
:deep(.node-task.shape-trapezoid),
|
|
:deep(.node-form.shape-trapezoid),
|
|
:deep(.node-script.shape-trapezoid) {
|
|
background: transparent;
|
|
border: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
:deep(.node-business-rule) {
|
|
min-width: 160px;
|
|
background: var(--node-bg-color, white);
|
|
border: 1px solid var(--node-border-color, #ddd);
|
|
border-left: 4px solid var(--node-border-color, #9333ea); /* Purple border to match our icon color */
|
|
color: var(--node-text-color, #333333);
|
|
}
|
|
|
|
:deep(.node-notification) {
|
|
min-width: 160px;
|
|
background: var(--node-bg-color, white);
|
|
border: 1px solid var(--node-border-color, #ddd);
|
|
border-left: 4px solid var(--node-border-color, #3b82f6); /* Blue border to match our icon color */
|
|
color: var(--node-text-color, #333333);
|
|
}
|
|
|
|
:deep(.node-api) {
|
|
min-width: 160px;
|
|
background: var(--node-bg-color, white);
|
|
border: 1px solid var(--node-border-color, #ddd);
|
|
border-left: 4px solid var(--node-border-color, #10b981); /* Green border for API nodes */
|
|
color: var(--node-text-color, #333333);
|
|
}
|
|
|
|
/* Override borders for shaped business-rule, notification, and api nodes */
|
|
:deep(.node-business-rule.shape-hexagon),
|
|
:deep(.node-business-rule.shape-trapezoid),
|
|
:deep(.node-notification.shape-hexagon),
|
|
:deep(.node-notification.shape-trapezoid),
|
|
:deep(.node-api.shape-hexagon),
|
|
:deep(.node-api.shape-trapezoid) {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
|
|
:deep(.node-details) {
|
|
margin-top: 8px;
|
|
font-size: 0.75rem;
|
|
width: 100%;
|
|
}
|
|
|
|
:deep(.node-description) {
|
|
color: #666;
|
|
margin-bottom: 4px;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.process-builder-container {
|
|
background-color: #f8fafc;
|
|
}
|
|
|
|
.process-name-input {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.process-name-input :deep(.formkit-inner) {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
color: white;
|
|
}
|
|
|
|
.process-name-input :deep(.formkit-inner:focus-within) {
|
|
border-color: rgba(255, 255, 255, 0.5);
|
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.process-name-input :deep(input::placeholder) {
|
|
color: rgba(255, 255, 255, 0.6);
|
|
}
|
|
|
|
.process-name-input :deep(.formkit-message) {
|
|
color: rgba(255, 200, 200, 0.9);
|
|
font-size: 0.7rem;
|
|
position: absolute;
|
|
}
|
|
|
|
.process-builder-palette,
|
|
.process-builder-properties {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.process-component-item.dragging {
|
|
opacity: 0.5;
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.process-component-item {
|
|
transition: all 0.2s ease;
|
|
user-select: none;
|
|
}
|
|
|
|
.process-component-item:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* Mobile responsive adjustments */
|
|
@media (max-width: 768px) {
|
|
.process-name-input {
|
|
max-width: 200px;
|
|
}
|
|
|
|
.process-name-input :deep(.formkit-inner) {
|
|
font-size: 0.875rem;
|
|
padding: 0.5rem 0.75rem;
|
|
}
|
|
}
|
|
|
|
/* Shape node styles */
|
|
:deep(.shape-node) {
|
|
border-radius: 4px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
user-select: none;
|
|
}
|
|
|
|
:deep(.shape-node.selected) {
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.6);
|
|
}
|
|
|
|
/* Ensure process nodes always stay above shapes, even during drag operations */
|
|
:deep(.vue-flow__node:not([data-id*="swimlane"]):not([data-id*="rectangle-shape"]):not([data-id*="text-annotation"]):not([data-id*="process-group"]):not([data-id*="hexagon-shape"]):not([data-id*="trapezoid-shape"])) {
|
|
z-index: 10 !important; /* Process nodes always above shapes */
|
|
}
|
|
|
|
:deep(.vue-flow__node:not([data-id*="swimlane"]):not([data-id*="rectangle-shape"]):not([data-id*="text-annotation"]):not([data-id*="process-group"]):not([data-id*="hexagon-shape"]):not([data-id*="trapezoid-shape"]).selected) {
|
|
z-index: 15 !important; /* Selected process nodes even higher */
|
|
}
|
|
|
|
:deep(.vue-flow__node:not([data-id*="swimlane"]):not([data-id*="rectangle-shape"]):not([data-id*="text-annotation"]):not([data-id*="process-group"]):not([data-id*="hexagon-shape"]):not([data-id*="trapezoid-shape"]).dragging) {
|
|
z-index: 20 !important; /* Dragging process nodes on top */
|
|
}
|
|
|
|
/* Vue Flow state-specific z-index rules */
|
|
:deep(.vue-flow__node[data-id*="start"]),
|
|
:deep(.vue-flow__node[data-id*="end"]),
|
|
:deep(.vue-flow__node[data-id*="form"]),
|
|
:deep(.vue-flow__node[data-id*="api"]),
|
|
:deep(.vue-flow__node[data-id*="gateway"]),
|
|
:deep(.vue-flow__node[data-id*="script"]),
|
|
:deep(.vue-flow__node[data-id*="business-rule"]),
|
|
:deep(.vue-flow__node[data-id*="notification"]) {
|
|
z-index: 10 !important; /* All process node types above shapes */
|
|
}
|
|
|
|
:deep(.vue-flow__node[data-id*="start"].selected),
|
|
:deep(.vue-flow__node[data-id*="end"].selected),
|
|
:deep(.vue-flow__node[data-id*="form"].selected),
|
|
:deep(.vue-flow__node[data-id*="api"].selected),
|
|
:deep(.vue-flow__node[data-id*="gateway"].selected),
|
|
:deep(.vue-flow__node[data-id*="script"].selected),
|
|
:deep(.vue-flow__node[data-id*="business-rule"].selected),
|
|
:deep(.vue-flow__node[data-id*="notification"].selected) {
|
|
z-index: 15 !important; /* Selected process nodes even higher */
|
|
}
|
|
|
|
/* Handle Vue Flow's internal dragging state classes */
|
|
:deep(.vue-flow__node.dragging:not(.shape-node)) {
|
|
z-index: 25 !important; /* Dragging process nodes should be on top of everything */
|
|
}
|
|
|
|
/* Ensure edge connection during drag always works for process nodes */
|
|
:deep(.vue-flow.connecting .vue-flow__node:not(.shape-node)) {
|
|
z-index: 30 !important; /* During connection mode, process nodes are highest */
|
|
}
|
|
|
|
/* Shape nodes should never interfere with edge connections */
|
|
:deep(.shape-node .vue-flow__handle) {
|
|
display: none !important; /* Shapes should not have connection handles */
|
|
}
|
|
|
|
/* Ensure custom nodes (process nodes) maintain proper layering */
|
|
:deep(.custom-node:not(.shape-node)) {
|
|
position: relative;
|
|
}
|
|
|
|
:deep(.custom-node:not(.shape-node).selected) {
|
|
}
|
|
|
|
/* Ensure edges/connection lines stay above shapes */
|
|
:deep(.vue-flow__edges) {
|
|
z-index: 5 !important; /* Edges above shapes but below nodes */
|
|
}
|
|
|
|
:deep(.vue-flow__edge) {
|
|
z-index: 5 !important; /* Individual edges above shapes */
|
|
}
|
|
|
|
:deep(.vue-flow__edge.selected) {
|
|
z-index: 8 !important; /* Selected edges higher */
|
|
}
|
|
|
|
:deep(.vue-flow__connection) {
|
|
z-index: 12 !important; /* Active connection line above everything except dragging nodes */
|
|
}
|
|
|
|
:deep(.vue-flow__connection-path) {
|
|
z-index: 12 !important; /* Connection path above shapes and nodes */
|
|
}
|
|
|
|
/* Ensure edge paths and labels stay above shapes */
|
|
:deep(.vue-flow__edge-path) {
|
|
z-index: 5 !important; /* Edge paths above shapes */
|
|
}
|
|
|
|
:deep(.vue-flow__edge-text) {
|
|
z-index: 6 !important; /* Edge labels above edge paths and shapes */
|
|
}
|
|
|
|
:deep(.vue-flow__edge-textbg) {
|
|
z-index: 5 !important; /* Edge text background above shapes */
|
|
}
|
|
|
|
/* Ensure SVG elements in edges maintain proper z-index */
|
|
:deep(.vue-flow__edge svg) {
|
|
z-index: 5 !important;
|
|
}
|
|
|
|
:deep(.shape-node .shape-content) {
|
|
padding: 8px;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
:deep(.shape-node .shape-label) {
|
|
font-weight: 500;
|
|
line-height: 1.2;
|
|
word-wrap: break-word;
|
|
max-width: 100%;
|
|
}
|
|
|
|
:deep(.shape-node .shape-description) {
|
|
font-size: 0.75rem;
|
|
opacity: 0.8;
|
|
margin-top: 4px;
|
|
line-height: 1.2;
|
|
word-wrap: break-word;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* Specific shape styling */
|
|
:deep(.shape-node.swimlane-horizontal) {
|
|
min-width: 600px;
|
|
min-height: 150px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
:deep(.shape-node.swimlane-vertical) {
|
|
min-width: 200px;
|
|
min-height: 400px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
:deep(.shape-node.rectangle-shape) {
|
|
min-width: 300px;
|
|
min-height: 200px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
:deep(.shape-node.text-annotation) {
|
|
min-width: 200px;
|
|
min-height: 80px;
|
|
border-radius: 4px;
|
|
font-style: italic;
|
|
}
|
|
|
|
:deep(.shape-node.process-group) {
|
|
min-width: 400px;
|
|
min-height: 300px;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
}
|
|
|
|
:deep(.shape-node.process-group .shape-content) {
|
|
align-items: flex-start;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
/* Touch-friendly adjustments */
|
|
@media (hover: none) and (pointer: coarse) {
|
|
.component-item {
|
|
min-height: 44px; /* iOS touch target minimum */
|
|
}
|
|
|
|
.dropdown-menu button {
|
|
min-height: 44px;
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
|
|
/* Larger tap targets for mobile */
|
|
.process-builder :deep(.vue-flow__node) {
|
|
min-width: 120px;
|
|
min-height: 40px;
|
|
}
|
|
|
|
.process-builder :deep(.vue-flow__handle) {
|
|
width: 12px;
|
|
height: 12px;
|
|
}
|
|
}
|
|
|
|
/* Panel transitions and animations */
|
|
.panel-enter-active,
|
|
.panel-leave-active {
|
|
transition: transform 0.3s ease-in-out;
|
|
}
|
|
|
|
.panel-enter-from {
|
|
transform: translateX(-100%);
|
|
}
|
|
|
|
.panel-leave-to {
|
|
transform: translateX(-100%);
|
|
}
|
|
|
|
.panel-right-enter-from {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
.panel-right-leave-to {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
/* Improved mobile canvas interactions */
|
|
@media (max-width: 768px) {
|
|
.process-builder :deep(.vue-flow__pane) {
|
|
touch-action: pan-x pan-y;
|
|
}
|
|
|
|
.process-builder :deep(.vue-flow__node) {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.process-builder :deep(.vue-flow__edge) {
|
|
pointer-events: stroke;
|
|
stroke-width: 3px; /* Thicker edges for easier mobile selection */
|
|
}
|
|
}
|
|
|
|
/* Modern icon button style for node actions */
|
|
.modern-icon-btn {
|
|
padding: 0.375rem;
|
|
border-radius: 9999px;
|
|
min-width: 32px;
|
|
min-height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.15s;
|
|
}
|
|
.modern-icon-btn:hover {
|
|
background: #f3f4f6;
|
|
}
|
|
.modern-icon-btn[variant~='danger-text']:hover {
|
|
background: #fee2e2;
|
|
}
|
|
|
|
/* Ensure full canvas coverage */
|
|
.process-builder {
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Override Vue Flow container to fill full height */
|
|
:deep(.vue-flow) {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
min-height: 100% !important;
|
|
}
|
|
|
|
:deep(.vue-flow__pane) {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
}
|
|
|
|
:deep(.vue-flow__renderer) {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
}
|
|
</style> |