Afiq 00d8341f3a Enhance Process Flow and Store Management with Node Handling Improvements
- Added functionality to handle node additions from drag & drop in ProcessFlowCanvas, ensuring immediate visual feedback and persistence in application state.
- Implemented logic in the process store to prevent duplicate nodes when adding new nodes from the canvas.
- Enhanced server-side logging to track process updates, including node and edge counts for better debugging.
- Improved node extraction logic in the process builder store to create placeholder nodes from edge references when no nodes are present.
- Added debug logging for saving process data to facilitate better tracking of changes in the application state.
2025-06-11 12:23:52 +08:00

1533 lines
48 KiB
Vue

<script setup>
import { ref, onMounted, computed, shallowRef, onUnmounted, nextTick, watch } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useVariableStore } from '~/stores/variableStore';
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 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();
const variableStore = useVariableStore();
// Initialize toast with fallback
let toast;
try {
toast = useToast();
} catch (error) {
// Create a simple toast object if composable is not available
toast = {
success: (msg) => console.log('Success:', msg),
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 showTemplatesModal = ref(false);
const showProcessSettings = ref(false);
const showDropdown = ref(false);
const showProcessHistoryModal = ref(false);
// 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: '' }
},
{
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'
}
},
{
type: 'gateway',
label: 'Decision Point',
icon: 'call_split',
iconColor: 'text-orange-500',
data: { description: 'Decision point for branching the workflow', conditions: [] }
},
{
type: 'script',
label: 'Script',
icon: 'code',
iconColor: 'text-gray-500',
data: { description: 'Script execution', language: 'JavaScript' }
},
{
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 shallow copy to avoid direct reactivity
return processStore.currentProcess.nodes.map(node => ({ ...node }));
});
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 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(() => {
const processVars = variableStore.getAllVariables.process.map(v => ({
name: v.name || 'unnamed',
label: v?.description
? `${v.description} (${v.name || 'unnamed'}, process)`
: `${v.name || 'unnamed'} (process)` ,
type: v.type || 'string',
scope: 'process'
}));
const globalVars = variableStore.getAllVariables.global.map(v => ({
name: v.name || 'unnamed',
label: v?.description
? `${v.description} (${v.name || 'unnamed'}, global)`
: `${v.name || 'unnamed'} (global)` ,
type: v.type || 'string',
scope: 'global'
}));
const allVars = [...processVars, ...globalVars];
// console.log('Gateway available variables:', allVars);
return allVars;
});
// 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
selectedNodeData.value = nodeCopy;
// Keep a reference to the original node
selectedNode.value = node;
selectedEdgeData.value = null;
processStore.selectNode(node.id);
};
// 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 = () => {
selectedNode.value = null;
selectedNodeData.value = null;
selectedEdgeData.value = null;
processStore.clearSelection();
};
// Update node in store
const updateNodeInStore = () => {
if (selectedNodeData.value) {
// console.log('Updating node:', selectedNodeData.value.id, selectedNodeData.value.label);
// Simplify the update to avoid recursive reactivity
const nodeId = selectedNodeData.value.id;
const newLabel = selectedNodeData.value.label;
const newData = { ...selectedNodeData.value.data };
// Update the node canvas separately to avoid reactivity chain
nextTick(() => {
if (processFlowCanvas.value) {
processFlowCanvas.value.updateNode(nodeId, {
label: newLabel,
data: {
...newData,
label: newLabel
}
});
}
});
// Update in store with minimal change
processStore.updateNode(nodeId, {
label: newLabel,
data: newData
});
}
};
// Handle condition update
const handleConditionUpdate = (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
updateNodeInStore();
}
};
// Handle updates from the canvas when nodes change
const onNodesChange = (changes, currentNodes) => {
if (!changes || !currentNodes) return;
// Handle node additions (from drag & drop)
const addedNodes = changes.filter(change => change.type === 'add' && change.item);
if (addedNodes.length > 0) {
addedNodes.forEach(change => {
// Check if node already exists in store to prevent duplicates
const existingNode = processStore.currentProcess?.nodes?.find(n => n.id === change.item.id);
if (!existingNode) {
console.log('🔥 Adding node to store from canvas:', change.item);
processStore.addNode(change.item);
} else {
console.log('⚠️ Node already exists in store, skipping:', change.item.id);
}
});
}
// Handle node removals
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 position changes (only when dragging is complete)
const positionChanges = {};
changes
.filter(change => change.type === 'position' && change.position && !change.dragging)
.forEach(change => {
positionChanges[change.id] = change.position;
});
if (Object.keys(positionChanges).length > 0) {
processStore.updateNodePositions(positionChanges);
}
// Update node selection if needed
if (selectedNodeData.value) {
const updatedNode = currentNodes.find(node => node.id === selectedNodeData.value.id);
if (updatedNode) {
selectedNodeData.value = { ...updatedNode };
}
}
};
// Handle updates from the canvas when edges change
const onEdgesChange = (changes, currentEdges) => {
if (!changes || !currentEdges) 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 || 'smoothstep',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data || {}
});
}
});
}
};
// 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);
});
// 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);
});
// 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;
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
};
// Add the node to the process store
processStore.addNode(newNode);
// Wait for the next tick to ensure the store update is complete
await nextTick();
// Explicitly sync the canvas with current store state
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
processFlowCanvas.value.syncCanvas(
processStore.currentProcess.nodes,
processStore.currentProcess.edges
);
}
// Select the newly added node
onNodeSelected(newNode);
console.log('Component added successfully:', component.type);
} catch (error) {
console.error('Error adding component:', error);
} finally {
// Reset the flag after a short delay to allow canvas to stabilize
setTimeout(() => {
isAddingComponent.value = false;
}, 100);
}
};
// Handle template application
const applyProcessTemplate = async (template) => {
try {
console.log('Applying process template:', template.name);
console.log('Template nodes:', template.nodes ? template.nodes.length : 0);
console.log('Template edges:', template.edges ? template.edges.length : 0);
// Create a new process if one doesn't exist
if (!processStore.currentProcess) {
console.log('No current process, creating new one...');
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) {
console.log('Clearing existing nodes and edges...');
processStore.currentProcess.nodes = [];
processStore.currentProcess.edges = [];
}
// Prepare template data
const templateNodes = template.nodes || [];
const templateEdges = template.edges || [];
console.log('Adding template nodes:', templateNodes.length);
console.log('Adding template edges:', templateEdges.length);
// 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 || 'smoothstep',
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) {
console.log('Forcing canvas sync after template application...');
processFlowCanvas.value.syncCanvas(
processStore.currentProcess.nodes,
processStore.currentProcess.edges
);
}
// Add template variables to the variable store
if (template.variables && template.variables.length > 0) {
console.log('Adding template variables:', template.variables.length);
// Clear existing process variables first
variableStore.clearAllProcessVariables();
template.variables.forEach((variable) => {
console.log(`Adding variable: ${variable.name} (${variable.type}) with scope: ${variable.scope}`);
variableStore.addVariable({
...variable,
id: crypto.randomUUID() // Generate unique ID for the variable
});
});
}
console.log('Template application completed - nodes:', processStore.currentProcess.nodes.length, 'edges:', processStore.currentProcess.edges.length);
// 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 = (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
updateNodeInStore();
}
};
// Add this function to handle form node updates
const handleFormNodeUpdate = (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
updateNodeInStore();
}
};
// Update handler for business rule node
const handleBusinessRuleUpdate = (data) => {
if (selectedNodeData.value) {
selectedNodeData.value.data = {
...selectedNodeData.value.data,
...data
};
updateNodeInStore();
}
};
// Add a method to handle default path updates
const handleDefaultPathUpdate = (path) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
selectedNodeData.value.data.defaultPath = path;
updateNodeInStore();
}
};
// Handle notification node update
const handleNotificationNodeUpdate = (updatedData) => {
if (selectedNodeData.value) {
selectedNodeData.value.data = { ...updatedData };
updateNodeInStore();
}
};
// Handle process restoration from history
const handleProcessRestored = (restoredProcess) => {
// The process has been restored in the backend, so we need to reload it
console.log('Process restored:', restoredProcess);
// 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;
}
});
</script>
<template>
<div class="process-builder flex flex-col h-screen bg-white">
<!-- Header Bar -->
<header
class="bg-gray-800 px-4 py-4 flex items-center justify-between text-white shadow-md"
>
<!-- Left section - Logo and navigation -->
<div class="flex items-center gap-3">
<Icon
@click="confirmNavigation('/process-builder/manage')"
name="ph:arrow-circle-left-duotone"
class="cursor-pointer w-6 h-6"
/>
<img
src="@/assets/img/logo/logo-word-white.svg"
alt="Corrad Logo"
class="h-7"
/>
</div>
<!-- Middle section - Process name -->
<div class="flex-1 flex justify-center items-center 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-md"
:classes="{
outer: 'mb-0 w-full',
}"
/>
<div v-else class="text-lg font-medium text-gray-400">No Process Selected</div>
</div>
<!-- Right section - Actions -->
<div class="flex items-center">
<!-- Primary actions -->
<div class="flex items-center mr-2 border-r border-gray-600 pr-2">
<RsButton @click="saveProcess" variant="primary" size="sm" class="mr-2" :disabled="!hasCurrentProcess || isSaving">
<Icon v-if="isSaving" name="material-symbols:progress-activity" class="mr-1 animate-spin" />
<Icon v-else name="material-symbols:save" class="mr-1" />
{{ isSaving ? 'Saving...' : 'Save' }}
</RsButton>
</div>
<!-- Templates button -->
<div class="mr-2 border-r border-gray-600 pr-2">
<RsButton @click="showTemplatesModal = true" variant="secondary" size="sm" :disabled="!hasCurrentProcess">
<Icon name="material-symbols:description-outline" class="mr-1" />
Templates
</RsButton>
</div>
<!-- Process History button - only show if process is saved -->
<div v-if="processStore.currentProcess && processStore.currentProcess.id" class="mr-2 border-r border-gray-600 pr-2">
<RsButton @click="showProcessHistoryModal = true" variant="secondary" size="sm">
<Icon name="material-symbols:history" class="mr-1" />
History
</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-5 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">
<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" v-if="hasCurrentProcess">
<!-- Left Panel - Components -->
<div class="w-64 border-r border-gray-200 flex flex-col overflow-hidden">
<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>
</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"
@node-selected="onNodeSelected"
@edge-selected="onEdgeSelected"
@pane-click="onPaneClick"
@nodes-change="onNodesChange"
@edges-change="onEdgesChange"
/>
</div>
<!-- Right Panel - Properties -->
<div class="w-80 border-l border-gray-200 flex flex-col overflow-hidden">
<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>
</div>
<div class="flex-1 overflow-y-auto">
<!-- Show variable manager when no node is selected -->
<VariableManager v-if="!selectedNodeData" />
<!-- Show node properties when a node is selected -->
<div v-else class="p-4 space-y-4">
<!-- Node Label -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
<input
v-model="nodeLabel"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<!-- Node Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
v-model="nodeDescription"
rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<!-- Node Type Specific Properties -->
<!-- Form Selection for Form Nodes -->
<div v-if="selectedNodeData.type === 'form'">
<RsButton @click="showFormConfigModal = true" variant="primary" class="w-full">
Configure Form Task
</RsButton>
</div>
<!-- API Configuration for API Nodes -->
<div v-if="selectedNodeData.type === 'api'">
<RsButton @click="showApiConfigModal = true" variant="primary" class="w-full">
Configure API Call
</RsButton>
</div>
<!-- Gateway Conditions -->
<div v-if="selectedNodeData.type === 'gateway'">
<RsButton @click="showGatewayConfigModal = true" variant="primary" class="w-full">
Configure Decision Paths
</RsButton>
</div>
<!-- Business Rule Configuration -->
<div v-if="selectedNodeData.type === 'business-rule'">
<RsButton @click="showBusinessRuleConfigModal = true" variant="primary" class="w-full">
Configure Business Rule
</RsButton>
</div>
<!-- Notification Configuration -->
<div v-if="selectedNodeData.type === 'notification'">
<RsButton @click="showNotificationConfigModal = true" variant="primary" class="w-full">
Configure Notification
</RsButton>
</div>
</div>
</div>
</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"
:nodeData="selectedNodeData.data"
:availableVariables="variableStore.getAllVariables.global"
@update="handleFormNodeUpdate"
/>
<!-- API Call Configuration Modal -->
<ApiNodeConfigurationModal
v-if="selectedNodeData && selectedNodeData.type === 'api'"
v-model="showApiConfigModal"
:nodeData="selectedNodeData.data"
:availableVariables="gatewayAvailableVariables"
@update="handleApiNodeUpdate"
/>
<!-- Gateway/Decision Point Configuration Modal -->
<GatewayConditionManagerModal
v-if="selectedNodeData && selectedNodeData.type === 'gateway'"
v-model="showGatewayConfigModal"
: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"
:nodeId="selectedNodeData.id"
:nodeData="selectedNodeData.data"
:availableVariables="gatewayAvailableVariables"
@update="handleBusinessRuleUpdate"
/>
<!-- Notification Configuration Modal -->
<NotificationNodeConfigurationModal
v-if="selectedNodeData && selectedNodeData.type === 'notification'"
v-model="showNotificationConfigModal"
:nodeData="selectedNodeData.data"
:availableVariables="gatewayAvailableVariables"
@update="handleNotificationNodeUpdate"
/>
<!-- 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;
}
:deep(.custom-node.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: white;
border: 2px solid #FF9800;
}
: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: white;
border: 1px solid #ddd;
}
:deep(.node-business-rule) {
min-width: 160px;
background: white;
border: 1px solid #ddd;
border-left: 4px solid #9333ea; /* Purple border to match our icon color */
}
:deep(.node-notification) {
min-width: 160px;
background: white;
border: 1px solid #ddd;
border-left: 4px solid #3b82f6; /* Blue border to match our icon color */
}
: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;
}
/* Responsive adjustments */
@media (max-width: 1024px) {
.process-builder-palette {
width: 200px;
}
.process-builder-properties {
width: 250px;
}
}
@media (max-width: 768px) {
.process-builder-actions {
flex-wrap: wrap;
}
.process-builder-palette,
.process-builder-properties {
display: none;
}
.process-builder-canvas {
width: 100%;
}
}
</style>