- Simplified navigation structure by consolidating child routes for Process and Form Designers, improving clarity and accessibility. - Updated Form Builder and Process Builder header styles for a more modern look, including logo and title adjustments. - Enhanced button styles and layout in the Form Builder and Process Management pages for better user interaction. - Introduced new dashboard metrics and recent activity sections in Process Management, providing users with quick insights into their processes. - Improved overall responsiveness and visual consistency across the application.
3828 lines
125 KiB
Vue
3828 lines
125 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';
|
|
|
|
// 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 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);
|
|
|
|
// 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
|
|
if (!showLeftPanel.value && !showRightPanel.value) {
|
|
showLeftPanel.value = true;
|
|
showRightPanel.value = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
};
|
|
|
|
// 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_filled',
|
|
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_filled',
|
|
'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(() => {
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
// 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) return; // Prevent concurrent additions
|
|
|
|
try {
|
|
isAddingComponent.value = true;
|
|
|
|
// Create a new node from the component definition
|
|
const newNode = {
|
|
id: `${component.type}_${Date.now()}`,
|
|
type: component.type,
|
|
position: { x: 100, y: 100 }, // Default position
|
|
label: component.label,
|
|
data: {
|
|
...component.data,
|
|
// Ensure shape is set for new nodes
|
|
shape: component.data.shape || (component.type === 'gateway' ? 'diamond' : 'rectangle'),
|
|
// Ensure default colors are set for new nodes
|
|
backgroundColor: component.data.backgroundColor,
|
|
borderColor: component.data.borderColor,
|
|
textColor: component.data.textColor
|
|
}
|
|
};
|
|
|
|
// 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
|
|
await processStore.addNode(newNode);
|
|
|
|
// Wait for store update and next render cycle
|
|
await nextTick();
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// CRITICAL FIX: Instead of calling syncCanvas (which can cause edge removal/re-addition),
|
|
// we'll add the node directly to the canvas and preserve existing edges
|
|
if (processFlowCanvas.value) {
|
|
try {
|
|
// Get the fresh node from store (with any store-side modifications)
|
|
const freshNode = processStore.currentProcess?.nodes.find(n => n.id === newNode.id);
|
|
|
|
if (freshNode && processFlowCanvas.value.addNode) {
|
|
// Add only the new node to the canvas directly
|
|
processFlowCanvas.value.addNode(freshNode);
|
|
|
|
// Wait for the node to be added to the canvas
|
|
await nextTick();
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Select the newly added node after it's stable
|
|
onNodeSelected(freshNode);
|
|
|
|
console.log('✅ Successfully added new node without affecting existing edges');
|
|
} else {
|
|
console.warn('⚠️ Fresh node not found in store, falling back to full sync');
|
|
// Fallback to full sync if something went wrong
|
|
const currentNodes = processStore.currentProcess?.nodes || [];
|
|
const currentEdges = processStore.currentProcess?.edges || [];
|
|
|
|
if (processFlowCanvas.value.syncCanvas) {
|
|
processFlowCanvas.value.syncCanvas(currentNodes, currentEdges);
|
|
|
|
await nextTick();
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
const addedNode = currentNodes.find(n => n.id === newNode.id);
|
|
if (addedNode) {
|
|
onNodeSelected(addedNode);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error adding node to canvas:', error);
|
|
|
|
// Fallback to full sync if direct addition fails
|
|
const currentNodes = processStore.currentProcess?.nodes || [];
|
|
const currentEdges = processStore.currentProcess?.edges || [];
|
|
|
|
if (processFlowCanvas.value.syncCanvas) {
|
|
console.log('🔄 Falling back to full canvas sync due to error');
|
|
processFlowCanvas.value.syncCanvas(currentNodes, currentEdges);
|
|
|
|
await nextTick();
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
const addedNode = currentNodes.find(n => n.id === newNode.id);
|
|
if (addedNode) {
|
|
onNodeSelected(addedNode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} 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(() => {
|
|
isAddingComponent.value = false;
|
|
}, 200);
|
|
}
|
|
};
|
|
|
|
// 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) {
|
|
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`
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// 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');
|
|
};
|
|
|
|
// 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 });
|
|
|
|
// Watch for process changes to restore Vue Flow state
|
|
watch(() => processStore.currentProcess, async (newProcess, oldProcess) => {
|
|
if (!newProcess) return;
|
|
|
|
// Only restore when a different process is loaded (not on updates)
|
|
if (oldProcess && newProcess.id === oldProcess.id) return;
|
|
|
|
|
|
|
|
// Wait for the canvas to be ready
|
|
await nextTick();
|
|
|
|
// Give the canvas component a moment to initialize
|
|
setTimeout(async () => {
|
|
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);
|
|
|
|
|
|
|
|
// Use Vue Flow's proper restoration method (now returns a Promise)
|
|
await processFlowCanvas.value.fromObject(flowObject);
|
|
|
|
|
|
|
|
// Fit view after restoration with a small delay to ensure everything is rendered
|
|
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) {
|
|
|
|
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);
|
|
}
|
|
}
|
|
}, 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'
|
|
);
|
|
});
|
|
|
|
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 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>
|
|
<!-- Mobile panel toggles -->
|
|
<div v-if="hasCurrentProcess && (isMobile || isTablet)" class="flex items-center gap-1 ml-2">
|
|
<RsButton
|
|
@click="toggleLeftPanel"
|
|
variant="tertiary"
|
|
size="sm"
|
|
:class="{ 'bg-gray-100': showLeftPanel }"
|
|
class="p-1"
|
|
>
|
|
<Icon name="material-symbols:widgets" class="w-4 h-4" />
|
|
</RsButton>
|
|
<RsButton
|
|
@click="toggleRightPanel"
|
|
variant="tertiary"
|
|
size="sm"
|
|
:class="{ 'bg-gray-100': showRightPanel }"
|
|
class="p-1"
|
|
>
|
|
<Icon name="material-symbols:tune" class="w-4 h-4" />
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Middle section - Process name -->
|
|
<div class="flex-1 flex justify-center items-center mx-2 md:mx-4">
|
|
<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>
|
|
|
|
<!-- Templates button - Hidden on mobile -->
|
|
<div class="mr-1 md:mr-2 border-r border-gray-200 pr-1 md:pr-2 hidden md:block">
|
|
<RsButton @click="showTemplatesModal = true" variant="secondary" size="sm" :disabled="!hasCurrentProcess">
|
|
<Icon name="material-symbols:description-outline" class="mr-0 md:mr-1" />
|
|
<span class="hidden lg:inline">Templates</span>
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Process History button - Hidden on mobile, only show if process is saved -->
|
|
<div v-if="processStore.currentProcess && processStore.currentProcess.id" class="mr-1 md:mr-2 border-r border-gray-200 pr-1 md:pr-2 hidden md:block">
|
|
<RsButton @click="showProcessHistoryModal = true" variant="secondary" size="sm">
|
|
<Icon name="material-symbols:history" class="mr-0 md:mr-1" />
|
|
<span class="hidden lg:inline">History</span>
|
|
</RsButton>
|
|
</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">
|
|
<!-- Mobile-only actions -->
|
|
<div v-if="isMobile" class="md:hidden border-b border-gray-200 pb-1 mb-1">
|
|
<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>
|
|
</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" 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-64': !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 mobile/tablet -->
|
|
<button
|
|
v-if="isMobile || isTablet"
|
|
@click="showLeftPanel = false"
|
|
class="p-1 hover:bg-gray-200 rounded"
|
|
>
|
|
<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">
|
|
<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"
|
|
/>
|
|
|
|
<!-- 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-72': !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 mobile/tablet -->
|
|
<button
|
|
v-if="isMobile || isTablet"
|
|
@click="showRightPanel = false"
|
|
class="p-1 hover:bg-gray-200 rounded"
|
|
>
|
|
<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" :key="`variables-${variablesUpdateKey}`" />
|
|
|
|
<!-- 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="duplicateNode"
|
|
variant="secondary-text"
|
|
size="sm"
|
|
icon
|
|
:title="'Duplicate Node'"
|
|
class="modern-icon-btn"
|
|
>
|
|
<Icon name="material-symbols:content-copy" />
|
|
</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"
|
|
/>
|
|
</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;
|
|
}
|
|
</style> |