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:
parent
dfaa2f1309
commit
0b0b4a90b9
@ -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
|
||||||
|
@ -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>
|
@ -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'
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
`;
|
`;
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user