Enhance Process Templates Modal with Preview Functionality and Styling Updates

- Introduced a preview modal for process templates, allowing users to view detailed information and flow before selection.
- Updated the template card styles for improved visual appeal and user interaction, including hover effects and background gradients.
- Enhanced the layout and structure of the template display, ensuring better organization of template details and action buttons.
- Added computed properties for dynamic node styling based on template definitions, improving the visual representation of nodes in the preview.
- Implemented responsive design adjustments to ensure usability across different screen sizes.
This commit is contained in:
Md Afiq Iskandar 2025-07-21 13:30:41 +08:00
parent 9f53c75716
commit c5edf2abfe
5 changed files with 1114 additions and 246 deletions

View File

@ -8,8 +8,8 @@
v-for="cat in categories"
:key="cat.id"
@click="activeCategory = cat.id"
class="px-4 py-2 text-sm font-medium"
:class="activeCategory === cat.id ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500 hover:text-gray-700'"
class="px-4 py-2 text-sm font-medium transition-colors duration-200"
:class="activeCategory === cat.id ? 'border-b-2 border-blue-500 text-blue-600 bg-blue-50' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'"
>
{{ cat.name }}
</button>
@ -18,38 +18,65 @@
<!-- Templates Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="template in filteredTemplates" :key="template.id" class="template-card border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<div class="template-preview bg-gray-50 p-4 border-b relative">
<div v-for="template in filteredTemplates" :key="template.id" class="template-card border border-gray-200 rounded-lg overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 bg-white">
<div class="template-preview bg-gradient-to-br from-gray-50 to-gray-100 p-4 border-b border-gray-200 relative">
<div class="w-full h-full flex flex-col items-center justify-center">
<div class="template-icon-wrapper">
<Icon :name="getTemplateIcon(template)" class="w-12 h-12 text-gray-400" />
<Icon :name="getTemplateIcon(template)" class="w-12 h-12 text-gray-500" />
</div>
<span class="text-sm text-gray-500 mt-2">{{ template.name }}</span>
<span class="text-sm text-gray-600 mt-2 font-medium">{{ template.name }}</span>
</div>
<!-- Preview overlay -->
<div class="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-10 transition-all duration-200 flex items-center justify-center">
<button
@click="previewTemplate(template)"
class="preview-btn opacity-0 hover:opacity-100 transition-opacity duration-200 bg-white bg-opacity-90 hover:bg-opacity-100 text-gray-700 px-3 py-1 rounded-full text-xs font-medium shadow-md border border-gray-200"
>
<Icon name="material-symbols:visibility" class="w-4 h-4 mr-1" />
Preview
</button>
</div>
</div>
<div class="p-4">
<h3 class="font-medium text-lg mb-1">{{ template.name }}</h3>
<p class="text-gray-600 text-sm mb-4">{{ template.description }}</p>
<h3 class="font-semibold text-lg mb-2 text-gray-800">{{ template.name }}</h3>
<p class="text-gray-600 text-sm mb-4 leading-relaxed">{{ template.description }}</p>
<div class="flex items-center text-xs text-gray-500 mb-4">
<div class="flex items-center mr-4">
<div class="flex items-center text-xs text-gray-500 mb-4 space-x-4">
<div class="flex items-center">
<Icon name="material-symbols:account-tree" class="mr-1 w-3 h-3" />
<span>{{ template.nodeCount }} nodes</span>
</div>
<div class="flex items-center mr-4">
<div class="flex items-center">
<Icon name="material-symbols:route" class="mr-1 w-3 h-3" />
<span>{{ template.edgeCount }} connections</span>
</div>
<div class="flex items-center">
<Icon name="material-symbols:timer" class="mr-1 w-3 h-3" />
<span>{{ template.complexity }}</span>
<span class="capitalize">{{ template.complexity }}</span>
</div>
</div>
<RsButton @click="useTemplate(template)" variant="primary" size="sm" class="w-full">
Use Template
</RsButton>
<div class="flex space-x-2">
<RsButton
@click="previewTemplate(template)"
variant="secondary"
size="sm"
class="flex-1"
>
<Icon name="material-symbols:visibility" class="w-4 h-4 mr-1" />
Preview
</RsButton>
<RsButton
@click="useTemplate(template)"
variant="primary"
size="sm"
class="flex-1"
>
<Icon name="material-symbols:add" class="w-4 h-4 mr-1" />
Use Template
</RsButton>
</div>
</div>
</div>
</div>
@ -62,9 +89,148 @@
</div>
</div>
</RsModal>
<!-- Preview Modal -->
<RsModal v-model="showPreviewModal" title="Template Preview" size="2xl" position="center">
<div v-if="selectedTemplate" class="template-preview-container">
<!-- Template Info -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<div class="flex items-start justify-between mb-3">
<div>
<h2 class="text-xl font-semibold text-gray-800 mb-1">{{ selectedTemplate.name }}</h2>
<p class="text-gray-600">{{ selectedTemplate.description }}</p>
</div>
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-medium rounded-full capitalize">
{{ selectedTemplate.complexity }}
</span>
<span class="px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
{{ selectedTemplate.category }}
</span>
</div>
</div>
<div class="flex items-center space-x-6 text-sm text-gray-600">
<div class="flex items-center">
<Icon name="material-symbols:account-tree" class="mr-1 w-4 h-4" />
<span>{{ selectedTemplate.nodeCount }} nodes</span>
</div>
<div class="flex items-center">
<Icon name="material-symbols:route" class="mr-1 w-4 h-4" />
<span>{{ selectedTemplate.edgeCount }} connections</span>
</div>
<div class="flex items-center">
<Icon name="material-symbols:data-object" class="mr-1 w-4 h-4" />
<span>{{ selectedTemplate.variables?.length || 0 }} variables</span>
</div>
</div>
</div>
<!-- Process Flow Preview -->
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-800 mb-3">Process Flow</h3>
<div class="process-flow-preview bg-white border border-gray-200 rounded-lg h-96 overflow-hidden">
<VueFlow
v-model="previewFlow"
:nodes="previewNodes"
:edges="previewEdges"
:node-types="customNodeTypes"
:default-viewport="{ x: 0, y: 0, zoom: 0.8 }"
:min-zoom="0.5"
:max-zoom="1.5"
:pan-on-drag="true"
:zoom-on-scroll="true"
:pan-on-scroll="false"
:zoom-on-double-click="false"
:select-nodes-on-drag="false"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="false"
:delete-key-code="null"
class="preview-vue-flow"
>
<Background pattern-color="#e2e8f0" gap="20" />
<Controls position="bottom-right" />
<MiniMap position="top-right" />
</VueFlow>
</div>
</div>
<!-- Variables -->
<div class="mb-6" v-if="selectedTemplate.variables && selectedTemplate.variables.length > 0">
<h3 class="text-lg font-medium text-gray-800 mb-3">Process Variables</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div
v-for="variable in selectedTemplate.variables"
:key="variable.name"
class="variable-item p-3 bg-gray-50 rounded-lg border border-gray-200"
>
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-sm text-gray-800">{{ variable.name }}</span>
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{{ variable.type }}
</span>
</div>
<p class="text-xs text-gray-600">{{ variable.description }}</p>
<div v-if="variable.defaultValue !== undefined && variable.defaultValue !== ''" class="mt-1">
<span class="text-xs text-gray-500">Default: </span>
<span class="text-xs font-mono bg-gray-200 px-1 rounded">{{ variable.defaultValue }}</span>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<RsButton @click="showPreviewModal = false" variant="secondary" size="sm">
Close
</RsButton>
<RsButton @click="useTemplateFromPreview" variant="primary" size="sm">
<Icon name="material-symbols:add" class="w-4 h-4 mr-1" />
Use This Template
</RsButton>
</div>
</div>
</RsModal>
</template>
<script setup>
import { VueFlow } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import { markRaw } from 'vue';
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
import "@vue-flow/minimap/dist/style.css";
// Import custom node components
import StartNode from "~/components/process-flow/custom/StartNode.vue";
import EndNode from "~/components/process-flow/custom/EndNode.vue";
import FormNode from "~/components/process-flow/custom/FormNode.vue";
import ApiNode from "~/components/process-flow/custom/ApiNode.vue";
import GatewayNode from "~/components/process-flow/custom/GatewayNode.vue";
import ScriptNode from "~/components/process-flow/custom/ScriptNode.vue";
import BusinessRuleNode from "~/components/process-flow/custom/BusinessRuleNode.vue";
import NotificationNode from "~/components/process-flow/custom/NotificationNode.vue";
import HtmlNode from "~/components/process-flow/custom/HtmlNode.vue";
import SubprocessNode from "~/components/process-flow/custom/SubprocessNode.vue";
// Create nodeTypes object with markRaw to prevent reactivity issues
const customNodeTypes = {
'start': markRaw(StartNode),
'end': markRaw(EndNode),
'form': markRaw(FormNode),
'api': markRaw(ApiNode),
'gateway': markRaw(GatewayNode),
'script': markRaw(ScriptNode),
'business-rule': markRaw(BusinessRuleNode),
'notification': markRaw(NotificationNode),
'html': markRaw(HtmlNode),
'subprocess': markRaw(SubprocessNode)
};
const props = defineProps({
modelValue: {
type: Boolean,
@ -79,6 +245,10 @@ const showModal = computed({
set: (value) => emit('update:modelValue', value)
});
// Preview modal state
const showPreviewModal = ref(false);
const selectedTemplate = ref(null);
// Categories
const categories = [
{ id: 'all', name: 'All Templates' },
@ -132,7 +302,11 @@ const templates = [
position: { x: 100, y: 100 },
data: {
label: 'Start',
description: 'Begin approval process'
description: 'Begin approval process',
backgroundColor: '#dcfce7',
borderColor: '#10b981',
textColor: '#065f46',
shape: 'circle'
}
},
{
@ -143,7 +317,11 @@ const templates = [
label: 'Submit Request',
description: 'Employee submits approval request',
formId: null,
formName: 'Approval Request Form'
formName: 'Approval Request Form',
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
}
},
{
@ -158,7 +336,11 @@ const templates = [
recipientRole: 'manager',
subject: 'New approval request',
message: 'A new approval request requires your review.',
priority: 'medium'
priority: 'medium',
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7',
shape: 'rectangle'
}
},
{
@ -169,7 +351,11 @@ const templates = [
label: 'Manager Review',
description: 'Manager reviews and approves/rejects',
formId: null,
formName: 'Manager Review Form'
formName: 'Manager Review Form',
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
}
},
{
@ -187,7 +373,11 @@ const templates = [
output: 'Approved'
}
],
defaultPath: 'Rejected'
defaultPath: 'Rejected',
backgroundColor: '#fff7ed',
borderColor: '#f97316',
textColor: '#c2410c',
shape: 'diamond'
}
},
{
@ -202,7 +392,11 @@ const templates = [
recipientVariable: 'requester_email',
subject: 'Request Approved',
message: 'Your request has been approved.',
priority: 'high'
priority: 'high',
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7',
shape: 'rectangle'
}
},
{
@ -217,7 +411,11 @@ const templates = [
recipientVariable: 'requester_email',
subject: 'Request Rejected',
message: 'Your request has been rejected.',
priority: 'medium'
priority: 'medium',
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7',
shape: 'rectangle'
}
},
{
@ -226,7 +424,11 @@ const templates = [
position: { x: 1300, y: 100 },
data: {
label: 'End',
description: 'Process completed'
description: 'Process completed',
backgroundColor: '#fee2e2',
borderColor: '#dc2626',
textColor: '#991b1b',
shape: 'circle'
}
}
],
@ -1022,6 +1224,21 @@ function useTemplate(template) {
showModal.value = false;
}
// Preview template
function previewTemplate(template) {
selectedTemplate.value = template;
showPreviewModal.value = true;
}
// Use template from preview
function useTemplateFromPreview() {
if (selectedTemplate.value) {
emit('select-template', selectedTemplate.value);
showPreviewModal.value = false;
showModal.value = false;
}
}
// Get icon based on template category/type
const getTemplateIcon = (template) => {
const icons = {
@ -1034,6 +1251,229 @@ const getTemplateIcon = (template) => {
return icons[template.category] || 'material-symbols:account-tree';
};
// Get icon for node types
const getNodeIcon = (nodeType) => {
const icons = {
'start': 'material-symbols:play-circle-outline',
'end': 'material-symbols:stop-circle-outline',
'form': 'material-symbols:description-outline',
'api': 'material-symbols:api',
'gateway': 'material-symbols:call-split',
'notification': 'material-symbols:notifications-outline',
'business-rule': 'material-symbols:rule',
'script': 'material-symbols:code',
'html': 'material-symbols:code',
'subprocess': 'material-symbols:hub-outline'
};
return icons[nodeType] || 'material-symbols:account-tree';
};
// Calculate edge path for preview
const getEdgePath = (edge) => {
const sourceNode = selectedTemplate.value?.nodes.find(n => n.id === edge.source);
const targetNode = selectedTemplate.value?.nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return '';
// Normalize positions to fit within the preview container
const sourceX = Math.max(5, Math.min(95, (sourceNode.position.x / 2000) * 100));
const sourceY = Math.max(5, Math.min(95, (sourceNode.position.y / 800) * 100));
const targetX = Math.max(5, Math.min(95, (targetNode.position.x / 2000) * 100));
const targetY = Math.max(5, Math.min(95, (targetNode.position.y / 800) * 100));
// Calculate the distance and direction
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Adjust target position to account for arrow marker
const arrowOffset = 3; // Adjust based on marker size
const targetAdjustedX = targetX - (dx / distance) * arrowOffset;
const targetAdjustedY = targetY - (dy / distance) * arrowOffset;
// Create a smooth curved path with proper control points
if (Math.abs(dx) > Math.abs(dy)) {
// Horizontal flow - use gentle curve
const controlX1 = sourceX + dx * 0.25;
const controlY1 = sourceY;
const controlX2 = sourceX + dx * 0.75;
const controlY2 = targetAdjustedY;
return `M ${sourceX} ${sourceY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${targetAdjustedX} ${targetAdjustedY}`;
} else {
// Vertical flow - use straight line with slight curve
const controlX1 = sourceX;
const controlY1 = sourceY + dy * 0.25;
const controlX2 = targetAdjustedX;
const controlY2 = sourceY + dy * 0.75;
return `M ${sourceX} ${sourceY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${targetAdjustedX} ${targetAdjustedY}`;
}
};
// Calculate arrow points for fallback arrows
const getArrowPoints = (edge) => {
const sourceNode = selectedTemplate.value?.nodes.find(n => n.id === edge.source);
const targetNode = selectedTemplate.value?.nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return '';
// Normalize positions
const sourceX = Math.max(5, Math.min(95, (sourceNode.position.x / 2000) * 100));
const sourceY = Math.max(5, Math.min(95, (sourceNode.position.y / 800) * 100));
const targetX = Math.max(5, Math.min(95, (targetNode.position.x / 2000) * 100));
const targetY = Math.max(5, Math.min(95, (targetNode.position.y / 800) * 100));
// Calculate direction
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return '';
// Normalize direction vector
const dirX = dx / distance;
const dirY = dy / distance;
// Arrow size
const arrowSize = 4;
// Arrow tip position (slightly before the target node)
const tipX = targetX - dirX * 6;
const tipY = targetY - dirY * 6;
// Arrow base points (perpendicular to direction)
const perpX = -dirY;
const perpY = dirX;
const base1X = tipX - dirX * arrowSize + perpX * arrowSize;
const base1Y = tipY - dirY * arrowSize + perpY * arrowSize;
const base2X = tipX - dirX * arrowSize - perpX * arrowSize;
const base2Y = tipY - dirY * arrowSize - perpY * arrowSize;
return `${tipX},${tipY} ${base1X},${base1Y} ${base2X},${base2Y}`;
};
// Vue Flow preview data
const previewFlow = ref(null);
// Computed properties for Vue Flow preview
const previewNodes = computed(() => {
if (!selectedTemplate.value?.nodes) return [];
return selectedTemplate.value.nodes.map(node => {
// Get default styling from ProcessBuilderComponents
const defaultStyling = getDefaultNodeStyling(node.type);
return {
...node,
data: {
...node.data,
// Ensure styling properties are included
backgroundColor: node.data.backgroundColor || defaultStyling.backgroundColor,
borderColor: node.data.borderColor || defaultStyling.borderColor,
textColor: node.data.textColor || defaultStyling.textColor,
shape: node.data.shape || defaultStyling.shape
}
};
});
});
const previewEdges = computed(() => {
if (!selectedTemplate.value?.edges) return [];
return selectedTemplate.value.edges.map(edge => ({
...edge,
type: 'smoothstep',
animated: false,
style: {
stroke: '#475569',
strokeWidth: 2,
strokeDasharray: 'none'
},
markerEnd: {
type: 'arrowclosed',
width: 20,
height: 20,
color: '#475569'
}
}));
});
// Get default styling for node types
const getDefaultNodeStyling = (nodeType) => {
const stylingMap = {
'start': {
backgroundColor: '#dcfce7',
borderColor: '#10b981',
textColor: '#065f46',
shape: 'circle'
},
'end': {
backgroundColor: '#fee2e2',
borderColor: '#dc2626',
textColor: '#991b1b',
shape: 'circle'
},
'form': {
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
},
'api': {
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af',
shape: 'rectangle'
},
'gateway': {
backgroundColor: '#fff7ed',
borderColor: '#f97316',
textColor: '#c2410c',
shape: 'diamond'
},
'notification': {
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7',
shape: 'rectangle'
},
'business-rule': {
backgroundColor: '#fdf4ff',
borderColor: '#a855f7',
textColor: '#7c3aed',
shape: 'rectangle'
},
'script': {
backgroundColor: '#f9fafb',
borderColor: '#6b7280',
textColor: '#374151',
shape: 'rectangle'
},
'html': {
backgroundColor: '#e0f2fe',
borderColor: '#0ea5e9',
textColor: '#0c4a6e',
shape: 'rectangle'
},
'subprocess': {
backgroundColor: '#f0fdfa',
borderColor: '#14b8a6',
textColor: '#134e4a',
shape: 'rectangle'
}
};
return stylingMap[nodeType] || {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
textColor: '#374151',
shape: 'rectangle'
};
};
</script>
<style scoped>
@ -1044,11 +1484,13 @@ const getTemplateIcon = (template) => {
}
.template-card {
transition: all 0.2s ease;
transition: all 0.3s ease;
cursor: pointer;
}
.template-card:hover {
transform: translateY(-2px);
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.template-preview {
@ -1057,6 +1499,7 @@ const getTemplateIcon = (template) => {
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.template-icon-wrapper {
@ -1067,7 +1510,221 @@ const getTemplateIcon = (template) => {
height: 80px;
border-radius: 50%;
background-color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 3px solid #e2e8f0;
transition: all 0.3s ease;
}
.template-card:hover .template-icon-wrapper {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.preview-btn {
backdrop-filter: blur(4px);
}
/* Preview Modal Styles */
.template-preview-container {
max-height: 80vh;
overflow-y: auto;
}
.process-flow-preview {
position: relative;
background: linear-gradient(45deg, #f8fafc 25%, transparent 25%),
linear-gradient(-45deg, #f8fafc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f8fafc 75%),
linear-gradient(-45deg, transparent 75%, #f8fafc 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
border-radius: 8px;
overflow: hidden;
}
.process-flow-diagram {
position: relative;
width: 100%;
height: 100%;
min-height: 300px;
}
.preview-node {
position: absolute;
transform: translate(-50%, -50%);
z-index: 10;
}
.node-content {
display: flex;
align-items: center;
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 8px 12px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
font-size: 11px;
white-space: nowrap;
transition: all 0.2s ease;
min-width: 80px;
justify-content: center;
backdrop-filter: blur(4px);
}
.node-content:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
border-color: #3b82f6;
z-index: 20;
}
/* Node type specific styling */
.preview-node .node-content {
border-color: #e2e8f0;
background: white;
}
.preview-node .node-content[data-type="start"] {
border-color: #10b981;
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
color: #065f46;
}
.preview-node .node-content[data-type="end"] {
border-color: #ef4444;
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
color: #991b1b;
}
.preview-node .node-content[data-type="gateway"] {
border-color: #f97316;
background: linear-gradient(135deg, #fff7ed 0%, #fed7aa 100%);
color: #c2410c;
border-radius: 50%;
width: 60px;
height: 60px;
min-width: 60px;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(45deg);
}
.preview-node .node-content[data-type="gateway"] > div {
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
}
.preview-node .node-content[data-type="form"] {
border-color: #9333ea;
background: linear-gradient(135deg, #faf5ff 0%, #e9d5ff 100%);
color: #6b21a8;
}
.preview-node .node-content[data-type="api"] {
border-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #1e40af;
}
.preview-node .node-content[data-type="notification"] {
border-color: #0ea5e9;
background: linear-gradient(135deg, #f0f9ff 0%, #bae6fd 100%);
color: #0284c7;
}
.preview-node .node-content[data-type="business-rule"] {
border-color: #a855f7;
background: linear-gradient(135deg, #fdf4ff 0%, #e9d5ff 100%);
color: #7c3aed;
}
.preview-node .node-content[data-type="script"] {
border-color: #6b7280;
background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
color: #374151;
}
.variable-item {
transition: all 0.2s ease;
}
.variable-item:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
/* Flow edge styling */
.flow-edge {
transition: all 0.3s ease;
}
.flow-edge:hover {
stroke-width: 3.5;
opacity: 1;
filter: brightness(1.2);
}
/* Vue Flow Preview Styling */
.preview-vue-flow {
width: 100%;
height: 100%;
}
.preview-vue-flow .vue-flow__node {
cursor: default;
}
.preview-vue-flow .vue-flow__node:hover {
transform: none;
}
.preview-vue-flow .vue-flow__controls {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.preview-vue-flow .vue-flow__minimap {
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.preview-vue-flow .vue-flow__background {
background-color: #f8fafc;
}
/* Enhanced Preview Node Styling */
.preview-node .node-content {
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-width: 2px;
border-style: solid;
}
.preview-node .node-content:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 20;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.template-card:hover {
transform: translateY(-2px);
}
.process-flow-preview {
height: 60vh;
}
.node-content {
font-size: 10px;
padding: 4px 8px;
}
}
</style>

View File

@ -74,7 +74,11 @@ const availableComponents = [
defaultProps: {
label: 'Start',
data: {
description: 'Process start point'
description: 'Process start point',
shape: 'circle',
backgroundColor: '#dcfce7',
borderColor: '#10b981',
textColor: '#065f46'
}
}
},
@ -87,7 +91,11 @@ const availableComponents = [
defaultProps: {
label: 'End',
data: {
description: 'Process end point'
description: 'Process end point',
shape: 'circle',
backgroundColor: '#fee2e2',
borderColor: '#dc2626',
textColor: '#991b1b'
}
}
},
@ -103,7 +111,11 @@ const availableComponents = [
data: {
description: 'Form submission task',
formId: null,
formName: null
formName: null,
shape: 'rectangle',
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8'
}
}
},
@ -123,7 +135,11 @@ const availableComponents = [
headers: '{ "Content-Type": "application/json" }',
outputVariable: 'apiResponse',
continueOnError: false,
errorVariable: 'apiError'
errorVariable: 'apiError',
shape: 'rectangle',
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af'
}
}
},
@ -138,7 +154,11 @@ const availableComponents = [
data: {
description: 'Decision point for branching the workflow',
conditions: [],
defaultPath: 'Default'
defaultPath: 'Default',
shape: 'diamond',
backgroundColor: '#fff7ed',
borderColor: '#f97316',
textColor: '#c2410c'
}
}
},
@ -170,7 +190,11 @@ const availableComponents = [
enabled: false,
value: 24,
unit: 'hours'
}
},
shape: 'rectangle',
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7'
}
}
},
@ -185,7 +209,11 @@ const availableComponents = [
data: {
description: 'Apply business rules',
ruleGroups: [],
priority: 'medium'
priority: 'medium',
shape: 'rectangle',
backgroundColor: '#fdf4ff',
borderColor: '#a855f7',
textColor: '#7c3aed'
}
}
},
@ -202,7 +230,11 @@ const availableComponents = [
scriptCode: '',
scriptLanguage: 'javascript',
inputVariables: [],
outputVariables: []
outputVariables: [],
shape: 'rectangle',
backgroundColor: '#f9fafb',
borderColor: '#6b7280',
textColor: '#374151'
}
}
},
@ -222,7 +254,11 @@ const availableComponents = [
inputVariables: [],
outputVariables: [],
allowVariableAccess: true,
autoRefresh: false
autoRefresh: false,
shape: 'rectangle',
backgroundColor: '#e0f2fe',
borderColor: '#0ea5e9',
textColor: '#0c4a6e'
}
}
},
@ -237,7 +273,11 @@ const availableComponents = [
data: {
description: 'Executes another process',
subprocessId: null,
subprocessName: ''
subprocessName: '',
shape: 'rectangle',
backgroundColor: '#f0fdfa',
borderColor: '#14b8a6',
textColor: '#134e4a'
}
}
},
@ -362,7 +402,6 @@ const onDragStart = (event, component) => {
// Add a component directly via click
const addComponent = (component) => {
return;
// Use same format as drag operation for consistency
const componentData = {
type: component.type,

View File

@ -39,24 +39,24 @@ import "@vue-flow/minimap/dist/style.css";
// Create nodeTypes object with markRaw to prevent reactivity issues
const customNodeTypes = {
'start': markRaw(StartNode),
'end': markRaw(EndNode),
'form': markRaw(FormNode),
'api': markRaw(ApiNode),
'gateway': markRaw(GatewayNode),
'script': markRaw(ScriptNode),
'business-rule': markRaw(BusinessRuleNode),
'notification': markRaw(NotificationNode),
'html': markRaw(HtmlNode),
'subprocess': markRaw(SubprocessNode),
start: markRaw(StartNode),
end: markRaw(EndNode),
form: markRaw(FormNode),
api: markRaw(ApiNode),
gateway: markRaw(GatewayNode),
script: markRaw(ScriptNode),
"business-rule": markRaw(BusinessRuleNode),
notification: markRaw(NotificationNode),
html: markRaw(HtmlNode),
subprocess: markRaw(SubprocessNode),
// Shape nodes
'hexagon-shape': markRaw(HexagonShape),
'trapezoid-shape': markRaw(TrapezoidShape),
'rectangle-shape': markRaw(RectangleShape),
'swimlane-horizontal': markRaw(SwimlaneHorizontal),
'swimlane-vertical': markRaw(SwimlaneVertical),
'text-annotation': markRaw(TextAnnotation),
'process-group': markRaw(ProcessGroup)
"hexagon-shape": markRaw(HexagonShape),
"trapezoid-shape": markRaw(TrapezoidShape),
"rectangle-shape": markRaw(RectangleShape),
"swimlane-horizontal": markRaw(SwimlaneHorizontal),
"swimlane-vertical": markRaw(SwimlaneVertical),
"text-annotation": markRaw(TextAnnotation),
"process-group": markRaw(ProcessGroup),
};
// Add Material Icons import
@ -276,10 +276,18 @@ onMounted(() => {
// Setup window resize handler
window.addEventListener("resize", resizeFlow);
// Initial fit view
setTimeout(() => {
fitView();
}, 100);
// Only fit view initially if there are no existing nodes with positions
// This prevents repositioning existing nodes when the canvas mounts
const hasExistingNodes = props.initialNodes.length > 0;
const hasPositions = props.initialNodes.some(
(node) => node.position && (node.position.x !== 0 || node.position.y !== 0)
);
if (!hasExistingNodes || !hasPositions) {
setTimeout(() => {
fitView();
}, 100);
}
});
// Center on a specific node
@ -869,7 +877,7 @@ defineExpose({
nodes.value = newNodes;
}
} catch (error) {
console.error('Error in setNodes:', error);
console.error("Error in setNodes:", error);
}
},
setEdges: (newEdges) => {
@ -878,13 +886,13 @@ defineExpose({
edges.value = newEdges;
}
} catch (error) {
console.error('Error in setEdges:', error);
console.error("Error in setEdges:", error);
}
},
// Provide access to the flow instance
get flowInstance() {
return flowInstance;
}
},
});
// Update an existing node
@ -978,15 +986,22 @@ function syncCanvas(newNodes, newEdges) {
);
// Remove nodes that are no longer in the new list
// Be more conservative - only remove if we're sure it's not a temporary state
const nodesToRemove = nodes.value.filter(
(node) => !newNodeIds.has(node.id)
);
if (nodesToRemove.length > 0) {
// Only remove nodes if the new list is not empty (prevents accidental clearing)
if (nodesToRemove.length > 0 && newNodes.length > 0) {
console.log(
"🗑️ Removing nodes:",
nodesToRemove.map((n) => n.id)
);
removeNodes(nodesToRemove);
} else if (nodesToRemove.length > 0 && newNodes.length === 0) {
console.log(
"⚠️ Not removing nodes - new list is empty, might be temporary state"
);
}
// Add new nodes that aren't already present
@ -1254,22 +1269,51 @@ function fromObject(flowObject) {
// Wait for any pending operations to complete
setTimeout(async () => {
try {
// 1. First, clear existing state
if (nodes.value.length > 0) {
removeNodes([...nodes.value]);
}
if (edges.value.length > 0) {
removeEdges([...edges.value]);
// Check if we're restoring the same data that's already there
const currentNodes = nodes.value.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
}));
const newNodes =
flowObject.nodes?.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
})) || [];
const nodesAreSame =
currentNodes.length === newNodes.length &&
currentNodes.every(
(node, index) =>
node.id === newNodes[index]?.id &&
node.type === newNodes[index]?.type
);
// Only clear if we're actually restoring different data
if (!nodesAreSame) {
// 1. First, clear existing state
if (nodes.value.length > 0) {
console.log("🔄 Clearing existing nodes for restoration");
removeNodes([...nodes.value]);
}
if (edges.value.length > 0) {
console.log("🔄 Clearing existing edges for restoration");
removeEdges([...edges.value]);
}
} else {
console.log("✅ Nodes are the same, skipping clear operation");
}
// Wait for clearing to complete
await nextTick();
// 2. Restore nodes first
// 2. Restore nodes first (only if we cleared or if no nodes exist)
if (
flowObject.nodes &&
Array.isArray(flowObject.nodes) &&
flowObject.nodes.length > 0
flowObject.nodes.length > 0 &&
(!nodesAreSame || nodes.value.length === 0)
) {
const nodesToRestore = flowObject.nodes.map((node) => ({
id: node.id,
@ -1280,19 +1324,23 @@ function fromObject(flowObject) {
// Only include essential properties needed for Vue Flow
}));
console.log("🔄 Restoring", nodesToRestore.length, "nodes");
addNodes(nodesToRestore);
await nextTick();
// Wait a bit more for nodes to be fully initialized
await new Promise((resolve) => setTimeout(resolve, 100));
} else if (nodesAreSame) {
console.log("✅ Nodes already exist, skipping node restoration");
}
// 3. Restore edges after nodes are ready
// 3. Restore edges after nodes are ready (only if we cleared or if no edges exist)
if (
flowObject.edges &&
Array.isArray(flowObject.edges) &&
flowObject.edges.length > 0 &&
nodes.value.length > 0
nodes.value.length > 0 &&
(!nodesAreSame || edges.value.length === 0)
) {
// Verify all edges have valid source and target nodes
const validEdges = flowObject.edges.filter((edge) => {
@ -1328,9 +1376,12 @@ function fromObject(flowObject) {
// Only include essential properties
}));
console.log("🔄 Restoring", cleanEdges.length, "edges");
addEdges(cleanEdges);
await nextTick();
}
} else if (nodesAreSame) {
console.log("✅ Edges already exist, skipping edge restoration");
}
// 4. Finally, restore viewport position and zoom

View File

@ -1331,25 +1331,37 @@ const saveProcess = async () => {
// Add a component handler to add components from the component panel
const onAddComponent = async (component) => {
if (isAddingComponent.value) return; // Prevent concurrent additions
if (isAddingComponent.value || isProcessLoading.value) return; // Prevent concurrent additions or interference with process loading
try {
isAddingComponent.value = true;
console.log('🎯 Adding component:', component.type);
// Create a new node from the component definition
// The component structure from ProcessBuilderComponents is:
// { type, label, data: { ...defaultProps.data } }
// Calculate a better position for the new node to avoid overlap
let nodePosition = { x: 100, y: 100 };
if (processStore.currentProcess && processStore.currentProcess.nodes.length > 0) {
// Find the rightmost and bottommost positions
const maxX = Math.max(...processStore.currentProcess.nodes.map(n => n.position?.x || 0));
const maxY = Math.max(...processStore.currentProcess.nodes.map(n => n.position?.y || 0));
// Place new node to the right with some spacing
nodePosition = {
x: maxX + 200, // 200px spacing from rightmost node
y: Math.max(100, maxY) // At least 100px from top, or at the level of the bottommost node
};
}
const newNode = {
id: `${component.type}_${Date.now()}`,
type: component.type,
position: { x: 100, y: 100 }, // Default position
position: nodePosition,
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
...component.data
}
};
@ -1390,78 +1402,51 @@ const onAddComponent = async (component) => {
}
// Add the node to the process store
await processStore.addNode(newNode);
console.log('📝 Adding node to store:', newNode.id);
const addedNode = await processStore.addNode(newNode);
if (!addedNode) {
console.error('❌ Failed to add node to store');
toast.error('Failed to add component to store. Please try again.');
return;
}
// Wait for store update and next render cycle
await nextTick();
await new Promise(resolve => setTimeout(resolve, 50));
// 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
// Add the node directly to the canvas using Vue Flow's addNode method
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);
console.log('🎨 Adding node to canvas:', addedNode.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);
}
}
// Use Vue Flow's addNode method directly to avoid any sync issues
if (processFlowCanvas.value.addNode) {
processFlowCanvas.value.addNode(addedNode);
}
// Wait for the node to be added to the canvas
await nextTick();
await new Promise(resolve => setTimeout(resolve, 100));
// Select the newly added node after it's stable
onNodeSelected(addedNode);
console.log('✅ Successfully added new node:', addedNode.id, 'at position:', addedNode.position);
} catch (error) {
console.error('❌ Error adding node to canvas:', error);
// 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);
}
}
toast.error('Failed to add component to canvas. Please try again.');
}
}
} catch (error) {
console.error('Error adding component:', error);
console.error('❌ Error adding component:', error);
toast.error('Failed to add component. Please try again.');
} finally {
// Reset the flag after a longer delay to ensure canvas is stable
setTimeout(() => {
console.log('🏁 Component addition completed, resetting flag');
isAddingComponent.value = false;
}, 200);
}, 300);
}
};
@ -1504,6 +1489,9 @@ const applyProcessTemplate = async (template) => {
// Process nodes first and wait for them to be fully added
for (const node of templateNodes) {
// Get default styling for the node type
const defaultStyling = getDefaultNodeStyling(node.type);
const newNode = {
...node,
id: node.id, // Keep original ID for edge references
@ -1511,7 +1499,12 @@ const applyProcessTemplate = async (template) => {
position: node.position || { x: 100, y: 100 },
data: {
...node.data,
label: node.data?.label || node.label || `${node.type} node`
label: node.data?.label || node.label || `${node.type} node`,
// Preserve styling properties from template or use defaults
backgroundColor: node.data?.backgroundColor || defaultStyling.backgroundColor,
borderColor: node.data?.borderColor || defaultStyling.borderColor,
textColor: node.data?.textColor || defaultStyling.textColor,
shape: node.data?.shape || defaultStyling.shape
}
};
@ -1584,6 +1577,78 @@ const applyProcessTemplate = async (template) => {
}
};
// Get default styling for node types
const getDefaultNodeStyling = (nodeType) => {
const stylingMap = {
'start': {
backgroundColor: '#dcfce7',
borderColor: '#10b981',
textColor: '#065f46',
shape: 'circle'
},
'end': {
backgroundColor: '#fee2e2',
borderColor: '#dc2626',
textColor: '#991b1b',
shape: 'circle'
},
'form': {
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
},
'api': {
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af',
shape: 'rectangle'
},
'gateway': {
backgroundColor: '#fff7ed',
borderColor: '#f97316',
textColor: '#c2410c',
shape: 'diamond'
},
'notification': {
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7',
shape: 'rectangle'
},
'business-rule': {
backgroundColor: '#fdf4ff',
borderColor: '#a855f7',
textColor: '#7c3aed',
shape: 'rectangle'
},
'script': {
backgroundColor: '#f9fafb',
borderColor: '#6b7280',
textColor: '#374151',
shape: 'rectangle'
},
'html': {
backgroundColor: '#e0f2fe',
borderColor: '#0ea5e9',
textColor: '#0c4a6e',
shape: 'rectangle'
},
'subprocess': {
backgroundColor: '#f0fdfa',
borderColor: '#14b8a6',
textColor: '#134e4a',
shape: 'rectangle'
}
};
return stylingMap[nodeType] || {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
textColor: '#374151',
shape: 'rectangle'
};
};
// Fix references to functions
const onFormSelected = (formData) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'form') {
@ -1818,62 +1883,85 @@ watch(() => canvasNodes.value, (newNodes) => {
}
}, { 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;
// Track if we're in the middle of adding a component to prevent interference
const isProcessLoading = ref(false);
// Watch for process changes to restore Vue Flow state
// Only trigger when the entire process object changes (different process loaded)
watch(() => processStore.currentProcess?.id, async (newProcessId, oldProcessId) => {
if (!newProcessId) return;
// Only restore when a different process is loaded (not on updates to the same process)
if (oldProcessId && newProcessId === oldProcessId) return;
// Don't interfere if we're in the middle of adding a component or modifying nodes
if (isAddingComponent.value || processStore.isModifyingNodes) {
console.log('🚫 Skipping process restoration during component/node modification');
return;
}
const newProcess = processStore.currentProcess;
console.log('🔄 Loading process:', newProcessId, 'Nodes:', newProcess.nodes?.length || 0);
isProcessLoading.value = true;
// Wait for the canvas to be ready
await nextTick();
// Give the canvas component a moment to initialize
setTimeout(async () => {
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();
try {
if (processFlowCanvas.value && processFlowCanvas.value.fromObject) {
try {
// Prepare the flow object for restoration
const rawFlowObject = {
nodes: newProcess.nodes || [],
edges: newProcess.edges || [],
viewport: newProcess.viewport || { x: 0, y: 0, zoom: 1 }
};
// Clean the data to remove any Vue Flow internal properties
const flowObject = processStore.cleanFlowData(rawFlowObject);
console.log('🔄 Restoring Vue Flow state with', flowObject.nodes.length, 'nodes');
// Use Vue Flow's proper restoration method (now returns a Promise)
await processFlowCanvas.value.fromObject(flowObject);
console.log('✅ Vue Flow state restored successfully');
// Only fit view if nodes don't have meaningful positions (all at 0,0)
// This prevents repositioning nodes that were carefully positioned
const hasPositionedNodes = flowObject.nodes.some(node =>
node.position && (node.position.x !== 0 || node.position.y !== 0)
);
if (!hasPositionedNodes && flowObject.nodes.length > 0) {
setTimeout(() => {
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
processFlowCanvas.value.fitView();
}
}, 300);
}
}, 300);
} catch (error) {
console.error('❌ Error restoring Vue Flow state:', error);
// Fallback to manual sync if Vue Flow restoration fails
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
console.log('🔄 Falling back to manual sync');
processFlowCanvas.value.syncCanvas(newProcess.nodes, newProcess.edges);
}
}
} else {
console.warn('⚠️ Vue Flow canvas not available for restoration, using sync fallback');
} catch (error) {
console.error('❌ Error restoring Vue Flow state:', error);
// Fallback to manual sync if Vue Flow restoration fails
// Fallback sync method
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);
}
} finally {
isProcessLoading.value = false;
}
}, 200); // Allow time for canvas to initialize
}, { immediate: false });

View File

@ -11,7 +11,8 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
history: [],
historyIndex: -1,
unsavedChanges: false,
lastChangeDescription: ''
lastChangeDescription: '',
isModifyingNodes: false // Flag to prevent interference during node operations
}),
getters: {
@ -653,37 +654,48 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
return existingNode;
}
// Create a new node with proper data structure
const newNode = {
id: node.id || uuidv4(),
type: node.type,
label: node.label || node.data?.label || 'New Node',
position: node.position || { x: 100, y: 100 },
data: {
...node.data,
label: node.data?.label || node.label || 'New Node',
// Ensure shape is set for new nodes
shape: node.data?.shape || (node.type === 'gateway' ? 'diamond' : 'rectangle'),
// Ensure default colors are set for new nodes
backgroundColor: node.data?.backgroundColor || '#ffffff',
borderColor: node.data?.borderColor || '#000000'
}
};
// Set flag to prevent interference during node addition
this.isModifyingNodes = true;
// Create a deep copy to avoid reference issues
const nodeCopy = JSON.parse(JSON.stringify(newNode));
try {
// Create a new node with proper data structure
const newNode = {
id: node.id || uuidv4(),
type: node.type,
label: node.label || node.data?.label || 'New Node',
position: node.position || { x: 100, y: 100 },
data: {
...node.data,
label: node.data?.label || node.label || 'New Node',
// Ensure shape is set for new nodes
shape: node.data?.shape || (node.type === 'gateway' ? 'diamond' : 'rectangle'),
// Ensure default colors are set for new nodes
backgroundColor: node.data?.backgroundColor || '#ffffff',
borderColor: node.data?.borderColor || '#000000'
}
};
// Add to current process nodes array
this.currentProcess.nodes = [...this.currentProcess.nodes, nodeCopy];
// Update selection
this.selectedNodeId = nodeCopy.id;
// Save to history
this.saveToHistory('Add node');
this.unsavedChanges = true;
// Create a deep copy to avoid reference issues
const nodeCopy = JSON.parse(JSON.stringify(newNode));
return nodeCopy;
// Add to current process nodes array using push to avoid creating new array reference
// This prevents triggering the watch that monitors currentProcess changes
this.currentProcess.nodes.push(nodeCopy);
// Update selection
this.selectedNodeId = nodeCopy.id;
// Save to history
this.saveToHistory('Add node');
this.unsavedChanges = true;
console.log('📝 Store: Added node', nodeCopy.id, 'Total nodes:', this.currentProcess.nodes.length);
return nodeCopy;
} finally {
// Reset flag after operation completes
this.isModifyingNodes = false;
}
},
/**
@ -706,36 +718,46 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
deleteNode(nodeId) {
if (!this.currentProcess) return;
// Find the node index
const index = this.currentProcess.nodes.findIndex(n => n.id === nodeId);
if (index !== -1) {
// Remove the node
this.currentProcess.nodes.splice(index, 1);
// Remove any edges connected to this node
const edgesToRemove = this.currentProcess.edges.filter(
edge => edge.source === nodeId || edge.target === nodeId
);
edgesToRemove.forEach(edge => {
const edgeIndex = this.currentProcess.edges.findIndex(e => e.id === edge.id);
if (edgeIndex !== -1) {
this.currentProcess.edges.splice(edgeIndex, 1);
// Set flag to prevent interference during node deletion
this.isModifyingNodes = true;
try {
// Find the node index
const index = this.currentProcess.nodes.findIndex(n => n.id === nodeId);
if (index !== -1) {
// Remove the node using splice to avoid creating new array reference
this.currentProcess.nodes.splice(index, 1);
// Remove any edges connected to this node
const edgesToRemove = this.currentProcess.edges.filter(
edge => edge.source === nodeId || edge.target === nodeId
);
edgesToRemove.forEach(edge => {
const edgeIndex = this.currentProcess.edges.findIndex(e => e.id === edge.id);
if (edgeIndex !== -1) {
this.currentProcess.edges.splice(edgeIndex, 1);
}
});
// Clear selection if the deleted node was selected
if (this.selectedNodeId === nodeId) {
this.selectedNodeId = null;
}
});
// Clear selection if the deleted node was selected
if (this.selectedNodeId === nodeId) {
this.selectedNodeId = null;
this.saveToHistory('Delete node');
this.unsavedChanges = true;
console.log('🗑️ Store: Deleted node', nodeId, 'Total nodes:', this.currentProcess.nodes.length);
return true; // Return success
}
this.saveToHistory('Delete node');
this.unsavedChanges = true;
return true; // Return success
return false; // Return failure
} finally {
// Reset flag after operation completes
this.isModifyingNodes = false;
}
return false; // Return failure
},
/**
@ -744,22 +766,33 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
addEdge(edge) {
if (!this.currentProcess) return;
const newEdge = {
id: edge.id || `${edge.source}-${edge.target}`,
source: edge.source,
target: edge.target,
label: edge.label || '',
type: edge.type || 'default',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data || {}
};
// Set flag to prevent interference during edge addition
this.isModifyingNodes = true;
this.currentProcess.edges.push(newEdge);
this.selectedEdgeId = newEdge.id;
this.saveToHistory('Add edge');
this.unsavedChanges = true;
try {
const newEdge = {
id: edge.id || `${edge.source}-${edge.target}`,
source: edge.source,
target: edge.target,
label: edge.label || '',
type: edge.type || 'default',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data || {}
};
return newEdge;
// Use push to avoid creating new array reference
this.currentProcess.edges.push(newEdge);
this.selectedEdgeId = newEdge.id;
this.saveToHistory('Add edge');
this.unsavedChanges = true;
console.log('🔗 Store: Added edge', newEdge.id, 'Total edges:', this.currentProcess.edges.length);
return newEdge;
} finally {
// Reset flag after operation completes
this.isModifyingNodes = false;
}
},
/**