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
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
- Connect tasks to forms for data collection
- 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
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(() => props.conditions, (newConditions) => {
localConditions.value = [...(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 || [])];
}
} else {
// Empty array or undefined
localConditions.value = [];
}
}, { deep: true });
// Operators based on variable type
@ -74,8 +146,8 @@ const getInputTypeForVarType = (type) => {
}
};
// Add new condition
const addCondition = () => {
// Add new condition group (represents one outgoing path with potentially multiple conditions)
const addConditionGroup = () => {
if (!props.availableVariables || !props.availableVariables.length) {
alert('No variables available. Please add a variable before creating a condition.');
return;
@ -87,44 +159,102 @@ const addCondition = () => {
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 = {
id: `condition-${Date.now()}`,
variable: defaultVar.name,
operator: getOperatorsForType(defaultVar.type || 'string')[0].value,
value: '',
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('add-condition', newCondition);
};
// Remove condition
const removeCondition = (index) => {
localConditions.value.splice(index, 1);
emit('update:conditions', [...localConditions.value]);
emit('remove-condition', index);
// Remove a single condition from a group
const removeConditionFromGroup = (groupIndex, conditionIndex) => {
// 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]);
} else {
alert('A condition group must have at least one condition. Remove the entire group instead.');
}
};
// Update condition
const updateCondition = (index, field, value) => {
const updateCondition = (groupIndex, conditionIndex, field, value) => {
if (field === 'variable') {
const selectedVar = props.availableVariables.find(v => v.name === value);
localConditions.value[index].variable = value;
localConditions.value[index].valueType = selectedVar.type;
if (!selectedVar) return;
localConditions.value[groupIndex].conditions[conditionIndex].variable = value;
localConditions.value[groupIndex].conditions[conditionIndex].valueType = selectedVar.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
localConditions.value[index].value = '';
localConditions.value[groupIndex].conditions[conditionIndex].value = '';
} else {
localConditions.value[index][field] = value;
localConditions.value[groupIndex].conditions[conditionIndex][field] = 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
const conditionText = (condition) => {
if (!condition.variable || !condition.operator) return '';
@ -137,18 +267,58 @@ const conditionText = (condition) => {
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>
<template>
<div class="gateway-condition-manager">
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<h3 class="text-sm font-medium text-gray-700">Gateway Conditions</h3>
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-medium text-gray-700">Decision Point Conditions</h3>
<button
@click="addCondition"
class="px-2 py-1 bg-orange-500 text-white text-xs rounded hover:bg-orange-600"
@click="addConditionGroup"
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>
</div>
@ -156,93 +326,170 @@ const conditionText = (condition) => {
No conditions defined. Default path will be taken.
</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
v-for="(condition, index) in localConditions"
:key="condition.id"
class="condition-item p-3 border rounded-md bg-orange-50 relative"
v-for="(group, groupIndex) in localConditions"
:key="group.id"
class="condition-group border-2 rounded-md bg-orange-50"
:data-collapsed="isGroupCollapsed(group.id)"
@click.stop="isGroupCollapsed(group.id) ? toggleGroupCollapse(group.id) : null"
>
<button
@click="removeCondition(index)"
class="absolute top-1 right-1 text-gray-400 hover:text-red-500"
>
<i class="material-icons text-sm">close</i>
</button>
<div class="mb-2">
<div class="text-xs font-medium mb-1">If</div>
<div class="condition-grid grid grid-cols-3 gap-2">
<!-- Variable -->
<select
:value="condition.variable"
@change="updateCondition(index, 'variable', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs"
>
<option
v-for="variable in availableVariables"
:key="variable.name"
:value="variable.name"
>
{{ variable.label || variable.name || 'Unnamed variable' }}
</option>
</select>
<!-- Operator -->
<select
:value="condition.operator"
@change="updateCondition(index, 'operator', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs"
>
<option
v-for="operator in getOperatorsForType(
props.availableVariables.find(v => v.name === condition.variable)?.type || 'string'
)"
:key="operator.value"
:value="operator.value"
>
{{ operator.label }}
</option>
</select>
<!-- Value -->
<!-- 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
v-if="condition.valueType !== 'boolean'"
:type="getInputTypeForVarType(condition.valueType)"
:value="condition.value"
@input="updateCondition(index, 'value', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs"
:placeholder="'Value'"
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 v-else class="col-span-1 p-2 border rounded flex items-center">
<input
type="checkbox"
:checked="condition.value === true || condition.value === 'true'"
@change="updateCondition(index, 'value', $event.target.checked)"
class="mr-2"
</div>
<div class="flex items-center">
<!-- Collapse/expand button -->
<button
@click="toggleGroupCollapse(group.id); $event.stopPropagation();"
class="p-1 text-gray-500 hover:text-gray-700 mr-2"
:title="isGroupCollapsed(group.id) ? 'Expand' : 'Collapse'"
>
<Icon
:name="isGroupCollapsed(group.id) ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-5 h-5"
/>
<span class="text-xs">{{ condition.value === true || condition.value === 'true' ? 'Yes' : 'No' }}</span>
</button>
<!-- Delete button -->
<button
@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 -->
<select
:value="condition.variable"
@change="updateCondition(groupIndex, conditionIndex, 'variable', $event.target.value)"
@blur="saveChanges"
class="w-full p-2 border rounded text-xs"
>
<option
v-for="variable in availableVariables"
:key="variable.name"
:value="variable.name"
>
{{ variable.label || variable.name || 'Unnamed variable' }}
</option>
</select>
<!-- Operator -->
<select
:value="condition.operator"
@change="updateCondition(groupIndex, conditionIndex, 'operator', $event.target.value)"
@blur="saveChanges"
class="w-full p-2 border rounded text-xs"
>
<option
v-for="operator in getOperatorsForType(
props.availableVariables.find(v => v.name === condition.variable)?.type || 'string'
)"
:key="operator.value"
:value="operator.value"
>
{{ operator.label }}
</option>
</select>
<!-- Value -->
<input
v-if="condition.valueType !== 'boolean'"
:type="getInputTypeForVarType(condition.valueType)"
:value="condition.value"
@input="updateCondition(groupIndex, conditionIndex, 'value', $event.target.value)"
@blur="saveChanges"
class="w-full p-2 border rounded text-xs"
:placeholder="'Value'"
/>
<div v-else class="w-full p-2 border rounded flex items-center">
<input
type="checkbox"
:checked="condition.value === true || condition.value === 'true'"
@change="updateCondition(groupIndex, conditionIndex, 'value', $event.target.checked)"
@blur="saveChanges"
class="mr-2"
/>
<span class="text-xs">{{ condition.value === true || condition.value === 'true' ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Add another condition button -->
<div class="text-center">
<button
@click.stop="addConditionToGroup(groupIndex)"
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"
>
<Icon name="material-symbols:add" class="w-3 h-3 mr-1" />
Add Another Condition
</button>
</div>
</div>
</div>
<!-- Output path -->
<div class="mb-1">
<div class="text-xs font-medium mb-1">Then follow path:</div>
<input
type="text"
:value="condition.output"
@input="updateCondition(index, 'output', $event.target.value)"
class="w-full p-2 border rounded text-xs"
placeholder="Path label (e.g. 'Yes', 'Approved')"
/>
</div>
<!-- Condition summary -->
<div class="mt-2 p-2 bg-white rounded-md border border-orange-200">
<div class="text-xs text-orange-800">
<span class="font-medium">Condition:</span>
{{ conditionText(condition) }} {{ condition.output || 'Unlabeled path' }}
</div>
<!-- Condition summary (always visible, even when collapsed) -->
<div class="p-2 bg-orange-100 rounded-b-md border-t border-orange-200 text-xs text-orange-800">
<span class="font-medium">Outcome:</span>
<span v-if="group.conditions.length === 1">
If {{ conditionText(group.conditions[0]) }}
</span>
<span v-else>
If {{ groupedConditionText(group) }}
</span>
<span class="font-medium"> follow path "{{ group.output || 'Unlabeled path' }}"</span>
</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>
<input
type="text"
value="Default"
v-model="defaultPath"
@blur="saveChanges"
class="w-full p-2 border rounded text-xs"
placeholder="Default path label (e.g. 'No', 'Rejected')"
/>
@ -266,6 +514,15 @@ const conditionText = (condition) => {
font-size: 0.875rem;
}
.condition-group {
border-color: #fdba74;
transition: all 0.2s;
}
.condition-group:hover {
border-color: #f97316;
}
.condition-item {
transition: all 0.2s;
}
@ -273,4 +530,18 @@ const conditionText = (condition) => {
.condition-item:hover {
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>

View File

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

View File

@ -1,5 +1,5 @@
<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 { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
@ -44,7 +44,8 @@ const {
fitView,
project,
removeNodes,
removeEdges
removeEdges,
updateNodeInternals
} = useVueFlow({
defaultEdgeOptions: {
animated: true,
@ -89,9 +90,6 @@ const selectedNode = shallowRef(null);
// Handle node selection
const onNodeClick = ({ node }) => {
// Add detailed debugging
// console.log('Raw node:', node);
// Check if node exists and has required properties
if (!node || !node.id) {
console.warn('Invalid node clicked - Missing required properties');
@ -103,6 +101,7 @@ const onNodeClick = ({ node }) => {
const nodeData = {
id: node.id,
type: node.type,
label: node.label || (node.data && node.data.label) || '',
data: node.data ? JSON.parse(JSON.stringify(node.data)) : {},
position: node.dimensions ? {
x: node.dimensions.x || 0,
@ -171,6 +170,8 @@ onMounted(() => {
}, 100);
});
// Remove the deep watch as it's causing recursive updates
// Handle node changes
onNodesChange((changes) => {
emit('nodesChange', changes, nodes.value);
@ -309,6 +310,49 @@ const onDragOver = (event) => {
event.stopPropagation();
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>
<template>

View File

@ -21,7 +21,7 @@ const CustomNode = markRaw({
<div class="custom-node-icon">
<slot name="icon"></slot>
</div>
<div class="custom-node-title">{{ label }}</div>
<div class="custom-node-title">{{ nodeLabel }}</div>
<div class="custom-node-badge" v-if="showBadge">
<slot name="badge"></slot>
</div>
@ -33,7 +33,7 @@ const CustomNode = markRaw({
<div class="custom-node-icon" v-if="type !== 'gateway'">
<slot name="icon"></slot>
</div>
<div class="custom-node-title">{{ label }}</div>
<div class="custom-node-title">{{ nodeLabel }}</div>
<slot></slot>
</template>
</div>
@ -48,6 +48,10 @@ const CustomNode = markRaw({
`,
props: ['id', 'type', 'label', 'selected', 'data'],
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() {
return this.$slots.badge;
}
@ -65,11 +69,17 @@ const CustomNode = markRaw({
// Task node
export const TaskNode = markRaw({
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() {
return h(CustomNode, {
id: this.id,
type: 'task',
label: this.label,
label: this.nodeLabel,
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
@ -89,11 +99,17 @@ export const TaskNode = markRaw({
// Start node
export const StartNode = markRaw({
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() {
return h(CustomNode, {
id: this.id,
type: 'start',
label: this.label || 'Start',
label: this.nodeLabel,
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
@ -107,11 +123,17 @@ export const StartNode = markRaw({
// End node
export const EndNode = markRaw({
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() {
return h(CustomNode, {
id: this.id,
type: 'end',
label: this.label || 'End',
label: this.nodeLabel,
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
@ -125,22 +147,52 @@ export const EndNode = markRaw({
// 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.label || '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.data?.conditions?.length
? `${this.data.conditions.length} condition${this.data.conditions.length > 1 ? 's' : ''}`
: ''
)
h('div', { class: 'node-conditions-value' }, this.conditionSummary)
])
});
}
@ -149,19 +201,27 @@ export const GatewayNode = markRaw({
// Form node
export const FormNode = markRaw({
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() {
// Check if we have a form selected
const hasForm = this.data?.formId && this.data?.formName;
// Create badge content based on form selection status
const badgeContent = hasForm ?
const badgeContent = this.hasForm ?
h('span', { class: 'node-badge bg-purple-100 text-purple-600 px-1 text-xs rounded' }, 'Form') :
null;
return h(CustomNode, {
id: this.id,
type: 'form',
label: this.label || 'Form Task',
label: this.nodeLabel,
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
@ -171,10 +231,10 @@ export const FormNode = markRaw({
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Form submission task'),
h('div', { class: 'node-form-info' }, [
h('span', { class: 'node-form-label' }, 'Form:'),
h('span', { class: 'node-form-label' }, 'Form: '),
h('span', {
class: hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic'
}, hasForm ? this.data.formName : 'None selected')
class: this.hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic'
}, this.formName)
])
])
});
@ -184,11 +244,17 @@ export const FormNode = markRaw({
// Script node
export const ScriptNode = markRaw({
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() {
return h(CustomNode, {
id: this.id,
type: 'script',
label: this.label || 'Script',
label: this.nodeLabel,
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
@ -244,12 +310,17 @@ export const nodeStyles = `
}
.node-gateway {
width: 50px;
height: 50px;
width: 120px !important;
height: 120px !important;
background: white;
transform: rotate(45deg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #FF9800;
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-start, .node-end {
@ -280,14 +351,15 @@ export const nodeStyles = `
}
.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;
height: 100%;
width: 100%;
padding: 0;
padding: 8px;
}
.node-start .custom-node-content,
@ -359,17 +431,16 @@ export const nodeStyles = `
}
.node-gateway .custom-node-title {
font-size: 9px;
font-size: 12px;
font-weight: 500;
position: absolute;
width: 60px;
color: #333;
margin: 0;
text-align: center;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
bottom: -18px;
left: 50%;
transform: translateX(-50%);
text-align: center;
line-height: 1.2;
}
.node-details {
@ -413,12 +484,13 @@ export const nodeStyles = `
}
.node-conditions-value {
font-size: 9px;
font-size: 11px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
line-height: 1.2;
}
.node-form-id {
@ -427,8 +499,9 @@ export const nodeStyles = `
}
.gateway-details {
font-size: 9px;
width: 100%;
text-align: center;
margin-top: 4px;
}
.handle-task-input,
@ -467,11 +540,19 @@ export const nodeStyles = `
/* Position handles correctly for gateway node */
.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 {
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 */
@ -481,4 +562,30 @@ export const nodeStyles = `
border-radius: 3px;
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
5. When form tasks are encountered, users fill out forms
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
## 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:
- Start/End events
- Tasks (manual, form, script)
- Gateways for decision points
- Decision Points for branching workflows
- Connection management between nodes
- Properties panel for node configuration
- Variable management for process data

View File

@ -469,19 +469,26 @@ function createConnection(connection: Connection): Edge {
// Form task node implementation
const FormNode = markRaw({
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() {
// Check if we have a form selected
const hasForm = this.data?.formId && this.data?.formName;
// Create badge content based on form selection status
const badgeContent = hasForm ?
const badgeContent = this.hasForm ?
h('span', { class: 'node-badge bg-purple-100 text-purple-600 px-1 text-xs rounded' }, 'Form') :
null;
return h(CustomNode, {
id: this.id,
type: 'form',
label: this.label || 'Form Task',
label: this.nodeLabel,
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
@ -491,10 +498,10 @@ const FormNode = markRaw({
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Form submission task'),
h('div', { class: 'node-form-info' }, [
h('span', { class: 'node-form-label' }, 'Form:'),
h('span', { class: 'node-form-label' }, 'Form: '),
h('span', {
class: hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic'
}, hasForm ? this.data.formName : 'None selected')
class: this.hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic'
}, this.formName)
])
])
});
@ -509,16 +516,29 @@ const FormNode = markRaw({
<FormSelector
v-model="selectedNodeData.data.formId"
@select="handleFormSelection"
@clear="clearFormSelection"
:formId="selectedNodeData.data?.formId"
/>
</div>
<script setup>
// Form selection handler
const handleFormSelection = (form) => {
if (selectedNodeData.value && form) {
selectedNodeData.value.data.formId = form.formID;
selectedNodeData.value.data.formName = form.formName;
selectedNodeData.value.data.formUuid = form.formUUID;
if (selectedNodeData.value) {
// Update all form-related data
selectedNodeData.value.data = {
...selectedNodeData.value.data,
formId: form.formID,
formName: form.formName,
formUuid: form.formUUID,
label: form.formName,
description: `Form: ${form.formName}`
};
// Also update the node's root label
selectedNodeData.value.label = form.formName;
// Update the node in store to trigger reactivity
updateNodeInStore();
}
};
@ -526,15 +546,223 @@ const handleFormSelection = (form) => {
// Clear form selection
const clearFormSelection = () => {
if (selectedNodeData.value) {
selectedNodeData.value.data.formId = null;
selectedNodeData.value.data.formName = '';
selectedNodeData.value.data.formUuid = null;
selectedNodeData.value.data = {
...selectedNodeData.value.data,
formId: null,
formName: '',
formUuid: null,
label: 'Form Task',
description: 'Form submission task'
};
// Reset the node's root label
selectedNodeData.value.label = 'Form Task';
// Update the node in store
updateNodeInStore();
}
};
</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
### Node Events

View File

@ -71,12 +71,15 @@ Activities represent work performed in a process:
- Properties: Language, description, script content
### 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
- 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
- Connection labels automatically match condition paths
## Working with the Process Canvas

View File

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