Add Form and Process Management Features

- Introduced new components for form selection and gateway condition management within the process builder.
- Implemented a `FormSelector` component for selecting and managing forms, including search functionality and loading states.
- Developed a `GatewayConditionManager` component to manage conditions for gateways, allowing users to define and edit conditions visually.
- Created a `ProcessBuilderComponents` component to facilitate the addition of core components in the process builder.
- Enhanced the `ProcessFlowCanvas` to support new features, including edge selection and improved node management.
- Updated the backend API to handle CRUD operations for forms and processes, including error handling for associated tasks.
- Integrated new database models for forms and processes in Prisma, ensuring proper relationships and data integrity.
- Improved state management in the form builder store to accommodate new features and enhance user experience.
This commit is contained in:
Md Afiq Iskandar 2025-05-15 10:27:55 +08:00
parent ac76e02825
commit bb5e4c0637
23 changed files with 3072 additions and 598 deletions

View File

@ -0,0 +1,263 @@
<template>
<div class="form-selector">
<div class="form-selector-header mb-2">
<h3 class="text-sm font-medium text-gray-700">Form Selection</h3>
</div>
<div v-if="loading" class="flex justify-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-2 border-blue-500 border-t-transparent"></div>
</div>
<div v-else-if="forms.length === 0" class="text-center py-4 text-gray-500">
<p>No forms available</p>
<RsButton
variant="secondary"
size="sm"
class="mt-2"
@click="createNewForm"
>
Create New Form
</RsButton>
</div>
<div v-else class="form-selector-content">
<div class="form-search mb-2">
<FormKit
type="text"
name="formSearch"
placeholder="Search forms..."
v-model="searchQuery"
:delay="200"
:classes="{
outer: 'mb-0',
input: 'w-full'
}"
/>
</div>
<div class="form-list max-h-60 overflow-y-auto border rounded-md">
<div
v-for="form in filteredForms"
:key="form.formUUID"
class="form-item p-2 hover:bg-gray-50 cursor-pointer border-b"
:class="{'bg-blue-50': selectedFormId === form.formID}"
@click="selectForm(form)"
>
<div class="flex justify-between items-center">
<div>
<div class="font-medium text-sm">{{ form.formName }}</div>
<div class="text-xs text-gray-500 truncate">{{ form.formDescription || 'No description' }}</div>
</div>
<div v-if="selectedFormId === form.formID" class="text-blue-500">
<Icon name="material-symbols:check-circle" />
</div>
</div>
</div>
</div>
<div class="form-selector-footer mt-3 flex justify-between">
<RsButton
variant="secondary"
size="sm"
@click="createNewForm"
>
Create New
</RsButton>
<RsButton
v-if="selectedForm"
variant="secondary"
size="sm"
@click="previewForm"
>
Preview Form
</RsButton>
</div>
</div>
<div v-if="selectedForm" class="selected-form-preview mt-4 p-3 border rounded-md bg-gray-50">
<div class="flex justify-between items-center mb-2">
<h4 class="text-sm font-medium">Selected Form</h4>
<div @click="clearSelection" class="text-red-500 cursor-pointer text-sm">
<Icon name="material-symbols:close" />
</div>
</div>
<div class="text-sm">{{ selectedForm.formName }}</div>
<div class="text-xs text-gray-500">{{ selectedForm.formDescription || 'No description' }}</div>
<div class="mt-2 text-xs text-gray-500">
<div class="flex justify-between">
<span>Created:</span>
<span>{{ formatDate(selectedForm.formCreatedDate) }}</span>
</div>
<div class="flex justify-between">
<span>Status:</span>
<span class="capitalize">{{ selectedForm.formStatus }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useFormBuilderStore } from '~/stores/formBuilder';
import { useRouter } from 'vue-router';
const props = defineProps({
formId: {
type: Number,
default: null
},
nodeData: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['select', 'clear']);
const router = useRouter();
const formStore = useFormBuilderStore();
const forms = ref([]);
const loading = ref(true);
const searchQuery = ref('');
const selectedFormId = ref(props.formId);
const selectedNodeData = ref(null);
// Fetch forms from the API
const fetchForms = async () => {
loading.value = true;
try {
// Use the API endpoint we created
const response = await fetch('/api/forms');
const result = await response.json();
if (result.success && Array.isArray(result.forms)) {
forms.value = result.forms;
} else {
console.error('Error in API response:', result.error || 'Unknown error');
forms.value = [];
}
} catch (error) {
console.error('Error fetching forms:', error);
forms.value = [];
} finally {
loading.value = false;
}
};
// Filter forms based on search query
const filteredForms = computed(() => {
if (!searchQuery.value) return forms.value;
const query = searchQuery.value.toLowerCase();
return forms.value.filter(form =>
form.formName.toLowerCase().includes(query) ||
(form.formDescription && form.formDescription.toLowerCase().includes(query))
);
});
// Get the selected form
const selectedForm = computed(() => {
if (!selectedFormId.value) return null;
return forms.value.find(form => form.formID === selectedFormId.value);
});
// Select a form
const selectForm = async (form) => {
selectedFormId.value = form.formID;
try {
// If we have a task ID in the node, update the task in the database
if (selectedNodeData.value?.id) {
const response = await fetch(`/api/tasks/${selectedNodeData.value.id}/form`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
formId: form.formID
})
});
const result = await response.json();
if (!result.success) {
console.error('Error connecting form to task:', result.error);
}
}
// Emit the form selection event for parent components
emit('select', form);
} catch (error) {
console.error('Error selecting form:', error);
}
};
// Clear the selection
const clearSelection = async () => {
selectedFormId.value = null;
try {
// If we have a task ID in the node, remove the form from the task in the database
if (selectedNodeData.value?.id) {
const response = await fetch(`/api/tasks/${selectedNodeData.value.id}/form`, {
method: 'DELETE'
});
const result = await response.json();
if (!result.success) {
console.error('Error removing form from task:', result.error);
}
}
// Emit the clear selection event for parent components
emit('clear');
} catch (error) {
console.error('Error clearing form selection:', error);
}
};
// Navigate to create a new form
const createNewForm = () => {
router.push('/form-builder');
};
// Preview the selected form
const previewForm = () => {
// This would open a preview modal or navigate to form preview
// For now, we'll just navigate to the form builder with the form ID
if (selectedForm.value) {
router.push(`/form-builder?id=${selectedForm.value.formUUID}`);
}
};
// Format date for display
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString();
};
// Load forms when component mounts
onMounted(() => {
fetchForms();
selectedNodeData.value = props.nodeData;
});
</script>
<style scoped>
.form-selector {
border-radius: 0.375rem;
}
.form-list {
border-radius: 0.375rem;
}
.form-item:last-child {
border-bottom: none;
}
</style>

View File

@ -0,0 +1,270 @@
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
conditions: {
type: Array,
default: () => []
},
availableVariables: {
type: Array,
default: () => [
{ name: 'amount', label: 'Amount', type: 'number' },
{ name: 'status', label: 'Status', type: 'string' },
{ name: 'priority', label: 'Priority', type: 'string' },
{ name: 'requestType', label: 'Request Type', type: 'string' },
{ name: 'dueDate', label: 'Due Date', type: 'date' },
{ name: 'isUrgent', label: 'Is Urgent', type: 'boolean' }
]
}
});
const emit = defineEmits(['update:conditions', 'add-condition', 'remove-condition']);
// Local copy of conditions
const localConditions = ref([...(props.conditions || [])]);
// Watch for external changes
watch(() => props.conditions, (newConditions) => {
localConditions.value = [...(newConditions || [])];
}, { deep: true });
// Operators based on variable type
const getOperatorsForType = (type) => {
switch (type) {
case 'number':
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' },
{ value: 'gt', label: '> (Greater than)' },
{ value: 'gte', label: '≥ (Greater than or equal to)' },
{ value: 'lt', label: '< (Less than)' },
{ value: 'lte', label: '≤ (Less than or equal to)' }
];
case 'string':
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' },
{ value: 'contains', label: 'Contains' },
{ value: 'startsWith', label: 'Starts with' },
{ value: 'endsWith', label: 'Ends with' }
];
case 'boolean':
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' }
];
case 'date':
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' },
{ value: 'gt', label: '> (After)' },
{ value: 'gte', label: '≥ (On or after)' },
{ value: 'lt', label: '< (Before)' },
{ value: 'lte', label: '≤ (On or before)' }
];
default:
return [
{ value: 'eq', label: '= (Equal to)' },
{ value: 'neq', label: '≠ (Not equal to)' }
];
}
};
// Get value input type based on variable type
const getInputTypeForVarType = (type) => {
switch (type) {
case 'number': return 'number';
case 'date': return 'date';
case 'boolean': return 'checkbox';
default: return 'text';
}
};
// Add new condition
const addCondition = () => {
const defaultVar = props.availableVariables[0];
const newCondition = {
id: `condition-${Date.now()}`,
variable: defaultVar.name,
operator: getOperatorsForType(defaultVar.type)[0].value,
value: '',
valueType: defaultVar.type,
output: '', // Output path label (e.g., "Yes" or "No")
};
localConditions.value.push(newCondition);
emit('update:conditions', [...localConditions.value]);
emit('add-condition', newCondition);
};
// Remove condition
const removeCondition = (index) => {
localConditions.value.splice(index, 1);
emit('update:conditions', [...localConditions.value]);
emit('remove-condition', index);
};
// Update condition
const updateCondition = (index, field, value) => {
if (field === 'variable') {
const selectedVar = props.availableVariables.find(v => v.name === value);
localConditions.value[index].variable = value;
localConditions.value[index].valueType = selectedVar.type;
// Reset operator to a valid one for this type
localConditions.value[index].operator = getOperatorsForType(selectedVar.type)[0].value;
// Reset value
localConditions.value[index].value = '';
} else {
localConditions.value[index][field] = value;
}
emit('update:conditions', [...localConditions.value]);
};
// Generate human-readable condition text
const conditionText = (condition) => {
if (!condition.variable || !condition.operator) return '';
const variable = props.availableVariables.find(v => v.name === condition.variable);
const operator = getOperatorsForType(variable?.type || 'string').find(op => op.value === condition.operator);
return `${variable?.label || condition.variable} ${operator?.label.split(' ')[0] || condition.operator} ${condition.value}`;
};
</script>
<template>
<div class="gateway-condition-manager">
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<h3 class="text-sm font-medium text-gray-700">Gateway Conditions</h3>
<button
@click="addCondition"
class="px-2 py-1 bg-orange-500 text-white text-xs rounded hover:bg-orange-600"
>
Add Condition
</button>
</div>
<div v-if="localConditions.length === 0" class="text-gray-500 text-sm italic mb-2">
No conditions defined. Default path will be taken.
</div>
<div v-else class="conditions-list space-y-4 mb-4">
<div
v-for="(condition, index) in localConditions"
:key="condition.id"
class="condition-item p-3 border rounded-md bg-orange-50 relative"
>
<button
@click="removeCondition(index)"
class="absolute top-1 right-1 text-gray-400 hover:text-red-500"
>
<i class="material-icons text-sm">close</i>
</button>
<div class="mb-2">
<div class="text-xs font-medium mb-1">If</div>
<div class="condition-grid grid grid-cols-3 gap-2">
<!-- Variable -->
<select
:value="condition.variable"
@change="updateCondition(index, 'variable', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs"
>
<option
v-for="variable in availableVariables"
:key="variable.name"
:value="variable.name"
>
{{ variable.label }}
</option>
</select>
<!-- Operator -->
<select
:value="condition.operator"
@change="updateCondition(index, 'operator', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs"
>
<option
v-for="operator in getOperatorsForType(
props.availableVariables.find(v => v.name === condition.variable)?.type || 'string'
)"
:key="operator.value"
:value="operator.value"
>
{{ operator.label }}
</option>
</select>
<!-- Value -->
<input
v-if="condition.valueType !== 'boolean'"
:type="getInputTypeForVarType(condition.valueType)"
:value="condition.value"
@input="updateCondition(index, 'value', $event.target.value)"
class="col-span-1 p-2 border rounded text-xs"
:placeholder="'Value'"
/>
<div v-else class="col-span-1 p-2 border rounded flex items-center">
<input
type="checkbox"
:checked="condition.value === true || condition.value === 'true'"
@change="updateCondition(index, 'value', $event.target.checked)"
class="mr-2"
/>
<span class="text-xs">{{ condition.value === true || condition.value === 'true' ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Output path -->
<div class="mb-1">
<div class="text-xs font-medium mb-1">Then follow path:</div>
<input
type="text"
:value="condition.output"
@input="updateCondition(index, 'output', $event.target.value)"
class="w-full p-2 border rounded text-xs"
placeholder="Path label (e.g. 'Yes', 'Approved')"
/>
</div>
<!-- Condition summary -->
<div class="mt-2 p-2 bg-white rounded-md border border-orange-200">
<div class="text-xs text-orange-800">
<span class="font-medium">Condition:</span>
{{ conditionText(condition) }} {{ condition.output || 'Unlabeled path' }}
</div>
</div>
</div>
</div>
<!-- Default path -->
<div class="default-path p-3 border rounded-md bg-gray-50">
<div class="text-xs font-medium mb-1">Default Path (when no conditions match):</div>
<input
type="text"
value="Default"
class="w-full p-2 border rounded text-xs"
placeholder="Default path label (e.g. 'No', 'Rejected')"
/>
</div>
</div>
</div>
</template>
<style scoped>
.gateway-condition-manager {
font-size: 0.875rem;
}
.condition-item {
transition: all 0.2s;
}
.condition-item:hover {
border-color: #f97316;
}
</style>

View File

@ -0,0 +1,180 @@
<template>
<div class="process-components">
<!-- Search Bar -->
<div class="search-container p-3 mb-2">
<div class="relative">
<input
type="text"
v-model="searchQuery"
placeholder="Search..."
class="w-full px-3 py-2 pl-9 bg-white border border-gray-300 rounded text-gray-700 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<Icon
name="material-symbols:search"
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"
/>
</div>
</div>
<!-- Core Components -->
<div class="component-category mb-6">
<h3 class="text-gray-700 text-sm font-medium px-3 mb-2">Core Components</h3>
<div class="grid grid-cols-2 gap-1 px-2">
<div
v-for="component in getComponentsByCategory('Core')"
:key="component.type"
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200"
:class="{ 'hidden': !matchesSearch(component) }"
draggable="true"
@dragstart="onDragStart($event, component)"
@click="addComponent(component)"
>
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['add-component']);
const searchQuery = ref('');
// Define basic process components aligned with BPMN notation
const availableComponents = [
// Core components
{
type: 'start',
name: 'Start Event',
category: 'Core',
icon: 'material-symbols:play-circle-outline',
description: 'Initiates the process flow',
defaultProps: {
label: 'Start',
data: {
description: 'Process start point'
}
}
},
{
type: 'end',
name: 'End Event',
category: 'Core',
icon: 'material-symbols:stop-circle-outline',
description: 'Terminates the process flow',
defaultProps: {
label: 'End',
data: {
description: 'Process end point'
}
}
},
{
type: 'task',
name: 'Task',
category: 'Core',
icon: 'material-symbols:assignment-outline',
description: 'Manual or automated task',
defaultProps: {
label: 'Task',
data: {
description: 'A general task',
assignee: ''
}
}
},
{
type: 'form',
name: 'Form Task',
category: 'Core',
icon: 'material-symbols:description-outline',
description: 'Form to be filled out',
defaultProps: {
label: 'Form Task',
data: {
description: 'Form submission task',
formId: null,
formName: null
}
}
},
{
type: 'gateway',
name: 'Gateway',
category: 'Core',
icon: 'material-symbols:call-split',
description: 'Decision point for flow control',
defaultProps: {
label: 'Gateway',
data: {
description: 'Decision gateway',
conditions: [],
defaultPath: 'Default'
}
}
}
];
// Get components by category for rendering in sections
const getComponentsByCategory = (category) => {
return availableComponents.filter(component => component.category === category);
};
// Check if component matches search query
const matchesSearch = (component) => {
if (!searchQuery.value) return true;
const query = searchQuery.value.toLowerCase();
return (
component.name.toLowerCase().includes(query) ||
component.description.toLowerCase().includes(query) ||
component.type.toLowerCase().includes(query)
);
};
// Handle drag start event
const onDragStart = (event, component) => {
// Set the component data in the format expected by ProcessFlowCanvas
const componentData = {
type: component.type,
label: component.defaultProps.label,
data: component.defaultProps.data
};
// Set the drag data
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/json', JSON.stringify(componentData));
};
// Add a component directly via click
const addComponent = (component) => {
// Use same format as drag operation for consistency
const componentData = {
type: component.type,
label: component.defaultProps.label,
data: component.defaultProps.data
};
emit('add-component', componentData);
};
</script>
<style scoped>
.process-components {
@apply bg-white h-full;
}
.component-item {
@apply h-20;
transition: all 0.15s ease-in-out;
}
.component-item:hover {
transform: translateY(-2px);
}
.component-item:active {
transform: scale(0.97);
}
</style>

View File

@ -27,7 +27,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['nodeClick', 'edgeClick', 'paneClick', 'nodesChange', 'edgesChange', 'nodeSelected']);
const emit = defineEmits(['nodeClick', 'edgeClick', 'paneClick', 'nodesChange', 'edgesChange', 'nodeSelected', 'edgeSelected']);
// Get the flow instance
const { flowInstance } = useVueFlow();
@ -57,110 +57,10 @@ const {
});
// Default nodes if empty
const defaultNodes = [
{
id: 'start',
type: 'start',
label: 'Start Process',
position: { x: 100, y: 100 },
data: {
description: 'Process starts here'
}
},
{
id: 'form1',
type: 'form',
label: 'Request Form',
position: { x: 100, y: 250 },
data: {
description: 'User fills out request form',
formName: 'Request Form'
}
},
{
id: 'gateway',
type: 'gateway',
label: 'Approval Required?',
position: { x: 100, y: 400 },
data: {
description: 'Check if approval is required',
conditions: ['Amount > 1000', 'Special Request']
}
},
{
id: 'task1',
type: 'task',
label: 'Manager Approval',
position: { x: 250, y: 550 },
data: {
description: 'Manager reviews the request',
assignee: 'Department Manager'
}
},
{
id: 'script1',
type: 'script',
label: 'Process Request',
position: { x: -50, y: 550 },
data: {
description: 'Automatically process the request',
language: 'JavaScript'
}
},
{
id: 'end',
type: 'end',
label: 'End Process',
position: { x: 100, y: 700 },
data: {
description: 'Process completes here'
}
}
];
const defaultNodes = [];
// Default edges if empty
const defaultEdges = [
{
id: 'start-form1',
source: 'start',
target: 'form1',
animated: true
},
{
id: 'form1-gateway',
source: 'form1',
target: 'gateway',
animated: true
},
{
id: 'gateway-task1',
source: 'gateway',
target: 'task1',
animated: true,
label: 'Yes',
type: 'smoothstep'
},
{
id: 'gateway-script1',
source: 'gateway',
target: 'script1',
animated: true,
label: 'No',
type: 'smoothstep'
},
{
id: 'task1-end',
source: 'task1',
target: 'end',
animated: true
},
{
id: 'script1-end',
source: 'script1',
target: 'end',
animated: true
}
];
const defaultEdges = [];
// Flow configuration
const flowOptions = ref({
@ -219,7 +119,18 @@ const onNodeClick = ({ node }) => {
};
// Handle edge click
const onEdgeClick = (_, edge) => {
const onEdgeClick = (event, edge) => {
// Create a simplified copy of the edge data
const edgeData = {
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label || '',
sourceNode: nodes.value.find(node => node.id === edge.source),
targetNode: nodes.value.find(node => node.id === edge.target)
};
emit('edgeSelected', edgeData);
emit('edgeClick', edge);
};
@ -274,13 +185,46 @@ onEdgesChange((changes) => {
const handleConnect = (connection) => {
if (!connection.source || !connection.target) return;
// Try to determine if this is coming from a gateway
const sourceNode = nodes.value.find(node => node.id === connection.source);
let label = '';
// If the source is a gateway, we should add a label based on conditions
if (sourceNode && sourceNode.type === 'gateway') {
// Check if there's a default path label
if (sourceNode.data && sourceNode.data.defaultPath) {
label = sourceNode.data.defaultPath;
}
// For existing gateway connections, check if we should use a condition's output
const existingEdges = edges.value.filter(edge => edge.source === connection.source);
if (existingEdges.length === 0 && sourceNode.data.conditions && sourceNode.data.conditions.length > 0) {
// If this is the first connection and we have conditions, use the first condition's output
const firstCondition = sourceNode.data.conditions[0];
if (firstCondition && firstCondition.output) {
label = firstCondition.output;
}
} else if (sourceNode.data.conditions) {
// If we already have connections, try to find an unused condition
const usedOutputs = existingEdges.map(edge => edge.label);
const unusedCondition = sourceNode.data.conditions.find(condition =>
condition.output && !usedOutputs.includes(condition.output)
);
if (unusedCondition) {
label = unusedCondition.output;
}
}
}
const newEdge = {
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
type: 'smoothstep',
animated: true,
style: { stroke: '#555' }
style: { stroke: '#555' },
label: label
};
addEdges([newEdge]);
@ -288,15 +232,38 @@ const handleConnect = (connection) => {
};
// Handle node removal
const onNodeDelete = (nodes) => {
removeNodes(nodes);
emit('nodesChange', nodes.value);
const onNodeDelete = (event) => {
// Check if we have a node in the event
if (event && event.node) {
removeNodes([event.node]);
emit('nodesChange', nodes.value);
}
};
// Handle edge removal
const onEdgeDelete = (edges) => {
removeEdges(edges);
emit('edgesChange', edges.value);
const onEdgeDelete = (event) => {
// Check if we have an edge in the event
if (event && event.edge) {
removeEdges([event.edge]);
emit('edgesChange', edges.value);
}
};
// Handle delete key press to remove selected elements
const onDeleteKeyPress = () => {
const { getSelectedNodes, getSelectedEdges } = flowInstance.value;
const selectedNodes = getSelectedNodes();
const selectedEdges = getSelectedEdges();
if (selectedNodes.length > 0) {
removeNodes(selectedNodes);
emit('nodesChange', nodes.value);
}
if (selectedEdges.length > 0) {
removeEdges(selectedEdges);
emit('edgesChange', edges.value);
}
};
// Handle drop event
@ -359,6 +326,7 @@ const onDragOver = (event) => {
@connect="handleConnect"
@nodeDoubleClick="onNodeDelete"
@edgeDoubleClick="onEdgeDelete"
@keyup.delete="onDeleteKeyPress"
>
<Background pattern-color="#aaa" gap="20" />
<Controls />
@ -385,10 +353,10 @@ const onDragOver = (event) => {
/* Node styles from ProcessFlowNodes.js are imported globally in a plugin */
.process-flow-container {
width: 100%;
height: calc(100vh - 216px); /* Adjust based on header/footer height */
min-height: 600px;
height: calc(100vh - 190px); /* Adjust based on new header/footer height */
min-height: 500px;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
border-radius: 0;
overflow: hidden;
position: relative;
flex: 1;

View File

@ -12,27 +12,37 @@ const CustomNode = markRaw({
v-if="type !== 'start'"
type="target"
position="top"
:style="{ background: '#555' }"
:class="'handle-' + type + '-input'"
/>
<div class="custom-node-header">
<div class="custom-node-icon">
<slot name="icon"></slot>
</div>
<div class="custom-node-title">{{ label }}</div>
<div class="custom-node-badge" v-if="showBadge">
<slot name="badge"></slot>
</div>
</div>
<div class="custom-node-content">
<slot></slot>
<template v-if="type === 'task' || type === 'form' || type === 'script'">
<div class="flex items-center mb-1">
<div class="custom-node-icon">
<slot name="icon"></slot>
</div>
<div class="custom-node-title">{{ label }}</div>
<div class="custom-node-badge" v-if="showBadge">
<slot name="badge"></slot>
</div>
</div>
<slot></slot>
</template>
<template v-else>
<div class="custom-node-icon" v-if="type !== 'gateway'">
<slot name="icon"></slot>
</div>
<div class="custom-node-title">{{ label }}</div>
<slot></slot>
</template>
</div>
<Handle
v-if="type !== 'end'"
type="source"
position="bottom"
:style="{ background: '#555' }"
:class="'handle-' + type + '-output'"
/>
</div>
`,
@ -64,11 +74,11 @@ export const TaskNode = markRaw({
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons' }, 'assignment'),
icon: () => h('i', { class: 'material-icons text-blue-500' }, 'assignment'),
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Task node'),
h('p', { class: 'node-description' }, this.data?.description || 'A general task'),
h('div', { class: 'node-assignee' }, [
h('span', { class: 'node-assignee-label' }, 'Assigned to: '),
h('span', { class: 'node-assignee-label' }, 'Assigned to:'),
h('span', { class: 'node-assignee-value' }, this.data?.assignee || 'Unassigned')
])
])
@ -88,10 +98,8 @@ export const StartNode = markRaw({
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons' }, 'play_circle_filled'),
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Process starts here')
])
icon: () => h('i', { class: 'material-icons text-green-600' }, 'play_arrow'),
default: () => null
});
}
});
@ -108,10 +116,8 @@ export const EndNode = markRaw({
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons' }, 'stop_circle'),
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Process ends here')
])
icon: () => h('i', { class: 'material-icons text-red-600' }, 'stop'),
default: () => null
});
}
});
@ -123,18 +129,18 @@ export const GatewayNode = markRaw({
return h(CustomNode, {
id: this.id,
type: 'gateway',
label: this.label || 'Decision',
label: this.label || 'Gateway',
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons' }, 'call_split'),
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Decision point'),
h('div', { class: 'node-conditions' }, [
h('span', { class: 'node-conditions-label' }, 'Conditions: '),
h('span', { class: 'node-conditions-value' }, this.data?.conditions?.length || '0')
])
icon: () => h('i', { class: 'material-icons text-orange-500' }, 'call_split'),
default: () => h('div', { class: 'gateway-details' }, [
h('div', { class: 'node-conditions-value' },
this.data?.conditions?.length
? `${this.data.conditions.length} condition${this.data.conditions.length > 1 ? 's' : ''}`
: ''
)
])
});
}
@ -144,21 +150,31 @@ export const GatewayNode = markRaw({
export const FormNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'],
render() {
// Check if we have a form selected
const hasForm = this.data?.formId && this.data?.formName;
// Create badge content based on form selection status
const badgeContent = hasForm ?
h('span', { class: 'node-badge bg-purple-100 text-purple-600 px-1 text-xs rounded' }, 'Form') :
null;
return h(CustomNode, {
id: this.id,
type: 'form',
label: this.label || 'Form',
label: this.label || 'Form Task',
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons' }, 'description'),
badge: () => this.data?.formId ? h('span', { class: 'node-badge' }, 'F') : null,
icon: () => h('i', { class: 'material-icons text-purple-500' }, 'description'),
badge: () => badgeContent,
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Form submission'),
h('p', { class: 'node-description' }, this.data?.description || 'Form submission task'),
h('div', { class: 'node-form-info' }, [
h('span', { class: 'node-form-label' }, 'Form: '),
h('span', { class: 'node-form-value' }, this.data?.formName || 'None selected')
h('span', { class: 'node-form-label' }, 'Form:'),
h('span', {
class: hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic'
}, hasForm ? this.data.formName : 'None selected')
])
])
});
@ -177,7 +193,7 @@ export const ScriptNode = markRaw({
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons' }, 'code'),
icon: () => h('i', { class: 'material-icons script-icon' }, 'code'),
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Script execution'),
h('div', { class: 'node-script-info' }, [
@ -202,16 +218,11 @@ export const nodeTypes = markRaw({
// Default CSS for the nodes to be imported where needed
export const nodeStyles = `
.custom-node {
border-radius: 6px;
padding: 12px;
color: #333;
background: white;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
width: 200px;
font-size: 12px;
border: 2px solid transparent;
transition: all 0.2s;
position: relative;
color: #333;
font-size: 12px;
transition: all 0.2s;
border: 1px solid transparent;
}
.custom-node.selected {
@ -219,79 +230,255 @@ export const nodeStyles = `
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.2);
}
.custom-node-header {
/* Base styles for different node types */
.node-task, .node-form, .node-script {
width: 180px;
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-height: 50px;
display: flex;
flex-direction: column;
padding: 0;
border: 1px solid #e0e0e0;
}
.node-gateway {
width: 50px;
height: 50px;
background: white;
transform: rotate(45deg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #FF9800;
}
.node-start, .node-end {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
margin-bottom: 8px;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.node-start {
background: #e8f5e9;
border: 1px solid #4CAF50;
}
.node-end {
background: #ffebee;
border: 1px solid #f44336;
}
/* Content positioning */
.custom-node-content {
padding: 8px;
position: relative;
z-index: 2;
}
.node-gateway .custom-node-content {
transform: rotate(-45deg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
padding: 0;
}
.node-start .custom-node-content,
.node-end .custom-node-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 0;
}
.custom-node-icon {
margin-right: 8px;
margin-right: 6px;
display: inline-flex;
align-items: center;
}
.node-start .custom-node-icon,
.node-end .custom-node-icon {
margin: 0;
}
.custom-node-icon .material-icons {
font-size: 20px;
font-size: 16px;
}
.custom-node-title {
.node-start .material-icons,
.node-end .material-icons {
font-size: 14px;
}
.node-task .custom-node-title,
.node-form .custom-node-title,
.node-script .custom-node-title {
font-weight: 500;
flex-grow: 1;
font-size: 11px;
display: flex;
align-items: center;
margin-bottom: 4px;
}
.custom-node-content {
font-size: 12px;
color: #666;
.node-end .custom-node-title {
position: absolute;
width: 60px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
bottom: -29px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
font-weight: 500;
text-align: center;
}
.node-start .custom-node-title {
position: absolute;
width: 60px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
bottom: 51px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
font-weight: 500;
text-align: center;
}
.node-gateway .custom-node-title {
font-size: 9px;
font-weight: 500;
position: absolute;
width: 60px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
bottom: -18px;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.node-details {
margin-top: 8px;
margin-top: 4px;
}
.node-description {
margin-bottom: 4px;
margin-bottom: 2px;
color: #666;
white-space: normal;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 10px;
}
.node-assignee,
.node-form-info,
.node-script-info,
.node-conditions {
font-size: 11px;
color: #888;
display: flex;
gap: 4px;
}
.node-badge {
background: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
color: #666;
align-items: center;
}
.node-assignee-label,
.node-form-label,
.node-script-label {
font-weight: 500;
margin-right: 4px;
}
/* Node type specific styles */
.node-start .custom-node-icon .material-icons {
color: #4CAF50;
.node-form-value,
.node-script-value,
.node-assignee-value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-end .custom-node-icon .material-icons {
color: #f44336;
.node-conditions-value {
font-size: 9px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.node-task .custom-node-icon .material-icons {
color: #2196F3;
.node-form-id {
font-size: 9px;
color: #999;
}
.node-form .custom-node-icon .material-icons {
color: #9C27B0;
.gateway-details {
font-size: 9px;
text-align: center;
}
.node-gateway .custom-node-icon .material-icons {
color: #FF9800;
.handle-task-input,
.handle-form-input,
.handle-script-input,
.handle-gateway-input {
top: -5px !important;
width: 8px !important;
height: 8px !important;
border-radius: 50% !important;
}
.node-script .custom-node-icon .material-icons {
color: #607D8B;
.handle-task-output,
.handle-form-output,
.handle-script-output,
.handle-gateway-output {
bottom: -5px !important;
width: 8px !important;
height: 8px !important;
border-radius: 50% !important;
}
.handle-start-output {
bottom: -5px !important;
width: 8px !important;
height: 8px !important;
border-radius: 50% !important;
}
.handle-end-input {
top: -5px !important;
width: 8px !important;
height: 8px !important;
border-radius: 50% !important;
}
/* Position handles correctly for gateway node */
.handle-gateway-input {
transform: translateY(-14px) !important;
}
.handle-gateway-output {
transform: translateY(14px) !important;
}
/* Badge style */
.node-badge {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
margin-left: 4px;
}
`;

View File

@ -251,7 +251,20 @@ definePageMeta({
const router = useRouter();
const formStore = useFormBuilderStore();
const toast = useToast();
let toast;
// Try to use the toast composable if available
try {
toast = useToast();
} catch (error) {
// Create a simple toast object if composable is not available
toast = {
success: (msg) => console.log('Success:', msg),
error: (msg) => console.error('Error:', msg),
info: (msg) => console.info('Info:', msg),
warning: (msg) => console.warn('Warning:', msg)
};
}
const showPreview = ref(false);
const showUnsavedChangesModal = ref(false);
@ -271,8 +284,27 @@ const formName = computed({
});
// Initialize the form builder
onMounted(() => {
formStore.loadSavedForms();
onMounted(async () => {
try {
await formStore.loadSavedForms();
// Check if there's a form ID in the URL query parameters
const route = useRoute();
const formId = route.query.id;
if (formId) {
try {
await formStore.loadForm(formId);
toast.success(`Form '${formStore.formName}' loaded successfully`);
} catch (error) {
console.error('Error loading form from ID:', error);
toast.error(`Failed to load form: ${error.message || 'Unknown error'}`);
}
}
} catch (error) {
console.error('Error initializing form builder:', error);
toast.error(`Initialization error: ${error.message || 'Unknown error'}`);
}
// Add the beforeunload event listener
window.addEventListener('beforeunload', handleBeforeUnload);
@ -391,7 +423,7 @@ const handleDrop = (event) => {
formStore.addComponent(componentData);
};
const handleSave = () => {
const handleSave = async () => {
if (!formStore.formName.trim()) {
toast.error("Please enter a form name");
return;
@ -402,8 +434,17 @@ const handleSave = () => {
return;
}
formStore.saveForm();
toast.success("Form saved successfully");
try {
const savedForm = await formStore.saveForm();
toast.success("Form saved successfully");
// Update URL to include the form ID without reloading the page
const newPath = `/form-builder?id=${savedForm.formUUID}`;
window.history.replaceState({}, '', newPath);
} catch (error) {
console.error("Error saving form:", error);
toast.error(`Failed to save form: ${error.message || 'Unknown error'}`);
}
};
const handlePreview = () => {

View File

@ -160,13 +160,32 @@ definePageMeta({
const router = useRouter();
const formStore = useFormBuilderStore();
const toast = useToast();
let toast;
// Try to use the toast composable if available
try {
toast = useToast();
} catch (error) {
// Create a simple toast object if composable is not available
toast = {
success: (msg) => console.log('Success:', msg),
error: (msg) => console.error('Error:', msg),
info: (msg) => console.info('Info:', msg),
warning: (msg) => console.warn('Warning:', msg)
};
}
const searchQuery = ref("");
const showUnsavedChangesModal = ref(false);
// Initialize and load forms
onMounted(() => {
formStore.loadSavedForms();
onMounted(async () => {
try {
await formStore.loadSavedForms();
} catch (error) {
console.error("Error loading forms:", error);
toast.error("Failed to load forms: " + (error.message || "Unknown error"));
}
});
// Format date for display
@ -213,18 +232,29 @@ const confirmNavigation = () => {
router.push("/form-builder");
};
const editForm = (formId) => {
formStore.loadForm(formId);
router.push("/form-builder");
const editForm = async (formId) => {
try {
await formStore.loadForm(formId);
router.push("/form-builder");
} catch (error) {
console.error("Error loading form:", error);
toast.error("Failed to load form: " + (error.message || "Unknown error"));
}
};
const deleteForm = (formId) => {
const deleteForm = async (formId) => {
if (confirm("Are you sure you want to delete this form?")) {
const index = formStore.savedForms.findIndex((f) => f.id === formId);
if (index !== -1) {
formStore.savedForms.splice(index, 1);
localStorage.setItem("savedForms", JSON.stringify(formStore.savedForms));
try {
// Call the API to delete the form
await formStore.deleteForm(formId);
// Refresh the forms list
await formStore.loadSavedForms();
toast.success("Form deleted successfully");
} catch (error) {
console.error("Error deleting form:", error);
toast.error("Failed to delete form: " + (error.message || "Unknown error"));
}
}
};

View File

@ -1,8 +1,21 @@
<script setup>
import { ref, onMounted, computed, shallowRef } from 'vue';
import { ref, onMounted, computed, shallowRef, onUnmounted } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useRouter } from 'vue-router';
import ProcessFlowCanvas from '~/components/process-flow/ProcessFlowCanvas.vue';
import ProcessBuilderComponents from '~/components/process-flow/ProcessBuilderComponents.vue';
import FormSelector from '~/components/process-flow/FormSelector.vue';
import GatewayConditionManager from '~/components/process-flow/GatewayConditionManager.vue';
import { onBeforeRouteLeave } from 'vue-router';
// Define page meta
definePageMeta({
title: "Process Builder",
description: "Create business processes with drag and drop",
layout: "empty",
middleware: ["auth"],
requiresAuth: true,
});
// Initialize the store and router
const processStore = useProcessBuilderStore();
@ -23,6 +36,15 @@ const selectedNodeData = ref(null);
// Track drag data
const draggedComponent = ref(null);
// Add a variable to track selected edge
const selectedEdgeData = ref(null);
// Add variables for navigation handling
const showUnsavedChangesModal = ref(false);
const pendingNavigation = ref(null);
const navigationTarget = ref(null);
const navigationConfirmed = ref(false);
// Component definitions
const components = [
{
@ -125,37 +147,142 @@ const nodeLanguage = computed({
}
});
// Add a computed property for gateway conditions
const nodeConditions = computed({
get: () => selectedNodeData.value?.data?.conditions || [],
set: (value) => {
if (selectedNodeData.value) {
selectedNodeData.value.data.conditions = value;
updateNodeInStore();
}
}
});
// Add a computed property for gateway default path
const nodeDefaultPath = computed({
get: () => selectedNodeData.value?.data?.defaultPath || 'Default',
set: (value) => {
if (selectedNodeData.value) {
selectedNodeData.value.data.defaultPath = value;
updateNodeInStore();
}
}
});
// Handle node selection
const onNodeSelected = (node) => {
selectedNodeData.value = JSON.parse(JSON.stringify(node));
selectedNode.value = node;
selectedEdgeData.value = null;
processStore.selectNode(node.id);
};
// Handle edge selection
const onEdgeSelected = (edge) => {
selectedEdgeData.value = edge;
selectedNode.value = null;
selectedNodeData.value = null;
processStore.selectEdge(edge.id);
};
// Update edge label
const updateEdgeLabel = (value) => {
if (selectedEdgeData.value) {
processStore.updateEdge(selectedEdgeData.value.id, { label: value });
selectedEdgeData.value.label = value;
}
};
// Update the pane click handler to clear edge selection too
const onPaneClick = () => {
selectedNode.value = null;
selectedNodeData.value = null;
selectedEdgeData.value = null;
processStore.clearSelection();
};
// Update node in store
const updateNodeInStore = () => {
if (selectedNodeData.value) {
processStore.updateNode(selectedNodeData.value);
// Make sure we're passing the nodeId and updates correctly
processStore.updateNode(selectedNodeData.value.id, {
label: selectedNodeData.value.label,
data: selectedNodeData.value.data
});
}
};
// Handle pane click (deselection)
const onPaneClick = () => {
selectedNode.value = null;
selectedNodeData.value = null;
processStore.clearSelection();
// Handle condition update
const handleConditionUpdate = (conditions) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
selectedNodeData.value.data.conditions = conditions;
updateNodeInStore();
}
};
// Handle node changes
const onNodesChange = (changes, nodes) => {
// For now just log changes
// console.log('Nodes changed:', changes);
// Handle updates from the canvas when nodes change
const onNodesChange = (changes, currentNodes) => {
if (!changes || !currentNodes) return;
// Handle node removals
const removedNodes = changes
.filter(change => change.type === 'remove')
.map(change => change.id);
if (removedNodes.length > 0) {
removedNodes.forEach(nodeId => {
processStore.deleteNode(nodeId);
});
// Clear selection if the selected node was deleted
if (selectedNodeData.value && removedNodes.includes(selectedNodeData.value.id)) {
selectedNodeData.value = null;
}
}
// Handle position changes
const positionChanges = {};
changes
.filter(change => change.type === 'position' && change.position)
.forEach(change => {
positionChanges[change.id] = change.position;
});
if (Object.keys(positionChanges).length > 0) {
processStore.updateNodePositions(positionChanges);
}
// Update node selection if needed
if (selectedNodeData.value) {
const updatedNode = currentNodes.find(node => node.id === selectedNodeData.value.id);
if (updatedNode) {
selectedNodeData.value = { ...updatedNode };
}
}
};
// Handle edge changes
const onEdgesChange = (changes, edges) => {
// For now just log changes
// console.log('Edges changed:', changes);
// Handle updates from the canvas when edges change
const onEdgesChange = (changes, currentEdges) => {
if (!changes || !currentEdges) return;
// Handle edge removals
const removedEdges = changes
.filter(change => change.type === 'remove')
.map(change => change.id);
if (removedEdges.length > 0) {
removedEdges.forEach(edgeId => {
processStore.deleteEdge(edgeId);
});
// Clear selection if the selected edge was deleted
if (selectedEdgeData.value && removedEdges.includes(selectedEdgeData.value.id)) {
selectedEdgeData.value = null;
}
}
// Sync all edges
processStore.currentProcess.edges = currentEdges;
};
// Handle creating a new process
@ -172,24 +299,91 @@ const createNewProcess = () => {
newProcessDescription.value = '';
};
// Go to process management
const goToManage = () => {
router.push('/process-builder/manage');
};
// Mock demo process for testing if no process exists
const createDemoProcess = () => {
const process = processStore.createProcess('Demo Process', 'A demonstration process flow');
processStore.setCurrentProcess(process.id);
};
// Check if we have any processes, if not create a demo one
onMounted(() => {
if (!processStore.currentProcess && processStore.processes.length === 0) {
createDemoProcess();
// Add navigation guard
onBeforeRouteLeave((to, from, next) => {
// If navigation was already confirmed or there are no unsaved changes, proceed
if (navigationConfirmed.value || !processStore.hasUnsavedChanges) {
next();
return;
}
// Otherwise show the confirmation modal
showUnsavedChangesModal.value = true;
pendingNavigation.value = () => {
navigationConfirmed.value = true;
next();
};
next(false);
});
// Navigation handlers
const cancelNavigation = () => {
showUnsavedChangesModal.value = false;
pendingNavigation.value = null;
navigationTarget.value = null;
navigationConfirmed.value = false;
};
// Update the confirmNavigation function to handle targets
const confirmNavigation = (target) => {
// If already confirmed or no unsaved changes, navigate directly
if (navigationConfirmed.value || !processStore.hasUnsavedChanges) {
router.push(target);
return;
}
// Otherwise show confirmation modal
showUnsavedChangesModal.value = true;
navigationTarget.value = target;
};
// Add proceeding with navigation
const proceedWithNavigation = () => {
showUnsavedChangesModal.value = false;
if (pendingNavigation.value) {
pendingNavigation.value();
} else if (navigationTarget.value) {
navigationConfirmed.value = true; // Mark as confirmed before navigating
router.push(navigationTarget.value);
}
};
// Update the goToManage function to use the navigation system
const goToManage = () => {
// If already confirmed or no unsaved changes, navigate directly
if (navigationConfirmed.value || !processStore.hasUnsavedChanges) {
router.push('/process-builder/manage');
return;
}
// Otherwise show confirmation modal
showUnsavedChangesModal.value = true;
navigationTarget.value = "/process-builder/manage";
};
// Add events for beforeunload
onMounted(() => {
// No automatic process creation - let the user create one explicitly
// Add the beforeunload event listener
window.addEventListener('beforeunload', handleBeforeUnload);
});
onUnmounted(() => {
// Remove event listeners
window.removeEventListener('beforeunload', handleBeforeUnload);
});
// Show warning if there are unsaved changes
const handleBeforeUnload = (event) => {
if (processStore.hasUnsavedChanges) {
event.preventDefault();
event.returnValue = '';
return '';
}
};
// Handle drag start
const onDragStart = (event, component) => {
try {
@ -219,225 +413,447 @@ const onDragEnd = (event) => {
draggedComponent.value = null;
};
// Generate unique node ID
const generateNodeId = (type) => {
return `${type}-${Math.random().toString(36).substr(2, 9)}`;
// Add these functions to handle form selection
const handleFormSelection = (form) => {
if (selectedNodeData.value && form) {
selectedNodeData.value.data.formId = form.formID;
selectedNodeData.value.data.formName = form.formName;
selectedNodeData.value.data.formUuid = form.formUUID;
updateNodeInStore();
}
};
const clearFormSelection = () => {
if (selectedNodeData.value) {
selectedNodeData.value.data.formId = null;
selectedNodeData.value.data.formName = '';
selectedNodeData.value.data.formUuid = null;
updateNodeInStore();
}
};
// Delete current node
const deleteNode = () => {
if (selectedNodeData.value) {
// Store the node ID before clearing the selection
const nodeId = selectedNodeData.value.id;
// Clear selection first to avoid references to deleted node
selectedNodeData.value = null;
selectedNode.value = null;
// Delete the node
processStore.deleteNode(nodeId);
}
};
// Delete current edge
const deleteEdge = () => {
if (selectedEdgeData.value) {
processStore.deleteEdge(selectedEdgeData.value.id);
selectedEdgeData.value = null;
}
};
// Save current process
const saveProcess = () => {
processStore.saveProcess();
};
// 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
};
// Add the node to the process
processStore.addNode(newNode);
// Select the newly added node
onNodeSelected(newNode);
};
// Fix references to functions
const onFormSelected = (formData) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'form') {
selectedNodeData.value.data.formId = formData.id;
selectedNodeData.value.data.formName = formData.name;
updateNodeInStore();
}
};
const onConditionsUpdated = (conditions) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
selectedNodeData.value.data.conditions = conditions;
updateNodeInStore();
}
};
</script>
<template>
<div class="process-builder-container h-[calc(100vh-100px)] flex flex-col">
<div class="process-builder-header mb-4 flex justify-between items-center p-4 bg-white shadow-sm">
<h1 class="text-2xl font-bold">Process Builder</h1>
<div class="process-builder-actions flex gap-2">
<button
v-if="!isCreatingProcess"
@click="isCreatingProcess = true"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
New Process
</button>
<button
v-if="hasCurrentProcess"
@click="processStore.saveProcess()"
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
:disabled="!processStore.hasUnsavedChanges"
>
Save Process
</button>
<button
@click="goToManage"
class="px-4 py-2 border rounded hover:bg-gray-100"
>
Manage Processes
</button>
<div class="process-builder flex flex-col h-screen bg-white">
<!-- Header Bar -->
<header
class="bg-gray-800 px-4 py-4 flex items-center justify-between text-white shadow-md"
>
<div class="flex items-center gap-3">
<img
src="@/assets/img/logo/logo-word-white.svg"
alt="Corrad Logo"
class="h-7"
/>
</div>
</div>
<!-- New process form -->
<div v-if="isCreatingProcess" class="mb-4 p-4 border rounded bg-gray-50 mx-4">
<h2 class="text-lg font-bold mb-2">Create New Process</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Process Name</label>
<input
v-model="newProcessName"
<div class="flex items-center gap-3">
<FormKit
v-if="hasCurrentProcess"
v-model="processStore.currentProcess.name"
type="text"
class="w-full px-3 py-2 border rounded"
placeholder="Enter process name"
:classes="{
outer: 'w-64 mb-0',
input: 'w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
}"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
v-model="newProcessDescription"
rows="2"
class="w-full px-3 py-2 border rounded"
placeholder="Enter process description"
></textarea>
<div class="flex items-center gap-3">
<RsButton @click="saveProcess" variant="primary" size="sm" :disabled="!hasCurrentProcess">
<Icon name="material-symbols:save" class="mr-1" />
Save Process
</RsButton>
<RsButton @click="confirmNavigation('/process-builder/manage')" variant="tertiary" size="sm">
<Icon name="material-symbols:arrow-back" class="mr-1" />
Back to Processes
</RsButton>
</div>
<div class="flex gap-2">
<button
@click="createNewProcess"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
:disabled="!newProcessName.trim()"
>
Create
</button>
<button
@click="isCreatingProcess = false"
class="px-4 py-2 border rounded hover:bg-gray-100"
>
Cancel
</button>
</header>
<!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden" v-if="hasCurrentProcess">
<!-- Left Panel - Components -->
<div class="w-64 border-r border-gray-200 flex flex-col overflow-hidden">
<div class="bg-gray-100 p-3 flex items-center justify-between border-b border-gray-200">
<h2 class="text-sm font-medium text-gray-700">Process Components</h2>
</div>
<div class="flex-1 overflow-y-auto">
<ProcessBuilderComponents @add-component="onAddComponent" />
</div>
</div>
</div>
<div v-if="hasCurrentProcess" class="flex flex-1 gap-4 p-4 min-h-0 overflow-hidden">
<!-- Left panel - Component palette -->
<div class="process-builder-palette w-64 bg-white shadow-md rounded-md overflow-y-auto">
<div class="p-4">
<h2 class="text-lg font-bold mb-2">Components</h2>
<div class="process-builder-component-list space-y-2">
<div
v-for="component in components"
:key="component.type"
class="process-component-item p-2 border rounded cursor-grab hover:bg-gray-50 transition-transform"
draggable="true"
@dragstart="(e) => onDragStart(e, component)"
@dragend="onDragEnd"
>
<div class="flex items-center">
<i :class="['material-icons mr-2', component.iconColor]">{{ component.icon }}</i>
<span>{{ component.label }}</span>
<!-- Center Panel - Process Canvas -->
<div class="flex-1 relative">
<ProcessFlowCanvas
:initial-nodes="processStore.currentProcess.nodes"
:initial-edges="processStore.currentProcess.edges"
@node-selected="onNodeSelected"
@edge-selected="onEdgeSelected"
@pane-click="onPaneClick"
@nodes-change="onNodesChange"
@edges-change="onEdgesChange"
/>
</div>
<!-- Right Panel - Properties -->
<div class="w-72 border-l border-gray-200 flex flex-col overflow-hidden">
<div class="bg-gray-100 p-3 flex items-center justify-between border-b border-gray-200">
<h2 class="text-sm font-medium text-gray-700">Properties</h2>
</div>
<div class="flex-1 overflow-y-auto p-4 bg-white">
<!-- No selection state -->
<div v-if="!selectedNodeData && !selectedEdgeData" class="text-gray-500 text-center py-8">
<Icon name="material-symbols:touch-app" class="w-12 h-12 mx-auto mb-2" />
<p>Select a node or connection to edit its properties</p>
</div>
<!-- Node properties -->
<div v-else-if="selectedNodeData" class="space-y-4">
<h3 class="text-sm font-medium text-gray-700 mb-2">{{ selectedNodeData.type.charAt(0).toUpperCase() + selectedNodeData.type.slice(1) }} Node Properties</h3>
<!-- Common properties for all nodes -->
<div class="space-y-3">
<FormKit
v-model="nodeLabel"
type="text"
label="Label"
placeholder="Node label"
/>
<FormKit
v-model="nodeDescription"
type="textarea"
label="Description"
placeholder="Enter description"
:rows="3"
/>
</div>
<!-- Task specific properties -->
<div v-if="selectedNodeData.type === 'task'" class="space-y-3">
<FormKit
v-model="nodeAssignee"
type="text"
label="Assignee"
placeholder="Enter assignee"
/>
</div>
<!-- Form specific properties -->
<div v-if="selectedNodeData.type === 'form'" class="space-y-3">
<FormSelector
v-model="selectedNodeData.data.formId"
@select="onFormSelected"
/>
</div>
<!-- Script specific properties -->
<div v-if="selectedNodeData.type === 'script'" class="space-y-3">
<FormKit
v-model="nodeLanguage"
type="select"
label="Language"
:options="['JavaScript', 'Python', 'PHP']"
/>
<FormKit
v-if="selectedNodeData.data.script !== undefined"
v-model="selectedNodeData.data.script"
type="textarea"
label="Script"
placeholder="Enter script code"
:rows="5"
/>
</div>
<!-- Gateway specific properties -->
<div v-if="selectedNodeData.type === 'gateway'" class="space-y-3">
<FormKit
v-model="nodeDefaultPath"
type="text"
label="Default Path Label"
placeholder="Default"
/>
<GatewayConditionManager
v-model="nodeConditions"
:gateway-id="selectedNodeData.id"
@update:modelValue="handleConditionUpdate"
/>
</div>
<!-- Delete button -->
<div class="pt-4 border-t border-gray-200 mt-4">
<RsButton @click="deleteNode" variant="danger" size="sm" class="w-full">
<Icon name="material-symbols:delete" class="mr-1" />
Delete Node
</RsButton>
</div>
</div>
<!-- Edge properties -->
<div v-else-if="selectedEdgeData" class="space-y-4">
<h3 class="text-sm font-medium text-gray-700 mb-2">Connection Properties</h3>
<div class="space-y-3">
<FormKit
:model-value="selectedEdgeData.label"
@input="updateEdgeLabel"
type="text"
label="Label"
placeholder="Connection label"
/>
<div class="pt-4">
<div class="mb-2 text-sm text-gray-500">Connection Details</div>
<div class="p-3 bg-gray-50 rounded-md border border-gray-200 text-sm">
<div class="mb-1">
<span class="font-medium">From:</span>
{{ selectedEdgeData.sourceNode?.label || selectedEdgeData.source }}
</div>
<div>
<span class="font-medium">To:</span>
{{ selectedEdgeData.targetNode?.label || selectedEdgeData.target }}
</div>
</div>
</div>
<!-- Delete button -->
<div class="pt-4 border-t border-gray-200 mt-4">
<RsButton @click="deleteEdge" variant="danger" size="sm" class="w-full">
<Icon name="material-symbols:delete" class="mr-1" />
Delete Connection
</RsButton>
</div>
</div>
</div>
</div>
</div>
<!-- Middle panel - Canvas -->
<div class="process-builder-canvas flex-1 min-w-0">
<ProcessFlowCanvas
@node-selected="onNodeSelected"
@pane-click="onPaneClick"
@nodes-change="onNodesChange"
@edges-change="onEdgesChange"
/>
</div>
<!-- Right panel - Properties -->
<div class="process-builder-properties w-72 bg-white shadow-md rounded-md overflow-y-auto">
<div class="p-4">
<h2 class="text-lg font-bold mb-2">Properties</h2>
<div v-if="selectedNodeData" class="process-properties-content space-y-4">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Node ID</label>
<div class="px-3 py-2 border rounded bg-gray-50 text-sm">{{ selectedNodeData.id }}</div>
</div>
</div>
<!-- Empty state - No process selected -->
<div v-else class="flex-1 flex items-center justify-center bg-gray-50">
<div class="text-center p-8 max-w-md">
<Icon name="material-symbols:flowchart" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
<h2 class="text-xl font-semibold text-gray-800 mb-2">Create a New Process</h2>
<p class="text-gray-600 mb-6">Get started by creating a new process or navigate back to manage your existing processes.</p>
<div class="space-y-3">
<div class="mb-4">
<FormKit
v-model="newProcessName"
type="text"
label="Process Name"
placeholder="Enter a name for your new process"
validation="required"
/>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Node Type</label>
<div class="px-3 py-2 border rounded bg-gray-50 text-sm">{{ selectedNodeData.type }}</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
<input
type="text"
v-model="nodeLabel"
class="w-full px-3 py-2 border rounded text-sm"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
v-model="nodeDescription"
rows="3"
class="w-full px-3 py-2 border rounded text-sm"
></textarea>
</div>
<!-- Conditional fields based on node type -->
<div v-if="selectedNodeData.type === 'task'" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
<input
type="text"
v-model="nodeAssignee"
class="w-full px-3 py-2 border rounded text-sm"
/>
</div>
<div v-if="selectedNodeData.type === 'form'" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Form Name</label>
<input
type="text"
v-model="nodeFormName"
class="w-full px-3 py-2 border rounded text-sm"
/>
</div>
<div v-if="selectedNodeData.type === 'script'" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Language</label>
<select
v-model="nodeLanguage"
class="w-full px-3 py-2 border rounded text-sm"
>
<option value="JavaScript">JavaScript</option>
<option value="PHP">PHP</option>
<option value="Python">Python</option>
</select>
</div>
<FormKit
v-model="newProcessDescription"
type="textarea"
label="Description (Optional)"
placeholder="Enter a description"
:rows="3"
/>
</div>
<div v-else class="text-gray-500 text-sm italic">
Select a node to view its properties
<div class="flex justify-center gap-4">
<RsButton @click="createNewProcess" variant="primary" :disabled="!newProcessName.trim()">
<Icon name="material-symbols:add" class="mr-1" />
Create Process
</RsButton>
<RsButton @click="confirmNavigation('/process-builder/manage')" variant="tertiary">
<Icon name="material-symbols:arrow-back" class="mr-1" />
Back to Processes
</RsButton>
</div>
</div>
</div>
</div>
<!-- No process message -->
<div v-if="!hasCurrentProcess && !isCreatingProcess" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="text-gray-500 mb-4">
<i class="material-icons text-5xl">account_tree</i>
<p class="mt-2 text-lg">No process is currently open</p>
</div>
<div class="flex flex-col items-center space-y-3">
<button
@click="isCreatingProcess = true"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Create New Process
</button>
<button
@click="goToManage"
class="px-4 py-2 border rounded hover:bg-gray-100"
>
Go to Process Management
</button>
<!-- Unsaved changes dialog -->
<RsModal v-model="showUnsavedChangesModal" title="Unsaved Changes" 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" />
<div>
<p class="text-gray-600">You have unsaved changes that will be lost if you leave the page.</p>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="cancelNavigation" variant="tertiary">
Stay on this Page
</RsButton>
<RsButton @click="proceedWithNavigation" variant="danger">
Discard Changes
</RsButton>
</div>
</template>
</RsModal>
</div>
</template>
<style scoped>
.process-builder {
--flow-node-selected-color: theme('colors.blue.500');
--flow-background-color: theme('colors.gray.100');
--flow-connection-path-color: theme('colors.gray.400');
--flow-connection-path-hover-color: theme('colors.blue.400');
}
:deep(.custom-node) {
border-radius: 4px;
padding: 10px;
}
:deep(.custom-node.selected) {
border-color: var(--flow-node-selected-color);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
:deep(.custom-node-content) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
:deep(.custom-node-title) {
margin-top: 4px;
text-align: center;
font-weight: 500;
}
:deep(.node-gateway) {
transform: rotate(45deg);
background: white;
border: 2px solid #FF9800;
}
:deep(.node-gateway .custom-node-content) {
transform: rotate(-45deg);
}
:deep(.node-start), :deep(.node-end) {
border-radius: 50%;
width: 60px;
height: 60px;
}
:deep(.node-task), :deep(.node-form), :deep(.node-script) {
min-width: 160px;
background: white;
border: 1px solid #ddd;
}
:deep(.node-details) {
margin-top: 8px;
font-size: 0.75rem;
width: 100%;
}
:deep(.node-description) {
color: #666;
margin-bottom: 4px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.process-builder-container {
background-color: #f8fafc;
}
.process-name-input :deep(.formkit-inner) {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
min-width: 200px;
}
.process-name-input :deep(.formkit-inner:focus-within) {
border-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.process-name-input :deep(input::placeholder) {
color: rgba(255, 255, 255, 0.6);
}
.process-name-input :deep(.formkit-message) {
color: rgba(255, 200, 200, 0.9);
font-size: 0.7rem;
position: absolute;
}
.process-builder-palette,
.process-builder-properties {
height: 100%;

View File

@ -3,6 +3,15 @@ import { ref, computed, onMounted } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useRouter } from 'vue-router';
// Define page meta
definePageMeta({
title: "Process Management",
description: "Manage your business processes",
layout: "empty",
middleware: ["auth"],
requiresAuth: true,
});
// Initialize the store and router
const processStore = useProcessBuilderStore();
const router = useRouter();
@ -100,7 +109,7 @@ const createNewProcess = () => {
// Go back to builder
const goToBuilder = () => {
router.push('/process-builder');
router.push('/');
};
// Check if we have processes, if not create a demo one
@ -119,126 +128,137 @@ onMounted(() => {
</script>
<template>
<div class="process-management p-6">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center">
<button
@click="goToBuilder"
class="mr-3 text-gray-500 hover:text-gray-700 transition-colors"
>
<i class="material-icons">arrow_back</i>
</button>
<h1 class="text-2xl font-bold">Process Management</h1>
<div class="flex flex-col h-screen bg-gray-50">
<!-- Header Bar -->
<header
class="bg-gray-800 px-4 py-4 flex items-center justify-between text-white shadow-md"
>
<div class="flex items-center gap-3">
<Icon
@click="goToBuilder"
name="ph:arrow-circle-left-duotone"
class="cursor-pointer w-6 h-6"
/>
<img
src="@/assets/img/logo/logo-word-white.svg"
alt="Corrad Logo"
class="h-7"
/>
</div>
<div class="flex items-center gap-3">
<h1 class="text-xl font-semibold text-white">Process Management</h1>
</div>
<button
@click="createNewProcess"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Create New Process
</button>
</div>
<div class="mb-6">
<div class="relative">
<input
v-model="searchQuery"
type="text"
placeholder="Search processes..."
class="w-full px-4 py-2 pl-10 border rounded"
/>
<span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
<i class="material-icons text-lg">search</i>
</span>
<div class="flex items-center gap-3">
<RsButton @click="createNewProcess" variant="primary" size="sm">
<Icon name="material-symbols:add" class="mr-1" />
Create New Process
</RsButton>
</div>
</header>
<!-- Main Content Area -->
<div class="flex-1 p-6 overflow-auto">
<div class="mb-6">
<div class="relative max-w-md">
<input
v-model="searchQuery"
type="text"
placeholder="Search processes..."
class="w-full px-4 py-2 pl-10 border rounded bg-white"
/>
<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>
</div>
</div>
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<table class="w-full table-auto">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-if="filteredProcesses.length === 0">
<td colspan="5" class="px-6 py-4 text-center text-gray-500 italic">
No processes found
</td>
</tr>
<tr v-for="process in filteredProcesses" :key="process.id" class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="font-medium text-gray-900">{{ process.name }}</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-500 truncate max-w-xs">{{ process.description }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500">{{ formatDate(process.createdAt) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500">{{ formatDate(process.updatedAt) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex space-x-3 justify-end">
<button
@click="editProcess(process.id)"
class="text-blue-600 hover:text-blue-900"
title="Edit Process"
>
<Icon name="material-symbols:edit" class="text-lg" />
</button>
<button
@click="duplicateProcess(process)"
class="text-green-600 hover:text-green-900"
title="Duplicate Process"
>
<Icon name="material-symbols:content-copy" class="text-lg" />
</button>
<button
@click="confirmDelete(process.id)"
class="text-red-600 hover:text-red-900"
title="Delete Process"
>
<Icon name="material-symbols:delete" class="text-lg" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<table class="w-full table-auto">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-if="filteredProcesses.length === 0">
<td colspan="5" class="px-6 py-4 text-center text-gray-500 italic">
No processes found
</td>
</tr>
<tr v-for="process in filteredProcesses" :key="process.id" class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="font-medium text-gray-900">{{ process.name }}</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-500 truncate max-w-xs">{{ process.description }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500">{{ formatDate(process.createdAt) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500">{{ formatDate(process.updatedAt) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex space-x-2 justify-end">
<button
@click="editProcess(process.id)"
class="text-blue-600 hover:text-blue-900"
title="Edit Process"
>
<i class="material-icons">edit</i>
</button>
<button
@click="duplicateProcess(process)"
class="text-green-600 hover:text-green-900"
title="Duplicate Process"
>
<i class="material-icons">content_copy</i>
</button>
<button
@click="confirmDelete(process.id)"
class="text-red-600 hover:text-red-900"
title="Delete Process"
>
<i class="material-icons">delete</i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Delete confirmation dialog -->
<div v-if="showDeleteConfirm" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 w-96">
<h3 class="text-lg font-bold mb-4">Confirm Delete</h3>
<p class="mb-6">Are you sure you want to delete this process? This action cannot be undone.</p>
<div class="flex justify-end space-x-2">
<button
@click="cancelDelete"
class="px-4 py-2 border rounded hover:bg-gray-100"
>
Cancel
</button>
<button
@click="deleteProcess"
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Delete
</button>
<RsModal v-model="showDeleteConfirm" title="Confirm Delete" 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" />
<div>
<p class="text-gray-600">Are you sure you want to delete this process? This action cannot be undone.</p>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="cancelDelete" variant="tertiary">
Cancel
</RsButton>
<RsButton @click="deleteProcess" variant="danger">
Delete
</RsButton>
</div>
</template>
</RsModal>
</div>
</template>
<style scoped>
.process-management {
min-height: calc(100vh - 80px);
}
/* No need for any special styles, using the flex layout */
</style>

View File

@ -141,6 +141,24 @@
"items": {
"$ref": "#/definitions/userrole"
}
},
"processes": {
"type": "array",
"items": {
"$ref": "#/definitions/process"
}
},
"forms": {
"type": "array",
"items": {
"$ref": "#/definitions/form"
}
},
"assignedTasks": {
"type": "array",
"items": {
"$ref": "#/definitions/task"
}
}
}
},
@ -265,6 +283,200 @@
"$ref": "#/definitions/user"
}
}
},
"form": {
"type": "object",
"properties": {
"formID": {
"type": "integer"
},
"formUUID": {
"type": "string"
},
"formName": {
"type": "string"
},
"formDescription": {
"type": [
"string",
"null"
]
},
"formComponents": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"formStatus": {
"type": "string",
"default": "active"
},
"formCreatedDate": {
"type": "string",
"format": "date-time"
},
"formModifiedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"creator": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
},
"formTasks": {
"type": "array",
"items": {
"$ref": "#/definitions/task"
}
}
}
},
"process": {
"type": "object",
"properties": {
"processID": {
"type": "integer"
},
"processUUID": {
"type": "string"
},
"processName": {
"type": "string"
},
"processDescription": {
"type": [
"string",
"null"
]
},
"processDefinition": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"processVersion": {
"type": "integer",
"default": 1
},
"processStatus": {
"type": "string",
"default": "draft"
},
"processCreatedDate": {
"type": "string",
"format": "date-time"
},
"processModifiedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"creator": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
},
"tasks": {
"type": "array",
"items": {
"$ref": "#/definitions/task"
}
}
}
},
"task": {
"type": "object",
"properties": {
"taskID": {
"type": "integer"
},
"taskUUID": {
"type": "string"
},
"taskNodeId": {
"type": "string"
},
"taskName": {
"type": "string"
},
"taskType": {
"type": "string"
},
"taskData": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"taskStatus": {
"type": "string",
"default": "pending"
},
"taskCreatedDate": {
"type": "string",
"format": "date-time"
},
"taskModifiedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"process": {
"$ref": "#/definitions/process"
},
"form": {
"anyOf": [
{
"$ref": "#/definitions/form"
},
{
"type": "null"
}
]
},
"assignee": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
}
}
}
},
"type": "object",
@ -283,6 +495,15 @@
},
"userrole": {
"$ref": "#/definitions/userrole"
},
"form": {
"$ref": "#/definitions/form"
},
"process": {
"$ref": "#/definitions/process"
},
"task": {
"$ref": "#/definitions/task"
}
}
}

View File

@ -41,6 +41,9 @@ model user {
userModifiedDate DateTime? @db.DateTime(0)
audit audit[]
userrole userrole[]
processes process[] @relation("ProcessCreator")
forms form[] @relation("FormCreator")
assignedTasks task[] @relation("TaskAssignee")
}
model role {
@ -76,3 +79,66 @@ model userrole {
@@index([userRoleRoleID], map: "FK_userrole_role")
@@index([userRoleUserID], map: "FK_userrole_user")
}
// New models for Form Builder
model form {
formID Int @id @default(autoincrement())
formUUID String @unique @db.VarChar(36)
formName String @db.VarChar(255)
formDescription String? @db.Text
formComponents Json @db.Json
formStatus String @default("active") @db.VarChar(50)
formCreatedBy Int?
formCreatedDate DateTime @default(now()) @db.DateTime(0)
formModifiedDate DateTime? @updatedAt @db.DateTime(0)
// Relations
creator user? @relation("FormCreator", fields: [formCreatedBy], references: [userID])
formTasks task[] @relation("FormTask")
@@index([formCreatedBy], map: "FK_form_creator")
}
// New models for Process Builder
model process {
processID Int @id @default(autoincrement())
processUUID String @unique @db.VarChar(36)
processName String @db.VarChar(255)
processDescription String? @db.Text
processDefinition Json @db.Json
processVersion Int @default(1)
processStatus String @default("draft") @db.VarChar(50)
processCreatedBy Int?
processCreatedDate DateTime @default(now()) @db.DateTime(0)
processModifiedDate DateTime? @updatedAt @db.DateTime(0)
// Relations
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
tasks task[]
@@index([processCreatedBy], map: "FK_process_creator")
}
model task {
taskID Int @id @default(autoincrement())
taskUUID String @unique @db.VarChar(36)
taskNodeId String @db.VarChar(255)
taskName String @db.VarChar(255)
taskType String @db.VarChar(50)
taskData Json? @db.Json
taskProcessId Int
taskFormId Int?
taskAssigneeId Int?
taskStatus String @default("pending") @db.VarChar(50)
taskCreatedDate DateTime @default(now()) @db.DateTime(0)
taskModifiedDate DateTime? @updatedAt @db.DateTime(0)
// Relations
process process @relation(fields: [taskProcessId], references: [processID])
form form? @relation("FormTask", fields: [taskFormId], references: [formID])
assignee user? @relation("TaskAssignee", fields: [taskAssigneeId], references: [userID])
@@index([taskProcessId], map: "FK_task_process")
@@index([taskFormId], map: "FK_task_form")
@@index([taskAssigneeId], map: "FK_task_assignee")
}

View File

@ -0,0 +1,57 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the form ID from the route params
const id = event.context.params.id;
try {
// Try to delete by UUID first
let form;
try {
form = await prisma.form.delete({
where: { formUUID: id }
});
} catch (e) {
// If UUID not found, try numeric ID
if (!isNaN(parseInt(id))) {
form = await prisma.form.delete({
where: { formID: parseInt(id) }
});
} else {
throw e;
}
}
return {
success: true,
message: 'Form deleted successfully'
};
} catch (error) {
console.error(`Error deleting form ${id}:`, error);
// Handle specific errors
if (error.code === 'P2025') {
return {
success: false,
error: 'Form not found'
};
}
// Handle cases where the form has associated tasks
if (error.code === 'P2003') {
return {
success: false,
error: 'Cannot delete form because it is associated with one or more tasks'
};
}
return {
success: false,
error: 'Failed to delete form',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,64 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the form ID from the route params
const id = event.context.params.id;
try {
// Try to find by UUID first
let form = await prisma.form.findUnique({
where: {
formUUID: id
},
include: {
creator: {
select: {
userID: true,
userFullName: true
}
}
}
});
// If not found, try to find by numeric ID
if (!form && !isNaN(parseInt(id))) {
form = await prisma.form.findUnique({
where: {
formID: parseInt(id)
},
include: {
creator: {
select: {
userID: true,
userFullName: true
}
}
}
});
}
// If form not found, return 404
if (!form) {
return {
success: false,
error: 'Form not found'
};
}
return {
success: true,
form
};
} catch (error) {
console.error(`Error fetching form ${id}:`, error);
return {
success: false,
error: 'Failed to fetch form',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,78 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the form ID from the route params
const id = event.context.params.id;
try {
// Parse the request body
const body = await readBody(event);
// Validate required fields
if (!body.formName) {
return {
success: false,
error: 'Form name is required'
};
}
// Prepare update data
const updateData = {
formName: body.formName,
formComponents: body.components || [],
formModifiedDate: new Date()
};
// Add optional fields if provided
if (body.formDescription !== undefined) {
updateData.formDescription = body.formDescription;
}
if (body.status !== undefined) {
updateData.formStatus = body.status;
}
// Try to update by UUID first
let form;
try {
form = await prisma.form.update({
where: { formUUID: id },
data: updateData
});
} catch (e) {
// If UUID not found, try numeric ID
if (!isNaN(parseInt(id))) {
form = await prisma.form.update({
where: { formID: parseInt(id) },
data: updateData
});
} else {
throw e;
}
}
return {
success: true,
form
};
} catch (error) {
console.error(`Error updating form ${id}:`, error);
// Handle specific errors
if (error.code === 'P2025') {
return {
success: false,
error: 'Form not found'
};
}
return {
success: false,
error: 'Failed to update form',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,45 @@
import { PrismaClient } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
try {
// Parse the request body
const body = await readBody(event);
// Validate required fields
if (!body.formName) {
return {
success: false,
error: 'Form name is required'
};
}
// Create a new form in the database
const form = await prisma.form.create({
data: {
formUUID: uuidv4(),
formName: body.formName,
formDescription: body.formDescription || null,
formComponents: body.components || [],
formStatus: body.status || 'active',
formCreatedBy: body.createdBy || null // In a real app, this would come from the authenticated user
}
});
return {
success: true,
form
};
} catch (error) {
console.error('Error creating form:', error);
return {
success: false,
error: 'Failed to create form',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,47 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
try {
// Get all active forms
const forms = await prisma.form.findMany({
where: {
formStatus: 'active'
},
orderBy: {
formCreatedDate: 'desc'
},
select: {
formID: true,
formUUID: true,
formName: true,
formDescription: true,
formStatus: true,
formCreatedDate: true,
formModifiedDate: true,
// Don't include the full components data to keep response size small
creator: {
select: {
userID: true,
userFullName: true
}
}
}
});
return {
success: true,
forms
};
} catch (error) {
console.error('Error fetching forms:', error);
return {
success: false,
error: 'Failed to fetch forms',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,85 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the process ID from the route params
const id = event.context.params.id;
try {
// Parse the request body
const body = await readBody(event);
// Validate required fields
if (!body.processName) {
return {
success: false,
error: 'Process name is required'
};
}
// Prepare update data
const updateData = {
processName: body.processName,
processModifiedDate: new Date()
};
// Add optional fields if provided
if (body.processDescription !== undefined) {
updateData.processDescription = body.processDescription;
}
if (body.definition !== undefined) {
updateData.processDefinition = body.definition;
}
if (body.processStatus !== undefined) {
updateData.processStatus = body.processStatus;
}
if (body.processVersion !== undefined) {
updateData.processVersion = body.processVersion;
}
// Try to update by UUID first
let process;
try {
process = await prisma.process.update({
where: { processUUID: id },
data: updateData
});
} catch (e) {
// If UUID not found, try numeric ID
if (!isNaN(parseInt(id))) {
process = await prisma.process.update({
where: { processID: parseInt(id) },
data: updateData
});
} else {
throw e;
}
}
return {
success: true,
process
};
} catch (error) {
console.error(`Error updating process ${id}:`, error);
// Handle specific errors
if (error.code === 'P2025') {
return {
success: false,
error: 'Process not found'
};
}
return {
success: false,
error: 'Failed to update process',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,71 @@
import { PrismaClient } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the process ID from the route params
const processId = event.context.params.id;
try {
// Parse the request body
const body = await readBody(event);
// Validate required fields
if (!body.taskName || !body.taskType || !body.taskNodeId) {
return {
success: false,
error: 'Task name, type, and node ID are required'
};
}
// Find the process
let process;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(processId)) {
// If it looks like a UUID
process = await prisma.process.findUnique({
where: { processUUID: processId }
});
} else if (!isNaN(parseInt(processId))) {
// If it's a numeric ID
process = await prisma.process.findUnique({
where: { processID: parseInt(processId) }
});
}
if (!process) {
return {
success: false,
error: 'Process not found'
};
}
// Create a new task
const task = await prisma.task.create({
data: {
taskUUID: uuidv4(),
taskNodeId: body.taskNodeId,
taskName: body.taskName,
taskType: body.taskType,
taskData: body.taskData || {},
taskProcessId: process.processID,
taskAssigneeId: body.assigneeId || null
}
});
return {
success: true,
task
};
} catch (error) {
console.error(`Error creating task for process ${processId}:`, error);
return {
success: false,
error: 'Failed to create task',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,46 @@
import { PrismaClient } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
try {
// Parse the request body
const body = await readBody(event);
// Validate required fields
if (!body.processName) {
return {
success: false,
error: 'Process name is required'
};
}
// Create a new process in the database
const process = await prisma.process.create({
data: {
processUUID: uuidv4(),
processName: body.processName,
processDescription: body.processDescription || null,
processDefinition: body.definition || { nodes: [], edges: [] },
processVersion: 1,
processStatus: body.status || 'draft',
processCreatedBy: body.createdBy || null // In a real app, this would come from the authenticated user
}
});
return {
success: true,
process
};
} catch (error) {
console.error('Error creating process:', error);
return {
success: false,
error: 'Failed to create process',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,63 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the task ID from the route params
const taskId = event.context.params.id;
try {
// Find the task
let task;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskId)) {
// If it looks like a UUID
task = await prisma.task.findUnique({
where: { taskUUID: taskId }
});
} else if (!isNaN(parseInt(taskId))) {
// If it's a numeric ID
task = await prisma.task.findUnique({
where: { taskID: parseInt(taskId) }
});
}
if (!task) {
return {
success: false,
error: 'Task not found'
};
}
// Prepare task data without form information
let taskData = task.taskData || {};
delete taskData.formName;
delete taskData.formId;
delete taskData.formUuid;
// Unlink the form from the task
const updatedTask = await prisma.task.update({
where: {
taskID: task.taskID
},
data: {
taskFormId: null,
taskData: taskData
}
});
return {
success: true,
task: updatedTask
};
} catch (error) {
console.error(`Error removing form from task ${taskId}:`, error);
return {
success: false,
error: 'Failed to remove form from task',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,99 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the task ID from the route params
const taskId = event.context.params.id;
try {
// Parse the request body
const body = await readBody(event);
// Validate required fields
if (!body.formId) {
return {
success: false,
error: 'Form ID is required'
};
}
// Find the task
let task;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskId)) {
// If it looks like a UUID
task = await prisma.task.findUnique({
where: { taskUUID: taskId }
});
} else if (!isNaN(parseInt(taskId))) {
// If it's a numeric ID
task = await prisma.task.findUnique({
where: { taskID: parseInt(taskId) }
});
}
if (!task) {
return {
success: false,
error: 'Task not found'
};
}
// Find the form
let form;
const formId = body.formId;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(formId)) {
// If it looks like a UUID
form = await prisma.form.findUnique({
where: { formUUID: formId }
});
} else if (!isNaN(parseInt(formId))) {
// If it's a numeric ID
form = await prisma.form.findUnique({
where: { formID: parseInt(formId) }
});
}
if (!form) {
return {
success: false,
error: 'Form not found'
};
}
// Link the form to the task
const updatedTask = await prisma.task.update({
where: {
taskID: task.taskID
},
data: {
taskFormId: form.formID,
taskData: {
...task.taskData,
formName: form.formName,
formId: form.formID,
formUuid: form.formUUID
}
},
include: {
form: true
}
});
return {
success: true,
task: updatedTask
};
} catch (error) {
console.error(`Error connecting form to task ${taskId}:`, error);
return {
success: false,
error: 'Failed to connect form to task',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -12,7 +12,8 @@ export const useFormBuilderStore = defineStore('formBuilder', {
hasUnsavedChanges: false,
actionHistory: [],
currentHistoryIndex: -1,
maxHistoryLength: 30 // Maximum number of history entries to keep
maxHistoryLength: 30, // Maximum number of history entries to keep
currentFormId: null
}),
getters: {
@ -414,32 +415,184 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.hasUnsavedChanges = false;
},
saveForm() {
const formData = this.formConfig;
// Add to saved forms array
const existingIndex = this.savedForms.findIndex(f => f.id === formData.id);
if (existingIndex !== -1) {
this.savedForms[existingIndex] = formData;
} else {
this.savedForms.push(formData);
// Get forms from the backend
async getForms() {
try {
// Use the API endpoint to fetch forms
const response = await fetch('/api/forms');
const result = await response.json();
if (result.success && Array.isArray(result.forms)) {
return result.forms;
} else {
console.error('Error in API response:', result.error || 'Unknown error');
return [];
}
} catch (error) {
console.error('Error fetching forms:', error);
return [];
}
// Save to localStorage for persistence
localStorage.setItem('savedForms', JSON.stringify(this.savedForms));
this.hasUnsavedChanges = false;
// Record in history
this.recordHistory('save_form', {
formName: this.formName,
formDescription: this.formDescription,
componentCount: this.formComponents.length
});
return formData;
},
// Load saved forms from the API
async loadSavedForms() {
try {
// Fetch forms from the API
const forms = await this.getForms();
// Transform to the format expected by the UI
this.savedForms = forms.map(form => ({
id: form.formUUID,
name: form.formName,
description: form.formDescription || '',
components: form.formComponents || [],
createdAt: form.formCreatedDate,
updatedAt: form.formModifiedDate
}));
return this.savedForms;
} catch (error) {
console.error('Error loading saved forms:', error);
this.savedForms = [];
return [];
}
},
// Save form to the database
async saveForm() {
try {
const formData = {
formName: this.formName,
formDescription: this.formDescription,
components: this.formComponents.map(c => ({
type: c.type,
props: c.props
}))
};
// Determine if this is a new form or an update
const isNewForm = !this.currentFormId;
let response;
if (isNewForm) {
// Create a new form
response = await fetch('/api/forms/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
} else {
// Update existing form
response = await fetch(`/api/forms/${this.currentFormId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
}
const result = await response.json();
if (result.success) {
// Update store state with the saved form
this.currentFormId = result.form.formUUID;
this.hasUnsavedChanges = false;
// Record in history
this.recordHistory('save_form', {
formName: this.formName,
formDescription: this.formDescription,
componentCount: this.formComponents.length
});
return result.form;
} else {
throw new Error(result.error || 'Failed to save form');
}
} catch (error) {
console.error('Error saving form:', error);
throw error;
}
},
// Load a form from the database
async loadForm(formId) {
if (!formId) {
throw new Error('Form ID is required');
}
try {
const response = await fetch(`/api/forms/${formId}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP error ${response.status}`);
}
const result = await response.json();
if (result.success && result.form) {
// Clear existing data
this.formComponents = [];
this.selectedComponentId = null;
// Set form data
this.formName = result.form.formName;
this.formDescription = result.form.formDescription || '';
this.currentFormId = result.form.formUUID;
// Transform components from DB format to store format
if (Array.isArray(result.form.formComponents)) {
this.formComponents = result.form.formComponents.map(c => ({
...c,
id: uuidv4() // Assign a new UUID for each component
}));
}
// Clear and initialize history when loading a form
this.actionHistory = [];
this.currentHistoryIndex = -1;
// Record initial state in history
this.recordHistory('load_form', {
formName: result.form.formName,
formId: formId
});
return result.form;
} else {
throw new Error(result.error || 'Failed to load form');
}
} catch (error) {
console.error(`Error loading form ${formId}:`, error);
throw error;
}
},
// Delete a form from the database
async deleteForm(formId) {
try {
const response = await fetch(`/api/forms/${formId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
return true;
} else {
throw new Error(result.error || 'Failed to delete form');
}
} catch (error) {
console.error(`Error deleting form ${formId}:`, error);
throw error;
}
},
// Clear the current form
clearForm() {
// Capture the current state before clearing
const oldComponents = [...this.formComponents];
@ -451,6 +604,7 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.selectedComponentId = null;
this.formName = 'New Form';
this.formDescription = '';
this.currentFormId = null;
this.hasUnsavedChanges = false;
// Clear history when starting a new form and add initial state
@ -463,40 +617,6 @@ export const useFormBuilderStore = defineStore('formBuilder', {
});
},
loadForm(formId) {
const savedForms = JSON.parse(localStorage.getItem('savedForms') || '[]');
const form = savedForms.find(f => f.id === formId);
if (form) {
// Clear existing data
this.formComponents = [];
this.selectedComponentId = null;
// Set form data
this.formName = form.name;
this.formDescription = form.description;
this.formComponents = form.components.map(c => ({
...c,
id: uuidv4()
}));
// Clear and initialize history when loading a form
this.actionHistory = [];
this.currentHistoryIndex = -1;
// Record initial state in history
this.recordHistory('load_form', {
formName: form.name,
formId: formId
});
}
},
loadSavedForms() {
const savedForms = JSON.parse(localStorage.getItem('savedForms') || '[]');
this.savedForms = savedForms;
},
// Optimize the grid layout by analyzing the current components
// and adjusting their sizes to fill available spaces
optimizeGridLayout() {

View File

@ -181,22 +181,36 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
deleteNode(nodeId) {
if (!this.currentProcess) return;
// Find the node index
const index = this.currentProcess.nodes.findIndex(n => n.id === nodeId);
if (index !== -1) {
// Remove the node
this.currentProcess.nodes.splice(index, 1);
// Remove any edges connected to this node
this.currentProcess.edges = this.currentProcess.edges.filter(
edge => edge.source !== nodeId && edge.target !== nodeId
const edgesToRemove = this.currentProcess.edges.filter(
edge => edge.source === nodeId || edge.target === nodeId
);
edgesToRemove.forEach(edge => {
const edgeIndex = this.currentProcess.edges.findIndex(e => e.id === edge.id);
if (edgeIndex !== -1) {
this.currentProcess.edges.splice(edgeIndex, 1);
}
});
// Clear selection if the deleted node was selected
if (this.selectedNodeId === nodeId) {
this.selectedNodeId = null;
}
this.saveToHistory('Delete node');
this.unsavedChanges = true;
return true; // Return success
}
return false; // Return failure
},
/**
@ -226,12 +240,35 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
/**
* Update an edge in the current process
*/
updateEdge(edgeId, updates) {
updateEdge(edgeIdOrObject, updates) {
if (!this.currentProcess) return;
// Handle different parameter formats
let edgeId, edgeUpdates;
if (typeof edgeIdOrObject === 'string') {
// Called with (id, updates)
edgeId = edgeIdOrObject;
edgeUpdates = updates || {};
} else if (typeof edgeIdOrObject === 'object') {
// Called with an edge object
edgeId = edgeIdOrObject.id;
if (updates) {
// Called with (edge, updates)
edgeUpdates = updates;
} else {
// Called with just the edge object containing updates
edgeUpdates = { ...edgeIdOrObject };
delete edgeUpdates.id; // Don't update the ID
}
} else {
return; // Invalid parameters
}
const edge = this.currentProcess.edges.find(e => e.id === edgeId);
if (edge) {
Object.assign(edge, updates);
Object.assign(edge, edgeUpdates);
this.saveToHistory('Update edge');
this.unsavedChanges = true;
}