Add Process Builder Functionality and Vue Flow Integration

- Introduced a new process builder feature with a dedicated page for managing processes.
- Implemented a `ProcessFlowCanvas` component utilizing Vue Flow for visual process management.
- Created custom node types for the process flow, including Start, Task, Form, Gateway, Script, and End nodes.
- Developed a Pinia store for managing process data, including actions for creating, updating, and deleting processes and nodes.
- Added a search functionality for filtering processes and a confirmation dialog for process deletion.
- Integrated responsive design adjustments for better usability across devices.
- Included styles for custom nodes and flow components to enhance visual representation.
- Updated navigation to include links to the new process builder and management features.
This commit is contained in:
Md Afiq Iskandar 2025-04-23 10:47:26 +08:00
parent 93dbb7d5ee
commit 591f5ca4d8
9 changed files with 2159 additions and 91 deletions

View File

@ -0,0 +1,550 @@
<script setup>
import { ref, onMounted, computed, shallowRef } from 'vue';
import { VueFlow, useVueFlow, Panel } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import { nodeTypes as customNodeTypes, nodeStyles } from './ProcessFlowNodes';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/minimap/dist/style.css';
// Add Material Icons import
const materialIconsLink = document.createElement('link');
materialIconsLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
materialIconsLink.rel = 'stylesheet';
document.head.appendChild(materialIconsLink);
const props = defineProps({
initialNodes: {
type: Array,
default: () => []
},
initialEdges: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['nodeClick', 'edgeClick', 'paneClick', 'nodesChange', 'edgesChange', 'nodeSelected']);
// Get the flow instance
const { flowInstance } = useVueFlow();
// Initialize Vue Flow
const {
nodes,
edges,
addNodes,
addEdges,
onNodesChange,
onEdgesChange,
onConnect,
fitView,
project,
removeNodes,
removeEdges
} = useVueFlow({
defaultEdgeOptions: {
animated: true,
type: 'smoothstep'
},
deleteKeyCode: 'Delete',
selectionKeyCode: 'Shift',
multiSelectionKeyCode: 'Control',
connectionMode: 'loose'
});
// 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'
}
}
];
// 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
}
];
// Flow configuration
const flowOptions = ref({
defaultZoom: 1,
minZoom: 0.2,
maxZoom: 4,
fitViewOnInit: true,
snapToGrid: true,
snapGrid: [15, 15],
edgeUpdaterRadius: 10,
connectionMode: 'loose',
elevateEdgesOnSelect: true,
nodesDraggable: true,
nodesConnectable: true,
elementsSelectable: true,
selectNodesOnDrag: false,
panOnDrag: [0, 2],
panOnScroll: false,
zoomOnScroll: true,
zoomOnPinch: true,
zoomOnDoubleClick: false
});
// Use shallowRef for selected node to avoid unnecessary reactivity
const selectedNode = shallowRef(null);
// Handle node selection
const onNodeClick = ({ node }) => {
// Add detailed debugging
// console.log('Raw node:', node);
// Check if node exists and has required properties
if (!node || !node.id) {
console.warn('Invalid node clicked - Missing required properties');
return;
}
try {
// Create a plain object copy of the node to avoid reactivity issues
const nodeData = {
id: node.id,
type: node.type,
data: node.data ? JSON.parse(JSON.stringify(node.data)) : {},
position: node.dimensions ? {
x: node.dimensions.x || 0,
y: node.dimensions.y || 0
} : { x: 0, y: 0 }
};
// console.log('Processed node data:', nodeData);
selectedNode.value = nodeData;
emit('nodeSelected', nodeData);
} catch (error) {
console.error('Error processing node data:', error);
}
};
// Handle edge click
const onEdgeClick = (_, edge) => {
emit('edgeClick', edge);
};
// Handle pane (background) click
const onPaneClick = () => {
selectedNode.value = null;
emit('paneClick');
};
// Window resize handler
const resizeFlow = () => {
setTimeout(() => {
fitView();
}, 200);
};
onMounted(() => {
// Initialize with provided nodes or default ones
if (props.initialNodes.length) {
addNodes(props.initialNodes);
} else {
addNodes(defaultNodes);
}
// Initialize with provided edges or default ones
if (props.initialEdges.length) {
addEdges(props.initialEdges);
} else {
addEdges(defaultEdges);
}
// Setup window resize handler
window.addEventListener('resize', resizeFlow);
// Initial fit view
setTimeout(() => {
fitView();
}, 100);
});
// Handle node changes
onNodesChange((changes) => {
emit('nodesChange', changes, nodes.value);
});
// Handle edge changes
onEdgesChange((changes) => {
emit('edgesChange', changes, edges.value);
});
// Handle new connections
const handleConnect = (connection) => {
if (!connection.source || !connection.target) return;
const newEdge = {
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
type: 'smoothstep',
animated: true,
style: { stroke: '#555' }
};
addEdges([newEdge]);
emit('edgesChange', edges.value);
};
// Handle node removal
const onNodeDelete = (nodes) => {
removeNodes(nodes);
emit('nodesChange', nodes.value);
};
// Handle edge removal
const onEdgeDelete = (edges) => {
removeEdges(edges);
emit('edgesChange', edges.value);
};
// Handle drop event
const onDrop = (event) => {
event.preventDefault();
try {
// Get the dragged component data
const componentData = JSON.parse(event.dataTransfer.getData('application/json'));
if (!componentData) return;
// Get the Vue Flow wrapper element
const flowWrapper = event.currentTarget;
const bounds = flowWrapper.getBoundingClientRect();
// Calculate the position relative to the wrapper
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
// Create new node
const newNode = {
id: `${componentData.type}-${Date.now()}`,
type: componentData.type,
position,
data: {
...componentData.data,
label: componentData.label
}
};
// console.log('Adding new node:', newNode);
addNodes([newNode]);
} catch (error) {
console.error('Error handling drop:', error);
}
};
// Handle drag over
const onDragOver = (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
</script>
<template>
<div
class="process-flow-container"
@drop="onDrop"
@dragover="onDragOver"
>
<VueFlow
v-bind="flowOptions"
class="bg-slate-50 process-flow"
:nodeTypes="customNodeTypes"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
@connect="handleConnect"
@nodeDoubleClick="onNodeDelete"
@edgeDoubleClick="onEdgeDelete"
>
<Background pattern-color="#aaa" gap="20" />
<Controls />
<MiniMap />
<template #edge-label="{ label }">
<div class="edge-label">{{ label }}</div>
</template>
<Panel position="top-right" class="node-controls">
<div class="p-2 bg-white rounded shadow-sm text-sm">
<div class="mb-1">Controls:</div>
<div> Delete: Remove selected</div>
<div> Shift: Select nodes</div>
<div> Drag between nodes to connect</div>
<div> Double-click to remove</div>
</div>
</Panel>
</VueFlow>
</div>
</template>
<style>
/* 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;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
overflow: hidden;
position: relative;
flex: 1;
}
/* Make sure VueFlow takes full height */
:deep(.vue-flow) {
height: 100% !important;
}
/* Ensure the flow wrapper fills container */
:deep(.vue-flow__container) {
height: 100% !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.process-flow-container {
height: calc(100vh - 150px);
min-height: 400px;
}
}
@media (max-width: 480px) {
.process-flow-container {
height: calc(100vh - 120px);
min-height: 300px;
}
:deep(.vue-flow__controls) {
transform: scale(0.8);
transform-origin: bottom right;
}
:deep(.vue-flow__minimap) {
transform: scale(0.8);
transform-origin: top right;
}
}
.edge-label {
background-color: white;
padding: 2px 4px;
border-radius: 4px;
font-size: 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Vue Flow custom styles */
:deep(.vue-flow__edge-path) {
stroke: #555;
stroke-width: 2px;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #ff6b6b;
stroke-width: 3px;
}
:deep(.vue-flow__edge.animated .vue-flow__edge-path) {
stroke-dasharray: 5;
animation: flowEdgeAnimation 2s linear infinite;
}
/* Add custom node icon styles */
:deep(.custom-node) {
border-radius: 6px;
padding: 12px;
background: white;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
min-width: 150px;
}
:deep(.custom-node-header) {
display: flex;
align-items: center;
margin-bottom: 8px;
}
:deep(.custom-node-icon) {
margin-right: 8px;
}
:deep(.custom-node-icon .material-icons) {
font-size: 20px;
}
:deep(.node-start .custom-node-icon .material-icons) {
color: #4CAF50;
}
:deep(.node-end .custom-node-icon .material-icons) {
color: #f44336;
}
:deep(.node-task .custom-node-icon .material-icons) {
color: #2196F3;
}
:deep(.node-form .custom-node-icon .material-icons) {
color: #9C27B0;
}
:deep(.node-gateway .custom-node-icon .material-icons) {
color: #FF9800;
}
:deep(.node-script .custom-node-icon .material-icons) {
color: #607D8B;
}
:deep(.custom-node-title) {
font-weight: 500;
flex-grow: 1;
}
:deep(.custom-node-content) {
font-size: 12px;
color: #666;
}
@keyframes flowEdgeAnimation {
from {
stroke-dashoffset: 10;
}
to {
stroke-dashoffset: 0;
}
}
.node-controls {
margin: 10px;
color: #666;
font-size: 12px;
background: white;
border-radius: 4px;
pointer-events: all;
}
:deep(.vue-flow__handle) {
width: 8px;
height: 8px;
background: #555;
border: 2px solid white;
}
:deep(.vue-flow__handle:hover) {
background: #ff6b6b;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #ff6b6b;
stroke-width: 3px;
}
:deep(.vue-flow__node.selected) {
box-shadow: 0 0 0 2px #ff6b6b;
}
</style>

View File

@ -0,0 +1,297 @@
import { h, markRaw } from 'vue';
import { Handle, Position } from '@vue-flow/core';
// Custom node renderer
const CustomNode = markRaw({
template: `
<div
:class="['custom-node', 'node-' + type, selected ? 'selected' : '']"
@click="onClick"
>
<Handle
v-if="type !== 'start'"
type="target"
position="top"
:style="{ background: '#555' }"
/>
<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>
</div>
<Handle
v-if="type !== 'end'"
type="source"
position="bottom"
:style="{ background: '#555' }"
/>
</div>
`,
props: ['id', 'type', 'label', 'selected', 'data'],
computed: {
showBadge() {
return this.$slots.badge;
}
},
methods: {
onClick() {
this.$emit('node-click', this.id);
}
},
components: {
Handle
}
});
// Task node
export const TaskNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'],
render() {
return h(CustomNode, {
id: this.id,
type: 'task',
label: this.label,
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons' }, 'assignment'),
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Task node'),
h('div', { class: 'node-assignee' }, [
h('span', { class: 'node-assignee-label' }, 'Assigned to: '),
h('span', { class: 'node-assignee-value' }, this.data?.assignee || 'Unassigned')
])
])
});
}
});
// Start node
export const StartNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'],
render() {
return h(CustomNode, {
id: this.id,
type: 'start',
label: this.label || 'Start',
selected: this.selected,
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')
])
});
}
});
// End node
export const EndNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'],
render() {
return h(CustomNode, {
id: this.id,
type: 'end',
label: this.label || 'End',
selected: this.selected,
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')
])
});
}
});
// Decision/Gateway node
export const GatewayNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'],
render() {
return h(CustomNode, {
id: this.id,
type: 'gateway',
label: this.label || 'Decision',
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')
])
])
});
}
});
// Form node
export const FormNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'],
render() {
return h(CustomNode, {
id: this.id,
type: 'form',
label: this.label || 'Form',
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,
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Form submission'),
h('div', { class: 'node-form-info' }, [
h('span', { class: 'node-form-label' }, 'Form: '),
h('span', { class: 'node-form-value' }, this.data?.formName || 'None selected')
])
])
});
}
});
// Script node
export const ScriptNode = markRaw({
props: ['id', 'type', 'label', 'selected', 'data'],
render() {
return h(CustomNode, {
id: this.id,
type: 'script',
label: this.label || 'Script',
selected: this.selected,
data: this.data,
onClick: () => this.$emit('node-click', this.id)
}, {
icon: () => h('i', { class: 'material-icons' }, 'code'),
default: () => h('div', { class: 'node-details' }, [
h('p', { class: 'node-description' }, this.data?.description || 'Script execution'),
h('div', { class: 'node-script-info' }, [
h('span', { class: 'node-script-label' }, 'Language: '),
h('span', { class: 'node-script-value' }, this.data?.language || 'Not specified')
])
])
});
}
});
// Export the node types object to use with Vue Flow
export const nodeTypes = markRaw({
task: TaskNode,
start: StartNode,
end: EndNode,
gateway: GatewayNode,
form: FormNode,
script: ScriptNode
});
// 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;
}
.custom-node.selected {
border-color: #ff6b6b;
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.2);
}
.custom-node-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.custom-node-icon {
margin-right: 8px;
}
.custom-node-icon .material-icons {
font-size: 20px;
}
.custom-node-title {
font-weight: 500;
flex-grow: 1;
}
.custom-node-content {
font-size: 12px;
color: #666;
}
.node-details {
margin-top: 8px;
}
.node-description {
margin-bottom: 4px;
color: #666;
}
.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;
font-weight: 500;
}
/* Node type specific styles */
.node-start .custom-node-icon .material-icons {
color: #4CAF50;
}
.node-end .custom-node-icon .material-icons {
color: #f44336;
}
.node-task .custom-node-icon .material-icons {
color: #2196F3;
}
.node-form .custom-node-icon .material-icons {
color: #9C27B0;
}
.node-gateway .custom-node-icon .material-icons {
color: #FF9800;
}
.node-script .custom-node-icon .material-icons {
color: #607D8B;
}
`;

View File

@ -1,120 +1,111 @@
export default [
{
"header": "",
"description": "",
"child": [
header: "",
description: "",
child: [
{
"title": "Dashboard",
"path": "/dashboard",
"icon": "ic:outline-dashboard",
"child": [],
"meta": {}
}
]
title: "Dashboard",
path: "/dashboard",
icon: "ic:outline-dashboard",
child: [],
meta: {},
},
],
},
{
"header": "Administration",
"description": "Manage your application",
"child": [
header: "Administration",
description: "Manage your application",
child: [
{
"title": "Configuration",
"icon": "ic:outline-settings",
"child": [
title: "Configuration",
icon: "ic:outline-settings",
child: [
{
"title": "Environment",
"path": "/devtool/config/environment"
}
]
title: "Environment",
path: "/devtool/config/environment",
},
],
},
{
"title": "Menu Editor",
"icon": "ci:menu-alt-03",
"path": "/devtool/menu-editor",
"child": []
},
{
"title": "Manage Users",
"path": "/devtool/user-management",
"icon": "ph:user-circle-gear",
"child": [
title: "Manage Users",
path: "/devtool/user-management",
icon: "ph:user-circle-gear",
child: [
{
"title": "User List",
"path": "/devtool/user-management/user",
"icon": "",
"child": []
title: "User List",
path: "/devtool/user-management/user",
icon: "",
child: [],
},
{
"title": "Role List",
"path": "/devtool/user-management/role",
"icon": "",
"child": []
}
]
title: "Role List",
path: "/devtool/user-management/role",
icon: "",
child: [],
},
],
},
{
"title": "Content",
"icon": "mdi:pencil-ruler",
"child": [
title: "Content",
icon: "mdi:pencil-ruler",
child: [
{
"title": "Editor",
"path": "/devtool/content-editor"
title: "Editor",
path: "/devtool/content-editor",
},
{
"title": "Template",
"path": "/devtool/content-editor/template"
}
]
title: "Template",
path: "/devtool/content-editor/template",
},
],
},
{
"title": "API Editor",
"path": "/devtool/api-editor",
"icon": "material-symbols:api-rounded",
"child": []
title: "API Editor",
path: "/devtool/api-editor",
icon: "material-symbols:api-rounded",
child: [],
},
{
"title": "Code Playground",
"path": "/devtool/code-playground",
"icon": "mdi:code-braces",
"child": []
title: "Code Playground",
path: "/devtool/code-playground",
icon: "mdi:code-braces",
child: [],
},
{
"title": "Form Builder",
"path": "/form-builder/manage",
"icon": "mdi:form-select",
"child": []
}
],
"meta": {
"auth": {
"role": [
"Developer"
]
}
}
meta: {
auth: {
role: ["Developer"],
},
},
},
{
"header": "Help",
"description": "Help and documentation",
"child": [
header: "Process Builder",
description: "Build and manage your processes",
child: [
{
"title": "Documentation",
"icon": "solar:book-bookmark-minimalistic-bold",
"path": "https://manual.corrad.ai",
"external": true
title: "Process Management",
icon: "mdi:sitemap",
child: [
{
title: "Process Builder",
path: "/process-builder",
icon: "material-symbols:network-node",
child: [],
},
{
title: "Process List",
path: "/process-builder/manage",
icon: "mdi:format-list-bulleted",
child: [],
},
],
},
{
"title": "UI Components",
"icon": "material-symbols:settings-input-component-outline-rounded",
"path": "https://ui.corrad.ai",
"external": true
}
title: "Form Builder",
path: "/form-builder/manage",
icon: "mdi:form-select",
child: [],
},
],
"meta": {
"auth": {
"role": [
"Developer"
]
}
}
}
];
},
];

View File

@ -47,6 +47,10 @@
"@prisma/client": "^5.1.1",
"@shimyshack/uid": "^0.1.7",
"@sweetalert2/theme-dark": "^5.0.14",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.5",
"@vue-flow/minimap": "^1.5.3",
"@vueup/vue-quill": "^1.0.0",
"@vueuse/core": "^9.5.0",
"@vueuse/nuxt": "^9.5.0",

View File

@ -0,0 +1,487 @@
<script setup>
import { ref, onMounted, computed, shallowRef } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useRouter } from 'vue-router';
import ProcessFlowCanvas from '~/components/process-flow/ProcessFlowCanvas.vue';
// Initialize the store and router
const processStore = useProcessBuilderStore();
const router = useRouter();
// Track selected node local state (syncs with store)
// Using shallowRef to avoid making Vue components reactive
const selectedNode = shallowRef(null);
// Track if we're creating a new process
const isCreatingProcess = ref(false);
const newProcessName = ref('');
const newProcessDescription = ref('');
// Track selected node data
const selectedNodeData = ref(null);
// Track drag data
const draggedComponent = ref(null);
// Component definitions
const components = [
{
type: 'start',
label: 'Start',
icon: 'play_circle_filled',
iconColor: 'text-green-500',
data: { description: 'Process starts here' }
},
{
type: 'task',
label: 'Task',
icon: 'assignment',
iconColor: 'text-blue-500',
data: { description: 'Task node', assignee: '' }
},
{
type: 'form',
label: 'Form',
icon: 'description',
iconColor: 'text-purple-500',
data: { description: 'Form submission', formName: '' }
},
{
type: 'gateway',
label: 'Gateway',
icon: 'call_split',
iconColor: 'text-orange-500',
data: { description: 'Decision point', conditions: [] }
},
{
type: 'script',
label: 'Script',
icon: 'code',
iconColor: 'text-gray-500',
data: { description: 'Script execution', language: 'JavaScript' }
},
{
type: 'end',
label: 'End',
icon: 'stop_circle',
iconColor: 'text-red-500',
data: { description: 'Process completes here' }
}
];
// Computed to check if we have a current process
const hasCurrentProcess = computed(() => {
return !!processStore.currentProcess;
});
// Computed properties for node data
const nodeLabel = computed({
get: () => selectedNodeData.value?.data?.label || '',
set: (value) => {
if (selectedNodeData.value) {
selectedNodeData.value.data.label = value;
updateNodeInStore();
}
}
});
const nodeDescription = computed({
get: () => selectedNodeData.value?.data?.description || '',
set: (value) => {
if (selectedNodeData.value) {
selectedNodeData.value.data.description = value;
updateNodeInStore();
}
}
});
const nodeAssignee = computed({
get: () => selectedNodeData.value?.data?.assignee || '',
set: (value) => {
if (selectedNodeData.value) {
selectedNodeData.value.data.assignee = value;
updateNodeInStore();
}
}
});
const nodeFormName = computed({
get: () => selectedNodeData.value?.data?.formName || '',
set: (value) => {
if (selectedNodeData.value) {
selectedNodeData.value.data.formName = value;
updateNodeInStore();
}
}
});
const nodeLanguage = computed({
get: () => selectedNodeData.value?.data?.language || 'JavaScript',
set: (value) => {
if (selectedNodeData.value) {
selectedNodeData.value.data.language = value;
updateNodeInStore();
}
}
});
// Handle node selection
const onNodeSelected = (node) => {
selectedNodeData.value = JSON.parse(JSON.stringify(node));
selectedNode.value = node;
processStore.selectNode(node.id);
};
// Update node in store
const updateNodeInStore = () => {
if (selectedNodeData.value) {
processStore.updateNode(selectedNodeData.value);
}
};
// Handle pane click (deselection)
const onPaneClick = () => {
selectedNode.value = null;
selectedNodeData.value = null;
processStore.clearSelection();
};
// Handle node changes
const onNodesChange = (changes, nodes) => {
// For now just log changes
// console.log('Nodes changed:', changes);
};
// Handle edge changes
const onEdgesChange = (changes, edges) => {
// For now just log changes
// console.log('Edges changed:', changes);
};
// Handle creating a new process
const createNewProcess = () => {
if (!newProcessName.value.trim()) return;
processStore.createProcess(
newProcessName.value.trim(),
newProcessDescription.value.trim()
);
isCreatingProcess.value = false;
newProcessName.value = '';
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();
}
});
// Handle drag start
const onDragStart = (event, component) => {
try {
const dragData = {
type: component.type,
label: component.label,
data: {
...component.data,
label: component.label
}
};
draggedComponent.value = dragData;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/json', JSON.stringify(dragData));
// Add visual feedback
event.target.classList.add('dragging');
} catch (error) {
console.error('Error starting drag:', error);
}
};
// Handle drag end
const onDragEnd = (event) => {
event.target.classList.remove('dragging');
draggedComponent.value = null;
};
// Generate unique node ID
const generateNodeId = (type) => {
return `${type}-${Math.random().toString(36).substr(2, 9)}`;
};
</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>
</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"
type="text"
class="w-full px-3 py-2 border rounded"
placeholder="Enter process name"
/>
</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>
<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>
</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>
</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 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>
</div>
<div v-else class="text-gray-500 text-sm italic">
Select a node to view its properties
</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>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.process-builder-container {
background-color: #f8fafc;
}
.process-builder-palette,
.process-builder-properties {
height: 100%;
display: flex;
flex-direction: column;
}
.process-component-item.dragging {
opacity: 0.5;
transform: scale(0.95);
}
.process-component-item {
transition: all 0.2s ease;
user-select: none;
}
.process-component-item:active {
cursor: grabbing;
}
/* Responsive adjustments */
@media (max-width: 1024px) {
.process-builder-palette {
width: 200px;
}
.process-builder-properties {
width: 250px;
}
}
@media (max-width: 768px) {
.process-builder-actions {
flex-wrap: wrap;
}
.process-builder-palette,
.process-builder-properties {
display: none;
}
.process-builder-canvas {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,244 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useRouter } from 'vue-router';
// Initialize the store and router
const processStore = useProcessBuilderStore();
const router = useRouter();
// Reference to the search input
const searchQuery = ref('');
// Confirmation dialog state
const showDeleteConfirm = ref(false);
const processToDelete = ref(null);
// Filtered processes
const filteredProcesses = computed(() => {
if (!searchQuery.value) {
return processStore.processes;
}
const query = searchQuery.value.toLowerCase();
return processStore.processes.filter(
process => process.name.toLowerCase().includes(query) ||
process.description.toLowerCase().includes(query)
);
});
// Format date for display
const formatDate = (isoString) => {
if (!isoString) return '';
const date = new Date(isoString);
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric'
}).format(date);
};
// Edit a process
const editProcess = (processId) => {
processStore.setCurrentProcess(processId);
router.push('/process-builder');
};
// Duplicate a process
const duplicateProcess = (process) => {
const newName = `${process.name} (Copy)`;
const newProcess = processStore.createProcess(newName, process.description);
// Copy nodes and edges
process.nodes.forEach(node => {
processStore.addNode({
...node,
id: undefined // Let the store generate a new ID
});
});
process.edges.forEach(edge => {
processStore.addEdge({
...edge,
id: undefined // Let the store generate a new ID
});
});
processStore.saveProcess();
};
// Delete a process
const deleteProcess = () => {
if (processToDelete.value) {
processStore.deleteProcess(processToDelete.value);
showDeleteConfirm.value = false;
processToDelete.value = null;
}
};
// Show delete confirmation
const confirmDelete = (processId) => {
processToDelete.value = processId;
showDeleteConfirm.value = true;
};
// Close delete confirmation
const cancelDelete = () => {
showDeleteConfirm.value = false;
processToDelete.value = null;
};
// Create a new process
const createNewProcess = () => {
// Clear current process to start fresh
processStore.currentProcess = null;
router.push('/process-builder');
};
// Go back to builder
const goToBuilder = () => {
router.push('/process-builder');
};
// Check if we have processes, if not create a demo one
onMounted(() => {
if (processStore.processes.length === 0) {
// Create a demo process
const process = processStore.createProcess(
'Demo Process',
'A demonstration process flow for testing purposes'
);
// Save it
processStore.saveProcess();
}
});
</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>
<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>
</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>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.process-management {
min-height: calc(100vh - 80px);
}
</style>

View File

@ -0,0 +1,10 @@
import { nodeStyles } from '~/components/process-flow/ProcessFlowNodes';
export default defineNuxtPlugin(() => {
// Create a style element
const styleEl = document.createElement('style');
styleEl.textContent = nodeStyles;
// Append to document head
document.head.appendChild(styleEl);
});

363
stores/processBuilder.js Normal file
View File

@ -0,0 +1,363 @@
import { defineStore } from 'pinia';
import { v4 as uuidv4 } from 'uuid';
export const useProcessBuilderStore = defineStore('processBuilder', {
state: () => ({
processes: [],
currentProcess: null,
selectedNodeId: null,
selectedEdgeId: null,
history: [],
historyIndex: -1,
unsavedChanges: false
}),
getters: {
/**
* Get the current process object
*/
process: (state) => {
return state.currentProcess;
},
/**
* Get the selected node
*/
selectedNode: (state) => {
if (!state.currentProcess || !state.selectedNodeId) return null;
return state.currentProcess.nodes.find(node => node.id === state.selectedNodeId);
},
/**
* Get the selected edge
*/
selectedEdge: (state) => {
if (!state.currentProcess || !state.selectedEdgeId) return null;
return state.currentProcess.edges.find(edge => edge.id === state.selectedEdgeId);
},
/**
* Check if there are unsaved changes
*/
hasUnsavedChanges: (state) => {
return state.unsavedChanges;
},
/**
* Check if undo is available
*/
canUndo: (state) => {
return state.historyIndex > 0;
},
/**
* Check if redo is available
*/
canRedo: (state) => {
return state.historyIndex < state.history.length - 1;
}
},
actions: {
/**
* Create a new process
*/
createProcess(name, description = '') {
const newProcess = {
id: uuidv4(),
name,
description,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nodes: [],
edges: []
};
this.processes.push(newProcess);
this.setCurrentProcess(newProcess.id);
this.saveToHistory('Create process');
this.unsavedChanges = true;
return newProcess;
},
/**
* Load a process
*/
loadProcess(processId) {
const process = this.processes.find(p => p.id === processId);
if (process) {
this.currentProcess = JSON.parse(JSON.stringify(process)); // Deep clone
this.selectedNodeId = null;
this.selectedEdgeId = null;
this.clearHistory();
this.unsavedChanges = false;
}
},
/**
* Set the current process
*/
setCurrentProcess(processId) {
const process = this.processes.find(p => p.id === processId);
if (process) {
this.currentProcess = JSON.parse(JSON.stringify(process)); // Deep clone
this.selectedNodeId = null;
this.selectedEdgeId = null;
this.clearHistory();
this.unsavedChanges = false;
}
},
/**
* Save the current process
*/
saveProcess() {
if (!this.currentProcess) return;
const index = this.processes.findIndex(p => p.id === this.currentProcess.id);
if (index !== -1) {
this.currentProcess.updatedAt = new Date().toISOString();
this.processes[index] = JSON.parse(JSON.stringify(this.currentProcess)); // Deep clone
this.unsavedChanges = false;
}
},
/**
* Delete a process
*/
deleteProcess(processId) {
const index = this.processes.findIndex(p => p.id === processId);
if (index !== -1) {
this.processes.splice(index, 1);
if (this.currentProcess && this.currentProcess.id === processId) {
this.currentProcess = null;
this.selectedNodeId = null;
this.selectedEdgeId = null;
this.clearHistory();
}
}
},
/**
* Add a node to the current process
*/
addNode(node) {
if (!this.currentProcess) return;
const newNode = {
id: node.id || uuidv4(),
type: node.type,
label: node.label || 'New Node',
position: node.position || { x: 0, y: 0 },
data: node.data || {}
};
this.currentProcess.nodes.push(newNode);
this.selectedNodeId = newNode.id;
this.saveToHistory('Add node');
this.unsavedChanges = true;
return newNode;
},
/**
* Update a node in the current process
*/
updateNode(nodeId, updates) {
if (!this.currentProcess) return;
const node = this.currentProcess.nodes.find(n => n.id === nodeId);
if (node) {
Object.assign(node, updates);
this.saveToHistory('Update node');
this.unsavedChanges = true;
}
},
/**
* Delete a node from the current process
*/
deleteNode(nodeId) {
if (!this.currentProcess) return;
const index = this.currentProcess.nodes.findIndex(n => n.id === nodeId);
if (index !== -1) {
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
);
if (this.selectedNodeId === nodeId) {
this.selectedNodeId = null;
}
this.saveToHistory('Delete node');
this.unsavedChanges = true;
}
},
/**
* Add an edge to the current process
*/
addEdge(edge) {
if (!this.currentProcess) return;
const newEdge = {
id: edge.id || `${edge.source}-${edge.target}`,
source: edge.source,
target: edge.target,
label: edge.label || '',
type: edge.type || 'default',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data || {}
};
this.currentProcess.edges.push(newEdge);
this.selectedEdgeId = newEdge.id;
this.saveToHistory('Add edge');
this.unsavedChanges = true;
return newEdge;
},
/**
* Update an edge in the current process
*/
updateEdge(edgeId, updates) {
if (!this.currentProcess) return;
const edge = this.currentProcess.edges.find(e => e.id === edgeId);
if (edge) {
Object.assign(edge, updates);
this.saveToHistory('Update edge');
this.unsavedChanges = true;
}
},
/**
* Delete an edge from the current process
*/
deleteEdge(edgeId) {
if (!this.currentProcess) return;
const index = this.currentProcess.edges.findIndex(e => e.id === edgeId);
if (index !== -1) {
this.currentProcess.edges.splice(index, 1);
if (this.selectedEdgeId === edgeId) {
this.selectedEdgeId = null;
}
this.saveToHistory('Delete edge');
this.unsavedChanges = true;
}
},
/**
* Update node positions after drag
*/
updateNodePositions(nodePositions) {
if (!this.currentProcess) return;
Object.entries(nodePositions).forEach(([nodeId, position]) => {
const node = this.currentProcess.nodes.find(n => n.id === nodeId);
if (node) {
node.position = position;
}
});
this.saveToHistory('Move nodes');
this.unsavedChanges = true;
},
/**
* Select a node
*/
selectNode(nodeId) {
this.selectedNodeId = nodeId;
this.selectedEdgeId = null;
},
/**
* Select an edge
*/
selectEdge(edgeId) {
this.selectedEdgeId = edgeId;
this.selectedNodeId = null;
},
/**
* Clear selection
*/
clearSelection() {
this.selectedNodeId = null;
this.selectedEdgeId = null;
},
/**
* Save the current state to history
*/
saveToHistory(actionName) {
// Remove any future states if we're in the middle of the history
if (this.historyIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.historyIndex + 1);
}
// Add current state to history
this.history.push({
state: JSON.parse(JSON.stringify(this.currentProcess)),
action: actionName,
timestamp: new Date().toISOString()
});
// Move history pointer
this.historyIndex = this.history.length - 1;
// Limit history size
if (this.history.length > 50) {
this.history.shift();
this.historyIndex--;
}
},
/**
* Undo the last action
*/
undo() {
if (this.historyIndex > 0) {
this.historyIndex--;
this.currentProcess = JSON.parse(JSON.stringify(this.history[this.historyIndex].state));
this.unsavedChanges = true;
}
},
/**
* Redo the last undone action
*/
redo() {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.currentProcess = JSON.parse(JSON.stringify(this.history[this.historyIndex].state));
this.unsavedChanges = true;
}
},
/**
* Clear history
*/
clearHistory() {
this.history = [];
if (this.currentProcess) {
this.history.push({
state: JSON.parse(JSON.stringify(this.currentProcess)),
action: 'Initial state',
timestamp: new Date().toISOString()
});
}
this.historyIndex = 0;
}
}
});

122
yarn.lock
View File

@ -2335,6 +2335,11 @@
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz#1d12873a8e49567371f2a75fe3e7f7edca6662d8"
integrity sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==
"@types/web-bluetooth@^0.0.20":
version "0.0.20"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
"@typescript-eslint/eslint-plugin@~5.37.0":
version "5.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.37.0.tgz#5ccdd5d9004120f28fc6e717fb4b5c9bddcfbc04"
@ -2544,6 +2549,34 @@
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz#ee0b6dfcc62fe65364e6395bf38fa2ba10bb44b6"
integrity sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==
"@vue-flow/background@^1.3.2":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@vue-flow/background/-/background-1.3.2.tgz#0c90cd05e5d60da017bbaf5a1c3eb6af7ed9b778"
integrity sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==
"@vue-flow/controls@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@vue-flow/controls/-/controls-1.1.2.tgz#d71899ed793f741400d043efdd49765a3a94c75f"
integrity sha512-6dtl/JnwDBNau5h3pDBdOCK6tdxiVAOL3cyruRL61gItwq5E97Hmjmj2BIIqX2p7gU1ENg3z80Z4zlu58fGlsg==
"@vue-flow/core@^1.42.5":
version "1.42.5"
resolved "https://registry.yarnpkg.com/@vue-flow/core/-/core-1.42.5.tgz#c4bd1d1b1fafd6e3c1807bbfe2124111c82b0e14"
integrity sha512-fNaBzt5i/JYHIzfmR4wtT1TkpfZBgB+Pe/LjCG+aXdNOpeveuegv3AmEcU3GFqf/uYrd1rsma877Lncu1uwz1w==
dependencies:
"@vueuse/core" "^10.5.0"
d3-drag "^3.0.0"
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
"@vue-flow/minimap@^1.5.3":
version "1.5.3"
resolved "https://registry.yarnpkg.com/@vue-flow/minimap/-/minimap-1.5.3.tgz#de0a6031668c94874e6d66d7830c7ba05c366c88"
integrity sha512-w8VQc8orPdzfstIPI4/u6H7qlc/uVM1W6b5Upd5NQi0+S9seYl3CiUrzO9liW/f8Fuvr5oHVQg0X6nn2K083rA==
dependencies:
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
"@vue-macros/common@^1.3.1":
version "1.7.0"
resolved "https://registry.yarnpkg.com/@vue-macros/common/-/common-1.7.0.tgz#67e4ee8831ad70383c9496cebeb23850e6a6e3c5"
@ -2698,6 +2731,21 @@
"@vueuse/shared" "9.13.0"
vue-demi "*"
"@vueuse/core@^10.5.0":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6"
integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==
dependencies:
"@types/web-bluetooth" "^0.0.20"
"@vueuse/metadata" "10.11.1"
"@vueuse/shared" "10.11.1"
vue-demi ">=0.14.8"
"@vueuse/metadata@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==
"@vueuse/metadata@9.13.0":
version "9.13.0"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.13.0.tgz#bc25a6cdad1b1a93c36ce30191124da6520539ff"
@ -2714,6 +2762,13 @@
local-pkg "^0.4.3"
vue-demi "*"
"@vueuse/shared@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.11.1.tgz#62b84e3118ae6e1f3ff38f4fbe71b0c5d0f10938"
integrity sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==
dependencies:
vue-demi ">=0.14.8"
"@vueuse/shared@9.13.0":
version "9.13.0"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-9.13.0.tgz#089ff4cc4e2e7a4015e57a8f32e4b39d096353b9"
@ -3793,6 +3848,68 @@ cuint@^0.2.2:
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
integrity sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==
"d3-color@1 - 3":
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
"d3-dispatch@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
"d3-drag@2 - 3", d3-drag@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
dependencies:
d3-dispatch "1 - 3"
d3-selection "3"
"d3-ease@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
"d3-interpolate@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
dependencies:
d3-color "1 - 3"
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
"d3-timer@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
"d3-transition@2 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
dependencies:
d3-color "1 - 3"
d3-dispatch "1 - 3"
d3-ease "1 - 3"
d3-interpolate "1 - 3"
d3-timer "1 - 3"
d3-zoom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
dependencies:
d3-dispatch "1 - 3"
d3-drag "2 - 3"
d3-interpolate "1 - 3"
d3-selection "2 - 3"
d3-transition "2 - 3"
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@ -9221,6 +9338,11 @@ vue-demi@*, vue-demi@>=0.14.5:
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.5.tgz#676d0463d1a1266d5ab5cba932e043d8f5f2fbd9"
integrity sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==
vue-demi@>=0.14.8:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
vue-devtools-stub@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz#a65b9485edecd4273cedcb8102c739b83add2c81"