Refactor Process Builder Components and Update Documentation

- Renamed components and terminology for clarity, changing "Gateway" to "Decision Point" and updating related documentation.
- Enhanced the GatewayConditionManager to support condition groups, allowing for better organization and management of conditions.
- Improved the ProcessFlowCanvas to handle node updates more efficiently and ensure consistent label handling.
- Updated the README and various documentation files to reflect changes in terminology and provide clearer guidance on using the Process Builder.
- Enhanced user experience by refining the UI for condition management and node selection processes.
This commit is contained in:
Md Afiq Iskandar 2025-05-19 11:56:55 +08:00
parent dfaa2f1309
commit 0b0b4a90b9
10 changed files with 941 additions and 195 deletions

View File

@ -12,7 +12,7 @@ The platform consists of two primary modules:
#### 1. Process Builder #### 1. Process Builder
A visual workflow designer that allows you to create and manage business processes with a drag-and-drop interface. A visual workflow designer that allows you to create and manage business processes with a drag-and-drop interface.
- Create process flows with start/end events, tasks, gateways, and more - Create process flows with start/end points, tasks, decision points, and more
- Define conditions for workflow branching - Define conditions for workflow branching
- Connect tasks to forms for data collection - Connect tasks to forms for data collection
- Manage process variables and data flow - Manage process variables and data flow

View File

@ -17,9 +17,81 @@ const emit = defineEmits(['update:conditions', 'add-condition', 'remove-conditio
// Local copy of conditions // Local copy of conditions
const localConditions = ref([...(props.conditions || [])]); const localConditions = ref([...(props.conditions || [])]);
// Track which condition groups are collapsed
const collapsedGroups = ref({});
// Toggle collapse state of a group
const toggleGroupCollapse = (groupId) => {
collapsedGroups.value[groupId] = !collapsedGroups.value[groupId];
};
// Check if a group is collapsed
const isGroupCollapsed = (groupId) => {
return !!collapsedGroups.value[groupId];
};
// Watch for external changes // Watch for external changes
watch(() => props.conditions, (newConditions) => { watch(() => props.conditions, (newConditions) => {
// If we receive conditions in the old format, migrate them to the new format
if (Array.isArray(newConditions) && newConditions.length > 0) {
// Check if this is the old flat format with 'output' property
if (newConditions[0].output !== undefined && !newConditions[0].conditions) {
// Old format had individual conditions with 'output' property
const conditionsByOutput = {};
// Group conditions by their output path
newConditions.forEach(condition => {
const output = condition.output || 'Default Path';
if (!conditionsByOutput[output]) {
conditionsByOutput[output] = [];
}
// Create a copy without the output property
const { output: _, ...conditionWithoutOutput } = condition;
// Add logicalOperator to each condition (will only be used for 2nd+ conditions)
conditionWithoutOutput.logicalOperator = 'and';
conditionsByOutput[output].push(conditionWithoutOutput);
});
// Convert to new format
const groupedConditions = Object.entries(conditionsByOutput).map(([output, conditions]) => ({
id: `condition-group-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
output,
conditions
}));
localConditions.value = groupedConditions;
}
// Check if this is the old group format with logicalOperator at the group level
else if (newConditions[0].conditions && newConditions[0].logicalOperator) {
// Migrate from group-level logicalOperator to condition-level logicalOperator
const migratedConditions = newConditions.map(group => {
const groupCopy = { ...group };
delete groupCopy.logicalOperator; // Remove group-level operator
// Add logicalOperator to each condition
if (groupCopy.conditions && groupCopy.conditions.length > 0) {
groupCopy.conditions = groupCopy.conditions.map((condition, index) => {
// First condition doesn't need an operator as it's the starting point
if (index === 0) return { ...condition, logicalOperator: 'and' };
// Apply the group's original operator to each subsequent condition
return { ...condition, logicalOperator: group.logicalOperator || 'and' };
});
}
return groupCopy;
});
localConditions.value = migratedConditions;
} else {
// It's already in the new format or is just empty
localConditions.value = [...(newConditions || [])]; localConditions.value = [...(newConditions || [])];
}
} else {
// Empty array or undefined
localConditions.value = [];
}
}, { deep: true }); }, { deep: true });
// Operators based on variable type // Operators based on variable type
@ -74,8 +146,8 @@ const getInputTypeForVarType = (type) => {
} }
}; };
// Add new condition // Add new condition group (represents one outgoing path with potentially multiple conditions)
const addCondition = () => { const addConditionGroup = () => {
if (!props.availableVariables || !props.availableVariables.length) { if (!props.availableVariables || !props.availableVariables.length) {
alert('No variables available. Please add a variable before creating a condition.'); alert('No variables available. Please add a variable before creating a condition.');
return; return;
@ -87,44 +159,102 @@ const addCondition = () => {
return; return;
} }
const newGroupId = `condition-group-${Date.now()}`;
const newConditionGroup = {
id: newGroupId,
output: '', // Output path label (e.g., "Yes" or "Approved")
conditions: [
{
id: `condition-${Date.now()}`,
variable: defaultVar.name,
operator: getOperatorsForType(defaultVar.type || 'string')[0].value,
value: '',
valueType: defaultVar.type || 'string',
logicalOperator: 'and' // This won't be used for the first condition but included for consistency
}
]
};
localConditions.value.push(newConditionGroup);
// Make sure the new group is expanded
collapsedGroups.value[newGroupId] = false;
emit('update:conditions', [...localConditions.value]);
emit('add-condition', newConditionGroup);
};
// Remove condition group
const removeConditionGroup = (groupIndex) => {
// Get the ID before removing
const groupId = localConditions.value[groupIndex].id;
// Remove from conditions array
localConditions.value.splice(groupIndex, 1);
// Remove from collapsed state tracker
delete collapsedGroups.value[groupId];
emit('update:conditions', [...localConditions.value]);
emit('remove-condition', groupIndex);
};
// Add a single condition to a group
const addConditionToGroup = (groupIndex) => {
if (!props.availableVariables || !props.availableVariables.length) {
alert('No variables available. Please add a variable before creating a condition.');
return;
}
const defaultVar = props.availableVariables[0];
const newCondition = { const newCondition = {
id: `condition-${Date.now()}`, id: `condition-${Date.now()}`,
variable: defaultVar.name, variable: defaultVar.name,
operator: getOperatorsForType(defaultVar.type || 'string')[0].value, operator: getOperatorsForType(defaultVar.type || 'string')[0].value,
value: '', value: '',
valueType: defaultVar.type || 'string', valueType: defaultVar.type || 'string',
output: '', // Output path label (e.g., "Yes" or "No") logicalOperator: 'and' // Default operator for this condition
}; };
localConditions.value.push(newCondition); localConditions.value[groupIndex].conditions.push(newCondition);
emit('update:conditions', [...localConditions.value]); emit('update:conditions', [...localConditions.value]);
emit('add-condition', newCondition);
}; };
// Remove condition // Remove a single condition from a group
const removeCondition = (index) => { const removeConditionFromGroup = (groupIndex, conditionIndex) => {
localConditions.value.splice(index, 1); // Ensure we always have at least one condition in a group
if (localConditions.value[groupIndex].conditions.length > 1) {
localConditions.value[groupIndex].conditions.splice(conditionIndex, 1);
emit('update:conditions', [...localConditions.value]); emit('update:conditions', [...localConditions.value]);
emit('remove-condition', index); } else {
alert('A condition group must have at least one condition. Remove the entire group instead.');
}
}; };
// Update condition // Update condition
const updateCondition = (index, field, value) => { const updateCondition = (groupIndex, conditionIndex, field, value) => {
if (field === 'variable') { if (field === 'variable') {
const selectedVar = props.availableVariables.find(v => v.name === value); const selectedVar = props.availableVariables.find(v => v.name === value);
localConditions.value[index].variable = value; if (!selectedVar) return;
localConditions.value[index].valueType = selectedVar.type;
localConditions.value[groupIndex].conditions[conditionIndex].variable = value;
localConditions.value[groupIndex].conditions[conditionIndex].valueType = selectedVar.type;
// Reset operator to a valid one for this type // Reset operator to a valid one for this type
localConditions.value[index].operator = getOperatorsForType(selectedVar.type)[0].value; localConditions.value[groupIndex].conditions[conditionIndex].operator = getOperatorsForType(selectedVar.type)[0].value;
// Reset value // Reset value
localConditions.value[index].value = ''; localConditions.value[groupIndex].conditions[conditionIndex].value = '';
} else { } else {
localConditions.value[index][field] = value; localConditions.value[groupIndex].conditions[conditionIndex][field] = value;
} }
emit('update:conditions', [...localConditions.value]); emit('update:conditions', [...localConditions.value]);
}; };
// Update condition group properties
const updateConditionGroup = (groupIndex, field, value) => {
localConditions.value[groupIndex][field] = value;
emit('update:conditions', [...localConditions.value]);
};
// Generate human-readable condition text // Generate human-readable condition text
const conditionText = (condition) => { const conditionText = (condition) => {
if (!condition.variable || !condition.operator) return ''; if (!condition.variable || !condition.operator) return '';
@ -137,18 +267,58 @@ const conditionText = (condition) => {
return `${variableName} ${operatorText} ${condition.value}`; return `${variableName} ${operatorText} ${condition.value}`;
}; };
// Default path value
const defaultPath = ref('Default');
// Update default path
const updateDefaultPath = (value) => {
defaultPath.value = value;
// Also emit the change to persist in parent component
emit('update:conditions', [...localConditions.value]);
};
// Save when inputs lose focus
const saveChanges = () => {
emit('update:conditions', [...localConditions.value]);
};
// Add this new computed property below other computed properties but before methods
const groupedConditionText = (group) => {
if (!group.conditions || group.conditions.length === 0) return '';
if (group.conditions.length === 1) {
return conditionText(group.conditions[0]);
}
const conditions = group.conditions.map((c, i) => {
const text = conditionText(c);
return {
index: i,
text: text,
operator: c.logicalOperator || 'and' // Individual condition operator (for conditions after first)
};
});
// Build readable text with individual operators
return conditions.map((c, i) => {
if (i === 0) return c.text;
return `${c.operator.toUpperCase()} ${c.text}`;
}).join(' ');
};
</script> </script>
<template> <template>
<div class="gateway-condition-manager"> <div class="gateway-condition-manager">
<div class="mb-4"> <div class="mb-4">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-medium text-gray-700">Gateway Conditions</h3> <h3 class="text-sm font-medium text-gray-700">Decision Point Conditions</h3>
<button <button
@click="addCondition" @click="addConditionGroup"
class="px-2 py-1 bg-orange-500 text-white text-xs rounded hover:bg-orange-600" class="px-2 py-1 bg-orange-500 text-white text-xs rounded hover:bg-orange-600 flex items-center"
> >
Add Condition <Icon name="material-symbols:add" class="w-3.5 h-3.5 mr-1" />
Add Path
</button> </button>
</div> </div>
@ -156,27 +326,96 @@ const conditionText = (condition) => {
No conditions defined. Default path will be taken. No conditions defined. Default path will be taken.
</div> </div>
<div v-else class="conditions-list space-y-4 mb-4"> <div v-else class="conditions-list space-y-3 mb-4">
<!-- One condition group per outgoing path -->
<div <div
v-for="(condition, index) in localConditions" v-for="(group, groupIndex) in localConditions"
:key="condition.id" :key="group.id"
class="condition-item p-3 border rounded-md bg-orange-50 relative" class="condition-group border-2 rounded-md bg-orange-50"
:data-collapsed="isGroupCollapsed(group.id)"
@click.stop="isGroupCollapsed(group.id) ? toggleGroupCollapse(group.id) : null"
> >
<!-- Group header (always visible) -->
<div class="p-3 border-b border-orange-200 flex items-center justify-between bg-orange-100">
<div class="flex-1 mr-2">
<div class="text-xs font-medium mb-1">
Path Name:
<span v-if="isGroupCollapsed(group.id)" class="ml-1 text-orange-600">
({{ group.conditions.length }} condition{{ group.conditions.length !== 1 ? 's' : '' }})
</span>
</div>
<input
type="text"
:value="group.output"
@input="updateConditionGroup(groupIndex, 'output', $event.target.value)"
@blur="saveChanges"
class="w-full p-2 border rounded text-xs"
placeholder="Path label (e.g. 'Yes', 'Approved')"
/>
</div>
<div class="flex items-center">
<!-- Collapse/expand button -->
<button <button
@click="removeCondition(index)" @click="toggleGroupCollapse(group.id); $event.stopPropagation();"
class="absolute top-1 right-1 text-gray-400 hover:text-red-500" class="p-1 text-gray-500 hover:text-gray-700 mr-2"
:title="isGroupCollapsed(group.id) ? 'Expand' : 'Collapse'"
> >
<i class="material-icons text-sm">close</i> <Icon
:name="isGroupCollapsed(group.id) ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-5 h-5"
/>
</button> </button>
<div class="mb-2"> <!-- Delete button -->
<div class="text-xs font-medium mb-1">If</div> <button
<div class="condition-grid grid grid-cols-3 gap-2"> @click="removeConditionGroup(groupIndex); $event.stopPropagation();"
class="p-1 text-gray-400 hover:text-red-500"
title="Remove path"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Collapsible content -->
<div v-if="!isGroupCollapsed(group.id)" class="p-3">
<!-- Individual conditions in this group -->
<div class="space-y-3">
<div
v-for="(condition, conditionIndex) in group.conditions"
:key="condition.id"
class="condition-item p-3 border border-orange-200 rounded-md bg-white relative"
>
<button
v-if="group.conditions.length > 1"
@click.stop="removeConditionFromGroup(groupIndex, conditionIndex)"
class="absolute top-1 right-1 text-gray-400 hover:text-red-500"
title="Remove condition"
>
<Icon name="material-symbols:close" class="w-4 h-4" />
</button>
<!-- Logical operator selector before each condition except the first one -->
<div v-if="conditionIndex > 0" class="mb-2 pb-2 border-b border-gray-200">
<select
:value="condition.logicalOperator || 'and'"
@change="updateCondition(groupIndex, conditionIndex, 'logicalOperator', $event.target.value)"
@blur="saveChanges"
class="w-full text-xs border border-gray-300 rounded px-2 py-1 bg-gray-50"
>
<option value="and">AND (Both conditions must be true)</option>
<option value="or">OR (Either condition can be true)</option>
</select>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<!-- Variable --> <!-- Variable -->
<select <select
:value="condition.variable" :value="condition.variable"
@change="updateCondition(index, 'variable', $event.target.value)" @change="updateCondition(groupIndex, conditionIndex, 'variable', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs" @blur="saveChanges"
class="w-full p-2 border rounded text-xs"
> >
<option <option
v-for="variable in availableVariables" v-for="variable in availableVariables"
@ -190,8 +429,9 @@ const conditionText = (condition) => {
<!-- Operator --> <!-- Operator -->
<select <select
:value="condition.operator" :value="condition.operator"
@change="updateCondition(index, 'operator', $event.target.value)" @change="updateCondition(groupIndex, conditionIndex, 'operator', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs" @blur="saveChanges"
class="w-full p-2 border rounded text-xs"
> >
<option <option
v-for="operator in getOperatorsForType( v-for="operator in getOperatorsForType(
@ -209,15 +449,17 @@ const conditionText = (condition) => {
v-if="condition.valueType !== 'boolean'" v-if="condition.valueType !== 'boolean'"
:type="getInputTypeForVarType(condition.valueType)" :type="getInputTypeForVarType(condition.valueType)"
:value="condition.value" :value="condition.value"
@input="updateCondition(index, 'value', $event.target.value)" @input="updateCondition(groupIndex, conditionIndex, 'value', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs" @blur="saveChanges"
class="w-full p-2 border rounded text-xs"
:placeholder="'Value'" :placeholder="'Value'"
/> />
<div v-else class="col-span-1 p-2 border rounded flex items-center"> <div v-else class="w-full p-2 border rounded flex items-center">
<input <input
type="checkbox" type="checkbox"
:checked="condition.value === true || condition.value === 'true'" :checked="condition.value === true || condition.value === 'true'"
@change="updateCondition(index, 'value', $event.target.checked)" @change="updateCondition(groupIndex, conditionIndex, 'value', $event.target.checked)"
@blur="saveChanges"
class="mr-2" class="mr-2"
/> />
<span class="text-xs">{{ condition.value === true || condition.value === 'true' ? 'Yes' : 'No' }}</span> <span class="text-xs">{{ condition.value === true || condition.value === 'true' ? 'Yes' : 'No' }}</span>
@ -225,24 +467,29 @@ const conditionText = (condition) => {
</div> </div>
</div> </div>
<!-- Output path --> <!-- Add another condition button -->
<div class="mb-1"> <div class="text-center">
<div class="text-xs font-medium mb-1">Then follow path:</div> <button
<input @click.stop="addConditionToGroup(groupIndex)"
type="text" class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded hover:bg-gray-200 border border-gray-300 flex items-center mx-auto"
:value="condition.output" >
@input="updateCondition(index, 'output', $event.target.value)" <Icon name="material-symbols:add" class="w-3 h-3 mr-1" />
class="w-full p-2 border rounded text-xs" Add Another Condition
placeholder="Path label (e.g. 'Yes', 'Approved')" </button>
/> </div>
</div>
</div> </div>
<!-- Condition summary --> <!-- Condition summary (always visible, even when collapsed) -->
<div class="mt-2 p-2 bg-white rounded-md border border-orange-200"> <div class="p-2 bg-orange-100 rounded-b-md border-t border-orange-200 text-xs text-orange-800">
<div class="text-xs text-orange-800"> <span class="font-medium">Outcome:</span>
<span class="font-medium">Condition:</span> <span v-if="group.conditions.length === 1">
{{ conditionText(condition) }} {{ condition.output || 'Unlabeled path' }} If {{ conditionText(group.conditions[0]) }}
</div> </span>
<span v-else>
If {{ groupedConditionText(group) }}
</span>
<span class="font-medium"> follow path "{{ group.output || 'Unlabeled path' }}"</span>
</div> </div>
</div> </div>
</div> </div>
@ -252,7 +499,8 @@ const conditionText = (condition) => {
<div class="text-xs font-medium mb-1">Default Path (when no conditions match):</div> <div class="text-xs font-medium mb-1">Default Path (when no conditions match):</div>
<input <input
type="text" type="text"
value="Default" v-model="defaultPath"
@blur="saveChanges"
class="w-full p-2 border rounded text-xs" class="w-full p-2 border rounded text-xs"
placeholder="Default path label (e.g. 'No', 'Rejected')" placeholder="Default path label (e.g. 'No', 'Rejected')"
/> />
@ -266,6 +514,15 @@ const conditionText = (condition) => {
font-size: 0.875rem; font-size: 0.875rem;
} }
.condition-group {
border-color: #fdba74;
transition: all 0.2s;
}
.condition-group:hover {
border-color: #f97316;
}
.condition-item { .condition-item {
transition: all 0.2s; transition: all 0.2s;
} }
@ -273,4 +530,18 @@ const conditionText = (condition) => {
.condition-item:hover { .condition-item:hover {
border-color: #f97316; border-color: #f97316;
} }
/* Adding new styles for collapsed state */
.condition-group[data-collapsed="true"]:hover {
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.condition-group[data-collapsed="true"] {
background-color: #ffedd5;
}
.condition-group[data-collapsed="true"] .p-3.border-b {
border-bottom: none;
}
</style> </style>

View File

@ -46,7 +46,7 @@ const availableComponents = [
// Core components // Core components
{ {
type: 'start', type: 'start',
name: 'Start Event', name: 'Start Point',
category: 'Core', category: 'Core',
icon: 'material-symbols:play-circle-outline', icon: 'material-symbols:play-circle-outline',
description: 'Initiates the process flow', description: 'Initiates the process flow',
@ -59,7 +59,7 @@ const availableComponents = [
}, },
{ {
type: 'end', type: 'end',
name: 'End Event', name: 'End Point',
category: 'Core', category: 'Core',
icon: 'material-symbols:stop-circle-outline', icon: 'material-symbols:stop-circle-outline',
description: 'Terminates the process flow', description: 'Terminates the process flow',
@ -101,14 +101,14 @@ const availableComponents = [
}, },
{ {
type: 'gateway', type: 'gateway',
name: 'Gateway', name: 'Decision Point',
category: 'Core', category: 'Core',
icon: 'material-symbols:call-split', icon: 'material-symbols:call-split',
description: 'Decision point for flow control', description: 'Decision point for flow control',
defaultProps: { defaultProps: {
label: 'Gateway', label: 'Decision Point',
data: { data: {
description: 'Decision gateway', description: 'Decision point for branching the workflow',
conditions: [], conditions: [],
defaultPath: 'Default' defaultPath: 'Default'
} }

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, computed, shallowRef } from 'vue'; import { ref, onMounted, computed, shallowRef, watch, defineExpose } from 'vue';
import { VueFlow, useVueFlow, Panel } from '@vue-flow/core'; import { VueFlow, useVueFlow, Panel } from '@vue-flow/core';
import { Background } from '@vue-flow/background'; import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls'; import { Controls } from '@vue-flow/controls';
@ -44,7 +44,8 @@ const {
fitView, fitView,
project, project,
removeNodes, removeNodes,
removeEdges removeEdges,
updateNodeInternals
} = useVueFlow({ } = useVueFlow({
defaultEdgeOptions: { defaultEdgeOptions: {
animated: true, animated: true,
@ -89,9 +90,6 @@ const selectedNode = shallowRef(null);
// Handle node selection // Handle node selection
const onNodeClick = ({ node }) => { const onNodeClick = ({ node }) => {
// Add detailed debugging
// console.log('Raw node:', node);
// Check if node exists and has required properties // Check if node exists and has required properties
if (!node || !node.id) { if (!node || !node.id) {
console.warn('Invalid node clicked - Missing required properties'); console.warn('Invalid node clicked - Missing required properties');
@ -103,6 +101,7 @@ const onNodeClick = ({ node }) => {
const nodeData = { const nodeData = {
id: node.id, id: node.id,
type: node.type, type: node.type,
label: node.label || (node.data && node.data.label) || '',
data: node.data ? JSON.parse(JSON.stringify(node.data)) : {}, data: node.data ? JSON.parse(JSON.stringify(node.data)) : {},
position: node.dimensions ? { position: node.dimensions ? {
x: node.dimensions.x || 0, x: node.dimensions.x || 0,
@ -171,6 +170,8 @@ onMounted(() => {
}, 100); }, 100);
}); });
// Remove the deep watch as it's causing recursive updates
// Handle node changes // Handle node changes
onNodesChange((changes) => { onNodesChange((changes) => {
emit('nodesChange', changes, nodes.value); emit('nodesChange', changes, nodes.value);
@ -309,6 +310,49 @@ const onDragOver = (event) => {
event.stopPropagation(); event.stopPropagation();
event.dataTransfer.dropEffect = 'copy'; event.dataTransfer.dropEffect = 'copy';
}; };
// Add a method to update a node in the flow
const updateNode = (nodeId, updates) => {
if (!nodeId) return;
// console.log('ProcessFlowCanvas: Updating node:', nodeId, updates);
// Find the node in Vue Flow nodes
const nodeIndex = nodes.value.findIndex(n => n.id === nodeId);
if (nodeIndex === -1) {
console.warn(`Node with ID ${nodeId} not found in flow`);
return;
}
// Update the node with new values
const node = nodes.value[nodeIndex];
// Ensure label is consistently set in both places
const updatedLabel = updates.label || node.label;
const updatedData = {
...node.data,
...(updates.data || {}),
label: updatedLabel // Ensure label is also in data
};
// Update the node directly to avoid triggering watchers unnecessarily
Object.assign(nodes.value[nodeIndex], {
label: updatedLabel,
data: updatedData
});
// Notify Vue Flow to update the node's internals
updateNodeInternals(nodeId);
// console.log('Node updated:', updatedData);
return updatedData;
};
// Expose methods to parent components
defineExpose({
updateNode
});
</script> </script>
<template> <template>

View File

@ -21,7 +21,7 @@ const CustomNode = markRaw({
<div class="custom-node-icon"> <div class="custom-node-icon">
<slot name="icon"></slot> <slot name="icon"></slot>
</div> </div>
<div class="custom-node-title">{{ label }}</div> <div class="custom-node-title">{{ nodeLabel }}</div>
<div class="custom-node-badge" v-if="showBadge"> <div class="custom-node-badge" v-if="showBadge">
<slot name="badge"></slot> <slot name="badge"></slot>
</div> </div>
@ -33,7 +33,7 @@ const CustomNode = markRaw({
<div class="custom-node-icon" v-if="type !== 'gateway'"> <div class="custom-node-icon" v-if="type !== 'gateway'">
<slot name="icon"></slot> <slot name="icon"></slot>
</div> </div>
<div class="custom-node-title">{{ label }}</div> <div class="custom-node-title">{{ nodeLabel }}</div>
<slot></slot> <slot></slot>
</template> </template>
</div> </div>
@ -48,6 +48,10 @@ const CustomNode = markRaw({
`, `,
props: ['id', 'type', 'label', 'selected', 'data'], props: ['id', 'type', 'label', 'selected', 'data'],
computed: { computed: {
nodeLabel() {
// First try the label prop, then try data.label, then provide a default
return this.label || (this.data && this.data.label) || this.type || 'Node';
},
showBadge() { showBadge() {
return this.$slots.badge; return this.$slots.badge;
} }
@ -65,11 +69,17 @@ const CustomNode = markRaw({
// Task node // Task node
export const TaskNode = markRaw({ export const TaskNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'], props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
nodeLabel() {
// Get label from either prop or data, with fallback
return this.label || (this.data && this.data.label) || 'Task';
}
},
render() { render() {
return h(CustomNode, { return h(CustomNode, {
id: this.id, id: this.id,
type: 'task', type: 'task',
label: this.label, label: this.nodeLabel,
selected: this.selected, selected: this.selected,
data: this.data, data: this.data,
onClick: () => this.$emit('node-click', this.id) onClick: () => this.$emit('node-click', this.id)
@ -89,11 +99,17 @@ export const TaskNode = markRaw({
// Start node // Start node
export const StartNode = markRaw({ export const StartNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'], props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
nodeLabel() {
// Get label from either prop or data, with fallback
return this.label || (this.data && this.data.label) || 'Start';
}
},
render() { render() {
return h(CustomNode, { return h(CustomNode, {
id: this.id, id: this.id,
type: 'start', type: 'start',
label: this.label || 'Start', label: this.nodeLabel,
selected: this.selected, selected: this.selected,
data: this.data, data: this.data,
onClick: () => this.$emit('node-click', this.id) onClick: () => this.$emit('node-click', this.id)
@ -107,11 +123,17 @@ export const StartNode = markRaw({
// End node // End node
export const EndNode = markRaw({ export const EndNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'], props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
nodeLabel() {
// Get label from either prop or data, with fallback
return this.label || (this.data && this.data.label) || 'End';
}
},
render() { render() {
return h(CustomNode, { return h(CustomNode, {
id: this.id, id: this.id,
type: 'end', type: 'end',
label: this.label || 'End', label: this.nodeLabel,
selected: this.selected, selected: this.selected,
data: this.data, data: this.data,
onClick: () => this.$emit('node-click', this.id) onClick: () => this.$emit('node-click', this.id)
@ -125,22 +147,52 @@ export const EndNode = markRaw({
// Decision/Gateway node // Decision/Gateway node
export const GatewayNode = markRaw({ export const GatewayNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'], props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
nodeLabel() {
return this.label || (this.data && this.data.label) || 'Decision Point';
},
totalPaths() {
return Array.isArray(this.data?.conditions) ? this.data.conditions.length : 0;
},
totalConditions() {
if (!Array.isArray(this.data?.conditions)) return 0;
return this.data.conditions.reduce((total, group) => {
return total + (Array.isArray(group.conditions) ? group.conditions.length : 0);
}, 0);
},
conditionSummary() {
if (this.totalPaths === 0) return 'No paths';
const paths = this.data.conditions
.map(group => group.output || 'Unlabeled')
.filter(Boolean)
.join(', ');
return paths || 'Unconfigured paths';
}
},
render() { render() {
// Create the badge content
const badgeContent = h('span', {
class: 'node-badge bg-orange-100 text-orange-600 px-1 text-xs rounded absolute -top-5 left-1/2 transform -translate-x-1/2 whitespace-nowrap'
}, `${this.totalPaths} path${this.totalPaths !== 1 ? 's' : ''}`);
return h(CustomNode, { return h(CustomNode, {
id: this.id, id: this.id,
type: 'gateway', type: 'gateway',
label: this.label || 'Gateway', label: this.nodeLabel,
selected: this.selected, selected: this.selected,
data: this.data, data: this.data,
onClick: () => this.$emit('node-click', this.id) onClick: () => this.$emit('node-click', this.id)
}, { }, {
icon: () => h('i', { class: 'material-icons text-orange-500' }, 'call_split'), icon: () => h('i', { class: 'material-icons text-orange-500' }, 'call_split'),
badge: () => badgeContent,
default: () => h('div', { class: 'gateway-details' }, [ default: () => h('div', { class: 'gateway-details' }, [
h('div', { class: 'node-conditions-value' }, h('div', { class: 'node-conditions-value' }, this.conditionSummary)
this.data?.conditions?.length
? `${this.data.conditions.length} condition${this.data.conditions.length > 1 ? 's' : ''}`
: ''
)
]) ])
}); });
} }
@ -149,19 +201,27 @@ export const GatewayNode = markRaw({
// Form node // Form node
export const FormNode = markRaw({ export const FormNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'], props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
nodeLabel() {
// Get label from either prop or data, with fallback
return this.label || (this.data && this.data.label) || 'Form Task';
},
formName() {
return this.data?.formName || 'None selected';
},
hasForm() {
return !!(this.data?.formId && this.data?.formName);
}
},
render() { render() {
// Check if we have a form selected const badgeContent = this.hasForm ?
const hasForm = this.data?.formId && this.data?.formName;
// Create badge content based on form selection status
const badgeContent = hasForm ?
h('span', { class: 'node-badge bg-purple-100 text-purple-600 px-1 text-xs rounded' }, 'Form') : h('span', { class: 'node-badge bg-purple-100 text-purple-600 px-1 text-xs rounded' }, 'Form') :
null; null;
return h(CustomNode, { return h(CustomNode, {
id: this.id, id: this.id,
type: 'form', type: 'form',
label: this.label || 'Form Task', label: this.nodeLabel,
selected: this.selected, selected: this.selected,
data: this.data, data: this.data,
onClick: () => this.$emit('node-click', this.id) onClick: () => this.$emit('node-click', this.id)
@ -173,8 +233,8 @@ export const FormNode = markRaw({
h('div', { class: 'node-form-info' }, [ h('div', { class: 'node-form-info' }, [
h('span', { class: 'node-form-label' }, 'Form: '), h('span', { class: 'node-form-label' }, 'Form: '),
h('span', { h('span', {
class: hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic' class: this.hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic'
}, hasForm ? this.data.formName : 'None selected') }, this.formName)
]) ])
]) ])
}); });
@ -184,11 +244,17 @@ export const FormNode = markRaw({
// Script node // Script node
export const ScriptNode = markRaw({ export const ScriptNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'], props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
nodeLabel() {
// Get label from either prop or data, with fallback
return this.label || (this.data && this.data.label) || 'Script';
}
},
render() { render() {
return h(CustomNode, { return h(CustomNode, {
id: this.id, id: this.id,
type: 'script', type: 'script',
label: this.label || 'Script', label: this.nodeLabel,
selected: this.selected, selected: this.selected,
data: this.data, data: this.data,
onClick: () => this.$emit('node-click', this.id) onClick: () => this.$emit('node-click', this.id)
@ -244,12 +310,17 @@ export const nodeStyles = `
} }
.node-gateway { .node-gateway {
width: 50px; width: 120px !important;
height: 50px; height: 120px !important;
background: white; background: white;
transform: rotate(45deg); transform: rotate(45deg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #FF9800; border: 2px solid #f97316;
position: relative;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
} }
.node-start, .node-end { .node-start, .node-end {
@ -280,14 +351,15 @@ export const nodeStyles = `
} }
.node-gateway .custom-node-content { .node-gateway .custom-node-content {
position: absolute;
transform: rotate(-45deg); transform: rotate(-45deg);
width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; padding: 8px;
width: 100%;
padding: 0;
} }
.node-start .custom-node-content, .node-start .custom-node-content,
@ -359,17 +431,16 @@ export const nodeStyles = `
} }
.node-gateway .custom-node-title { .node-gateway .custom-node-title {
font-size: 9px; font-size: 12px;
font-weight: 500; font-weight: 500;
position: absolute; color: #333;
width: 60px; margin: 0;
text-align: center;
width: 100%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
bottom: -18px; line-height: 1.2;
left: 50%;
transform: translateX(-50%);
text-align: center;
} }
.node-details { .node-details {
@ -413,12 +484,13 @@ export const nodeStyles = `
} }
.node-conditions-value { .node-conditions-value {
font-size: 9px; font-size: 11px;
color: #666; color: #666;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 100%; text-align: center;
line-height: 1.2;
} }
.node-form-id { .node-form-id {
@ -427,8 +499,9 @@ export const nodeStyles = `
} }
.gateway-details { .gateway-details {
font-size: 9px; width: 100%;
text-align: center; text-align: center;
margin-top: 4px;
} }
.handle-task-input, .handle-task-input,
@ -467,11 +540,19 @@ export const nodeStyles = `
/* Position handles correctly for gateway node */ /* Position handles correctly for gateway node */
.handle-gateway-input { .handle-gateway-input {
transform: translateY(-14px) !important; transform: translateY(-42px) !important;
background-color: #f97316 !important;
border: 2px solid white !important;
width: 12px !important;
height: 12px !important;
} }
.handle-gateway-output { .handle-gateway-output {
transform: translateY(14px) !important; transform: translateY(42px) !important;
background-color: #f97316 !important;
border: 2px solid white !important;
width: 12px !important;
height: 12px !important;
} }
/* Badge style */ /* Badge style */
@ -481,4 +562,30 @@ export const nodeStyles = `
border-radius: 3px; border-radius: 3px;
margin-left: 4px; margin-left: 4px;
} }
/* Gateway specific styles */
.node-gateway:hover {
border-color: #ea580c;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.node-gateway .node-badge {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%) rotate(-45deg);
background-color: #fff7ed;
border: 1px solid #fdba74;
z-index: 10;
font-size: 11px;
padding: 2px 8px;
white-space: nowrap;
margin-top: 8px;
}
.node-gateway .material-icons {
font-size: 24px;
color: #f97316;
margin-bottom: 4px;
}
`; `;

View File

@ -179,7 +179,7 @@ The database schema is defined in Prisma schema (`/prisma/schema.prisma`) and in
4. Users are assigned tasks based on the process definition 4. Users are assigned tasks based on the process definition
5. When form tasks are encountered, users fill out forms 5. When form tasks are encountered, users fill out forms
6. Form data is stored in process variables 6. Form data is stored in process variables
7. Gateway conditions determine the next path 7. Decision Points determine the next path
8. The process continues until completion 8. The process continues until completion
## Security Architecture ## Security Architecture

View File

@ -48,7 +48,7 @@ The Process Builder is the heart of the system, allowing users to design executa
- Support for various node types: - Support for various node types:
- Start/End events - Start/End events
- Tasks (manual, form, script) - Tasks (manual, form, script)
- Gateways for decision points - Decision Points for branching workflows
- Connection management between nodes - Connection management between nodes
- Properties panel for node configuration - Properties panel for node configuration
- Variable management for process data - Variable management for process data

View File

@ -469,19 +469,26 @@ function createConnection(connection: Connection): Edge {
// Form task node implementation // Form task node implementation
const FormNode = markRaw({ const FormNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'], props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
nodeLabel() {
return this.label || (this.data && this.data.label) || 'Form Task';
},
formName() {
return this.data?.formName || 'None selected';
},
hasForm() {
return !!(this.data?.formId && this.data?.formName);
}
},
render() { render() {
// Check if we have a form selected const badgeContent = this.hasForm ?
const hasForm = this.data?.formId && this.data?.formName;
// Create badge content based on form selection status
const badgeContent = hasForm ?
h('span', { class: 'node-badge bg-purple-100 text-purple-600 px-1 text-xs rounded' }, 'Form') : h('span', { class: 'node-badge bg-purple-100 text-purple-600 px-1 text-xs rounded' }, 'Form') :
null; null;
return h(CustomNode, { return h(CustomNode, {
id: this.id, id: this.id,
type: 'form', type: 'form',
label: this.label || 'Form Task', label: this.nodeLabel,
selected: this.selected, selected: this.selected,
data: this.data, data: this.data,
onClick: () => this.$emit('node-click', this.id) onClick: () => this.$emit('node-click', this.id)
@ -493,8 +500,8 @@ const FormNode = markRaw({
h('div', { class: 'node-form-info' }, [ h('div', { class: 'node-form-info' }, [
h('span', { class: 'node-form-label' }, 'Form: '), h('span', { class: 'node-form-label' }, 'Form: '),
h('span', { h('span', {
class: hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic' class: this.hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic'
}, hasForm ? this.data.formName : 'None selected') }, this.formName)
]) ])
]) ])
}); });
@ -509,16 +516,29 @@ const FormNode = markRaw({
<FormSelector <FormSelector
v-model="selectedNodeData.data.formId" v-model="selectedNodeData.data.formId"
@select="handleFormSelection" @select="handleFormSelection"
@clear="clearFormSelection"
:formId="selectedNodeData.data?.formId"
/> />
</div> </div>
<script setup> <script setup>
// Form selection handler // Form selection handler
const handleFormSelection = (form) => { const handleFormSelection = (form) => {
if (selectedNodeData.value && form) { if (selectedNodeData.value) {
selectedNodeData.value.data.formId = form.formID; // Update all form-related data
selectedNodeData.value.data.formName = form.formName; selectedNodeData.value.data = {
selectedNodeData.value.data.formUuid = form.formUUID; ...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(); updateNodeInStore();
} }
}; };
@ -526,15 +546,223 @@ const handleFormSelection = (form) => {
// Clear form selection // Clear form selection
const clearFormSelection = () => { const clearFormSelection = () => {
if (selectedNodeData.value) { if (selectedNodeData.value) {
selectedNodeData.value.data.formId = null; selectedNodeData.value.data = {
selectedNodeData.value.data.formName = ''; ...selectedNodeData.value.data,
selectedNodeData.value.data.formUuid = null; 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(); updateNodeInStore();
} }
}; };
</script> </script>
``` ```
## Decision Point/Gateway Node
### Gateway Node Implementation
```typescript
// Decision/Gateway node
export const GatewayNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
nodeLabel() {
return this.label || (this.data && this.data.label) || 'Decision Point';
},
totalPaths() {
return Array.isArray(this.data?.conditions) ? this.data.conditions.length : 0;
},
totalConditions() {
if (!Array.isArray(this.data?.conditions)) return 0;
return this.data.conditions.reduce((total, group) => {
return total + (Array.isArray(group.conditions) ? group.conditions.length : 0);
}, 0);
},
conditionSummary() {
if (this.totalPaths === 0) return 'No paths';
const paths = this.data.conditions
.map(group => group.output || 'Unlabeled')
.filter(Boolean)
.join(', ');
return paths || 'Unconfigured paths';
}
},
render() {
// Create the badge content
const badgeContent = h('span', {
class: 'node-badge bg-orange-100 text-orange-600 px-1 text-xs rounded absolute -top-5 left-1/2 transform -translate-x-1/2 whitespace-nowrap'
}, `${this.totalPaths} path${this.totalPaths !== 1 ? 's' : ''}`);
return h(CustomNode, {
id: this.id,
type: 'gateway',
label: this.nodeLabel,
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons text-orange-500' }, 'call_split'),
badge: () => badgeContent,
default: () => h('div', { class: 'gateway-details' }, [
h('div', { class: 'node-conditions-value' }, this.conditionSummary)
])
});
}
});
```
### Gateway Node Styling
```css
/* Gateway specific styles */
.node-gateway {
width: 120px !important;
height: 120px !important;
background: white;
transform: rotate(45deg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 2px solid #f97316;
position: relative;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.node-gateway:hover {
border-color: #ea580c;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.node-gateway .custom-node-content {
position: absolute;
transform: rotate(-45deg);
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
}
.node-gateway .custom-node-title {
font-size: 12px;
font-weight: 500;
color: #333;
margin: 0;
text-align: center;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.node-gateway .gateway-details {
width: 100%;
text-align: center;
margin-top: 4px;
}
.node-gateway .node-conditions-value {
font-size: 11px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
line-height: 1.2;
}
.node-gateway .material-icons {
font-size: 24px;
color: #f97316;
margin-bottom: 4px;
}
.node-gateway .node-badge {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%) rotate(-45deg);
background-color: #fff7ed;
border: 1px solid #fdba74;
z-index: 10;
font-size: 11px;
padding: 2px 8px;
white-space: nowrap;
margin-top: 8px;
}
/* Position handles correctly for gateway node */
.handle-gateway-input {
transform: translateY(-42px) !important;
background-color: #f97316 !important;
border: 2px solid white !important;
width: 12px !important;
height: 12px !important;
}
.handle-gateway-output {
transform: translateY(42px) !important;
background-color: #f97316 !important;
border: 2px solid white !important;
width: 12px !important;
height: 12px !important;
}
```
### Gateway Condition Management
```typescript
// 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 (!matchingGroup) {
// If no matching group found, update edge label to default
return {
...edge,
label: selectedNodeData.value.data.defaultPath || 'Default'
};
}
}
return edge;
});
// Update edges in store
processStore.currentProcess.edges = updatedEdges;
}
// Update the node in store
updateNodeInStore();
}
};
```
## Event Handling ## Event Handling
### Node Events ### Node Events

View File

@ -71,12 +71,15 @@ Activities represent work performed in a process:
- Properties: Language, description, script content - Properties: Language, description, script content
### Gateways ### Gateways
Gateways control flow divergence and convergence: Decision points control flow divergence and convergence:
- **Gateway** (Orange Icon) - **Decision Point** (Orange Diamond Icon)
- Creates alternative paths based on conditions - Creates alternative paths based on conditions
- Has both input and output handles - Has both input and output handles
- Shows the number of configured paths in a badge
- Displays path names within the diamond
- Properties: Conditions, default path, description - Properties: Conditions, default path, description
- Connection labels automatically match condition paths
## Working with the Process Canvas ## Working with the Process Canvas

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, computed, shallowRef, onUnmounted } from 'vue'; import { ref, onMounted, computed, shallowRef, onUnmounted, nextTick } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder'; import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useVariableStore } from '~/stores/variableStore'; import { useVariableStore } from '~/stores/variableStore';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@ -48,11 +48,14 @@ const pendingNavigation = ref(null);
const navigationTarget = ref(null); const navigationTarget = ref(null);
const navigationConfirmed = ref(false); const navigationConfirmed = ref(false);
// Add a ref for the ProcessFlowCanvas component
const processFlowCanvas = ref(null);
// Component definitions // Component definitions
const components = [ const components = [
{ {
type: 'start', type: 'start',
label: 'Start', label: 'Start Point',
icon: 'play_circle_filled', icon: 'play_circle_filled',
iconColor: 'text-green-500', iconColor: 'text-green-500',
data: { description: 'Process starts here' } data: { description: 'Process starts here' }
@ -73,10 +76,10 @@ const components = [
}, },
{ {
type: 'gateway', type: 'gateway',
label: 'Gateway', label: 'Decision Point',
icon: 'call_split', icon: 'call_split',
iconColor: 'text-orange-500', iconColor: 'text-orange-500',
data: { description: 'Decision point', conditions: [] } data: { description: 'Decision point for branching the workflow', conditions: [] }
}, },
{ {
type: 'script', type: 'script',
@ -87,7 +90,7 @@ const components = [
}, },
{ {
type: 'end', type: 'end',
label: 'End', label: 'End Point',
icon: 'stop_circle', icon: 'stop_circle',
iconColor: 'text-red-500', iconColor: 'text-red-500',
data: { description: 'Process completes here' } data: { description: 'Process completes here' }
@ -101,10 +104,10 @@ const hasCurrentProcess = computed(() => {
// Computed properties for node data // Computed properties for node data
const nodeLabel = computed({ const nodeLabel = computed({
get: () => selectedNodeData.value?.data?.label || '', get: () => selectedNodeData.value?.label || '',
set: (value) => { set: (value) => {
if (selectedNodeData.value) { if (selectedNodeData.value) {
selectedNodeData.value.data.label = value; selectedNodeData.value.label = value;
updateNodeInStore(); updateNodeInStore();
} }
} }
@ -191,13 +194,30 @@ const gatewayAvailableVariables = computed(() => {
scope: 'global' scope: 'global'
})); }));
const allVars = [...processVars, ...globalVars]; const allVars = [...processVars, ...globalVars];
console.log('Gateway available variables:', allVars); // console.log('Gateway available variables:', allVars);
return allVars; return allVars;
}); });
// Handle node selection // Handle node selection
const onNodeSelected = (node) => { const onNodeSelected = (node) => {
selectedNodeData.value = JSON.parse(JSON.stringify(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; selectedNode.value = node;
selectedEdgeData.value = null; selectedEdgeData.value = null;
processStore.selectNode(node.id); processStore.selectNode(node.id);
@ -230,10 +250,30 @@ const onPaneClick = () => {
// Update node in store // Update node in store
const updateNodeInStore = () => { const updateNodeInStore = () => {
if (selectedNodeData.value) { if (selectedNodeData.value) {
// Make sure we're passing the nodeId and updates correctly // console.log('Updating node:', selectedNodeData.value.id, selectedNodeData.value.label);
processStore.updateNode(selectedNodeData.value.id, {
label: selectedNodeData.value.label, // Simplify the update to avoid recursive reactivity
data: selectedNodeData.value.data 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
}); });
} }
}; };
@ -241,7 +281,34 @@ const updateNodeInStore = () => {
// Handle condition update // Handle condition update
const handleConditionUpdate = (conditions) => { const handleConditionUpdate = (conditions) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') { if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
selectedNodeData.value.data.conditions = conditions; // 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 (!matchingGroup) {
// If no matching group found, update edge label to default
return {
...edge,
label: selectedNodeData.value.data.defaultPath || 'Default'
};
}
}
return edge;
});
// Update edges in store
processStore.currentProcess.edges = updatedEdges;
}
// Update the node in store
updateNodeInStore(); updateNodeInStore();
} }
}; };
@ -441,19 +508,40 @@ const onDragEnd = (event) => {
// Add these functions to handle form selection // Add these functions to handle form selection
const handleFormSelection = (form) => { const handleFormSelection = (form) => {
if (selectedNodeData.value && form) { if (selectedNodeData.value) {
selectedNodeData.value.data.formId = form.formID; // Update all form-related data
selectedNodeData.value.data.formName = form.formName; selectedNodeData.value.data = {
selectedNodeData.value.data.formUuid = form.formUUID; ...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(); updateNodeInStore();
} }
}; };
const clearFormSelection = () => { const clearFormSelection = () => {
if (selectedNodeData.value) { if (selectedNodeData.value) {
selectedNodeData.value.data.formId = null; selectedNodeData.value.data = {
selectedNodeData.value.data.formName = ''; ...selectedNodeData.value.data,
selectedNodeData.value.data.formUuid = null; 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(); updateNodeInStore();
} }
}; };
@ -575,6 +663,7 @@ const onConditionsUpdated = (conditions) => {
<!-- Center Panel - Process Canvas --> <!-- Center Panel - Process Canvas -->
<div class="flex-1 relative"> <div class="flex-1 relative">
<ProcessFlowCanvas <ProcessFlowCanvas
ref="processFlowCanvas"
:initial-nodes="processStore.currentProcess.nodes" :initial-nodes="processStore.currentProcess.nodes"
:initial-edges="processStore.currentProcess.edges" :initial-edges="processStore.currentProcess.edges"
@node-selected="onNodeSelected" @node-selected="onNodeSelected"
@ -628,14 +717,18 @@ const onConditionsUpdated = (conditions) => {
<!-- Form Selection for Form Nodes --> <!-- Form Selection for Form Nodes -->
<div v-if="selectedNodeData.type === 'form'"> <div v-if="selectedNodeData.type === 'form'">
<FormSelector @select="onFormSelected" /> <FormSelector
@select="handleFormSelection"
@clear="clearFormSelection"
:formId="selectedNodeData.data?.formId"
/>
</div> </div>
<!-- Gateway Conditions --> <!-- Gateway Conditions -->
<div v-if="selectedNodeData.type === 'gateway'"> <div v-if="selectedNodeData.type === 'gateway'">
<GatewayConditionManager <GatewayConditionManager
:conditions="selectedNodeData.data.conditions" :conditions="selectedNodeData.data.conditions"
@update="onConditionsUpdated" @update:conditions="handleConditionUpdate"
:availableVariables="gatewayAvailableVariables" :availableVariables="gatewayAvailableVariables"
/> />
</div> </div>