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:
parent
93dbb7d5ee
commit
591f5ca4d8
550
components/process-flow/ProcessFlowCanvas.vue
Normal file
550
components/process-flow/ProcessFlowCanvas.vue
Normal 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>
|
297
components/process-flow/ProcessFlowNodes.js
Normal file
297
components/process-flow/ProcessFlowNodes.js
Normal 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;
|
||||
}
|
||||
`;
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
},
|
||||
];
|
||||
|
@ -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",
|
||||
|
487
pages/process-builder/index.vue
Normal file
487
pages/process-builder/index.vue
Normal 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>
|
244
pages/process-builder/manage.vue
Normal file
244
pages/process-builder/manage.vue
Normal 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>
|
10
plugins/process-flow-styles.client.js
Normal file
10
plugins/process-flow-styles.client.js
Normal 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
363
stores/processBuilder.js
Normal 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
122
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user