Enhance Process Builder with Mobile Responsiveness and Node Highlighting

- Implemented mobile responsive states for the process builder, allowing for better usability on smaller screens by auto-hiding panels and providing toggle functionality.
- Added node highlighting capabilities to improve user interaction, enabling users to easily identify and navigate to specific nodes within the process flow.
- Updated the ProcessFlowCanvas component to support centering on highlighted nodes, enhancing the visual experience during node selection.
- Improved overall layout and interaction design for mobile and tablet devices, ensuring a seamless user experience across different screen sizes.
This commit is contained in:
Md Afiq Iskandar 2025-07-08 14:10:50 +08:00
parent 75e5e6f97e
commit b2692bec73
9 changed files with 3121 additions and 160 deletions

View File

@ -101,7 +101,7 @@ watch(
class="modal-dialog"
:class="dialogClass"
:style="{
width: size == 'sm' ? '300px' : size == 'md' ? '500px' : '800px',
width: size == 'sm' ? '300px' : size == 'md' ? '500px' : size == 'lg' ? '800px' : size == 'xl' ? '1000px' : '1200px',
}"
>
<div class="modal-content">

View File

@ -497,7 +497,7 @@ const getPathSummary = (group) => {
<div class="flex items-center">
<!-- Condition count badge -->
<div class="text-xs text-gray-500 bg-white px-2 py-0.5 rounded-full border mr-2">
{{ group.conditions.length }} condition{{ group.conditions.length !== 1 ? 's' : '' }}
{{ (group.conditions || []).length }} condition{{ (group.conditions || []).length !== 1 ? 's' : '' }}
</div>
<!-- Collapse/expand button -->
@ -557,7 +557,7 @@ const getPathSummary = (group) => {
<!-- Individual conditions -->
<div class="space-y-4">
<div
v-for="(condition, conditionIndex) in group.conditions"
v-for="(condition, conditionIndex) in (group.conditions || [])"
:key="condition.id"
class="condition-item p-3 border rounded-md bg-white shadow-sm hover:shadow-md transition-shadow duration-200"
>
@ -565,7 +565,7 @@ const getPathSummary = (group) => {
<div class="flex justify-between items-center mb-2">
<h5 class="text-xs font-medium">Condition {{ conditionIndex + 1 }}</h5>
<button
v-if="group.conditions.length > 1"
v-if="(group.conditions || []).length > 1"
@click.stop="removeConditionFromGroup(groupIndex, conditionIndex)"
class="text-gray-400 hover:text-red-500"
title="Remove condition"

View File

@ -19,18 +19,18 @@
<!-- Core Components -->
<div class="component-category mb-6">
<h3 class="text-gray-700 text-sm font-medium px-3 mb-2">Core Components</h3>
<div class="grid grid-cols-2 gap-1 px-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 px-2">
<div
v-for="component in getComponentsByCategory('Core')"
:key="component.type"
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200"
class="component-item rounded p-3 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200 touch-manipulation"
:class="{ 'hidden': !matchesSearch(component) }"
draggable="true"
@dragstart="onDragStart($event, component)"
@click="addComponent(component)"
>
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span>
<Icon :name="component.icon" class="mb-2 w-6 h-6 text-gray-600" />
<span class="text-xs text-gray-600 text-center leading-tight">{{ component.name }}</span>
</div>
</div>
</div>
@ -246,6 +246,7 @@ const addComponent = (component) => {
.component-item {
@apply h-20;
min-height: 80px;
transition: all 0.15s ease-in-out;
}
@ -256,4 +257,28 @@ const addComponent = (component) => {
.component-item:active {
transform: scale(0.97);
}
/* Mobile touch-friendly adjustments */
@media (max-width: 768px) {
.component-item {
@apply h-24;
min-height: 96px;
}
}
/* Touch device optimizations */
@media (hover: none) and (pointer: coarse) {
.component-item {
min-height: 44px; /* iOS recommended touch target */
}
.component-item:hover {
transform: none; /* Disable hover effects on touch devices */
}
.component-item:active {
transform: scale(0.95);
background-color: #f3f4f6;
}
}
</style>

View File

@ -24,6 +24,10 @@ const props = defineProps({
initialEdges: {
type: Array,
default: () => []
},
highlightedNodeId: {
type: String,
default: null
}
});
@ -45,7 +49,9 @@ const {
project,
removeNodes,
removeEdges,
updateNodeInternals
updateNodeInternals,
setViewport,
getViewport
} = useVueFlow({
defaultEdgeOptions: {
animated: true,
@ -129,18 +135,39 @@ const onNodeClick = ({ node }) => {
// Handle edge click
const onEdgeClick = (event, edge) => {
console.log('Edge click received:', { event, edge });
// Handle different parameter formats Vue Flow might send
let actualEdge = edge;
// Sometimes Vue Flow passes the edge in the event object
if (!actualEdge && event && typeof event === 'object') {
if (event.edge) {
actualEdge = event.edge;
} else if (event.id) {
// The event itself might be the edge
actualEdge = event;
}
}
// Add comprehensive null check for edge
if (!actualEdge || !actualEdge.id) {
console.warn('Invalid edge data received in onEdgeClick:', { event, edge, actualEdge });
return;
}
// Create a simplified copy of the edge data
const edgeData = {
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label || '',
sourceNode: nodes.value.find(node => node.id === edge.source),
targetNode: nodes.value.find(node => node.id === edge.target)
id: actualEdge.id,
source: actualEdge.source,
target: actualEdge.target,
label: actualEdge.label || '',
sourceNode: nodes.value.find(node => node.id === actualEdge.source),
targetNode: nodes.value.find(node => node.id === actualEdge.target)
};
emit('edgeSelected', edgeData);
emit('edgeClick', edge);
emit('edgeClick', actualEdge);
};
// Handle pane (background) click
@ -180,6 +207,139 @@ onMounted(() => {
}, 100);
});
// Center on a specific node
const centerOnNode = (nodeId, nodePosition) => {
console.log('centerOnNode called with:', nodeId, nodePosition);
if (!nodePosition) {
// Try to find the node position from current nodes
const targetNode = nodes.value.find(n => n.id === nodeId);
if (targetNode) {
nodePosition = targetNode.position;
} else {
console.warn('Could not find node to highlight:', nodeId, 'Available nodes:', nodes.value.map(n => n.id));
return;
}
}
try {
// Use the already available setViewport from useVueFlow
// Calculate the center position
// Account for the container size and panel offsets
const containerRect = document.querySelector('.vue-flow')?.getBoundingClientRect();
const centerX = containerRect ? containerRect.width / 2 : 400;
const centerY = containerRect ? containerRect.height / 2 : 300;
// Calculate new viewport to center the node
const newViewport = {
x: centerX - nodePosition.x - 100, // Offset for left panel
y: centerY - nodePosition.y,
zoom: 1.2
};
console.log('Setting viewport to:', newViewport);
setViewport(newViewport, { duration: 800 });
} catch (error) {
console.error('Error in centerOnNode:', error);
}
};
// The centerOnNode method will be exposed along with other methods below
// Watch for changes to highlightedNodeId and apply highlighting
watch(() => props.highlightedNodeId, (newHighlightedId, oldHighlightedId) => {
console.log('Highlighting changed:', { new: newHighlightedId, old: oldHighlightedId });
// Remove highlight from previously highlighted node
if (oldHighlightedId) {
const oldNode = nodes.value.find(node => node.id === oldHighlightedId);
if (oldNode) {
// Use node data approach for highlighting
if (oldNode.data) {
oldNode.data.highlighted = false;
}
// Also try class approach
if (oldNode.class) {
oldNode.class = oldNode.class.replace(' highlighted-node', '').trim();
}
// Try to find and modify the actual DOM element
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${oldHighlightedId}"]`);
if (nodeElement) {
nodeElement.classList.remove('highlighted-node');
nodeElement.style.removeProperty('box-shadow');
nodeElement.style.removeProperty('z-index');
nodeElement.style.removeProperty('transition');
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.classList.remove('highlighted-node');
customNode.style.removeProperty('box-shadow');
customNode.style.removeProperty('z-index');
customNode.style.removeProperty('transition');
}
}
});
console.log('Removed highlight from node:', oldHighlightedId);
}
}
// Add highlight to new highlighted node
if (newHighlightedId) {
const newNode = nodes.value.find(node => node.id === newHighlightedId);
if (newNode) {
// Use node data approach for highlighting
if (!newNode.data) {
newNode.data = {};
}
newNode.data.highlighted = true;
// Also try class approach
if (!newNode.class?.includes('highlighted-node')) {
newNode.class = (newNode.class || '').trim() + ' highlighted-node';
}
// Try to find and modify the actual DOM element directly with inline styles
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${newHighlightedId}"]`);
if (nodeElement) {
nodeElement.classList.add('highlighted-node');
nodeElement.setAttribute('data-highlighted', 'true');
// Apply subtle inline styles for immediate visual effect
nodeElement.style.boxShadow = '0 0 0 2px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.3)';
nodeElement.style.zIndex = '999';
nodeElement.style.transition = 'all 0.3s ease-in-out';
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.classList.add('highlighted-node');
customNode.setAttribute('data-highlighted', 'true');
// Apply subtle styles to custom node as well
customNode.style.boxShadow = '0 0 0 2px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.3)';
customNode.style.zIndex = '999';
customNode.style.transition = 'all 0.3s ease-in-out';
}
console.log('Applied highlight to DOM element with inline styles:', nodeElement);
} else {
console.warn('Could not find DOM element for node:', newHighlightedId);
}
});
console.log('Added highlight to node:', newHighlightedId, 'data:', newNode.data);
// Force node update
updateNodeInternals([newHighlightedId]);
} else {
console.warn('Could not find node to highlight:', newHighlightedId, 'Available nodes:', nodes.value.map(n => ({ id: n.id, type: n.type })));
}
}
});
// Watch for changes to initialNodes prop and update the canvas
watch(() => props.initialNodes, async (newNodes, oldNodes) => {
if (newNodes && Array.isArray(newNodes) && !isUpdatingNodes.value) {
@ -525,7 +685,11 @@ defineExpose({
addNode,
removeNode,
fitView,
syncCanvas
syncCanvas,
centerOnNode,
// Add Vue Flow save/restore methods
toObject,
fromObject
});
// Update an existing node
@ -715,6 +879,174 @@ function syncCanvas(newNodes, newEdges) {
}
}, 50); // Small delay to allow any pending operations to complete
}
// Add Vue Flow save method - captures complete flow state including viewport
function toObject() {
try {
// Create flow object using only essential data, excluding Vue Flow internals
const flowObject = {
nodes: nodes.value.map(node => ({
id: node.id,
type: node.type,
label: node.label,
position: { ...node.position },
data: { ...node.data }
// Exclude: events, dragging, dimensions, handleBounds, computedPosition, etc.
})),
edges: edges.value.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
label: edge.label || '',
type: edge.type || 'smoothstep',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data ? { ...edge.data } : {}
// Exclude: events, sourceNode, targetNode, sourceX, sourceY, targetX, targetY
})),
viewport: getViewport()
};
console.log('💾 Captured clean flow state:', {
nodes: flowObject.nodes?.length || 0,
edges: flowObject.edges?.length || 0,
viewport: flowObject.viewport
});
return flowObject;
} catch (error) {
console.error('Error capturing flow state:', error);
// Return a safe fallback object
return {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
};
}
}
// Add Vue Flow restore method - properly restores complete flow state
function fromObject(flowObject) {
if (!flowObject) {
console.warn('No flow object provided for restoration');
return Promise.resolve();
}
if (!flowObject.nodes && !flowObject.edges) {
console.warn('Flow object contains no nodes or edges');
return Promise.resolve();
}
return new Promise((resolve) => {
try {
console.log('🔄 Restoring complete flow state:', {
nodes: flowObject.nodes?.length || 0,
edges: flowObject.edges?.length || 0,
viewport: flowObject.viewport
});
// Clear updating flags to ensure clean restoration
isUpdatingNodes.value = false;
isUpdatingEdges.value = false;
// 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]);
}
// Wait for clearing to complete
await nextTick();
// 2. Restore nodes first
if (flowObject.nodes && Array.isArray(flowObject.nodes) && flowObject.nodes.length > 0) {
const nodesToRestore = flowObject.nodes.map(node => ({
id: node.id,
type: node.type,
label: node.label || '',
position: node.position || { x: 0, y: 0 },
data: node.data || {}
// Only include essential properties needed for Vue Flow
}));
console.log('Restoring nodes:', nodesToRestore.length, nodesToRestore.map(n => `${n.id} (${n.type})`));
addNodes(nodesToRestore);
await nextTick();
// Wait a bit more for nodes to be fully initialized
await new Promise(resolve => setTimeout(resolve, 100));
}
// 3. Restore edges after nodes are ready
if (flowObject.edges && Array.isArray(flowObject.edges) && flowObject.edges.length > 0 && nodes.value.length > 0) {
// Verify all edges have valid source and target nodes
const validEdges = flowObject.edges.filter(edge => {
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
if (!sourceExists || !targetExists) {
console.warn(`Skipping invalid edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
return true;
});
if (validEdges.length > 0) {
console.log('Restoring edges:', validEdges.length);
// Clean edge data before adding
const cleanEdges = validEdges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
label: edge.label || '',
type: edge.type || 'smoothstep',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data || {}
// Only include essential properties
}));
addEdges(cleanEdges);
await nextTick();
}
}
// 4. Finally, restore viewport position and zoom
if (flowObject.viewport && typeof flowObject.viewport === 'object') {
const viewport = {
x: flowObject.viewport.x || 0,
y: flowObject.viewport.y || 0,
zoom: flowObject.viewport.zoom || 1
};
console.log('Restoring viewport:', viewport);
setViewport(viewport, { duration: 0 }); // No animation for restore
}
console.log('✅ Flow state restoration completed successfully');
resolve();
} catch (error) {
console.error('Error during flow state restoration:', error);
resolve(); // Resolve anyway to prevent hanging
}
}, 100); // Small delay to ensure clean state
} catch (error) {
console.error('Error in fromObject:', error);
resolve(); // Resolve anyway to prevent hanging
}
});
}
</script>
<template>
@ -923,4 +1255,36 @@ function syncCanvas(newNodes, newEdges) {
:deep(.vue-flow__node.selected) {
box-shadow: 0 0 0 2px #ff6b6b;
}
/* Target both Vue Flow nodes and custom nodes - updated highlighting */
:deep(.vue-flow__node.highlighted-node),
:deep(.vue-flow__node[data-highlighted="true"]),
:deep(.vue-flow__node .custom-node.highlighted-node),
:deep(.vue-flow__node .custom-node[data-highlighted="true"]) {
animation: highlightPulse 2s ease-in-out infinite !important;
box-shadow: 0 0 0 3px #3b82f6, 0 0 20px rgba(59, 130, 246, 0.4) !important;
z-index: 999 !important;
transform: scale(1.05) !important;
transition: transform 0.3s ease-in-out !important;
}
/* Additional selector for direct custom node highlighting */
:deep(.custom-node.highlighted-node),
:deep(.custom-node[data-highlighted="true"]) {
animation: highlightPulse 2s ease-in-out infinite !important;
box-shadow: 0 0 0 3px #3b82f6, 0 0 20px rgba(59, 130, 246, 0.4) !important;
z-index: 999 !important;
transform: scale(1.05) !important;
transition: transform 0.3s ease-in-out !important;
}
@keyframes highlightPulse {
0%, 100% {
box-shadow: 0 0 0 3px #3b82f6, 0 0 20px rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 0 0 5px #60a5fa, 0 0 30px rgba(96, 165, 250, 0.6);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,383 @@
# Business Rules Node Troubleshooting Guide
## Overview
The Business Rules node allows you to define complex conditional logic in your process flows. This guide addresses common issues with variable dropdowns and operator selection.
## Common Issues and Solutions
### 1. Variable Dropdown Not Populating
**Problem**: The variable dropdown in business rules conditions shows "Select variable" but no options appear.
**Root Causes**:
- Process variables are not properly loaded in the store
- Variable types are not correctly defined
- The `gatewayAvailableVariables` computed property is not updating
**Solutions**:
#### A. Check Variable Store State
```javascript
// In browser console, check if variables are loaded:
console.log('Current process variables:', processStore.currentProcess?.variables);
console.log('Gateway available variables:', gatewayAvailableVariables.value);
```
#### B. Ensure Proper Variable Types
Variables must use these specific types that match the VariableBrowser component:
- `string` - for text values
- `int` - for whole numbers
- `decimal` - for decimal numbers
- `boolean` - for true/false values
- `date` - for date values
- `datetime` - for date-time values
- `object` - for JSON objects
**Incorrect**:
```json
{
"myVariable": {
"type": "number", // ❌ Should be "int" or "decimal"
"value": 123
}
}
```
**Correct**:
```json
{
"myVariable": {
"type": "int", // ✅ Correct type
"value": 123
}
}
```
#### C. Force Component Re-render
If variables exist but dropdown doesn't update, the issue is with reactivity. The component uses a `variablesUpdateKey` to force re-renders:
```javascript
// This key changes when variables are updated
const variablesUpdateKey = computed(() => {
// Creates hash of variable names to detect changes
const variableNames = Object.keys(processStore.currentProcess.variables).sort().join(',');
return `vars-${variableNames.length}-${hash}`;
});
```
### 2. Operator Dropdown Not Showing
**Problem**: After selecting a variable, the operator dropdown remains empty.
**Root Cause**: The `getOperatorsForType()` function cannot determine the variable type.
**Solution**: Ensure the variable has a proper type. The function expects these types:
```javascript
const getOperatorsForType = (variableType) => {
switch (variableType?.toLowerCase()) {
case 'string':
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' },
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Does not contain' },
{ value: 'starts_with', label: 'Starts with' },
{ value: 'ends_with', label: 'Ends with' },
{ value: 'is_empty', label: 'Is empty' },
{ value: 'is_not_empty', label: 'Is not empty' }
];
case 'int':
case 'decimal':
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' },
{ value: 'gt', label: '> (Greater than)' },
{ value: 'gte', label: '≥ (Greater than or equal to)' },
{ value: 'lt', label: '< (Less than)' },
{ value: 'lte', label: '≤ (Less than or equal to)' },
{ value: 'between', label: 'Between (inclusive)' }
];
case 'boolean':
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'is_true', label: 'Is True' },
{ value: 'is_false', label: 'Is False' }
];
case 'date':
case 'datetime':
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' },
{ value: 'gt', label: '> (After)' },
{ value: 'gte', label: '≥ (On or after)' },
{ value: 'lt', label: '< (Before)' },
{ value: 'lte', label: '≤ (On or before)' },
{ value: 'between', label: 'Between dates' }
];
default:
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' }
];
}
};
```
### 3. Variable Browser Component Issues
**Problem**: VariableBrowser component doesn't show variables even when they exist.
**Debugging Steps**:
1. **Check Props**: Ensure `availableVariables` prop is passed correctly:
```vue
<VariableBrowser
v-model="condition.variable"
:availableVariables="props.availableVariables" <!-- ✅ Correct prop passing -->
:allowCreate="true"
@change="updateConditionVariable(groupIndex, condIndex)"
/>
```
2. **Verify Variable Structure**: Each variable must have this structure:
```javascript
{
name: "variableName",
type: "string|int|decimal|boolean|date|datetime|object",
scope: "global|local",
description: "Variable description",
currentValue: null // Optional current value for preview
}
```
3. **Check Grouping Logic**: The VariableBrowser groups variables by type:
```javascript
const groupedVariables = computed(() => {
const types = {
string: { label: 'Text Variables', variables: [] },
int: { label: 'Integer Variables', variables: [] },
decimal: { label: 'Decimal Variables', variables: [] },
boolean: { label: 'Boolean Variables', variables: [] },
date: { label: 'Date Variables', variables: [] },
datetime: { label: 'DateTime Variables', variables: [] },
object: { label: 'Object Variables', variables: [] }
};
// Variables are grouped by type
variables.forEach(variable => {
const type = variable.type || 'string';
if (types[type]) {
types[type].variables.push(variable);
}
});
return Object.values(types).filter(group => group.variables.length > 0);
});
```
## Correct Business Rule Structure
### Complete Working Example
```json
{
"id": "business-rule-123",
"type": "business-rule",
"label": "Eligibility Check",
"position": { "x": 400, "y": 200 },
"data": {
"label": "Eligibility Check",
"description": "Check if applicant meets eligibility criteria",
"ruleGroups": [
{
"id": "group-1",
"name": "Age and Status Check",
"operator": "AND",
"conditions": [
{
"id": "condition-1-1",
"variable": "applicantAge",
"operator": "greater_than",
"value": 18
},
{
"id": "condition-1-2",
"variable": "maritalStatus",
"operator": "equals",
"value": "married"
}
],
"actions": [
{
"id": "action-1-1",
"type": "set_variable",
"variable": "isEligible",
"value": true
}
]
}
],
"outputVariable": "ruleResult",
"errorVariable": "ruleError"
}
}
```
### Required Process Variables
```json
{
"applicantAge": {
"name": "applicantAge",
"type": "int",
"scope": "global",
"value": 25,
"description": "Applicant's age in years"
},
"maritalStatus": {
"name": "maritalStatus",
"type": "string",
"scope": "global",
"value": "married",
"description": "Applicant's marital status"
},
"isEligible": {
"name": "isEligible",
"type": "boolean",
"scope": "global",
"value": null,
"description": "Whether applicant is eligible"
},
"ruleResult": {
"name": "ruleResult",
"type": "object",
"scope": "global",
"value": null,
"description": "Business rule execution result"
},
"ruleError": {
"name": "ruleError",
"type": "object",
"scope": "global",
"value": null,
"description": "Business rule execution error"
}
}
```
## Debugging Tips
### 1. Browser Console Debugging
```javascript
// Check if variables are loaded
console.log('Process variables:', processStore.currentProcess?.variables);
// Check available variables for business rules
console.log('Available variables:', gatewayAvailableVariables.value);
// Check selected node data
console.log('Selected node:', selectedNodeData.value);
// Debug operator dropdown issue
const testVariable = 'selectStatusperkahwinan';
const foundVar = gatewayAvailableVariables.value.find(v => v.name === testVariable);
console.log('Found variable:', foundVar);
console.log('Variable type:', foundVar?.type);
// Test getOperatorsForType function (if available in component)
if (typeof getOperatorsForType === 'function') {
console.log('Operators for string:', getOperatorsForType('string'));
console.log('Operators for boolean:', getOperatorsForType('boolean'));
console.log('Operators for decimal:', getOperatorsForType('decimal'));
}
```
### 2. Component Key Debugging
If the component isn't updating, check the key attribute:
```vue
<BusinessRuleNodeConfigurationModal
:key="`business-rule-${selectedNodeData.id}-${variablesUpdateKey}`"
<!-- This key should change when variables update -->
/>
```
### 3. Variable Type Validation
```javascript
// Validate variable types
const validateVariableTypes = (variables) => {
const validTypes = ['string', 'int', 'decimal', 'boolean', 'date', 'datetime', 'object'];
Object.values(variables).forEach(variable => {
if (!validTypes.includes(variable.type)) {
console.error(`Invalid variable type: ${variable.type} for ${variable.name}`);
}
});
};
```
## Best Practices
1. **Always define variable types correctly** - Use the exact types expected by the VariableBrowser
2. **Include unique IDs** - Each condition and action should have a unique ID
3. **Test incrementally** - Add one condition at a time to isolate issues
4. **Use descriptive names** - Make variable and rule names clear and meaningful
5. **Check browser console** - Most issues will show up in console errors
## Operator Values Reference
Use these exact operator values in your JSON definitions:
### String Operators
- `eq` - Equal to
- `neq` - Not equal to
- `contains` - Contains substring
- `not_contains` - Does not contain substring
- `starts_with` - Starts with
- `ends_with` - Ends with
- `is_empty` - Is empty
- `is_not_empty` - Is not empty
### Number Operators (int/decimal)
- `eq` - Equal to
- `neq` - Not equal to
- `gt` - Greater than
- `gte` - Greater than or equal to
- `lt` - Less than
- `lte` - Less than or equal to
- `between` - Between (inclusive)
### Boolean Operators
- `eq` - Equal to
- `is_true` - Is True
- `is_false` - Is False
### Date/DateTime Operators
- `eq` - Equal to
- `neq` - Not equal to
- `gt` - After
- `gte` - On or after
- `lt` - Before
- `lte` - On or before
- `between` - Between dates
## Common Error Messages
| Error | Cause | Solution |
|-------|-------|----------|
| "Variable not found" | Variable name doesn't match exactly | Check spelling and case sensitivity |
| "No operators available" | Variable type not recognized | Ensure variable type is one of the supported types |
| "Dropdown not populating" | Variables not loaded or wrong structure | Check variable store and structure |
| "Component not updating" | Reactivity issue | Check component key and force re-render |
| "Operator dropdown empty" | Using wrong operator values | Use correct operator values from reference above |
## Final Checklist
Before reporting issues, verify:
- [ ] All variables have correct types (`string`, `int`, `decimal`, `boolean`, `date`, `datetime`, `object`)
- [ ] Process variables are loaded in the store
- [ ] Component receives `availableVariables` prop
- [ ] Each condition/action has unique ID
- [ ] Browser console shows no errors
- [ ] Variable names match exactly between definition and usage

View File

@ -0,0 +1,833 @@
# Process Flow Creation Guide
## Overview
This guide explains how to create process flows using the node-based process builder. Process flows are defined as JSON structures containing nodes and edges that represent business processes.
## Process Definition Structure
### Root Structure
```json
{
"nodes": [...],
"edges": [...],
"viewport": {
"x": 0,
"y": 0,
"zoom": 1
}
}
```
## Node Types
### 1. Start Node
**Purpose**: Entry point for the process
```json
{
"id": "start-{timestamp}",
"type": "start",
"label": "Start",
"position": {"x": 210, "y": 180},
"data": {
"label": "Start",
"description": "Process start point"
}
}
```
### 2. Form Node
**Purpose**: User task requiring form completion
```json
{
"id": "form-{timestamp}",
"type": "form",
"label": "Form Name",
"position": {"x": 405, "y": 135},
"data": {
"label": "Borang Maklumat Peribadi",
"formId": 1,
"formName": "Borang Maklumat Peribadi",
"formUuid": "9f08fc8f-b170-478a-85fd-fa3364401533",
"description": "Form: Borang Maklumat Peribadi",
"assignmentType": "public|role|user",
"assignedRoles": [],
"assignedUsers": [],
"assignmentVariable": "",
"assignmentVariableType": "user_id",
"inputMappings": [
{
"formField": "reference_number",
"processVariable": "applicationId"
}
],
"outputMappings": [
{
"formField": "text_3",
"processVariable": "text3"
}
],
"fieldConditions": []
}
}
```
**Assignment Types:**
- `public`: Anyone can complete the form
- `role`: Assigned to specific roles
- `user`: Assigned to specific users
### 3. API Node
**Purpose**: External API calls
```json
{
"id": "api-{timestamp}",
"type": "api",
"label": "Submit Profile API",
"position": {"x": 600, "y": 135},
"data": {
"label": "Submit Profile API",
"description": "Submit user profile to external system",
"apiUrl": "https://jsonplaceholder.typicode.com/posts",
"apiMethod": "GET|POST|PUT|DELETE|PATCH",
"headers": "{ \"Content-Type\": \"application/json\" }",
"requestBody": "{\n \"applicantName\": \"{text3}\",\n \"idType\": \"{select1}\"\n}",
"outputVariable": "apiResponse",
"errorVariable": "apiError",
"continueOnError": false
}
}
```
**HTTP Methods**: GET, POST, PUT, DELETE, PATCH
**Variable References**: Use `{variableName}` syntax in URL, headers, and body
### 4. Script Node
**Purpose**: Data processing and transformation
```json
{
"id": "script-{timestamp}",
"type": "script",
"label": "Process API Response",
"position": {"x": 800, "y": 135},
"data": {
"label": "Process API Response",
"description": "Transform API response for document verification step",
"scriptLanguage": "javascript",
"scriptCode": "// Extract important data from API response\nconst apiData = processVariables.apiResponse;\n\nif (apiData && apiData.data) {\n processVariables.applicationId = apiData.data.id || 'APP-' + Date.now();\n}",
"inputVariables": ["apiResponse", "text3", "radioPendidikan"],
"outputVariables": [
{
"name": "applicationId",
"type": "string",
"description": "Generated application ID"
}
],
"errorVariable": "scriptError",
"continueOnError": false
}
}
```
**Script Languages**: javascript, python
**Variable Access**: Use `processVariables.variableName` to access/set variables
### 5. Decision Node (Gateway)
**Purpose**: Conditional branching
```json
{
"id": "gateway-{timestamp}",
"type": "gateway",
"label": "Check Documents",
"position": {"x": 700, "y": 200},
"data": {
"label": "Check Documents",
"description": "Route based on document requirements",
"gatewayType": "exclusive",
"conditions": [
{
"id": "condition-1",
"label": "Documents Required",
"variable": "documentsRequired",
"operator": "equals",
"value": true,
"targetNode": "form-verification"
},
{
"id": "condition-2",
"label": "No Documents",
"variable": "documentsRequired",
"operator": "equals",
"value": false,
"targetNode": "end-approved"
}
],
"defaultPath": "end-rejected"
}
}
```
**Gateway Types**: exclusive, inclusive, parallel
**Operators**: equals, not_equals, greater_than, less_than, contains
### 6. Business Rule Node
**Purpose**: Complex business logic evaluation
```json
{
"id": "business-rule-{timestamp}",
"type": "business-rule",
"label": "Eligibility Rules",
"position": {"x": 900, "y": 200},
"data": {
"label": "Eligibility Rules",
"description": "Evaluate application eligibility",
"ruleGroups": [
{
"id": "group-1",
"name": "Age Requirements",
"operator": "AND",
"conditions": [
{
"variable": "applicantAge",
"operator": "greater_than",
"value": 18
}
],
"actions": [
{
"type": "set_variable",
"variable": "ageEligible",
"value": true
}
]
}
],
"outputVariable": "ruleResult",
"errorVariable": "ruleError"
}
}
```
### 7. Notification Node
**Purpose**: Send notifications
```json
{
"id": "notification-{timestamp}",
"type": "notification",
"label": "Send Approval Email",
"position": {"x": 1000, "y": 200},
"data": {
"label": "Send Approval Email",
"description": "Notify applicant of approval",
"notificationType": "email|sms|push",
"recipientType": "variable|role|user|email",
"recipientValue": "applicantEmail",
"subject": "Application Approved - {applicationId}",
"message": "Dear {applicantName},\n\nYour application {applicationId} has been approved.",
"template": "approval-template",
"errorVariable": "notificationError"
}
}
```
**Notification Types**: email, sms, push, internal
**Recipient Types**: variable, role, user, email
### 8. End Node
**Purpose**: Process termination
```json
{
"id": "end-{timestamp}",
"type": "end",
"label": "End",
"position": {"x": 1200, "y": 180},
"data": {
"label": "End",
"description": "Process completion point"
}
}
```
## Edges (Connections)
### Basic Edge
```json
{
"id": "{sourceId}-{targetId}-{timestamp}",
"source": "start-1751870920411",
"target": "form-1751870928350",
"type": "smoothstep",
"animated": true,
"label": "",
"data": {}
}
```
### Conditional Edge (from Gateway)
```json
{
"id": "gateway-condition-{timestamp}",
"source": "gateway-1751870920411",
"target": "form-1751870928350",
"type": "smoothstep",
"animated": true,
"label": "Documents Required",
"data": {
"condition": "documentsRequired == true"
}
}
```
## Process Variables
### Variable Definition Structure
Process variables are stored as a JSON object where each variable has a specific structure:
```json
{
"variableName": {
"name": "variableName",
"type": "string|number|boolean|object|array",
"scope": "global|local",
"value": null,
"description": "Variable description"
}
}
```
### Complete Process Variables Format
Based on the actual database structure, here's the complete format for process variables:
```json
{
"text3": {
"name": "text3",
"type": "string",
"scope": "global",
"value": "Ahmad Bin Ali",
"description": "Form data from text_3 in Borang Maklumat Peribadi"
},
"select1": {
"name": "select1",
"type": "string",
"scope": "global",
"value": "staff",
"description": "Form data from select_1 in Borang Maklumat Peribadi"
},
"apiResponse": {
"name": "apiResponse",
"type": "object",
"scope": "global",
"value": {
"data": {
"id": "APP-2024-001234",
"score": 85,
"status": "pending_verification",
"profileId": "PROF-567890",
"riskLevel": "low",
"documentCount": 3,
"submissionDate": "2024-01-15T10:30:00Z"
},
"status": "success",
"message": "Profile submitted successfully"
},
"description": "API response from Submit Profile Data"
},
"radioJantina": {
"name": "radioJantina",
"type": "string",
"scope": "global",
"value": "Lelaki",
"description": "Form data from radio_jantina in Borang Maklumat Peribadi"
},
"documentsRequired": {
"name": "documentsRequired",
"type": "boolean",
"scope": "global",
"value": true,
"description": "Whether documents verification is required"
},
"applicationId": {
"name": "applicationId",
"type": "string",
"scope": "global",
"value": "APP-1751871528249",
"description": "Generated application ID from script processing"
},
"apiError": {
"name": "apiError",
"type": "object",
"scope": "global",
"value": null,
"description": "API error from Submit Profile Data"
},
"scriptError": {
"name": "scriptError",
"type": "object",
"scope": "global",
"value": null,
"description": "Error information from script execution"
}
}
```
### Variable Types and Examples
#### String Variables
```json
{
"applicantName": {
"name": "applicantName",
"type": "string",
"scope": "global",
"value": "Ahmad Bin Ali",
"description": "Applicant's full name"
},
"verificationLevel": {
"name": "verificationLevel",
"type": "string",
"scope": "global",
"value": "enhanced",
"description": "Level of verification required (standard/enhanced)"
}
}
```
#### Number Variables
```json
{
"profileScore": {
"name": "profileScore",
"type": "number",
"scope": "global",
"value": 85,
"description": "Profile evaluation score from API"
},
"requestAmount": {
"name": "requestAmount",
"type": "number",
"scope": "global",
"value": 1500.50,
"description": "Requested loan amount"
}
}
```
#### Boolean Variables
```json
{
"documentsRequired": {
"name": "documentsRequired",
"type": "boolean",
"scope": "global",
"value": true,
"description": "Whether documents verification is required"
},
"nextStepReady": {
"name": "nextStepReady",
"type": "boolean",
"scope": "global",
"value": false,
"description": "Whether ready for next step in process"
}
}
```
#### Object Variables
```json
{
"apiResponse": {
"name": "apiResponse",
"type": "object",
"scope": "global",
"value": {
"data": {
"id": "APP-2024-001234",
"score": 85,
"status": "pending_verification"
},
"status": "success",
"message": "Profile submitted successfully"
},
"description": "Complete API response object"
},
"userProfile": {
"name": "userProfile",
"type": "object",
"scope": "global",
"value": {
"name": "Ahmad Bin Ali",
"email": "ahmad@example.com",
"phone": "+60123456789",
"address": {
"street": "Jalan Bangsar",
"city": "Kuala Lumpur",
"postcode": "59000"
}
},
"description": "User profile information"
}
}
```
#### Array Variables
```json
{
"assignedRoles": {
"name": "assignedRoles",
"type": "array",
"scope": "global",
"value": ["manager", "supervisor", "admin"],
"description": "List of roles assigned to task"
},
"documentTypes": {
"name": "documentTypes",
"type": "array",
"scope": "global",
"value": [
{
"type": "IC",
"required": true,
"status": "pending"
},
{
"type": "Passport",
"required": false,
"status": "not_required"
}
],
"description": "Array of document requirement objects"
}
}
```
#### Date Variables
```json
{
"dateMasukislam": {
"name": "dateMasukislam",
"type": "string",
"scope": "global",
"value": "2020-05-15",
"description": "Islam conversion date in YYYY-MM-DD format"
},
"verificationDate": {
"name": "verificationDate",
"type": "string",
"scope": "global",
"value": "2024-01-15T10:30:00Z",
"description": "ISO 8601 formatted verification timestamp"
}
}
```
### Variable Scopes
- **global**: Available throughout the entire process
- **local**: Available only within the current node/task
### Variable Naming Conventions
- Use **camelCase** for consistency (e.g., `applicantName`, `verificationLevel`)
- Form field mappings often use **snake_case** to match form field names
- Keep names descriptive and meaningful
- Avoid spaces and special characters
- Use consistent prefixes for related variables
### Variable Usage Patterns
#### In API Calls
```json
{
"apiUrl": "https://api.example.com/users/{userId}/profile",
"headers": "{ \"Authorization\": \"Bearer {apiToken}\", \"Content-Type\": \"application/json\" }",
"requestBody": "{\n \"name\": \"{applicantName}\",\n \"score\": {profileScore},\n \"verified\": {documentsRequired}\n}"
}
```
#### In Script Nodes
```javascript
// Accessing variables
const applicantName = processVariables.applicantName;
const score = processVariables.profileScore;
const apiData = processVariables.apiResponse;
// Setting variables
processVariables.applicationId = 'APP-' + Date.now();
processVariables.verificationLevel = score > 80 ? 'enhanced' : 'standard';
processVariables.nextStepReady = true;
// Working with objects
if (processVariables.apiResponse && processVariables.apiResponse.data) {
processVariables.externalId = processVariables.apiResponse.data.id;
}
```
#### In Gateway Conditions
```json
{
"conditions": [
{
"variable": "profileScore",
"operator": "greater_than",
"value": 75
},
{
"variable": "documentsRequired",
"operator": "equals",
"value": true
},
{
"variable": "verificationLevel",
"operator": "equals",
"value": "enhanced"
}
]
}
```
#### In Notifications
```json
{
"subject": "Application {applicationId} - Status Update",
"message": "Dear {applicantName},\n\nYour application {applicationId} has been processed.\nScore: {profileScore}\nStatus: {verificationLevel}\n\nThank you."
}
```
#### In Form Mappings
```json
{
"inputMappings": [
{
"formField": "reference_number",
"processVariable": "applicationId"
},
{
"formField": "applicant_name",
"processVariable": "applicantName"
}
],
"outputMappings": [
{
"formField": "verification_result",
"processVariable": "verificationResult"
},
{
"formField": "verification_notes",
"processVariable": "verificationNotes"
}
]
}
```
### Error Variables
Always include error variables for nodes that can fail:
```json
{
"apiError": {
"name": "apiError",
"type": "object",
"scope": "global",
"value": null,
"description": "API error from Submit Profile Data"
},
"scriptError": {
"name": "scriptError",
"type": "object",
"scope": "global",
"value": null,
"description": "Error information from script execution"
},
"verificationApiError": {
"name": "verificationApiError",
"type": "object",
"scope": "global",
"value": null,
"description": "API error from verification submission"
}
}
```
### Variable Best Practices
1. **Initialize with null values** for variables that will be set during process execution
2. **Provide clear descriptions** explaining the variable's purpose and source
3. **Use consistent typing** - don't mix string and number types for the same concept
4. **Group related variables** with similar naming patterns
5. **Plan for error handling** by including error variables
6. **Document data formats** especially for dates and complex objects
## Process Settings
### Basic Settings
```json
{
"owner": "",
"category": "HR",
"priority": "normal|high|low",
"processType": "standard|template",
"allowParallel": false,
"requireApproval": false,
"enableAuditTrail": true,
"sendNotifications": true,
"executionPermission": "public|authenticated|role",
"modificationPermission": "owner|managers|admin"
}
```
### Advanced Settings
```json
{
"autoTimeout": 24,
"maxExecutionTime": 60,
"dataPersistence": "short_term|long_term|permanent",
"dataRetentionPolicy": "",
"enableErrorRecovery": true,
"encryptSensitiveData": false,
"logVariableChanges": true,
"allowedRoles": ""
}
```
## Complete Example Process
### Simple Approval Process
```json
{
"nodes": [
{
"id": "start-001",
"type": "start",
"label": "Start",
"position": {"x": 100, "y": 100},
"data": {"label": "Start", "description": "Begin approval process"}
},
{
"id": "form-application",
"type": "form",
"label": "Application Form",
"position": {"x": 300, "y": 100},
"data": {
"formId": 1,
"assignmentType": "public",
"outputMappings": [
{"formField": "applicant_name", "processVariable": "applicantName"},
{"formField": "amount", "processVariable": "requestAmount"}
]
}
},
{
"id": "gateway-amount",
"type": "gateway",
"label": "Check Amount",
"position": {"x": 500, "y": 100},
"data": {
"gatewayType": "exclusive",
"conditions": [
{
"variable": "requestAmount",
"operator": "greater_than",
"value": 1000,
"targetNode": "form-manager-approval"
}
],
"defaultPath": "notification-auto-approve"
}
},
{
"id": "form-manager-approval",
"type": "form",
"label": "Manager Approval",
"position": {"x": 500, "y": 250},
"data": {
"formId": 2,
"assignmentType": "role",
"assignedRoles": ["manager"],
"inputMappings": [
{"formField": "applicant", "processVariable": "applicantName"},
{"formField": "amount", "processVariable": "requestAmount"}
]
}
},
{
"id": "notification-auto-approve",
"type": "notification",
"label": "Auto Approval",
"position": {"x": 700, "y": 100},
"data": {
"notificationType": "email",
"recipientType": "variable",
"recipientValue": "applicantEmail",
"subject": "Application Approved",
"message": "Your request for {requestAmount} has been automatically approved."
}
},
{
"id": "end-001",
"type": "end",
"label": "End",
"position": {"x": 900, "y": 100},
"data": {"label": "End", "description": "Process completed"}
}
],
"edges": [
{"id": "e1", "source": "start-001", "target": "form-application", "type": "smoothstep"},
{"id": "e2", "source": "form-application", "target": "gateway-amount", "type": "smoothstep"},
{"id": "e3", "source": "gateway-amount", "target": "form-manager-approval", "type": "smoothstep", "label": "> 1000"},
{"id": "e4", "source": "gateway-amount", "target": "notification-auto-approve", "type": "smoothstep", "label": "≤ 1000"},
{"id": "e5", "source": "form-manager-approval", "target": "end-001", "type": "smoothstep"},
{"id": "e6", "source": "notification-auto-approve", "target": "end-001", "type": "smoothstep"}
],
"viewport": {"x": 0, "y": 0, "zoom": 1}
}
```
## Best Practices
### Node Naming
- Use descriptive labels that explain the purpose
- Include timestamps in IDs for uniqueness
- Keep descriptions clear and concise
### Variable Management
- Use consistent naming conventions (camelCase)
- Document all variables with descriptions
- Group related variables logically
### Flow Design
- Start simple and add complexity gradually
- Use gateways for clear decision points
- Include error handling paths
- Test each node configuration
### Error Handling
- Always define error variables for API and script nodes
- Use `continueOnError` appropriately
- Include notification nodes for error scenarios
- Plan alternative paths for failures
### Performance Considerations
- Minimize API calls in loops
- Use appropriate timeout values
- Consider parallel execution where possible
- Keep script code efficient
## Common Patterns
### Data Collection → Processing → Approval
1. Form node for data collection
2. Script node for data validation/transformation
3. Gateway for routing based on criteria
4. Form node for approval tasks
5. Notification for status updates
### External Integration
1. Form node for user input
2. Script node for data preparation
3. API node for external system call
4. Script node for response processing
5. Gateway for success/failure routing
### Multi-level Approval
1. Form node for request submission
2. Gateway for routing based on amount/type
3. Multiple form nodes for different approval levels
4. Parallel gateways for concurrent approvals
5. Join gateway to merge approval paths

View File

@ -91,6 +91,117 @@ const showProcessSettings = ref(false);
const showDropdown = ref(false);
const showProcessHistoryModal = ref(false);
// Add mobile responsive state
const showLeftPanel = ref(true);
const showRightPanel = ref(true);
const isMobile = ref(false);
const isTablet = ref(false);
// Add node highlighting state
const highlightedNodeId = ref(null);
const highlightTimeout = ref(null);
// Mobile breakpoint detection
const checkScreenSize = () => {
const width = window.innerWidth;
isMobile.value = width < 768; // Mobile: < 768px
isTablet.value = width >= 768 && width < 1024; // Tablet: 768px - 1024px
// Auto-hide panels on mobile
if (isMobile.value) {
showLeftPanel.value = false;
showRightPanel.value = false;
} else if (isTablet.value) {
// On tablet, show only one panel at a time if both were closed
if (!showLeftPanel.value && !showRightPanel.value) {
showRightPanel.value = true;
}
} else {
// Desktop: show both panels by default
if (!showLeftPanel.value && !showRightPanel.value) {
showLeftPanel.value = true;
showRightPanel.value = true;
}
}
};
// Toggle panel visibility
const toggleLeftPanel = () => {
showLeftPanel.value = !showLeftPanel.value;
// On mobile/tablet, close the other panel when opening one
if ((isMobile.value || isTablet.value) && showLeftPanel.value) {
showRightPanel.value = false;
}
};
const toggleRightPanel = () => {
showRightPanel.value = !showRightPanel.value;
// On mobile/tablet, close the other panel when opening one
if ((isMobile.value || isTablet.value) && showRightPanel.value) {
showLeftPanel.value = false;
}
};
// Close panels when clicking on canvas (mobile only)
const onPaneClickMobile = () => {
selectedNode.value = null;
selectedNodeData.value = null;
selectedEdgeData.value = null;
processStore.clearSelection();
// Close panels on mobile when clicking canvas
if (isMobile.value) {
showLeftPanel.value = false;
showRightPanel.value = false;
}
};
// Handle node highlighting from variable usage
const handleNodeHighlight = (event) => {
console.log('Received highlight event:', event.detail);
const { nodeId, node } = event.detail;
// Clear any existing highlight timeout
if (highlightTimeout.value) {
clearTimeout(highlightTimeout.value);
}
// Set the highlighted node
highlightedNodeId.value = nodeId;
console.log('Set highlighted node ID to:', nodeId);
// Center the view on the highlighted node
if (processFlowCanvas.value && node) {
nextTick(() => {
try {
console.log('Attempting to center view on node:', node);
// Call the exposed methods from ProcessFlowCanvas
if (processFlowCanvas.value.centerOnNode) {
processFlowCanvas.value.centerOnNode(nodeId, node.position);
} else {
console.warn('centerOnNode method not available on ProcessFlowCanvas');
}
} catch (error) {
console.error('Error centering view on node:', error);
}
});
}
// Show success toast
if (toast) {
toast.success(`Navigated to: ${node.data?.label || node.type} node`);
}
// Auto-remove highlight after 5 seconds
highlightTimeout.value = setTimeout(() => {
highlightedNodeId.value = null;
highlightTimeout.value = null;
}, 5000);
};
// Component definitions
const components = [
{
@ -315,10 +426,7 @@ const updateEdgeLabel = (value) => {
// Update the pane click handler to clear edge selection too
const onPaneClick = () => {
selectedNode.value = null;
selectedNodeData.value = null;
selectedEdgeData.value = null;
processStore.clearSelection();
onPaneClickMobile();
};
// Update node in store
@ -392,27 +500,32 @@ const handleConditionUpdate = (conditions) => {
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);
}
});
// Skip processing during component addition to avoid conflicts
if (isAddingComponent.value) {
console.log('🔄 Skipping node changes during component addition');
return;
}
// Handle node removals
// Handle position changes (only when dragging is complete)
const positionChanges = {};
const hasPositionChanges = changes
.filter(change => change.type === 'position' && change.position && !change.dragging)
.forEach(change => {
positionChanges[change.id] = change.position;
});
if (Object.keys(positionChanges).length > 0) {
console.log('📍 Updating node positions:', Object.keys(positionChanges));
processStore.updateNodePositions(positionChanges);
}
// Handle node removals (from delete key or other removal actions)
const removedNodes = changes
.filter(change => change.type === 'remove')
.map(change => change.id);
if (removedNodes.length > 0) {
console.log('🗑️ Removing nodes:', removedNodes);
removedNodes.forEach(nodeId => {
processStore.deleteNode(nodeId);
});
@ -423,18 +536,6 @@ const onNodesChange = (changes, currentNodes) => {
}
}
// 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);
@ -622,6 +723,15 @@ onMounted(() => {
// Add click outside listener for dropdown
document.addEventListener('click', handleClickOutside);
// Add resize listener for responsive behavior
window.addEventListener('resize', checkScreenSize);
// Add node highlight listener for variable navigation
window.addEventListener('highlightNode', handleNodeHighlight);
// Initial screen size check
checkScreenSize();
});
// Handle click outside dropdown
@ -635,6 +745,13 @@ onUnmounted(() => {
// Remove event listeners
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('click', handleClickOutside);
window.removeEventListener('resize', checkScreenSize);
window.removeEventListener('highlightNode', handleNodeHighlight);
// Clear highlight timeout if it exists
if (highlightTimeout.value) {
clearTimeout(highlightTimeout.value);
}
});
// Show warning if there are unsaved changes
@ -756,6 +873,29 @@ const saveProcess = async () => {
try {
isSaving.value = true;
// Capture complete Vue Flow state before saving
if (processFlowCanvas.value && processFlowCanvas.value.toObject) {
console.log('💾 Capturing Vue Flow state before save...');
try {
const flowState = processFlowCanvas.value.toObject();
if (flowState && flowState.nodes) {
// Set the captured flow state in the store for saving
processStore.setFlowStateForSave(flowState);
console.log('✅ Vue Flow state captured successfully');
} else {
console.warn('⚠️ Vue Flow state capture returned invalid data, proceeding with store data');
}
} catch (captureError) {
console.error('❌ Error capturing Vue Flow state:', captureError);
console.warn('⚠️ Proceeding with store data as fallback');
}
} else {
console.warn('⚠️ Vue Flow canvas not available for state capture');
}
const success = await processStore.saveProcess();
if (success) {
@ -1100,30 +1240,117 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
navigationConfirmed.value = false;
}
});
// 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;
console.log('🔄 Process changed, preparing Vue Flow restoration for:', newProcess.name);
// 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);
console.log('🔄 Restoring Vue Flow state:', {
nodes: flowObject.nodes.length,
edges: flowObject.edges.length,
viewport: flowObject.viewport,
nodeTypes: flowObject.nodes.map(n => ({ id: n.id, type: n.type, position: n.position })),
edgeConnections: flowObject.edges.map(e => `${e.source} -> ${e.target}`)
});
// Use Vue Flow's proper restoration method (now returns a Promise)
await processFlowCanvas.value.fromObject(flowObject);
console.log('✅ Vue Flow state restoration completed');
// Fit view after restoration with a small delay to ensure everything is rendered
setTimeout(() => {
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
processFlowCanvas.value.fitView();
}
}, 300);
} catch (error) {
console.error('❌ Error restoring Vue Flow state:', error);
// Fallback to manual sync if Vue Flow restoration fails
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
console.log('🔄 Falling back to manual canvas sync...');
processFlowCanvas.value.syncCanvas(newProcess.nodes, newProcess.edges);
}
}
} else {
console.warn('⚠️ Vue Flow canvas not available for restoration, using sync fallback');
// Fallback sync method
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
processFlowCanvas.value.syncCanvas(newProcess.nodes, newProcess.edges);
}
}
}, 200); // Allow time for canvas to initialize
}, { immediate: 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"
class="bg-gray-800 px-2 md:px-4 py-2 md:py-4 flex items-center justify-between text-white shadow-md"
>
<!-- Left section - Logo and navigation -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 md:gap-3">
<Icon
@click="confirmNavigation('/process-builder/manage')"
name="ph:arrow-circle-left-duotone"
class="cursor-pointer w-6 h-6"
class="cursor-pointer w-5 h-5 md:w-6 md:h-6"
/>
<img
src="@/assets/img/logo/logo-word-white.svg"
alt="Corrad Logo"
class="h-7"
class="h-5 md:h-7 hidden sm:block"
/>
<!-- Mobile panel toggles -->
<div v-if="hasCurrentProcess && (isMobile || isTablet)" class="flex items-center gap-1 ml-2">
<RsButton
@click="toggleLeftPanel"
variant="tertiary"
size="sm"
:class="{ 'bg-gray-700': showLeftPanel }"
class="p-1"
>
<Icon name="material-symbols:widgets" class="w-4 h-4" />
</RsButton>
<RsButton
@click="toggleRightPanel"
variant="tertiary"
size="sm"
:class="{ 'bg-gray-700': showRightPanel }"
class="p-1"
>
<Icon name="material-symbols:tune" class="w-4 h-4" />
</RsButton>
</div>
</div>
<!-- Middle section - Process name -->
<div class="flex-1 flex justify-center items-center mx-4">
<div class="flex-1 flex justify-center items-center mx-2 md:mx-4">
<FormKit
v-if="hasCurrentProcess"
v-model="processStore.currentProcess.name"
@ -1132,38 +1359,38 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
validation="required"
validation-visibility="live"
:validation-messages="{ required: 'Please enter a process name' }"
class="process-name-input max-w-md"
class="process-name-input max-w-xs md: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 v-else class="text-sm md:text-lg font-medium text-gray-400 hidden sm:block">No Process Selected</div>
</div>
<!-- Right section - Actions -->
<div class="flex items-center">
<!-- Primary actions -->
<div class="flex items-center mr-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' }}
<div class="flex items-center mr-1 md:mr-2 border-r border-gray-600 pr-1 md:pr-2">
<RsButton @click="saveProcess" variant="primary" size="sm" class="mr-1 md:mr-2" :disabled="!hasCurrentProcess || isSaving">
<Icon v-if="isSaving" name="material-symbols:progress-activity" class="mr-0 md:mr-1 animate-spin" />
<Icon v-else name="material-symbols:save" class="mr-0 md:mr-1" />
<span class="hidden md:inline">{{ isSaving ? 'Saving...' : 'Save' }}</span>
</RsButton>
</div>
<!-- Templates button -->
<div class="mr-2 border-r border-gray-600 pr-2">
<!-- Templates button - Hidden on mobile -->
<div class="mr-1 md:mr-2 border-r border-gray-600 pr-1 md:pr-2 hidden md:block">
<RsButton @click="showTemplatesModal = true" variant="secondary" size="sm" :disabled="!hasCurrentProcess">
<Icon name="material-symbols:description-outline" class="mr-1" />
Templates
<Icon name="material-symbols:description-outline" class="mr-0 md:mr-1" />
<span class="hidden lg:inline">Templates</span>
</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">
<!-- Process History button - Hidden on mobile, only show if process is saved -->
<div v-if="processStore.currentProcess && processStore.currentProcess.id" class="mr-1 md:mr-2 border-r border-gray-600 pr-1 md:pr-2 hidden md:block">
<RsButton @click="showProcessHistoryModal = true" variant="secondary" size="sm">
<Icon name="material-symbols:history" class="mr-1" />
History
<Icon name="material-symbols:history" class="mr-0 md:mr-1" />
<span class="hidden lg:inline">History</span>
</RsButton>
</div>
@ -1171,10 +1398,21 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
<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" />
<Icon name="material-symbols:more-vert" class="w-4 h-4 md:w-5 md:h-5" />
</RsButton>
<div v-if="showDropdown" class="dropdown-menu absolute right-0 mt-2 bg-white rounded shadow-lg py-1 z-10 w-48 text-gray-800">
<!-- Mobile-only actions -->
<div v-if="isMobile" class="md:hidden border-b border-gray-200 pb-1 mb-1">
<button @click="showTemplatesModal = true; showDropdown = false" :disabled="!hasCurrentProcess" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center disabled:opacity-50">
<Icon name="material-symbols:description-outline" class="mr-2 w-4 h-4" />
<span>Templates</span>
</button>
<button v-if="processStore.currentProcess && processStore.currentProcess.id" @click="showProcessHistoryModal = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:history" class="mr-2 w-4 h-4" />
<span>History</span>
</button>
</div>
<button @click="showProcessSettings = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:tune" class="mr-2 w-4 h-4" />
<span>Process Settings</span>
@ -1190,11 +1428,29 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
</header>
<!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden" v-if="hasCurrentProcess">
<div class="flex-1 flex overflow-hidden relative" v-if="hasCurrentProcess">
<!-- Left Panel - Components -->
<div class="w-64 border-r border-gray-200 flex flex-col overflow-hidden">
<div
v-show="showLeftPanel"
:class="{
'absolute inset-y-0 left-0 z-20 bg-white shadow-lg': isMobile,
'absolute inset-y-0 left-0 z-10 bg-white shadow-md': isTablet,
'relative w-64': !isMobile && !isTablet,
'w-72': isMobile,
'w-80': isTablet
}"
class="border-r border-gray-200 flex flex-col overflow-hidden transition-transform duration-300 ease-in-out"
>
<div class="bg-gray-100 p-3 flex items-center justify-between border-b border-gray-200">
<h2 class="text-sm font-medium text-gray-700">Process Components</h2>
<!-- Close button for mobile/tablet -->
<button
v-if="isMobile || isTablet"
@click="showLeftPanel = false"
class="p-1 hover:bg-gray-200 rounded"
>
<Icon name="material-symbols:close" class="w-4 h-4 text-gray-500" />
</button>
</div>
<div class="flex-1 overflow-y-auto">
<ProcessBuilderComponents @add-component="onAddComponent" />
@ -1207,18 +1463,57 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
ref="processFlowCanvas"
:initial-nodes="canvasNodes"
:initial-edges="canvasEdges"
:highlighted-node-id="highlightedNodeId"
@node-selected="onNodeSelected"
@edge-selected="onEdgeSelected"
@pane-click="onPaneClick"
@nodes-change="onNodesChange"
@edges-change="onEdgesChange"
/>
<!-- Mobile floating action buttons -->
<div v-if="isMobile" class="absolute bottom-4 right-4 flex flex-col gap-2">
<RsButton
@click="toggleLeftPanel"
variant="primary"
class="rounded-full w-12 h-12 p-0 shadow-lg"
:class="{ 'bg-blue-600': showLeftPanel }"
>
<Icon name="material-symbols:widgets" class="w-6 h-6" />
</RsButton>
<RsButton
@click="toggleRightPanel"
variant="primary"
class="rounded-full w-12 h-12 p-0 shadow-lg"
:class="{ 'bg-blue-600': showRightPanel }"
>
<Icon name="material-symbols:tune" class="w-6 h-6" />
</RsButton>
</div>
</div>
<!-- Right Panel - Properties -->
<div class="w-80 border-l border-gray-200 flex flex-col overflow-hidden">
<div
v-show="showRightPanel"
:class="{
'absolute inset-y-0 right-0 z-20 bg-white shadow-lg': isMobile,
'absolute inset-y-0 right-0 z-10 bg-white shadow-md': isTablet,
'relative w-72': !isMobile && !isTablet,
'w-72': isMobile,
'w-80': isTablet
}"
class="border-l border-gray-200 flex flex-col overflow-hidden transition-transform duration-300 ease-in-out"
>
<div class="bg-gray-100 p-3 flex items-center justify-between border-b border-gray-200">
<h2 class="text-sm font-medium text-gray-700">Properties</h2>
<!-- Close button for mobile/tablet -->
<button
v-if="isMobile || isTablet"
@click="showRightPanel = false"
class="p-1 hover:bg-gray-200 rounded"
>
<Icon name="material-symbols:close" class="w-4 h-4 text-gray-500" />
</button>
</div>
<div class="flex-1 overflow-y-auto">
<!-- Show variable manager when no node is selected -->
@ -1292,6 +1587,13 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
</div>
</div>
</div>
<!-- Backdrop for mobile panels -->
<div
v-if="(isMobile || isTablet) && (showLeftPanel || showRightPanel)"
@click="showLeftPanel = false; showRightPanel = false"
class="absolute inset-0 bg-black bg-opacity-50 z-5"
></div>
</div>
<!-- Empty state - No process selected -->
@ -1574,29 +1876,76 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
cursor: grabbing;
}
/* Responsive adjustments */
@media (max-width: 1024px) {
.process-builder-palette {
width: 200px;
/* Mobile responsive adjustments */
@media (max-width: 768px) {
.process-name-input {
max-width: 200px;
}
.process-builder-properties {
width: 250px;
.process-name-input :deep(.formkit-inner) {
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
}
}
/* Touch-friendly adjustments */
@media (hover: none) and (pointer: coarse) {
.component-item {
min-height: 44px; /* iOS touch target minimum */
}
.dropdown-menu button {
min-height: 44px;
padding: 0.75rem 1rem;
}
/* Larger tap targets for mobile */
.process-builder :deep(.vue-flow__node) {
min-width: 120px;
min-height: 40px;
}
.process-builder :deep(.vue-flow__handle) {
width: 12px;
height: 12px;
}
}
/* Panel transitions and animations */
.panel-enter-active,
.panel-leave-active {
transition: transform 0.3s ease-in-out;
}
.panel-enter-from {
transform: translateX(-100%);
}
.panel-leave-to {
transform: translateX(-100%);
}
.panel-right-enter-from {
transform: translateX(100%);
}
.panel-right-leave-to {
transform: translateX(100%);
}
/* Improved mobile canvas interactions */
@media (max-width: 768px) {
.process-builder-actions {
flex-wrap: wrap;
.process-builder :deep(.vue-flow__pane) {
touch-action: pan-x pan-y;
}
.process-builder-palette,
.process-builder-properties {
display: none;
.process-builder :deep(.vue-flow__node) {
cursor: pointer;
}
.process-builder-canvas {
width: 100%;
.process-builder :deep(.vue-flow__edge) {
pointer-events: stroke;
stroke-width: 3px; /* Thicker edges for easier mobile selection */
}
}
</style>

View File

@ -247,8 +247,6 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
updatedAt: apiProcess.processModifiedDate
};
this.currentProcess = process;
// Variables are already loaded in process.variables, no need to sync with variable store
@ -307,12 +305,29 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
// Get variables from the current process, not from variable store
const processVariables = this.currentProcess.variables || {};
// Check if we need to capture Vue Flow state (when Vue Flow is available)
let flowState = null;
// Try to get the Vue Flow state from the canvas component
// This will be set by the main process builder page when saving
if (this.currentProcess.flowState) {
flowState = this.currentProcess.flowState;
console.log('💾 Using captured Vue Flow state for save:', {
nodes: flowState.nodes?.length || 0,
edges: flowState.edges?.length || 0,
viewport: flowState.viewport
});
} else {
console.log('💾 No Vue Flow state captured, using store data');
}
const processData = {
processName: this.currentProcess.name,
processDescription: this.currentProcess.description,
nodes: this.currentProcess.nodes,
edges: this.currentProcess.edges,
viewport: this.currentProcess.viewport || { x: 0, y: 0, zoom: 1 },
// Use Vue Flow state if available, otherwise fallback to store data
nodes: flowState?.nodes || this.currentProcess.nodes,
edges: flowState?.edges || this.currentProcess.edges,
viewport: flowState?.viewport || this.currentProcess.viewport || { x: 0, y: 0, zoom: 1 },
variables: processVariables,
settings: this.currentProcess.settings || {},
permissions: this.currentProcess.permissions || {}
@ -326,7 +341,8 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
nodeCount: processData.nodes.length,
edgeCount: processData.edges.length,
variableCount: Object.keys(processVariables).length,
nodes: processData.nodes.map(n => ({ id: n.id, type: n.type, label: n.label })),
viewport: processData.viewport,
nodes: processData.nodes.map(n => ({ id: n.id, type: n.type, label: n.label, position: n.position })),
edges: processData.edges.map(e => ({ id: e.id, source: e.source, target: e.target }))
});
@ -340,6 +356,9 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
const apiProcess = response.process;
this.currentProcess.updatedAt = apiProcess.processModifiedDate;
// Clear the temporary flowState after successful save
delete this.currentProcess.flowState;
// Update in processes array if it exists there
const index = this.processes.findIndex(p => p.id === this.currentProcess.id);
if (index !== -1) {
@ -357,6 +376,81 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
}
},
/**
* Set Vue Flow state for saving
*/
setFlowStateForSave(flowState) {
if (this.currentProcess && flowState) {
this.currentProcess.flowState = flowState;
console.log('📊 Vue Flow state captured for save:', {
nodes: flowState.nodes?.length || 0,
edges: flowState.edges?.length || 0,
viewport: flowState.viewport
});
}
},
/**
* Clean flow data to remove Vue Flow internal properties
*/
cleanFlowData(flowData) {
if (!flowData) return { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
// Clean nodes - keep only essential properties
const cleanNodes = (flowData.nodes || []).map(node => ({
id: node.id,
type: node.type,
label: node.label || node.data?.label || '',
position: node.position || { x: 0, y: 0 },
data: {
label: node.data?.label || node.label || '',
description: node.data?.description || '',
// Include other essential data properties but exclude Vue Flow internals
...(node.data && typeof node.data === 'object' ?
Object.fromEntries(
Object.entries(node.data).filter(([key]) =>
!['events', 'dimensions', 'handleBounds', 'computedPosition'].includes(key)
)
) : {}
)
}
}));
// Clean edges - keep only essential properties
const cleanEdges = (flowData.edges || []).map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
label: edge.label || '',
type: edge.type || 'smoothstep',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data && typeof edge.data === 'object' ? { ...edge.data } : {}
// Exclude: sourceNode, targetNode, sourceX, sourceY, targetX, targetY, events
}));
// Clean viewport
const cleanViewport = {
x: flowData.viewport?.x || 0,
y: flowData.viewport?.y || 0,
zoom: flowData.viewport?.zoom || 1
};
console.log('🧹 Cleaned flow data:', {
originalNodes: flowData.nodes?.length || 0,
cleanNodes: cleanNodes.length,
originalEdges: flowData.edges?.length || 0,
cleanEdges: cleanEdges.length
});
return {
nodes: cleanNodes,
edges: cleanEdges,
viewport: cleanViewport
};
},
/**
* Delete a process
*/