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> <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 { VueFlow, useVueFlow, Panel } from '@vue-flow/core';
import { Background } from '@vue-flow/background'; import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls'; import { Controls } from '@vue-flow/controls';
@ -94,6 +94,10 @@ const flowOptions = ref({
// Use shallowRef for selected node to avoid unnecessary reactivity // Use shallowRef for selected node to avoid unnecessary reactivity
const selectedNode = shallowRef(null); const selectedNode = shallowRef(null);
// State management for preventing recursive updates
const isUpdatingNodes = ref(false);
const isUpdatingEdges = ref(false);
// Handle node selection // Handle node selection
const onNodeClick = ({ node }) => { const onNodeClick = ({ node }) => {
// Check if node exists and has required properties // Check if node exists and has required properties
@ -178,20 +182,63 @@ onMounted(() => {
// Watch for changes to initialNodes prop and update the canvas // Watch for changes to initialNodes prop and update the canvas
watch(() => props.initialNodes, async (newNodes, oldNodes) => { watch(() => props.initialNodes, async (newNodes, oldNodes) => {
if (newNodes && Array.isArray(newNodes)) { if (newNodes && Array.isArray(newNodes) && !isUpdatingNodes.value) {
// console.log('ProcessFlowCanvas: Updating nodes, count:', newNodes.length); isUpdatingNodes.value = true;
// Clear existing nodes and add new ones try {
nodes.value = []; // console.log('ProcessFlowCanvas: Updating nodes, count:', newNodes.length);
if (newNodes.length > 0) {
addNodes([...newNodes]); // Create a copy to avoid reactivity issues
// Fit view to show all nodes after both nodes and edges are processed // Instead of clearing all nodes, sync them intelligently
await nextTick(); const currentNodeIds = new Set(nodes.value.map(n => n.id));
const newNodeIds = new Set(newNodes.map(n => n.id));
// 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(() => { setTimeout(() => {
fitView(); isUpdatingNodes.value = false;
}, 100); }, 50);
} }
} }
}, { deep: true }); }, { deep: true });
@ -199,79 +246,119 @@ watch(() => props.initialNodes, async (newNodes, oldNodes) => {
// Watch for changes to initialEdges prop and update the canvas // Watch for changes to initialEdges prop and update the canvas
// This watcher depends on nodes being already present // This watcher depends on nodes being already present
watch(() => [props.initialEdges, nodes.value.length], async ([newEdges, nodeCount]) => { watch(() => [props.initialEdges, nodes.value.length], async ([newEdges, nodeCount]) => {
if (newEdges && Array.isArray(newEdges) && nodeCount > 0) { if (newEdges && Array.isArray(newEdges) && nodeCount > 0 && !isUpdatingEdges.value) {
// console.log('ProcessFlowCanvas: Updating edges, count:', newEdges.length, 'nodeCount:', nodeCount); isUpdatingEdges.value = true;
// Clear existing edges try {
edges.value = []; // 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);
if (!sourceExists || !targetExists) {
console.warn(`Skipping edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
return true;
});
if (validEdges.length > 0) { // Instead of clearing all edges, sync them intelligently
// Ensure all edges have proper handle specifications const currentEdgeIds = new Set(edges.value.map(e => e.id));
const edgesWithHandles = validEdges.map(edge => { const newEdgeIds = new Set(newEdges.map(e => e.id));
// If edge already has sourceHandle and targetHandle, use them
if (edge.sourceHandle && edge.targetHandle) { // Remove edges that are no longer in the new list
return edge; const edgesToRemove = edges.value.filter(edge => !newEdgeIds.has(edge.id));
if (edgesToRemove.length > 0) {
removeEdges(edgesToRemove);
}
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 (!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 return true;
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 // Add new edges that aren't already present
// console.log('ProcessFlowCanvas: Successfully added edges with handles:', edgesWithHandles.length); 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) { } else if (newEdges && Array.isArray(newEdges) && newEdges.length > 0 && nodeCount === 0) {
// console.log('ProcessFlowCanvas: Edges provided but no nodes yet, waiting...'); // console.log('ProcessFlowCanvas: Edges provided but no nodes yet, waiting...');
} }
}, { deep: true }); }, { deep: true });
// Remove the deep watch as it's causing recursive updates
// Handle node changes // Handle node changes
onNodesChange((changes) => { onNodesChange((changes) => {
emit('nodesChange', changes, nodes.value); emit('nodesChange', changes, nodes.value);
@ -339,8 +426,11 @@ const handleConnect = (connection) => {
target: connection.target target: connection.target
}); });
// Add the edge directly to Vue Flow for immediate visual feedback
addEdges([newEdge]); 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 // Handle node removal
@ -427,7 +517,8 @@ defineExpose({
updateNode, updateNode,
addNode, addNode,
removeNode, removeNode,
fitView fitView,
syncCanvas
}); });
// Update an existing node // Update an existing node
@ -468,6 +559,155 @@ function removeNode(nodeId) {
removeNodes([nodeToRemove]); removeNodes([nodeToRemove]);
return 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> </script>
<template> <template>

View File

@ -331,7 +331,7 @@ const settingsTabs = [
{ key: 'execution', label: 'Execution', icon: 'material-symbols:play-circle-outline' }, { key: 'execution', label: 'Execution', icon: 'material-symbols:play-circle-outline' },
{ key: 'variables', label: 'Variables & Data', icon: 'material-symbols:data-object' }, { key: 'variables', label: 'Variables & Data', icon: 'material-symbols:data-object' },
{ key: 'permissions', label: 'Permissions', icon: 'material-symbols:security' }, { 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') const activeTab = ref('info')

View File

@ -75,51 +75,65 @@
</div> </div>
<!-- Variable List --> <!-- Variable List -->
<div v-else-if="filteredVariables.length" class="space-y-4"> <div v-else-if="filteredVariables.length" class="space-y-2">
<div <div
v-for="variable in filteredVariables" v-for="variable in filteredVariables"
:key="variable.name" :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 <!-- Variable Header -->
class="bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition-all duration-300 overflow-hidden" <div class="px-4 py-3">
> <div class="flex items-center justify-between">
<!-- Header with Name and Actions --> <div class="flex items-center gap-3 min-w-0 flex-1">
<div class="flex items-center justify-between p-5 pb-3">
<div class="flex items-center gap-3 flex-1 min-w-0">
<!-- Variable Icon --> <!-- Variable Icon -->
<div class="flex-shrink-0 w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center"> <div class="flex-shrink-0">
<Icon <div
:name="getVariableIcon(variable.type)" class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-semibold"
class="w-5 h-5 text-blue-600" :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> </div>
<!-- Variable Name --> <!-- Variable Info -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<h4 class="text-lg font-semibold text-gray-900 truncate"> <div class="flex items-center gap-2 mb-1">
{{ variable.name }} <h4 class="text-sm font-medium text-gray-900 truncate">{{ variable.name }}</h4>
</h4> <RsBadge
<div class="flex items-center gap-2 mt-1"> :variant="getTypeColor(variable.type)"
<RsBadge :variant="getTypeColor(variable.type)" size="sm" class="font-medium"> size="sm"
class="flex-shrink-0"
>
{{ variable.type }} {{ variable.type }}
</RsBadge> </RsBadge>
</div> </div>
<!-- Description -->
<p v-if="variable.description" class="text-xs text-gray-500 line-clamp-1">
{{ variable.description }}
</p>
</div> </div>
</div> </div>
<!-- Action Buttons --> <!-- Actions -->
<div class="flex items-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity"> <div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
@click="editVariable(variable)" @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" title="Edit variable"
> >
<Icon name="material-symbols:edit" class="w-4 h-4" /> <Icon name="material-symbols:edit" class="w-4 h-4" />
</button> </button>
<button <button
@click="deleteVariable(variable)" @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" title="Delete variable"
> >
<Icon name="material-symbols:delete" class="w-4 h-4" /> <Icon name="material-symbols:delete" class="w-4 h-4" />
@ -127,34 +141,14 @@
</div> </div>
</div> </div>
<!-- Description --> <!-- Default Value Display -->
<div v-if="variable.description" class="px-5 pb-3"> <div v-if="variable.defaultValue !== undefined && variable.defaultValue !== ''" class="mt-3">
<p class="text-sm text-gray-600 leading-relaxed"> <div class="bg-amber-50 rounded-md p-2 border border-amber-100">
{{ variable.description }} <div class="flex items-center gap-1.5 mb-1">
</p> <Icon name="material-symbols:settings" class="w-3.5 h-3.5 text-amber-600" />
</div> <span class="text-xs font-medium text-amber-700 uppercase tracking-wide">Default Value</span>
<!-- 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>
</div> </div>
<div class="font-mono text-sm text-gray-800 break-all"> <div class="font-mono text-xs text-amber-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">
{{ formatValue(variable.defaultValue, variable.type) }} {{ formatValue(variable.defaultValue, variable.type) }}
</div> </div>
</div> </div>
@ -231,6 +225,19 @@
help="Select the type of data this variable will store" 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 <FormKit
name="description" name="description"
v-model="variableForm.description" v-model="variableForm.description"
@ -269,6 +276,7 @@ const variableForm = ref({
type: "string", type: "string",
scope: "global", scope: "global",
description: "", description: "",
defaultValue: ""
}); });
// Variable type options with descriptions // Variable type options with descriptions
@ -282,6 +290,67 @@ const variableTypes = [
{ label: 'Boolean - True/False values', value: 'boolean' } { 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 // Computed
const variables = computed(() => { const variables = computed(() => {
// Return all variables (treating everything as global) // Return all variables (treating everything as global)
@ -304,7 +373,10 @@ const filteredVariables = computed(() => {
// Methods // Methods
const editVariable = (variable) => { const editVariable = (variable) => {
editingVariable.value = variable; editingVariable.value = variable;
variableForm.value = { ...variable }; variableForm.value = {
...variable,
defaultValue: variable.defaultValue || ""
};
showAddVariable.value = true; showAddVariable.value = true;
}; };
@ -319,7 +391,8 @@ const resetForm = () => {
name: "", name: "",
type: "string", type: "string",
scope: "global", scope: "global",
description: "" description: "",
defaultValue: ""
}; };
editingVariable.value = null; editingVariable.value = null;
}; };
@ -331,12 +404,29 @@ const closeModal = () => {
const saveVariable = async (formData) => { const saveVariable = async (formData) => {
try { 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 // Create a new variable object
const newVariable = { const newVariable = {
name: formData.name, name: formData.name,
type: formData.type, type: formData.type,
scope: "global", scope: "global",
description: formData.description description: formData.description,
defaultValue: processedDefaultValue
}; };
if (editingVariable.value) { if (editingVariable.value) {
@ -355,7 +445,7 @@ const saveVariable = async (formData) => {
closeModal(); closeModal();
} catch (error) { } catch (error) {
console.error("Error saving variable:", 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; @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 */ /* Light styling for FormKit form */
:deep(.formkit-outer) { :deep(.formkit-outer) {
margin-bottom: 1rem; margin-bottom: 1rem;

View File

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

View File

@ -921,7 +921,7 @@ const settingsTabs = [
{ key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' }, { key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' },
{ key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' }, { key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' },
{ key: 'events', label: 'Events', icon: 'material-symbols:event-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 // Computed property for form name with getter and setter

View File

@ -33,22 +33,36 @@
<div class="flex-1 p-6 overflow-auto"> <div class="flex-1 p-6 overflow-auto">
<!-- Filters and Search --> <!-- Filters and Search -->
<div class="mb-6 flex flex-col sm:flex-row gap-4"> <div class="mb-6 flex flex-col sm:flex-row gap-4">
<div class="relative flex-1 max-w-md"> <div class="flex-1 max-w-md">
<input <FormKit
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="Search forms..." 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" :classes="{
/> outer: 'mb-0',
<span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"> wrapper: 'relative',
<Icon name="material-symbols:search" class="text-lg" /> inner: 'relative',
</span> 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> </div>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading"> <div class="flex gap-2">
<Icon name="material-symbols:refresh" class="mr-1" /> <RsButton @click="clearFilters" variant="secondary" size="sm" :disabled="loading" v-if="searchQuery">
Refresh <Icon name="material-symbols:filter-alt-off" class="mr-1" />
</RsButton> Clear Filters
</RsButton>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
</div>
</div> </div>
<!-- Loading State --> <!-- Loading State -->
@ -77,14 +91,28 @@
<td colspan="6" class="px-6 py-12 text-center text-gray-500"> <td colspan="6" class="px-6 py-12 text-center text-gray-500">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<Icon name="material-symbols:description-outline" class="w-12 h-12 text-gray-300 mb-2" /> <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-lg font-medium mb-1">
<p class="text-sm"> {{ 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' }} {{ searchQuery ? 'Try adjusting your search terms' : 'Create your first form to get started' }}
</p> </p>
<RsButton v-if="!searchQuery" @click="createNewForm" variant="primary" size="sm" class="mt-4">
<Icon name="material-symbols:add" class="mr-1" /> <!-- Show current search if any -->
Create New Form <div v-if="searchQuery" class="text-xs text-gray-400 mb-4">
</RsButton> <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> </div>
</td> </td>
</tr> </tr>
@ -207,7 +235,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useFormBuilderStore } from '~/stores/formBuilder'; import { useFormBuilderStore } from '~/stores/formBuilder';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@ -400,6 +428,28 @@ const goToDashboard = () => {
onMounted(async () => { onMounted(async () => {
await loadForms(); 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> </script>
<style scoped> <style scoped>

View File

@ -74,6 +74,7 @@ const pendingNavigation = ref(null);
const navigationTarget = ref(null); const navigationTarget = ref(null);
const navigationConfirmed = ref(false); const navigationConfirmed = ref(false);
const isSaving = 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 // Add a ref for the ProcessFlowCanvas component
const processFlowCanvas = ref(null); const processFlowCanvas = ref(null);
@ -148,6 +149,19 @@ const hasCurrentProcess = computed(() => {
return !!processStore.currentProcess; 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 // Computed properties for node data
const nodeLabel = computed({ const nodeLabel = computed({
get: () => selectedNodeData.value?.label || '', get: () => selectedNodeData.value?.label || '',
@ -735,21 +749,47 @@ const saveProcess = async () => {
}; };
// Add a component handler to add components from the component panel // Add a component handler to add components from the component panel
const onAddComponent = (component) => { const onAddComponent = async (component) => {
// Create a new node from the component definition if (isAddingComponent.value) return; // Prevent concurrent additions
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 try {
processStore.addNode(newNode); isAddingComponent.value = true;
// Select the newly added node // Create a new node from the component definition
onNodeSelected(newNode); 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 // Handle template application
@ -785,15 +825,15 @@ const applyProcessTemplate = async (template) => {
processStore.currentProcess.edges = []; processStore.currentProcess.edges = [];
} }
// Add nodes and edges together - let the canvas watchers handle the sequencing // Prepare template data
const templateNodes = template.nodes || []; const templateNodes = template.nodes || [];
const templateEdges = template.edges || []; const templateEdges = template.edges || [];
console.log('Adding template nodes:', templateNodes.length); console.log('Adding template nodes:', templateNodes.length);
console.log('Adding template edges:', templateEdges.length); console.log('Adding template edges:', templateEdges.length);
// Process nodes first // Process nodes first and wait for them to be fully added
templateNodes.forEach((node) => { for (const node of templateNodes) {
const newNode = { const newNode = {
...node, ...node,
id: node.id, // Keep original ID for edge references id: node.id, // Keep original ID for edge references
@ -806,10 +846,14 @@ const applyProcessTemplate = async (template) => {
}; };
processStore.addNode(newNode); processStore.addNode(newNode);
}); }
// Process edges after nodes // Wait for nodes to be processed by the canvas
templateEdges.forEach((edge) => { await nextTick();
await new Promise(resolve => setTimeout(resolve, 100));
// Process edges after nodes are fully loaded
for (const edge of templateEdges) {
const newEdge = { const newEdge = {
...edge, ...edge,
id: edge.id, // Keep original ID id: edge.id, // Keep original ID
@ -818,7 +862,19 @@ const applyProcessTemplate = async (template) => {
}; };
processStore.addEdge(newEdge); 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 // Add template variables to the variable store
if (template.variables && template.variables.length > 0) { if (template.variables && template.variables.length > 0) {
@ -841,19 +897,20 @@ const applyProcessTemplate = async (template) => {
// Mark the process as having unsaved changes // Mark the process as having unsaved changes
processStore.unsavedChanges = true; processStore.unsavedChanges = true;
// Fit the view to show all nodes // Fit the view to show all nodes after a short delay
if (processFlowCanvas.value && processFlowCanvas.value.fitView) { await nextTick();
nextTick(() => { setTimeout(() => {
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
processFlowCanvas.value.fitView(); processFlowCanvas.value.fitView();
}); }
} }, 200);
// Show success message // Show success message
console.log(`Template "${template.name}" applied successfully`); toast.success(`Template "${template.name}" applied successfully`);
} catch (error) { } catch (error) {
console.error('Error applying process template:', 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"> <div class="flex-1 relative">
<ProcessFlowCanvas <ProcessFlowCanvas
ref="processFlowCanvas" ref="processFlowCanvas"
:initial-nodes="processStore.currentProcess.nodes" :initial-nodes="canvasNodes"
:initial-edges="processStore.currentProcess.edges" :initial-edges="canvasEdges"
@node-selected="onNodeSelected" @node-selected="onNodeSelected"
@edge-selected="onEdgeSelected" @edge-selected="onEdgeSelected"
@pane-click="onPaneClick" @pane-click="onPaneClick"

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder'; import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@ -30,29 +30,13 @@ const statusOptions = [
{ value: '', label: 'All Status' }, { value: '', label: 'All Status' },
{ value: 'draft', label: 'Draft' }, { value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' }, { 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(() => { const filteredProcesses = computed(() => {
let filtered = processStore.processes; return 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;
}); });
// Format date for display // Format date for display
@ -75,18 +59,30 @@ const getStatusVariant = (status) => {
case 'published': return 'success'; case 'published': return 'success';
case 'draft': return 'warning'; case 'draft': return 'warning';
case 'archived': return 'secondary'; case 'archived': return 'secondary';
case 'deleted': return 'danger';
default: return 'primary'; default: return 'primary';
} }
}; };
// Load processes from API // Load processes from API with filters
const loadProcesses = async () => { const loadProcesses = async () => {
loading.value = true; loading.value = true;
try { try {
await processStore.fetchProcesses({ const options = {
sortBy: sortBy.value, sortBy: sortBy.value,
sortOrder: sortOrder.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) { } catch (error) {
console.error('Error loading processes:', error); console.error('Error loading processes:', error);
// TODO: Show error notification // 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 // Edit a process
const editProcess = async (processId) => { const editProcess = async (processId) => {
try { 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 // Show delete confirmation
const confirmDelete = (processId) => { const confirmDelete = (processId) => {
processToDelete.value = processId; processToDelete.value = processId;
@ -194,6 +219,18 @@ const goToBuilder = () => {
onMounted(async () => { onMounted(async () => {
await loadProcesses(); 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> </script>
<template> <template>
@ -231,31 +268,49 @@ onMounted(async () => {
<div class="flex-1 p-6 overflow-auto"> <div class="flex-1 p-6 overflow-auto">
<!-- Filters and Search --> <!-- Filters and Search -->
<div class="mb-6 flex flex-col sm:flex-row gap-4"> <div class="mb-6 flex flex-col sm:flex-row gap-4">
<div class="relative flex-1 max-w-md"> <div class="flex-1 max-w-md">
<input <FormKit
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="Search processes..." 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" :classes="{
/> outer: 'mb-0',
<span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"> wrapper: 'relative',
<Icon name="material-symbols:search" class="text-lg" /> inner: 'relative',
</span> 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> </div>
<select <div class="min-w-48">
v-model="statusFilter" <FormKit
class="px-3 py-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500" v-model="statusFilter"
> type="select"
<option v-for="option in statusOptions" :key="option.value" :value="option.value"> :options="statusOptions"
{{ option.label }} placeholder="Filter by status"
</option> :classes="{
</select> 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"> <div class="flex gap-2">
<Icon name="material-symbols:refresh" class="mr-1" /> <RsButton @click="clearFilters" variant="secondary" size="sm" :disabled="loading" v-if="searchQuery || statusFilter">
Refresh <Icon name="material-symbols:filter-alt-off" class="mr-1" />
</RsButton> Clear Filters
</RsButton>
<RsButton @click="loadProcesses" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
</div>
</div> </div>
<!-- Loading State --> <!-- Loading State -->
@ -285,14 +340,29 @@ onMounted(async () => {
<td colspan="7" class="px-6 py-12 text-center text-gray-500"> <td colspan="7" class="px-6 py-12 text-center text-gray-500">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<Icon name="material-symbols:folder-open-outline" class="w-12 h-12 text-gray-300 mb-2" /> <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-lg font-medium mb-1">
<p class="text-sm"> {{ (searchQuery || statusFilter) ? 'No processes match your filters' : 'No processes found' }}
{{ searchQuery || statusFilter ? 'Try adjusting your filters' : 'Create your first process to get started' }}
</p> </p>
<RsButton v-if="!searchQuery && !statusFilter" @click="createNewProcess" variant="primary" size="sm" class="mt-4"> <p class="text-sm mb-2">
<Icon name="material-symbols:add" class="mr-1" /> {{ (searchQuery || statusFilter) ? 'Try adjusting your search or filter criteria' : 'Create your first process to get started' }}
Create New Process </p>
</RsButton>
<!-- 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> </div>
</td> </td>
</tr> </tr>
@ -326,42 +396,56 @@ onMounted(async () => {
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex space-x-2 justify-end"> <div class="flex space-x-2 justify-end">
<!-- Show restore button for deleted processes -->
<button <button
@click="editProcess(process.id)" v-if="process.status === 'deleted'"
class="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded" @click="restoreProcess(process.id)"
title="Edit Process"
:disabled="loading"
>
<Icon name="material-symbols:edit" 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" class="p-1 text-green-600 hover:text-green-900 hover:bg-green-50 rounded"
title="Publish Process" title="Restore Process"
:disabled="loading" :disabled="loading"
> >
<Icon name="material-symbols:publish" class="text-lg" /> <Icon name="material-symbols:restore" class="text-lg" />
</button> </button>
<button <!-- Regular action buttons for non-deleted processes -->
@click="duplicateProcess(process)" <template v-else>
class="p-1 text-indigo-600 hover:text-indigo-900 hover:bg-indigo-50 rounded" <button
title="Duplicate Process" @click="editProcess(process.id)"
:disabled="loading" class="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
> title="Edit Process"
<Icon name="material-symbols:content-copy" class="text-lg" /> :disabled="loading"
</button> >
<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" <button
title="Delete Process" v-if="process.status === 'draft'"
:disabled="loading || process.status === 'published'" @click="publishProcess(process.id)"
> class="p-1 text-green-600 hover:text-green-900 hover:bg-green-50 rounded"
<Icon name="material-symbols:delete" class="text-lg" /> title="Publish Process"
</button> :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> </div>
</td> </td>
</tr> </tr>
@ -379,14 +463,17 @@ onMounted(async () => {
</div> </div>
<!-- Delete confirmation dialog --> <!-- 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="p-4">
<div class="flex items-center mb-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> <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"> <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> </p>
</div> </div>
</div> </div>
@ -396,9 +483,9 @@ onMounted(async () => {
<RsButton @click="cancelDelete" variant="tertiary" :disabled="loading"> <RsButton @click="cancelDelete" variant="tertiary" :disabled="loading">
Cancel Cancel
</RsButton> </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" /> <Icon v-if="loading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-1" />
Delete Move to Trash
</RsButton> </RsButton>
</div> </div>
</template> </template>

View File

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

View File

@ -88,6 +88,7 @@ model process {
processSettings Json? processSettings Json?
processVariables Json? processVariables Json?
templateCategory String? @db.VarChar(100) templateCategory String? @db.VarChar(100)
processDeletedDate DateTime? @db.DateTime(0)
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID]) creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
@@index([processCreatedBy], map: "FK_process_creator") @@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 // Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-'); 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({ const existingProcess = await prisma.process.findFirst({
where: isUUID where: {
? { processUUID: processId } ...(isUUID
: { processID: parseInt(processId) }, ? { processUUID: processId }
: { processID: parseInt(processId) }),
processStatus: { not: 'deleted' } // Exclude already deleted processes
},
select: { select: {
processID: true, processID: true,
processName: true, processName: true,
@ -33,7 +36,7 @@ export default defineEventHandler(async (event) => {
if (!existingProcess) { if (!existingProcess) {
return { return {
success: false, 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') { if (existingProcess.processStatus === 'published') {
return { return {
success: false, 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 // Soft delete: Update status to 'deleted' instead of actual deletion
await prisma.process.delete({ // This preserves all associated data and relationships
await prisma.process.update({
where: isUUID where: isUUID
? { processUUID: processId } ? { processUUID: processId }
: { processID: parseInt(processId) } : { processID: parseInt(processId) },
data: {
processStatus: 'deleted',
// Optionally add deletion metadata
processDeletedDate: new Date(),
processModifiedDate: new Date()
}
}); });
return { return {
success: true, 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) { } catch (error) {
console.error('Error deleting process:', 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.processCategory !== undefined) updateData.processCategory = body.processCategory;
if (body.processPriority !== undefined) updateData.processPriority = body.processPriority; if (body.processPriority !== undefined) updateData.processPriority = body.processPriority;
if (body.processOwner !== undefined) updateData.processOwner = body.processOwner; 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.isTemplate !== undefined) updateData.isTemplate = body.isTemplate;
if (body.templateCategory !== undefined) updateData.templateCategory = body.templateCategory; 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) // Process definition (nodes, edges, viewport)
if (body.nodes !== undefined || body.edges !== undefined || body.viewport !== undefined) { if (body.nodes !== undefined || body.edges !== undefined || body.viewport !== undefined) {
updateData.processDefinition = { updateData.processDefinition = {
@ -61,16 +98,7 @@ export default defineEventHandler(async (event) => {
// Version increment if major changes // Version increment if major changes
if (body.incrementVersion === true) { if (body.incrementVersion === true) {
const currentProcess = await prisma.process.findFirst({ updateData.processVersion = currentProcess.processVersion + 1;
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
select: { processVersion: true }
});
if (currentProcess) {
updateData.processVersion = currentProcess.processVersion + 1;
}
} }
// Update the process // 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 // Build where clause
const where = {}; 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; where.processStatus = status;
} else if (status === 'deleted') {
// If specifically requesting deleted processes, only show those
where.processStatus = 'deleted';
} }
if (category) { if (category) {

View File

@ -254,6 +254,8 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
variables: useVariableStore().getAllVariables.process || {}, variables: useVariableStore().getAllVariables.process || {},
settings: this.currentProcess.settings || {}, settings: this.currentProcess.settings || {},
permissions: this.currentProcess.permissions || {} 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}`, { const response = await $fetch(`/api/process/${this.currentProcess.id}`, {
@ -293,7 +295,7 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
}); });
if (response.success) { 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); const index = this.processes.findIndex(p => p.id === processId);
if (index !== -1) { if (index !== -1) {
this.processes.splice(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 * Fetch all processes from database
*/ */