Enhance Process Flow Components with Improved Node and Edge Management

- Introduced intelligent synchronization for nodes and edges in ProcessFlowCanvas, preventing unnecessary re-renders and improving performance.
- Added state management flags to prevent recursive updates during node and edge changes.
- Implemented explicit sync method for manual canvas updates, enhancing control over the rendering process.
- Updated ProcessSettingsModal to change the label for JSON export to "Source" for better clarity.
- Enhanced VariableManager with improved styling and default value handling, including validation for JSON objects.
- Updated navigation to remove unused icons and improve overall UI consistency.
- Added support for restoring deleted processes in the API, allowing for better data management and recovery options.
- Enhanced process management with new filters and improved loading states in the manage process view.
This commit is contained in:
Afiq 2025-05-30 18:36:50 +08:00
parent 44baddb6e3
commit 8805484de2
15 changed files with 986 additions and 296 deletions

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, computed, shallowRef, watch, defineExpose } from 'vue';
import { ref, onMounted, computed, shallowRef, watch, defineExpose, nextTick } from 'vue';
import { VueFlow, useVueFlow, Panel } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
@ -94,6 +94,10 @@ const flowOptions = ref({
// Use shallowRef for selected node to avoid unnecessary reactivity
const selectedNode = shallowRef(null);
// State management for preventing recursive updates
const isUpdatingNodes = ref(false);
const isUpdatingEdges = ref(false);
// Handle node selection
const onNodeClick = ({ node }) => {
// Check if node exists and has required properties
@ -178,20 +182,63 @@ onMounted(() => {
// Watch for changes to initialNodes prop and update the canvas
watch(() => props.initialNodes, async (newNodes, oldNodes) => {
if (newNodes && Array.isArray(newNodes)) {
// console.log('ProcessFlowCanvas: Updating nodes, count:', newNodes.length);
if (newNodes && Array.isArray(newNodes) && !isUpdatingNodes.value) {
isUpdatingNodes.value = true;
// Clear existing nodes and add new ones
nodes.value = [];
try {
// console.log('ProcessFlowCanvas: Updating nodes, count:', newNodes.length);
if (newNodes.length > 0) {
addNodes([...newNodes]); // Create a copy to avoid reactivity issues
// Instead of clearing all nodes, sync them intelligently
const currentNodeIds = new Set(nodes.value.map(n => n.id));
const newNodeIds = new Set(newNodes.map(n => n.id));
// Fit view to show all nodes after both nodes and edges are processed
await nextTick();
// Remove nodes that are no longer in the new list
const nodesToRemove = nodes.value.filter(node => !newNodeIds.has(node.id));
if (nodesToRemove.length > 0) {
removeNodes(nodesToRemove);
}
// Add new nodes that aren't already present
const nodesToAdd = newNodes.filter(node => !currentNodeIds.has(node.id));
if (nodesToAdd.length > 0) {
addNodes([...nodesToAdd]); // Create a copy to avoid reactivity issues
}
// Update existing nodes that have changed
newNodes.forEach(newNode => {
const existingNode = nodes.value.find(n => n.id === newNode.id);
if (existingNode) {
// Check if the node has actually changed before updating
const hasChanges = (
existingNode.label !== newNode.label ||
JSON.stringify(existingNode.data) !== JSON.stringify(newNode.data) ||
Math.abs(existingNode.position.x - newNode.position.x) > 1 ||
Math.abs(existingNode.position.y - newNode.position.y) > 1
);
if (hasChanges) {
Object.assign(existingNode, {
label: newNode.label,
data: { ...newNode.data },
position: { ...newNode.position }
});
updateNodeInternals([newNode.id]);
}
}
});
// Fit view only if we added new nodes and this is significant change
if (nodesToAdd.length > 0) {
await nextTick();
setTimeout(() => {
fitView();
}, 100);
}
} finally {
// Use a small delay to prevent immediate re-triggering
setTimeout(() => {
fitView();
}, 100);
isUpdatingNodes.value = false;
}, 50);
}
}
}, { deep: true });
@ -199,79 +246,119 @@ watch(() => props.initialNodes, async (newNodes, oldNodes) => {
// Watch for changes to initialEdges prop and update the canvas
// This watcher depends on nodes being already present
watch(() => [props.initialEdges, nodes.value.length], async ([newEdges, nodeCount]) => {
if (newEdges && Array.isArray(newEdges) && nodeCount > 0) {
// console.log('ProcessFlowCanvas: Updating edges, count:', newEdges.length, 'nodeCount:', nodeCount);
if (newEdges && Array.isArray(newEdges) && nodeCount > 0 && !isUpdatingEdges.value) {
isUpdatingEdges.value = true;
// Clear existing edges
edges.value = [];
try {
// console.log('ProcessFlowCanvas: Updating edges, count:', newEdges.length, 'nodeCount:', nodeCount);
if (newEdges.length > 0) {
// Verify all nodes exist before adding edges
const validEdges = newEdges.filter(edge => {
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
// Instead of clearing all edges, sync them intelligently
const currentEdgeIds = new Set(edges.value.map(e => e.id));
const newEdgeIds = new Set(newEdges.map(e => e.id));
if (!sourceExists || !targetExists) {
console.warn(`Skipping edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
// Remove edges that are no longer in the new list
const edgesToRemove = edges.value.filter(edge => !newEdgeIds.has(edge.id));
if (edgesToRemove.length > 0) {
removeEdges(edgesToRemove);
}
return true;
});
if (newEdges.length > 0) {
// Verify all nodes exist before adding edges
const validEdges = newEdges.filter(edge => {
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
if (validEdges.length > 0) {
// Ensure all edges have proper handle specifications
const edgesWithHandles = validEdges.map(edge => {
// If edge already has sourceHandle and targetHandle, use them
if (edge.sourceHandle && edge.targetHandle) {
return edge;
if (!sourceExists || !targetExists) {
console.warn(`Skipping edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
// Otherwise, generate default handles based on node types
const sourceNode = nodes.value.find(n => n.id === edge.source);
const targetNode = nodes.value.find(n => n.id === edge.target);
let sourceHandle = edge.sourceHandle;
let targetHandle = edge.targetHandle;
// Generate default source handle if missing
if (!sourceHandle && sourceNode) {
if (sourceNode.type === 'start') {
sourceHandle = `${edge.source}-bottom`; // Start nodes prefer bottom output
} else if (sourceNode.type === 'gateway') {
sourceHandle = `${edge.source}-right`; // Gateway nodes prefer right output for first connection
} else {
sourceHandle = `${edge.source}-bottom`; // Most nodes prefer bottom output
}
}
// Generate default target handle if missing
if (!targetHandle && targetNode) {
if (targetNode.type === 'end') {
targetHandle = `${edge.target}-top`; // End nodes prefer top input
} else {
targetHandle = `${edge.target}-top`; // Most nodes prefer top input
}
}
return {
...edge,
sourceHandle,
targetHandle
};
return true;
});
addEdges([...edgesWithHandles]); // Create a copy to avoid reactivity issues
// console.log('ProcessFlowCanvas: Successfully added edges with handles:', edgesWithHandles.length);
// Add new edges that aren't already present
const edgesToAdd = validEdges.filter(edge => !currentEdgeIds.has(edge.id));
if (edgesToAdd.length > 0) {
// Ensure all edges have proper handle specifications
const edgesWithHandles = edgesToAdd.map(edge => {
// If edge already has sourceHandle and targetHandle, use them
if (edge.sourceHandle && edge.targetHandle) {
return edge;
}
// Otherwise, generate default handles based on node types
const sourceNode = nodes.value.find(n => n.id === edge.source);
const targetNode = nodes.value.find(n => n.id === edge.target);
let sourceHandle = edge.sourceHandle;
let targetHandle = edge.targetHandle;
// Generate default source handle if missing
if (!sourceHandle && sourceNode) {
if (sourceNode.type === 'start') {
sourceHandle = `${edge.source}-bottom`; // Start nodes prefer bottom output
} else if (sourceNode.type === 'gateway') {
sourceHandle = `${edge.source}-right`; // Gateway nodes prefer right output for first connection
} else {
sourceHandle = `${edge.source}-bottom`; // Most nodes prefer bottom output
}
}
// Generate default target handle if missing
if (!targetHandle && targetNode) {
if (targetNode.type === 'end') {
targetHandle = `${edge.target}-top`; // End nodes prefer top input
} else {
targetHandle = `${edge.target}-top`; // Most nodes prefer top input
}
}
return {
...edge,
sourceHandle,
targetHandle
};
});
addEdges([...edgesWithHandles]); // Create a copy to avoid reactivity issues
// console.log('ProcessFlowCanvas: Successfully added edges with handles:', edgesWithHandles.length);
}
// Update existing edges that have changed
newEdges.forEach(newEdge => {
const existingEdge = edges.value.find(e => e.id === newEdge.id);
if (existingEdge) {
// Check if the edge has actually changed before updating
const hasChanges = (
existingEdge.label !== newEdge.label ||
existingEdge.sourceHandle !== newEdge.sourceHandle ||
existingEdge.targetHandle !== newEdge.targetHandle ||
JSON.stringify(existingEdge.style) !== JSON.stringify(newEdge.style)
);
if (hasChanges) {
Object.assign(existingEdge, {
label: newEdge.label,
sourceHandle: newEdge.sourceHandle,
targetHandle: newEdge.targetHandle,
style: newEdge.style ? { ...newEdge.style } : undefined
});
}
}
});
}
} finally {
// Use a small delay to prevent immediate re-triggering
setTimeout(() => {
isUpdatingEdges.value = false;
}, 50);
}
} else if (newEdges && Array.isArray(newEdges) && newEdges.length > 0 && nodeCount === 0) {
// console.log('ProcessFlowCanvas: Edges provided but no nodes yet, waiting...');
}
}, { deep: true });
// Remove the deep watch as it's causing recursive updates
// Handle node changes
onNodesChange((changes) => {
emit('nodesChange', changes, nodes.value);
@ -339,8 +426,11 @@ const handleConnect = (connection) => {
target: connection.target
});
// Add the edge directly to Vue Flow for immediate visual feedback
addEdges([newEdge]);
emit('edgesChange', edges.value);
// Emit the edge change in the format the parent expects
emit('edgesChange', [{ type: 'add', id: newEdge.id }], edges.value);
};
// Handle node removal
@ -427,7 +517,8 @@ defineExpose({
updateNode,
addNode,
removeNode,
fitView
fitView,
syncCanvas
});
// Update an existing node
@ -468,6 +559,155 @@ function removeNode(nodeId) {
removeNodes([nodeToRemove]);
return nodeToRemove;
}
// Add explicit sync method to manually update canvas
function syncCanvas(newNodes, newEdges) {
console.log('Explicit canvas sync called:', newNodes?.length, 'nodes,', newEdges?.length, 'edges');
// Force clear the updating flags first to ensure we can process
isUpdatingNodes.value = false;
isUpdatingEdges.value = false;
// Wait a moment for any ongoing operations to complete
setTimeout(() => {
try {
console.log('Starting canvas sync operation...');
// Sync nodes first
if (newNodes && Array.isArray(newNodes)) {
const currentNodeIds = new Set(nodes.value.map(n => n.id));
const newNodeIds = new Set(newNodes.map(n => n.id));
console.log('Current nodes:', currentNodeIds.size, 'New nodes:', newNodeIds.size);
// Remove nodes that are no longer in the new list
const nodesToRemove = nodes.value.filter(node => !newNodeIds.has(node.id));
if (nodesToRemove.length > 0) {
console.log('Removing nodes:', nodesToRemove.map(n => n.id));
removeNodes(nodesToRemove);
}
// Add new nodes that aren't already present
const nodesToAdd = newNodes.filter(node => !currentNodeIds.has(node.id));
if (nodesToAdd.length > 0) {
console.log('Adding nodes:', nodesToAdd.map(n => n.id));
addNodes([...nodesToAdd]);
}
// Update existing nodes
newNodes.forEach(newNode => {
const existingNode = nodes.value.find(n => n.id === newNode.id);
if (existingNode) {
Object.assign(existingNode, {
label: newNode.label,
data: { ...newNode.data },
position: { ...newNode.position }
});
updateNodeInternals([newNode.id]);
}
});
}
console.log('Nodes processed, current count:', nodes.value.length);
// Sync edges after nodes are updated
if (newEdges && Array.isArray(newEdges) && nodes.value.length > 0) {
const currentEdgeIds = new Set(edges.value.map(e => e.id));
const newEdgeIds = new Set(newEdges.map(e => e.id));
console.log('Current edges:', currentEdgeIds.size, 'New edges:', newEdgeIds.size);
// Remove edges that are no longer in the new list
const edgesToRemove = edges.value.filter(edge => !newEdgeIds.has(edge.id));
if (edgesToRemove.length > 0) {
console.log('Removing edges:', edgesToRemove.map(e => e.id));
removeEdges(edgesToRemove);
}
// Add new edges that aren't already present
const edgesToAdd = newEdges.filter(edge => !currentEdgeIds.has(edge.id));
if (edgesToAdd.length > 0) {
console.log('Processing new edges:', edgesToAdd.map(e => `${e.source}->${e.target}`));
// Verify nodes exist and add handles
const validEdges = edgesToAdd.filter(edge => {
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
if (!sourceExists || !targetExists) {
console.warn(`Skipping edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
return true;
});
console.log('Valid edges to add:', validEdges.length);
const edgesWithHandles = validEdges.map(edge => {
// If edge already has sourceHandle and targetHandle, use them
if (edge.sourceHandle && edge.targetHandle) {
console.log(`Edge ${edge.id} already has handles:`, edge.sourceHandle, '->', edge.targetHandle);
return edge;
}
const sourceNode = nodes.value.find(n => n.id === edge.source);
const targetNode = nodes.value.find(n => n.id === edge.target);
let sourceHandle = edge.sourceHandle;
let targetHandle = edge.targetHandle;
if (!sourceHandle && sourceNode) {
if (sourceNode.type === 'start') {
sourceHandle = `${edge.source}-bottom`;
} else if (sourceNode.type === 'gateway') {
sourceHandle = `${edge.source}-right`;
} else {
sourceHandle = `${edge.source}-bottom`;
}
}
if (!targetHandle && targetNode) {
if (targetNode.type === 'end') {
targetHandle = `${edge.target}-top`;
} else {
targetHandle = `${edge.target}-top`;
}
}
console.log(`Generated handles for edge ${edge.id}:`, sourceHandle, '->', targetHandle);
return { ...edge, sourceHandle, targetHandle };
});
if (edgesWithHandles.length > 0) {
console.log('Adding edges with handles:', edgesWithHandles.length);
addEdges([...edgesWithHandles]);
}
}
// Update existing edges
newEdges.forEach(newEdge => {
const existingEdge = edges.value.find(e => e.id === newEdge.id);
if (existingEdge) {
Object.assign(existingEdge, {
label: newEdge.label,
sourceHandle: newEdge.sourceHandle,
targetHandle: newEdge.targetHandle,
style: newEdge.style ? { ...newEdge.style } : undefined
});
}
});
} else if (newEdges && Array.isArray(newEdges) && newEdges.length > 0) {
console.warn('Cannot add edges: nodes not ready. Node count:', nodes.value.length);
}
console.log('Canvas sync completed - Final count: nodes:', nodes.value.length, 'edges:', edges.value.length);
} catch (error) {
console.error('Error during canvas sync:', error);
}
}, 50); // Small delay to allow any pending operations to complete
}
</script>
<template>

View File

@ -331,7 +331,7 @@ const settingsTabs = [
{ key: 'execution', label: 'Execution', icon: 'material-symbols:play-circle-outline' },
{ key: 'variables', label: 'Variables & Data', icon: 'material-symbols:data-object' },
{ key: 'permissions', label: 'Permissions', icon: 'material-symbols:security' },
{ key: 'json', label: 'JSON Export', icon: 'material-symbols:code' }
{ key: 'json', label: 'Source', icon: 'material-symbols:code' }
]
const activeTab = ref('info')

View File

@ -75,51 +75,65 @@
</div>
<!-- Variable List -->
<div v-else-if="filteredVariables.length" class="space-y-4">
<div v-else-if="filteredVariables.length" class="space-y-2">
<div
v-for="variable in filteredVariables"
:key="variable.name"
class="variable-item group"
class="variable-item bg-white border border-gray-200 rounded-lg hover:border-blue-300 hover:shadow-sm transition-all duration-150 group"
>
<div
class="bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition-all duration-300 overflow-hidden"
>
<!-- Header with Name and Actions -->
<div class="flex items-center justify-between p-5 pb-3">
<div class="flex items-center gap-3 flex-1 min-w-0">
<!-- Variable Header -->
<div class="px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0 flex-1">
<!-- Variable Icon -->
<div class="flex-shrink-0 w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
<Icon
:name="getVariableIcon(variable.type)"
class="w-5 h-5 text-blue-600"
/>
<div class="flex-shrink-0">
<div
class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-semibold"
:class="{
'bg-blue-100 text-blue-700': variable.type === 'string',
'bg-purple-100 text-purple-700': ['int', 'decimal'].includes(variable.type),
'bg-indigo-100 text-indigo-700': variable.type === 'boolean',
'bg-amber-100 text-amber-700': ['date', 'datetime'].includes(variable.type),
'bg-emerald-100 text-emerald-700': variable.type === 'object',
'bg-gray-100 text-gray-700': !['string', 'int', 'decimal', 'boolean', 'date', 'datetime', 'object'].includes(variable.type)
}"
>
<Icon :name="getVariableIcon(variable.type)" class="w-4 h-4" />
</div>
</div>
<!-- Variable Name -->
<div class="flex-1 min-w-0">
<h4 class="text-lg font-semibold text-gray-900 truncate">
{{ variable.name }}
</h4>
<div class="flex items-center gap-2 mt-1">
<RsBadge :variant="getTypeColor(variable.type)" size="sm" class="font-medium">
<!-- Variable Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-sm font-medium text-gray-900 truncate">{{ variable.name }}</h4>
<RsBadge
:variant="getTypeColor(variable.type)"
size="sm"
class="flex-shrink-0"
>
{{ variable.type }}
</RsBadge>
</div>
<!-- Description -->
<p v-if="variable.description" class="text-xs text-gray-500 line-clamp-1">
{{ variable.description }}
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity">
<!-- Actions -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@click="editVariable(variable)"
class="p-2.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="Edit variable"
>
<Icon name="material-symbols:edit" class="w-4 h-4" />
</button>
<button
@click="deleteVariable(variable)"
class="p-2.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
class="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete variable"
>
<Icon name="material-symbols:delete" class="w-4 h-4" />
@ -127,34 +141,14 @@
</div>
</div>
<!-- Description -->
<div v-if="variable.description" class="px-5 pb-3">
<p class="text-sm text-gray-600 leading-relaxed">
{{ variable.description }}
</p>
</div>
<!-- Current Value (if set) -->
<div v-if="variable.value !== undefined && variable.value !== ''" class="px-5 pb-4">
<div class="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div class="flex items-center gap-2 mb-2">
<Icon name="material-symbols:code" class="w-4 h-4 text-gray-500" />
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">Current Value</span>
<!-- Default Value Display -->
<div v-if="variable.defaultValue !== undefined && variable.defaultValue !== ''" class="mt-3">
<div class="bg-amber-50 rounded-md p-2 border border-amber-100">
<div class="flex items-center gap-1.5 mb-1">
<Icon name="material-symbols:settings" class="w-3.5 h-3.5 text-amber-600" />
<span class="text-xs font-medium text-amber-700 uppercase tracking-wide">Default Value</span>
</div>
<div class="font-mono text-sm text-gray-800 break-all">
{{ formatValue(variable.value, variable.type) }}
</div>
</div>
</div>
<!-- Default Value (if no current value but has default) -->
<div v-else-if="variable.defaultValue !== undefined && variable.defaultValue !== ''" class="px-5 pb-4">
<div class="bg-amber-50 rounded-lg p-3 border border-amber-100">
<div class="flex items-center gap-2 mb-2">
<Icon name="material-symbols:settings" class="w-4 h-4 text-amber-600" />
<span class="text-xs font-medium text-amber-600 uppercase tracking-wide">Default Value</span>
</div>
<div class="font-mono text-sm text-amber-800 break-all">
<div class="font-mono text-xs text-amber-800 break-all">
{{ formatValue(variable.defaultValue, variable.type) }}
</div>
</div>
@ -231,6 +225,19 @@
help="Select the type of data this variable will store"
/>
<FormKit
name="defaultValue"
v-model="variableForm.defaultValue"
:type="getInputTypeForVariableType(variableForm.type)"
:label="`Default Value${variableForm.type === 'boolean' ? '' : ' (Optional)'}`"
:placeholder="getPlaceholderForType(variableForm.type)"
:options="variableForm.type === 'boolean' ? [
{ label: 'True', value: true },
{ label: 'False', value: false }
] : undefined"
:help="getHelpTextForType(variableForm.type)"
/>
<FormKit
name="description"
v-model="variableForm.description"
@ -269,6 +276,7 @@ const variableForm = ref({
type: "string",
scope: "global",
description: "",
defaultValue: ""
});
// Variable type options with descriptions
@ -282,6 +290,67 @@ const variableTypes = [
{ label: 'Boolean - True/False values', value: 'boolean' }
];
// Helper functions for default value input
const getInputTypeForVariableType = (type) => {
switch (type) {
case 'int':
case 'decimal':
return 'number';
case 'boolean':
return 'select';
case 'date':
return 'date';
case 'datetime':
return 'datetime-local';
case 'object':
return 'textarea';
default:
return 'text';
}
};
const getPlaceholderForType = (type) => {
switch (type) {
case 'string':
return 'Enter default text value';
case 'int':
return 'Enter default number (e.g. 0, 100)';
case 'decimal':
return 'Enter default decimal (e.g. 0.0, 99.99)';
case 'object':
return 'Enter default JSON object (e.g. {"key": "value"})';
case 'date':
return 'Select default date';
case 'datetime':
return 'Select default date and time';
case 'boolean':
return 'Select default boolean value';
default:
return 'Enter default value';
}
};
const getHelpTextForType = (type) => {
switch (type) {
case 'string':
return 'Default text that will be used when the variable is first created';
case 'int':
return 'Default whole number (no decimals)';
case 'decimal':
return 'Default decimal number (with decimal places)';
case 'object':
return 'Default JSON object - must be valid JSON format';
case 'date':
return 'Default date value for this variable';
case 'datetime':
return 'Default date and time value for this variable';
case 'boolean':
return 'Default true/false value for this variable';
default:
return 'Default value that will be used when the variable is first created';
}
};
// Computed
const variables = computed(() => {
// Return all variables (treating everything as global)
@ -304,7 +373,10 @@ const filteredVariables = computed(() => {
// Methods
const editVariable = (variable) => {
editingVariable.value = variable;
variableForm.value = { ...variable };
variableForm.value = {
...variable,
defaultValue: variable.defaultValue || ""
};
showAddVariable.value = true;
};
@ -319,7 +391,8 @@ const resetForm = () => {
name: "",
type: "string",
scope: "global",
description: ""
description: "",
defaultValue: ""
};
editingVariable.value = null;
};
@ -331,12 +404,29 @@ const closeModal = () => {
const saveVariable = async (formData) => {
try {
// Process default value based on type
let processedDefaultValue = formData.defaultValue;
if (formData.type === 'int' && processedDefaultValue !== '') {
processedDefaultValue = parseInt(processedDefaultValue);
} else if (formData.type === 'decimal' && processedDefaultValue !== '') {
processedDefaultValue = parseFloat(processedDefaultValue);
} else if (formData.type === 'object' && processedDefaultValue !== '') {
try {
processedDefaultValue = JSON.parse(processedDefaultValue);
} catch (e) {
alert('Invalid JSON format for object type. Please enter valid JSON.');
return;
}
}
// Create a new variable object
const newVariable = {
name: formData.name,
type: formData.type,
scope: "global",
description: formData.description
description: formData.description,
defaultValue: processedDefaultValue
};
if (editingVariable.value) {
@ -355,7 +445,7 @@ const saveVariable = async (formData) => {
closeModal();
} catch (error) {
console.error("Error saving variable:", error);
// You might want to show an error message to the user here
alert('Error saving variable. Please check your input and try again.');
}
};
@ -426,6 +516,13 @@ const getVariableIcon = (type) => {
@apply transform -translate-y-0.5;
}
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Light styling for FormKit form */
:deep(.formkit-outer) {
margin-bottom: 1rem;

View File

@ -23,7 +23,6 @@ export default [
{
title: "Visual Builder",
path: "/process-builder",
icon: "material-symbols:schema",
child: [],
meta: {
description: "Design workflows with drag-and-drop interface"
@ -32,7 +31,6 @@ export default [
{
title: "Manage Processes",
path: "/process-builder/manage",
icon: "material-symbols:folder-open",
child: [],
meta: {
description: "View and manage all your processes"
@ -47,7 +45,6 @@ export default [
{
title: "Form Builder",
path: "/form-builder",
icon: "material-symbols:edit-document",
child: [],
meta: {
description: "Create dynamic forms with advanced components"
@ -56,7 +53,6 @@ export default [
{
title: "Manage Forms",
path: "/form-builder/manage",
icon: "material-symbols:library-books",
child: [],
meta: {
description: "View and manage all your forms"
@ -77,7 +73,6 @@ export default [
{
title: "Execution Dashboard",
path: "/execution",
icon: "material-symbols:monitoring",
child: [],
meta: {
description: "Monitor active processes and performance metrics"
@ -86,7 +81,6 @@ export default [
{
title: "Start New Process",
path: "/execution/new-case",
icon: "material-symbols:rocket-launch",
child: [],
meta: {
description: "Initiate a new process instance"
@ -101,7 +95,6 @@ export default [
{
title: "My Tasks",
path: "/execution/inbox",
icon: "material-symbols:inbox",
child: [],
meta: {
description: "View and complete assigned tasks"
@ -110,7 +103,6 @@ export default [
{
title: "Process History",
path: "/execution/history",
icon: "material-symbols:history",
child: [],
meta: {
description: "Review completed processes and audit trails"

View File

@ -921,7 +921,7 @@ const settingsTabs = [
{ key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' },
{ key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' },
{ key: 'events', label: 'Events', icon: 'material-symbols:event-outline' },
{ key: 'json', label: 'JSON', icon: 'material-symbols:data-object' }
{ key: 'json', label: 'Source', icon: 'material-symbols:data-object' }
];
// Computed property for form name with getter and setter

View File

@ -33,22 +33,36 @@
<div class="flex-1 p-6 overflow-auto">
<!-- Filters and Search -->
<div class="mb-6 flex flex-col sm:flex-row gap-4">
<div class="relative flex-1 max-w-md">
<input
<div class="flex-1 max-w-md">
<FormKit
v-model="searchQuery"
type="text"
placeholder="Search forms..."
class="w-full px-4 py-2 pl-10 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
<Icon name="material-symbols:search" class="text-lg" />
</span>
:classes="{
outer: 'mb-0',
wrapper: 'relative',
inner: 'relative',
input: 'w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
prefixIcon: 'absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none'
}"
>
<template #prefixIcon>
<Icon name="material-symbols:search" class="text-lg" />
</template>
</FormKit>
</div>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
<div class="flex gap-2">
<RsButton @click="clearFilters" variant="secondary" size="sm" :disabled="loading" v-if="searchQuery">
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear Filters
</RsButton>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
</div>
</div>
<!-- Loading State -->
@ -77,14 +91,28 @@
<td colspan="6" class="px-6 py-12 text-center text-gray-500">
<div class="flex flex-col items-center">
<Icon name="material-symbols:description-outline" class="w-12 h-12 text-gray-300 mb-2" />
<p class="text-lg font-medium mb-1">No forms found</p>
<p class="text-sm">
<p class="text-lg font-medium mb-1">
{{ searchQuery ? 'No forms match your search' : 'No forms found' }}
</p>
<p class="text-sm mb-2">
{{ searchQuery ? 'Try adjusting your search terms' : 'Create your first form to get started' }}
</p>
<RsButton v-if="!searchQuery" @click="createNewForm" variant="primary" size="sm" class="mt-4">
<Icon name="material-symbols:add" class="mr-1" />
Create New Form
</RsButton>
<!-- Show current search if any -->
<div v-if="searchQuery" class="text-xs text-gray-400 mb-4">
<div>Search: "{{ searchQuery }}"</div>
</div>
<div class="flex gap-2">
<RsButton v-if="searchQuery" @click="clearFilters" variant="secondary" size="sm">
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear Filters
</RsButton>
<RsButton v-if="!searchQuery" @click="createNewForm" variant="primary" size="sm">
<Icon name="material-symbols:add" class="mr-1" />
Create New Form
</RsButton>
</div>
</div>
</td>
</tr>
@ -207,7 +235,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useFormBuilderStore } from '~/stores/formBuilder';
import { useRouter } from 'vue-router';
@ -400,6 +428,28 @@ const goToDashboard = () => {
onMounted(async () => {
await loadForms();
});
// Watch for changes in search and reload forms
watch([searchQuery], () => {
// Debounce the search to avoid too many API calls
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadForms();
}, 500);
});
let searchTimeout = null;
// Clear all filters
const clearFilters = () => {
searchQuery.value = '';
// loadForms will be called automatically by the watcher
};
// Clean up the search timeout on component unmount
onUnmounted(() => {
clearTimeout(searchTimeout);
});
</script>
<style scoped>

View File

@ -74,6 +74,7 @@ const pendingNavigation = ref(null);
const navigationTarget = ref(null);
const navigationConfirmed = ref(false);
const isSaving = ref(false);
const isAddingComponent = ref(false); // Flag to prevent canvas reactivity issues during component addition
// Add a ref for the ProcessFlowCanvas component
const processFlowCanvas = ref(null);
@ -148,6 +149,19 @@ const hasCurrentProcess = computed(() => {
return !!processStore.currentProcess;
});
// Create computed properties for canvas props to control reactivity
const canvasNodes = computed(() => {
if (!processStore.currentProcess) return [];
// Return a shallow copy to avoid direct reactivity
return processStore.currentProcess.nodes.map(node => ({ ...node }));
});
const canvasEdges = computed(() => {
if (!processStore.currentProcess) return [];
// Return a shallow copy to avoid direct reactivity
return processStore.currentProcess.edges.map(edge => ({ ...edge }));
});
// Computed properties for node data
const nodeLabel = computed({
get: () => selectedNodeData.value?.label || '',
@ -735,21 +749,47 @@ const saveProcess = async () => {
};
// Add a component handler to add components from the component panel
const onAddComponent = (component) => {
// Create a new node from the component definition
const newNode = {
id: `${component.type}_${Date.now()}`,
type: component.type,
position: { x: 100, y: 100 }, // Default position
label: component.label,
data: component.data
};
const onAddComponent = async (component) => {
if (isAddingComponent.value) return; // Prevent concurrent additions
// Add the node to the process
processStore.addNode(newNode);
try {
isAddingComponent.value = true;
// Select the newly added node
onNodeSelected(newNode);
// Create a new node from the component definition
const newNode = {
id: `${component.type}_${Date.now()}`,
type: component.type,
position: { x: 100, y: 100 }, // Default position
label: component.label,
data: component.data
};
// Add the node to the process store
processStore.addNode(newNode);
// Wait for the next tick to ensure the store update is complete
await nextTick();
// Explicitly sync the canvas with current store state
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
processFlowCanvas.value.syncCanvas(
processStore.currentProcess.nodes,
processStore.currentProcess.edges
);
}
// Select the newly added node
onNodeSelected(newNode);
console.log('Component added successfully:', component.type);
} catch (error) {
console.error('Error adding component:', error);
} finally {
// Reset the flag after a short delay to allow canvas to stabilize
setTimeout(() => {
isAddingComponent.value = false;
}, 100);
}
};
// Handle template application
@ -785,15 +825,15 @@ const applyProcessTemplate = async (template) => {
processStore.currentProcess.edges = [];
}
// Add nodes and edges together - let the canvas watchers handle the sequencing
// Prepare template data
const templateNodes = template.nodes || [];
const templateEdges = template.edges || [];
console.log('Adding template nodes:', templateNodes.length);
console.log('Adding template edges:', templateEdges.length);
// Process nodes first
templateNodes.forEach((node) => {
// Process nodes first and wait for them to be fully added
for (const node of templateNodes) {
const newNode = {
...node,
id: node.id, // Keep original ID for edge references
@ -806,10 +846,14 @@ const applyProcessTemplate = async (template) => {
};
processStore.addNode(newNode);
});
}
// Process edges after nodes
templateEdges.forEach((edge) => {
// Wait for nodes to be processed by the canvas
await nextTick();
await new Promise(resolve => setTimeout(resolve, 100));
// Process edges after nodes are fully loaded
for (const edge of templateEdges) {
const newEdge = {
...edge,
id: edge.id, // Keep original ID
@ -818,7 +862,19 @@ const applyProcessTemplate = async (template) => {
};
processStore.addEdge(newEdge);
});
}
// Wait for edges to be added to store
await nextTick();
// Explicitly sync the canvas to ensure everything is displayed
if (processFlowCanvas.value && processFlowCanvas.value.syncCanvas) {
console.log('Forcing canvas sync after template application...');
processFlowCanvas.value.syncCanvas(
processStore.currentProcess.nodes,
processStore.currentProcess.edges
);
}
// Add template variables to the variable store
if (template.variables && template.variables.length > 0) {
@ -841,19 +897,20 @@ const applyProcessTemplate = async (template) => {
// Mark the process as having unsaved changes
processStore.unsavedChanges = true;
// Fit the view to show all nodes
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
nextTick(() => {
// Fit the view to show all nodes after a short delay
await nextTick();
setTimeout(() => {
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
processFlowCanvas.value.fitView();
});
}
}
}, 200);
// Show success message
console.log(`Template "${template.name}" applied successfully`);
toast.success(`Template "${template.name}" applied successfully`);
} catch (error) {
console.error('Error applying process template:', error);
alert('Failed to apply template: ' + error.message);
toast.error('Failed to apply template: ' + error.message);
}
};
@ -1072,8 +1129,8 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
<div class="flex-1 relative">
<ProcessFlowCanvas
ref="processFlowCanvas"
:initial-nodes="processStore.currentProcess.nodes"
:initial-edges="processStore.currentProcess.edges"
:initial-nodes="canvasNodes"
:initial-edges="canvasEdges"
@node-selected="onNodeSelected"
@edge-selected="onEdgeSelected"
@pane-click="onPaneClick"

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useRouter } from 'vue-router';
@ -30,29 +30,13 @@ const statusOptions = [
{ value: '', label: 'All Status' },
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'archived', label: 'Archived' }
{ value: 'archived', label: 'Archived' },
{ value: 'deleted', label: 'Deleted' }
];
// Filtered processes
// Filtered processes - now just returns the processes from store since filtering happens on backend
const filteredProcesses = computed(() => {
let filtered = processStore.processes;
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(
process =>
process.name.toLowerCase().includes(query) ||
(process.description && process.description.toLowerCase().includes(query))
);
}
// Filter by status
if (statusFilter.value) {
filtered = filtered.filter(process => process.status === statusFilter.value);
}
return filtered;
return processStore.processes;
});
// Format date for display
@ -75,18 +59,30 @@ const getStatusVariant = (status) => {
case 'published': return 'success';
case 'draft': return 'warning';
case 'archived': return 'secondary';
case 'deleted': return 'danger';
default: return 'primary';
}
};
// Load processes from API
// Load processes from API with filters
const loadProcesses = async () => {
loading.value = true;
try {
await processStore.fetchProcesses({
const options = {
sortBy: sortBy.value,
sortOrder: sortOrder.value
});
};
// Add filters if they have values
if (statusFilter.value) {
options.status = statusFilter.value;
}
if (searchQuery.value.trim()) {
options.search = searchQuery.value.trim();
}
await processStore.fetchProcesses(options);
} catch (error) {
console.error('Error loading processes:', error);
// TODO: Show error notification
@ -95,6 +91,17 @@ const loadProcesses = async () => {
}
};
// Watch for changes in filters and reload processes
watch([searchQuery, statusFilter], () => {
// Debounce the search to avoid too many API calls
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadProcesses();
}, 500);
});
let searchTimeout = null;
// Edit a process
const editProcess = async (processId) => {
try {
@ -166,6 +173,24 @@ const publishProcess = async (processId) => {
}
};
// Restore a deleted process
const restoreProcess = async (processId) => {
try {
loading.value = true;
await processStore.restoreProcess(processId);
// Refresh the process list
await loadProcesses();
// TODO: Show success notification
} catch (error) {
console.error('Error restoring process:', error);
// TODO: Show error notification
} finally {
loading.value = false;
}
};
// Show delete confirmation
const confirmDelete = (processId) => {
processToDelete.value = processId;
@ -194,6 +219,18 @@ const goToBuilder = () => {
onMounted(async () => {
await loadProcesses();
});
// Clear all filters
const clearFilters = () => {
searchQuery.value = '';
statusFilter.value = '';
// loadProcesses will be called automatically by the watcher
};
// Clean up the search timeout on component unmount
onUnmounted(() => {
clearTimeout(searchTimeout);
});
</script>
<template>
@ -231,31 +268,49 @@ onMounted(async () => {
<div class="flex-1 p-6 overflow-auto">
<!-- Filters and Search -->
<div class="mb-6 flex flex-col sm:flex-row gap-4">
<div class="relative flex-1 max-w-md">
<input
<div class="flex-1 max-w-md">
<FormKit
v-model="searchQuery"
type="text"
placeholder="Search processes..."
class="w-full px-4 py-2 pl-10 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
<Icon name="material-symbols:search" class="text-lg" />
</span>
:classes="{
outer: 'mb-0',
wrapper: 'relative',
inner: 'relative',
input: 'w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
prefixIcon: 'absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none'
}"
>
<template #prefixIcon>
<Icon name="material-symbols:search" class="text-lg" />
</template>
</FormKit>
</div>
<select
v-model="statusFilter"
class="px-3 py-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option v-for="option in statusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<div class="min-w-48">
<FormKit
v-model="statusFilter"
type="select"
:options="statusOptions"
placeholder="Filter by status"
:classes="{
outer: 'mb-0',
input: 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white'
}"
/>
</div>
<RsButton @click="loadProcesses" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
<div class="flex gap-2">
<RsButton @click="clearFilters" variant="secondary" size="sm" :disabled="loading" v-if="searchQuery || statusFilter">
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear Filters
</RsButton>
<RsButton @click="loadProcesses" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
</div>
</div>
<!-- Loading State -->
@ -285,14 +340,29 @@ onMounted(async () => {
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
<div class="flex flex-col items-center">
<Icon name="material-symbols:folder-open-outline" class="w-12 h-12 text-gray-300 mb-2" />
<p class="text-lg font-medium mb-1">No processes found</p>
<p class="text-sm">
{{ searchQuery || statusFilter ? 'Try adjusting your filters' : 'Create your first process to get started' }}
<p class="text-lg font-medium mb-1">
{{ (searchQuery || statusFilter) ? 'No processes match your filters' : 'No processes found' }}
</p>
<RsButton v-if="!searchQuery && !statusFilter" @click="createNewProcess" variant="primary" size="sm" class="mt-4">
<Icon name="material-symbols:add" class="mr-1" />
Create New Process
</RsButton>
<p class="text-sm mb-2">
{{ (searchQuery || statusFilter) ? 'Try adjusting your search or filter criteria' : 'Create your first process to get started' }}
</p>
<!-- Show current filters if any -->
<div v-if="searchQuery || statusFilter" class="text-xs text-gray-400 mb-4 space-y-1">
<div v-if="searchQuery">Search: "{{ searchQuery }}"</div>
<div v-if="statusFilter">Status: {{ statusOptions.find(opt => opt.value === statusFilter)?.label }}</div>
</div>
<div class="flex gap-2">
<RsButton v-if="searchQuery || statusFilter" @click="clearFilters" variant="secondary" size="sm">
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear Filters
</RsButton>
<RsButton v-if="!searchQuery && !statusFilter" @click="createNewProcess" variant="primary" size="sm">
<Icon name="material-symbols:add" class="mr-1" />
Create New Process
</RsButton>
</div>
</div>
</td>
</tr>
@ -326,42 +396,56 @@ onMounted(async () => {
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex space-x-2 justify-end">
<!-- Show restore button for deleted processes -->
<button
@click="editProcess(process.id)"
class="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
title="Edit Process"
:disabled="loading"
>
<Icon name="material-symbols:edit" class="text-lg" />
</button>
<button
v-if="process.status === 'draft'"
@click="publishProcess(process.id)"
v-if="process.status === 'deleted'"
@click="restoreProcess(process.id)"
class="p-1 text-green-600 hover:text-green-900 hover:bg-green-50 rounded"
title="Publish Process"
title="Restore Process"
:disabled="loading"
>
<Icon name="material-symbols:publish" class="text-lg" />
<Icon name="material-symbols:restore" class="text-lg" />
</button>
<button
@click="duplicateProcess(process)"
class="p-1 text-indigo-600 hover:text-indigo-900 hover:bg-indigo-50 rounded"
title="Duplicate Process"
:disabled="loading"
>
<Icon name="material-symbols:content-copy" class="text-lg" />
</button>
<!-- Regular action buttons for non-deleted processes -->
<template v-else>
<button
@click="editProcess(process.id)"
class="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
title="Edit Process"
:disabled="loading"
>
<Icon name="material-symbols:edit" class="text-lg" />
</button>
<button
@click="confirmDelete(process.id)"
class="p-1 text-red-600 hover:text-red-900 hover:bg-red-50 rounded"
title="Delete Process"
:disabled="loading || process.status === 'published'"
>
<Icon name="material-symbols:delete" class="text-lg" />
</button>
<button
v-if="process.status === 'draft'"
@click="publishProcess(process.id)"
class="p-1 text-green-600 hover:text-green-900 hover:bg-green-50 rounded"
title="Publish Process"
:disabled="loading"
>
<Icon name="material-symbols:publish" class="text-lg" />
</button>
<button
@click="duplicateProcess(process)"
class="p-1 text-indigo-600 hover:text-indigo-900 hover:bg-indigo-50 rounded"
title="Duplicate Process"
:disabled="loading"
>
<Icon name="material-symbols:content-copy" class="text-lg" />
</button>
<button
@click="confirmDelete(process.id)"
class="p-1 text-red-600 hover:text-red-900 hover:bg-red-50 rounded"
title="Delete Process"
:disabled="loading || process.status === 'published'"
>
<Icon name="material-symbols:delete" class="text-lg" />
</button>
</template>
</div>
</td>
</tr>
@ -379,14 +463,17 @@ onMounted(async () => {
</div>
<!-- Delete confirmation dialog -->
<RsModal v-model="showDeleteConfirm" title="Confirm Delete" size="md" position="center">
<RsModal v-model="showDeleteConfirm" title="Move Process to Trash" size="md" position="center">
<div class="p-4">
<div class="flex items-center mb-4">
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3 flex-shrink-0" />
<Icon name="material-symbols:delete-outline" class="text-orange-500 w-8 h-8 mr-3 flex-shrink-0" />
<div>
<p class="text-gray-600 font-medium mb-1">Delete Process</p>
<p class="text-gray-600 font-medium mb-1">Move to Trash</p>
<p class="text-gray-600 text-sm">
Are you sure you want to delete this process? This action cannot be undone and will permanently remove all process data.
This process will be moved to trash but not permanently deleted. You can restore it later from the "Deleted" filter if needed.
</p>
<p class="text-gray-500 text-xs mt-2">
Note: Published processes cannot be deleted and must be unpublished first.
</p>
</div>
</div>
@ -396,9 +483,9 @@ onMounted(async () => {
<RsButton @click="cancelDelete" variant="tertiary" :disabled="loading">
Cancel
</RsButton>
<RsButton @click="deleteProcess" variant="danger" :disabled="loading">
<RsButton @click="deleteProcess" variant="warning" :disabled="loading">
<Icon v-if="loading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-1" />
Delete
Move to Trash
</RsButton>
</div>
</template>

View File

@ -337,6 +337,13 @@
"null"
]
},
"processDeletedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"creator": {
"anyOf": [
{

View File

@ -88,6 +88,7 @@ model process {
processSettings Json?
processVariables Json?
templateCategory String? @db.VarChar(100)
processDeletedDate DateTime? @db.DateTime(0)
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
@@index([processCreatedBy], map: "FK_process_creator")

View File

@ -18,11 +18,14 @@ export default defineEventHandler(async (event) => {
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// First, check if the process exists
// First, check if the process exists and isn't already deleted
const existingProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
where: {
...(isUUID
? { processUUID: processId }
: { processID: parseInt(processId) }),
processStatus: { not: 'deleted' } // Exclude already deleted processes
},
select: {
processID: true,
processName: true,
@ -33,7 +36,7 @@ export default defineEventHandler(async (event) => {
if (!existingProcess) {
return {
success: false,
error: 'Process not found'
error: 'Process not found or already deleted'
};
}
@ -41,20 +44,28 @@ export default defineEventHandler(async (event) => {
if (existingProcess.processStatus === 'published') {
return {
success: false,
error: 'Cannot delete published processes. Please set status to draft first.'
error: 'Cannot delete published processes. Please unpublish the process first.'
};
}
// Delete the process
await prisma.process.delete({
// Soft delete: Update status to 'deleted' instead of actual deletion
// This preserves all associated data and relationships
await prisma.process.update({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) }
: { processID: parseInt(processId) },
data: {
processStatus: 'deleted',
// Optionally add deletion metadata
processDeletedDate: new Date(),
processModifiedDate: new Date()
}
});
return {
success: true,
message: `Process "${existingProcess.processName}" deleted successfully`
message: `Process "${existingProcess.processName}" has been moved to trash`,
note: 'Process data has been preserved and can be restored if needed'
};
} catch (error) {
console.error('Error deleting process:', error);

View File

@ -31,10 +31,47 @@ export default defineEventHandler(async (event) => {
if (body.processCategory !== undefined) updateData.processCategory = body.processCategory;
if (body.processPriority !== undefined) updateData.processPriority = body.processPriority;
if (body.processOwner !== undefined) updateData.processOwner = body.processOwner;
if (body.processStatus !== undefined) updateData.processStatus = body.processStatus;
if (body.isTemplate !== undefined) updateData.isTemplate = body.isTemplate;
if (body.templateCategory !== undefined) updateData.templateCategory = body.templateCategory;
// Get current process to check status
const currentProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
select: {
processStatus: true,
processVersion: true
}
});
if (!currentProcess) {
return {
success: false,
error: 'Process not found'
};
}
// Handle status changes with validation
if (body.processStatus !== undefined) {
const currentStatus = currentProcess.processStatus;
const newStatus = body.processStatus;
// Validate status transitions
if (currentStatus === 'published' && newStatus === 'draft') {
// Allow unpublishing only if explicitly requested
if (body.allowUnpublish !== true) {
return {
success: false,
error: 'Cannot change published process to draft without explicit confirmation. Use allowUnpublish: true.'
};
}
}
updateData.processStatus = newStatus;
}
// If no status provided, preserve current status (don't change it)
// Process definition (nodes, edges, viewport)
if (body.nodes !== undefined || body.edges !== undefined || body.viewport !== undefined) {
updateData.processDefinition = {
@ -61,16 +98,7 @@ export default defineEventHandler(async (event) => {
// Version increment if major changes
if (body.incrementVersion === true) {
const currentProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
select: { processVersion: true }
});
if (currentProcess) {
updateData.processVersion = currentProcess.processVersion + 1;
}
updateData.processVersion = currentProcess.processVersion + 1;
}
// Update the process

View File

@ -0,0 +1,86 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
try {
// Get the process ID from the route parameter
const processId = getRouterParam(event, 'id');
if (!processId) {
return {
success: false,
error: 'Process ID is required'
};
}
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// First, check if the process exists and is deleted
const existingProcess = await prisma.process.findFirst({
where: {
...(isUUID
? { processUUID: processId }
: { processID: parseInt(processId) }),
processStatus: 'deleted'
},
select: {
processID: true,
processName: true,
processStatus: true
}
});
if (!existingProcess) {
return {
success: false,
error: 'Deleted process not found'
};
}
// Restore the process by setting status back to draft
const restoredProcess = await prisma.process.update({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
data: {
processStatus: 'draft', // Restore as draft for safety
processDeletedDate: null, // Clear deletion date
processModifiedDate: new Date()
},
include: {
creator: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
return {
success: true,
message: `Process "${existingProcess.processName}" has been restored`,
process: restoredProcess
};
} catch (error) {
console.error('Error restoring process:', error);
// Handle specific Prisma errors
if (error.code === 'P2025') {
return {
success: false,
error: 'Process not found'
};
}
return {
success: false,
error: 'Failed to restore process',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -21,8 +21,18 @@ export default defineEventHandler(async (event) => {
// Build where clause
const where = {};
if (status) {
// Exclude deleted processes by default unless explicitly requested
if (query.includeDeleted !== 'true') {
where.processStatus = { not: 'deleted' };
}
if (status && status !== 'deleted') {
// If status filter is provided and it's not 'deleted', filter by that status
// and still exclude deleted processes
where.processStatus = status;
} else if (status === 'deleted') {
// If specifically requesting deleted processes, only show those
where.processStatus = 'deleted';
}
if (category) {

View File

@ -254,6 +254,8 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
variables: useVariableStore().getAllVariables.process || {},
settings: this.currentProcess.settings || {},
permissions: this.currentProcess.permissions || {}
// Note: processStatus is intentionally NOT included here to preserve current status
// Status changes should only happen through explicit publish/unpublish actions
};
const response = await $fetch(`/api/process/${this.currentProcess.id}`, {
@ -293,7 +295,7 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
});
if (response.success) {
// Remove from local processes array
// Remove from local processes array (since we're filtering out deleted ones)
const index = this.processes.findIndex(p => p.id === processId);
if (index !== -1) {
this.processes.splice(index, 1);
@ -317,6 +319,28 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
}
},
/**
* Restore a deleted process
*/
async restoreProcess(processId) {
try {
const response = await $fetch(`/api/process/${processId}/restore`, {
method: 'POST'
});
if (response.success) {
// Refresh the processes list to reflect the change
// Don't modify local array directly since status filtering might be complex
return true;
} else {
throw new Error(response.error || 'Failed to restore process');
}
} catch (error) {
console.error('Error restoring process:', error);
throw error;
}
},
/**
* Fetch all processes from database
*/