Add Design Elements and Custom Shapes to Process Builder

- Introduced a new section for Design Elements in ProcessBuilderComponents.vue, allowing users to drag and drop various shapes into the process flow.
- Added multiple custom shapes including Horizontal Swimlane, Vertical Swimlane, Rectangle, Text Annotation, Process Group, Hexagon, and Trapezoid to enhance visual organization within the process builder.
- Updated ProcessFlowCanvas.vue to ensure that shape nodes are treated correctly, preventing them from being connectable and ensuring proper rendering.
- Enhanced ProcessFlowNodes.js to include new shape components with dynamic styling and properties.
- Improved the user interface in index.vue to support shape size adjustments and provide clear information about shape elements.
- Ensured that all new shapes maintain consistent styling and behavior with existing process nodes, enhancing overall user experience and functionality.
This commit is contained in:
Afiq 2025-07-09 13:44:53 +08:00
parent 0a01f1116a
commit 5faefb8900
4 changed files with 970 additions and 75 deletions

View File

@ -35,6 +35,25 @@
</div>
</div>
<!-- Design Elements -->
<div class="component-category mb-6">
<h3 class="text-gray-700 text-sm font-medium px-3 mb-2">Design Elements</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 px-2">
<div
v-for="shape in getComponentsByCategory('Shape')"
:key="shape.type"
class="component-item rounded p-3 flex flex-col items-center justify-center cursor-grab hover:bg-blue-50 transition-colors border border-blue-200 touch-manipulation"
:class="{ 'hidden': !matchesSearch(shape) }"
draggable="true"
@dragstart="onDragStart($event, shape)"
@click="addComponent(shape)"
>
<Icon :name="shape.icon" class="mb-2 w-6 h-6 text-blue-500" />
<span class="text-xs text-blue-600 text-center leading-tight">{{ shape.name }}</span>
</div>
</div>
</div>
</div>
</template>
@ -188,6 +207,148 @@ const availableComponents = [
errorVariable: 'scriptError'
}
}
},
// Design Elements / Shapes
{
type: 'swimlane-horizontal',
name: 'Horizontal Swimlane',
category: 'Shape',
icon: 'material-symbols:view-stream',
description: 'Horizontal swimlane for organizing process sections',
defaultProps: {
label: '',
data: {
description: '',
width: 600,
height: 150,
backgroundColor: '#f8fafc',
borderColor: '#e2e8f0',
textColor: '#475569',
isShape: true,
shapeType: 'swimlane-horizontal'
}
}
},
{
type: 'swimlane-vertical',
name: 'Vertical Swimlane',
category: 'Shape',
icon: 'material-symbols:view-column',
description: 'Vertical swimlane for organizing process sections',
defaultProps: {
label: '',
data: {
description: '',
width: 200,
height: 400,
backgroundColor: '#f8fafc',
borderColor: '#e2e8f0',
textColor: '#475569',
isShape: true,
shapeType: 'swimlane-vertical'
}
}
},
{
type: 'rectangle-shape',
name: 'Rectangle',
category: 'Shape',
icon: 'material-symbols:rectangle',
description: 'Rectangle shape for grouping and annotation',
defaultProps: {
label: '',
data: {
description: '',
width: 300,
height: 200,
backgroundColor: '#fefefe',
borderColor: '#d1d5db',
textColor: '#374151',
isShape: true,
shapeType: 'rectangle'
}
}
},
{
type: 'text-annotation',
name: 'Text Annotation',
category: 'Shape',
icon: 'material-symbols:text-fields',
description: 'Text annotation for adding notes and comments',
defaultProps: {
label: '',
data: {
description: '',
width: 200,
height: 80,
backgroundColor: '#fffbeb',
borderColor: '#fbbf24',
textColor: '#92400e',
isShape: true,
shapeType: 'text-annotation'
}
}
},
{
type: 'process-group',
name: 'Process Group',
category: 'Shape',
icon: 'material-symbols:group-work',
description: 'Group container for organizing related processes',
defaultProps: {
label: '',
data: {
description: '',
width: 400,
height: 300,
backgroundColor: '#f0f9ff',
borderColor: '#0284c7',
textColor: '#0369a1',
isShape: true,
shapeType: 'process-group'
}
}
},
{
type: 'hexagon-shape',
name: 'Hexagon',
category: 'Shape',
icon: 'material-symbols:hexagon',
description: 'Hexagon shape for special processes or decision points',
defaultProps: {
label: '',
data: {
description: '',
width: 200,
height: 150,
backgroundColor: '#f8fafc',
borderColor: '#e2e8f0',
textColor: '#475569',
isShape: true,
shapeType: 'hexagon'
}
}
},
{
type: 'trapezoid-shape',
name: 'Trapezoid',
category: 'Shape',
icon: 'material-symbols:change-history',
description: 'Trapezoid shape for data processing or manual tasks',
defaultProps: {
label: '',
data: {
description: '',
width: 220,
height: 120,
backgroundColor: '#f8fafc',
borderColor: '#e2e8f0',
textColor: '#475569',
isShape: true,
shapeType: 'trapezoid'
}
}
}
];

View File

@ -356,22 +356,17 @@ watch(() => props.initialNodes, async (newNodes, oldNodes) => {
newNodes.forEach(newNode => {
const existingNode = nodes.value.find(n => n.id === newNode.id);
if (existingNode) {
// Check if the node has actually changed before updating
const hasChanges = (
existingNode.label !== newNode.label ||
JSON.stringify(existingNode.data) !== JSON.stringify(newNode.data) ||
Math.abs(existingNode.position.x - newNode.position.x) > 1 ||
Math.abs(existingNode.position.y - newNode.position.y) > 1
);
// Always update the existing node to ensure latest data is reflected
// This is critical for property changes to be visible in the canvas
Object.assign(existingNode, {
label: newNode.label,
data: { ...newNode.data },
position: { ...newNode.position }
});
if (hasChanges) {
Object.assign(existingNode, {
label: newNode.label,
data: { ...newNode.data },
position: { ...newNode.position }
});
updateNodeInternals([newNode.id]);
}
// Always call updateNodeInternals to force re-render
// This ensures property changes are reflected immediately
updateNodeInternals([newNode.id]);
}
});
@ -630,7 +625,7 @@ const onDrop = (event) => {
y: event.clientY - bounds.top
});
// Create new node
// Create new node (both process nodes and shapes are treated as nodes in Vue Flow)
const newNode = {
id: `${componentData.type}-${Date.now()}`,
type: componentData.type,
@ -642,6 +637,12 @@ const onDrop = (event) => {
}
};
// For shape nodes, ensure they don't have connection handles
if (componentData.data?.isShape) {
// Shapes should not be connectable
newNode.connectable = false;
}
// Add to Vue Flow for immediate visual feedback
addNodes([newNode]);
@ -678,22 +679,30 @@ defineExpose({
// Update an existing node
function updateNode(nodeId, newData) {
const nodeToUpdate = nodes.value.find(node => node.id === nodeId);
if (!nodeToUpdate) return;
if (!nodeToUpdate) {
console.warn(`Canvas updateNode: Node ${nodeId} not found`);
return;
}
// Update the node properties
if (newData.label) {
// Update the node properties - handle all provided data
if (newData.label !== undefined) {
nodeToUpdate.label = newData.label;
}
// Update the node data
if (newData.data) {
// Update the node data - merge with existing data
if (newData.data !== undefined) {
nodeToUpdate.data = {
...nodeToUpdate.data,
...newData.data
};
}
// Update node internals to trigger re-render
// Update position if provided
if (newData.position !== undefined) {
nodeToUpdate.position = { ...newData.position };
}
// Force re-render by updating node internals
updateNodeInternals([nodeId]);
return nodeToUpdate;
@ -741,15 +750,17 @@ function syncCanvas(newNodes, newEdges) {
addNodes([...nodesToAdd]);
}
// Update existing nodes
// Update existing nodes - force update all properties
newNodes.forEach(newNode => {
const existingNode = nodes.value.find(n => n.id === newNode.id);
if (existingNode) {
// Always update to ensure property changes are reflected
Object.assign(existingNode, {
label: newNode.label,
data: { ...newNode.data },
position: { ...newNode.position }
});
// Always force re-render for property changes
updateNodeInternals([newNode.id]);
}
});

View File

@ -151,14 +151,25 @@ const CustomNode = markRaw({
const borderColor = this.data?.borderColor || defaultBorder;
const textColor = this.data?.textColor || defaultText;
return {
// Get the current shape to determine if we should apply direct styles
const shape = this.data?.shape || 'rectangle';
const isShapedNode = shape !== 'rectangle' && shape !== 'rounded-rectangle' && shape !== 'circle';
const style = {
'--node-bg-color': backgroundColor,
'--node-border-color': borderColor,
'--node-text-color': textColor,
backgroundColor: backgroundColor,
borderColor: borderColor,
color: textColor
};
// Only apply direct background/border styles for non-shaped nodes
// Shaped nodes (hexagon, trapezoid) use CSS ::before pseudo-elements with clip-path
if (!isShapedNode) {
style.backgroundColor = backgroundColor;
style.borderColor = borderColor;
}
return style;
}
},
methods: {
@ -639,8 +650,305 @@ export const NotificationNode = markRaw({
}
});
// Shape Components (Design Elements)
const HorizontalSwimlaneShape = {
props: ['id', 'data', 'selected', 'label'],
computed: {
shapeStyle() {
return {
width: `${this.data?.width || 600}px`,
height: `${this.data?.height || 150}px`,
'--node-bg-color': this.data?.backgroundColor || '#f8fafc',
'--node-border-color': this.data?.borderColor || '#e2e8f0',
'--node-text-color': this.data?.textColor || '#475569',
backgroundColor: this.data?.backgroundColor || '#f8fafc',
border: `2px solid ${this.data?.borderColor || '#e2e8f0'}`,
borderRadius: '8px',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: this.data?.textColor || '#475569',
fontSize: '14px',
fontWeight: '500',
cursor: 'move',
zIndex: -10 // Behind process nodes
};
},
displayLabel() {
return this.label || this.data?.label || '';
},
displayDescription() {
return this.data?.description || '';
}
},
template: `
<div
:class="['shape-node', 'swimlane-horizontal', { 'selected': selected }]"
:style="shapeStyle"
>
<div class="shape-content" v-if="displayLabel || displayDescription">
<div class="shape-label" v-if="displayLabel">{{ displayLabel }}</div>
<div class="shape-description" v-if="displayDescription">{{ displayDescription }}</div>
</div>
</div>
`
};
const VerticalSwimlaneShape = {
props: ['id', 'data', 'selected', 'label'],
computed: {
shapeStyle() {
return {
width: `${this.data?.width || 200}px`,
height: `${this.data?.height || 400}px`,
'--node-bg-color': this.data?.backgroundColor || '#f8fafc',
'--node-border-color': this.data?.borderColor || '#e2e8f0',
'--node-text-color': this.data?.textColor || '#475569',
backgroundColor: this.data?.backgroundColor || '#f8fafc',
border: `2px solid ${this.data?.borderColor || '#e2e8f0'}`,
borderRadius: '8px',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: this.data?.textColor || '#475569',
fontSize: '14px',
fontWeight: '500',
cursor: 'move',
zIndex: -10, // Behind process nodes
writingMode: 'vertical-rl',
textOrientation: 'mixed'
};
},
displayLabel() {
return this.label || this.data?.label || '';
},
displayDescription() {
return this.data?.description || '';
}
},
template: `
<div
:class="['shape-node', 'swimlane-vertical', { 'selected': selected }]"
:style="shapeStyle"
>
<div class="shape-content" v-if="displayLabel || displayDescription">
<div class="shape-label" v-if="displayLabel">{{ displayLabel }}</div>
<div class="shape-description" v-if="displayDescription">{{ displayDescription }}</div>
</div>
</div>
`
};
const RectangleShape = {
props: ['id', 'data', 'selected', 'label'],
computed: {
shapeStyle() {
return {
width: `${this.data?.width || 300}px`,
height: `${this.data?.height || 200}px`,
'--node-bg-color': this.data?.backgroundColor || '#fefefe',
'--node-border-color': this.data?.borderColor || '#d1d5db',
'--node-text-color': this.data?.textColor || '#374151',
backgroundColor: this.data?.backgroundColor || '#fefefe',
border: `2px solid ${this.data?.borderColor || '#d1d5db'}`,
borderRadius: '4px',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: this.data?.textColor || '#374151',
fontSize: '14px',
fontWeight: '500',
cursor: 'move',
zIndex: -10 // Behind process nodes
};
},
displayLabel() {
return this.label || this.data?.label || '';
},
displayDescription() {
return this.data?.description || '';
}
},
template: `
<div
:class="['shape-node', 'rectangle-shape', { 'selected': selected }]"
:style="shapeStyle"
>
<div class="shape-content" v-if="displayLabel || displayDescription">
<div class="shape-label" v-if="displayLabel">{{ displayLabel }}</div>
<div class="shape-description" v-if="displayDescription">{{ displayDescription }}</div>
</div>
</div>
`
};
const TextAnnotationShape = {
props: ['id', 'data', 'selected', 'label'],
computed: {
shapeStyle() {
return {
width: `${this.data?.width || 200}px`,
height: `${this.data?.height || 80}px`,
'--node-bg-color': this.data?.backgroundColor || '#fffbeb',
'--node-border-color': this.data?.borderColor || '#fbbf24',
'--node-text-color': this.data?.textColor || '#92400e',
backgroundColor: this.data?.backgroundColor || '#fffbeb',
border: `2px dashed ${this.data?.borderColor || '#fbbf24'}`,
borderRadius: '4px',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: this.data?.textColor || '#92400e',
fontSize: '12px',
fontWeight: '400',
cursor: 'move',
zIndex: -10, // Behind process nodes
fontStyle: 'italic'
};
},
displayLabel() {
return this.label || this.data?.label || '';
},
displayDescription() {
return this.data?.description || '';
}
},
template: `
<div
:class="['shape-node', 'text-annotation', { 'selected': selected }]"
:style="shapeStyle"
>
<div class="shape-content" v-if="displayLabel || displayDescription">
<div class="shape-label" v-if="displayLabel">{{ displayLabel }}</div>
<div class="shape-description" v-if="displayDescription">{{ displayDescription }}</div>
</div>
</div>
`
};
const ProcessGroupShape = {
props: ['id', 'data', 'selected', 'label'],
computed: {
shapeStyle() {
return {
width: `${this.data?.width || 400}px`,
height: `${this.data?.height || 300}px`,
'--node-bg-color': this.data?.backgroundColor || '#f0f9ff',
'--node-border-color': this.data?.borderColor || '#0284c7',
'--node-text-color': this.data?.textColor || '#0369a1',
backgroundColor: this.data?.backgroundColor || '#f0f9ff',
border: `3px solid ${this.data?.borderColor || '#0284c7'}`,
borderRadius: '12px',
position: 'relative',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
color: this.data?.textColor || '#0369a1',
fontSize: '16px',
fontWeight: '600',
cursor: 'move',
zIndex: -10, // Behind process nodes
padding: '16px'
};
},
displayLabel() {
return this.label || this.data?.label || '';
},
displayDescription() {
return this.data?.description || '';
}
},
template: `
<div
:class="['shape-node', 'process-group', { 'selected': selected }]"
:style="shapeStyle"
>
<div class="shape-content" v-if="displayLabel || displayDescription">
<div class="shape-label" v-if="displayLabel">{{ displayLabel }}</div>
<div class="shape-description" v-if="displayDescription">{{ displayDescription }}</div>
</div>
</div>
`
};
// Hexagon Shape Component
const HexagonShape = {
props: ['id', 'data', 'selected', 'label'],
computed: {
shapeStyle() {
return {
width: `${this.data?.width || 200}px`,
height: `${this.data?.height || 150}px`,
'--node-bg-color': this.data?.backgroundColor || '#f8fafc',
'--node-border-color': this.data?.borderColor || '#e2e8f0',
'--node-text-color': this.data?.textColor || '#475569',
position: 'relative',
cursor: 'move',
zIndex: -10 // Behind process nodes
};
},
displayLabel() {
return this.label || this.data?.label || '';
},
displayDescription() {
return this.data?.description || '';
}
},
template: `
<div
:class="['custom-node', 'shape-node', 'shape-hexagon', { 'selected': selected }]"
:style="shapeStyle"
>
<div class="custom-node-content" v-if="displayLabel || displayDescription">
<div class="custom-node-label" v-if="displayLabel">{{ displayLabel }}</div>
<div class="shape-description" v-if="displayDescription">{{ displayDescription }}</div>
</div>
</div>
`
};
// Trapezoid Shape Component
const TrapezoidShape = {
props: ['id', 'data', 'selected', 'label'],
computed: {
shapeStyle() {
return {
width: `${this.data?.width || 220}px`,
height: `${this.data?.height || 120}px`,
'--node-bg-color': this.data?.backgroundColor || '#f8fafc',
'--node-border-color': this.data?.borderColor || '#e2e8f0',
'--node-text-color': this.data?.textColor || '#475569',
position: 'relative',
cursor: 'move',
zIndex: -10 // Behind process nodes
};
},
displayLabel() {
return this.label || this.data?.label || '';
},
displayDescription() {
return this.data?.description || '';
}
},
template: `
<div
:class="['custom-node', 'shape-node', 'shape-trapezoid', { 'selected': selected }]"
:style="shapeStyle"
>
<div class="custom-node-content" v-if="displayLabel || displayDescription">
<div class="custom-node-label" v-if="displayLabel">{{ displayLabel }}</div>
<div class="shape-description" v-if="displayDescription">{{ displayDescription }}</div>
</div>
</div>
`
};
// Export the node types object to use with Vue Flow
export const nodeTypes = markRaw({
export const nodeTypes = {
start: StartNode,
end: EndNode,
gateway: GatewayNode,
@ -648,8 +956,16 @@ export const nodeTypes = markRaw({
script: ScriptNode,
'business-rule': BusinessRuleNode,
api: ApiCallNode,
notification: NotificationNode
});
notification: NotificationNode,
// Shape nodes
'swimlane-horizontal': HorizontalSwimlaneShape,
'swimlane-vertical': VerticalSwimlaneShape,
'rectangle-shape': RectangleShape,
'text-annotation': TextAnnotationShape,
'process-group': ProcessGroupShape,
'hexagon-shape': HexagonShape,
'trapezoid-shape': TrapezoidShape
};
// Default CSS for the nodes to be imported where needed
export const nodeStyles = `
@ -1269,4 +1585,100 @@ export const nodeStyles = `
border: 1px solid #ddd;
border-left: 4px solid #6b7280; /* Gray border to match icon color */
}
/* Shape node styles */
.shape-node {
position: relative;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: white;
cursor: move;
transition: all 0.2s ease;
z-index: -10 !important; /* Lower z-index so shapes stay in background */
}
.shape-node.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
z-index: -8 !important; /* Still behind process nodes when selected */
}
.shape-node:hover {
border-color: #94a3b8;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.shape-content {
position: relative;
z-index: 1;
text-align: center;
overflow: hidden;
}
.shape-label {
font-weight: 500;
margin-bottom: 4px;
user-select: none;
word-wrap: break-word;
}
.shape-description {
font-size: 12px;
opacity: 0.7;
user-select: none;
word-wrap: break-word;
}
/* Specific shape styles */
.swimlane-horizontal {
border-style: solid;
border-width: 2px 0;
border-radius: 0;
}
.swimlane-vertical {
border-style: solid;
border-width: 0 2px;
border-radius: 0;
}
.text-annotation {
border-style: dashed;
background: rgba(255, 251, 235, 0.8);
font-style: italic;
}
.process-group {
border-width: 3px;
border-style: solid;
border-radius: 12px;
}
.rectangle-shape {
border-radius: 4px;
}
/* Ensure shapes don't interfere with node connections */
.shape-node * {
pointer-events: none;
}
.shape-node {
pointer-events: all;
}
/* Resize handles for shapes when selected */
.shape-node.selected::after {
content: '';
position: absolute;
bottom: -4px;
right: -4px;
width: 8px;
height: 8px;
background: #3b82f6;
border: 2px solid white;
border-radius: 50%;
cursor: nw-resize;
pointer-events: all;
}
`;

View File

@ -286,7 +286,9 @@ const canvasNodes = computed(() => {
// Return a deep copy to ensure proper reactivity for nested objects like data
return processStore.currentProcess.nodes.map(node => ({
...node,
data: { ...node.data }
data: { ...node.data },
// Ensure labels are synchronized both ways
label: node.label || node.data?.label || node.type
}));
});
@ -376,6 +378,26 @@ const shapeOptions = [
{ value: 'trapezoid', label: 'Trapezoid', icon: 'architecture' }
];
// Helper function to get icon for node type
const getNodeIcon = (nodeType) => {
const iconMap = {
'form': 'description',
'api': 'api',
'gateway': 'call_split',
'script': 'code',
'business-rule': 'rule',
'notification': 'notifications',
'start': 'play_circle_filled',
'end': 'stop_circle',
'swimlane-horizontal': 'view-stream',
'swimlane-vertical': 'view-column',
'rectangle-shape': 'rectangle',
'text-annotation': 'text-fields',
'process-group': 'group-work'
};
return iconMap[nodeType] || 'account_tree';
};
// Helper function to validate and normalize hex color
const normalizeHexColor = (value, fallback = '#ffffff') => {
if (!value || typeof value !== 'string') return fallback;
@ -638,21 +660,68 @@ const onPaneClick = () => {
onPaneClickMobile();
};
// Update node in store
const updateNodeInStore = () => {
// Enhanced updateNodeInStore with automatic refresh and canvas sync
const updateNodeInStore = async () => {
if (selectedNodeData.value) {
// console.log('Updating node:', selectedNodeData.value.id, selectedNodeData.value.label);
// Simplify the update to avoid recursive reactivity
const nodeId = selectedNodeData.value.id;
const newLabel = selectedNodeData.value.label;
const newData = { ...selectedNodeData.value.data };
// Update in store first - this will trigger canvasNodes computed to update
// Update in store first
processStore.updateNode(nodeId, {
label: newLabel,
data: newData
});
// Wait for store update to complete
await nextTick();
// Force canvas update directly for immediate feedback
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
processFlowCanvas.value.updateNode(nodeId, {
label: newLabel,
data: newData
});
}
// Refresh selected node from store to maintain consistency
refreshSelectedNodeFromStore();
}
};
// Enhanced refresh function with change detection to prevent loops
const refreshSelectedNodeFromStore = () => {
if (selectedNodeData.value && processStore.currentProcess) {
const storeNode = processStore.currentProcess.nodes.find(node => node.id === selectedNodeData.value.id);
if (storeNode) {
// Use a more sophisticated comparison to detect actual changes
const hasChanges = (
selectedNodeData.value.label !== storeNode.label ||
JSON.stringify(selectedNodeData.value.data) !== JSON.stringify(storeNode.data) ||
selectedNodeData.value.position?.x !== storeNode.position?.x ||
selectedNodeData.value.position?.y !== storeNode.position?.y
);
if (hasChanges) {
selectedNodeData.value = { ...storeNode, data: { ...storeNode.data } };
}
}
}
};
// Special handler for shape size updates to force reactivity
const updateShapeSize = async () => {
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
// Force update the node in store with new size
updateNodeInStore();
// Also force update the canvas to ensure immediate visual feedback
await nextTick();
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
data: { ...selectedNodeData.value.data }
});
}
}
};
@ -692,7 +761,7 @@ const resetNodeColors = () => {
};
// Handle condition update
const handleConditionUpdate = (conditions) => {
const handleConditionUpdate = async (conditions) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
// Update conditions in the node data
selectedNodeData.value.data = {
@ -722,8 +791,8 @@ const handleConditionUpdate = (conditions) => {
processStore.currentProcess.edges = updatedEdges;
}
// Update the node in store
updateNodeInStore();
// Update the node in store and refresh
await updateNodeInStore();
}
};
@ -745,7 +814,6 @@ const onNodesChange = (changes, currentNodes) => {
});
if (Object.keys(positionChanges).length > 0) {
processStore.updateNodePositions(positionChanges);
}
@ -755,7 +823,6 @@ const onNodesChange = (changes, currentNodes) => {
.map(change => change.id);
if (removedNodes.length > 0) {
removedNodes.forEach(nodeId => {
processStore.deleteNode(nodeId);
});
@ -766,13 +833,9 @@ const onNodesChange = (changes, currentNodes) => {
}
}
// Update node selection if needed
if (selectedNodeData.value) {
const updatedNode = currentNodes.find(node => node.id === selectedNodeData.value.id);
if (updatedNode) {
selectedNodeData.value = { ...updatedNode };
}
}
// REMOVED: Don't overwrite selectedNodeData from canvas changes to preserve local edits
// This was causing property panel changes to be lost when the canvas updated
// The canvasNodes computed and its watcher will handle synchronization properly
};
// Handle updates from the canvas when edges change
@ -1334,7 +1397,7 @@ const onConditionsUpdated = (conditions) => {
};
// Handle API node update
const handleApiNodeUpdate = (updatedData) => {
const handleApiNodeUpdate = async (updatedData) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'api') {
// Make sure to update the label both in data and at the root level
const newLabel = updatedData.label || 'API Call';
@ -1348,13 +1411,13 @@ const handleApiNodeUpdate = (updatedData) => {
// Also update the root label
selectedNodeData.value.label = newLabel;
// Update the node in store
updateNodeInStore();
// Update the node in store and refresh
await updateNodeInStore();
}
};
// Add this function to handle form node updates
const handleFormNodeUpdate = (updatedData) => {
const handleFormNodeUpdate = async (updatedData) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'form') {
// Make sure to update the label both in data and at the root level
const newLabel = updatedData.label || 'Form Task';
@ -1368,40 +1431,40 @@ const handleFormNodeUpdate = (updatedData) => {
// Also update the root label
selectedNodeData.value.label = newLabel;
// Update the node in store
updateNodeInStore();
// Update the node in store and refresh
await updateNodeInStore();
}
};
// Update handler for business rule node
const handleBusinessRuleUpdate = (data) => {
const handleBusinessRuleUpdate = async (data) => {
if (selectedNodeData.value) {
selectedNodeData.value.data = {
...selectedNodeData.value.data,
...data
};
updateNodeInStore();
await updateNodeInStore();
}
};
// Add a method to handle default path updates
const handleDefaultPathUpdate = (path) => {
const handleDefaultPathUpdate = async (path) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
selectedNodeData.value.data.defaultPath = path;
updateNodeInStore();
await updateNodeInStore();
}
};
// Handle notification node update
const handleNotificationNodeUpdate = (updatedData) => {
const handleNotificationNodeUpdate = async (updatedData) => {
if (selectedNodeData.value) {
selectedNodeData.value.data = { ...updatedData };
updateNodeInStore();
await updateNodeInStore();
}
};
// Handle script node update
const handleScriptNodeUpdate = (updatedData) => {
const handleScriptNodeUpdate = async (updatedData) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'script') {
// Make sure to update the label both in data and at the root level
const newLabel = updatedData.label || 'Script Task';
@ -1430,8 +1493,8 @@ const handleScriptNodeUpdate = (updatedData) => {
});
}
// Update the node in store
updateNodeInStore();
// Update the node in store and refresh
await updateNodeInStore();
}
};
@ -1475,6 +1538,27 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
}
});
// Watch for canvas nodes changes to ensure selected node stays in sync
watch(() => canvasNodes.value, (newNodes) => {
// If we have a selected node, make sure it's kept in sync with the canvas
if (selectedNodeData.value && newNodes.length > 0) {
const updatedNode = newNodes.find(node => node.id === selectedNodeData.value.id);
if (updatedNode) {
// Use the same change detection logic as refreshSelectedNodeFromStore
const hasChanges = (
selectedNodeData.value.label !== updatedNode.label ||
JSON.stringify(selectedNodeData.value.data) !== JSON.stringify(updatedNode.data) ||
selectedNodeData.value.position?.x !== updatedNode.position?.x ||
selectedNodeData.value.position?.y !== updatedNode.position?.y
);
if (hasChanges) {
selectedNodeData.value = { ...updatedNode, data: { ...updatedNode.data } };
}
}
}
}, { deep: true });
// Watch for process changes to restore Vue Flow state
watch(() => processStore.currentProcess, async (newProcess, oldProcess) => {
if (!newProcess) return;
@ -1749,21 +1833,18 @@ watch(() => processStore.currentProcess, async (newProcess, oldProcess) => {
<!-- Show node properties when a node is selected -->
<div v-else class="flex flex-col h-full">
<!-- Node Header -->
<!-- Node/Shape Header -->
<div class="p-4 border-b border-gray-200 bg-gray-50">
<div class="flex items-center space-x-2 mb-2">
<div class="w-8 h-8 rounded-lg flex items-center justify-center text-white text-sm font-medium"
:style="{ backgroundColor: nodeBorderColor }">
<Icon :name="`material-symbols:${selectedNodeData.type === 'form' ? 'description' :
selectedNodeData.type === 'api' ? 'api' :
selectedNodeData.type === 'gateway' ? 'call_split' :
selectedNodeData.type === 'script' ? 'code' :
selectedNodeData.type === 'business-rule' ? 'rule' :
selectedNodeData.type === 'notification' ? 'notifications' : 'account_tree'}`"
<Icon :name="`material-symbols:${getNodeIcon(selectedNodeData.type)}`"
class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900 capitalize">{{ selectedNodeData.type.replace('-', ' ') }} Node</h3>
<h3 class="text-sm font-semibold text-gray-900 capitalize">
{{ selectedNodeData.data?.isShape ? 'Shape' : 'Node' }}: {{ selectedNodeData.type.replace('-', ' ') }}
</h3>
<p class="text-xs text-gray-500">{{ selectedNodeData.id }}</p>
</div>
</div>
@ -1801,8 +1882,8 @@ watch(() => processStore.currentProcess, async (newProcess, oldProcess) => {
/>
</div>
<!-- Node Shape Selector (only for non-start/end nodes) -->
<div v-if="selectedNodeData.type !== 'start' && selectedNodeData.type !== 'end'">
<!-- Node Shape Selector (only for non-start/end nodes and non-shapes) -->
<div v-if="selectedNodeData.type !== 'start' && selectedNodeData.type !== 'end' && !selectedNodeData.data?.isShape">
<label class="block text-xs font-medium text-gray-600 mb-1">Shape</label>
<select
v-model="nodeShape"
@ -1817,6 +1898,34 @@ watch(() => processStore.currentProcess, async (newProcess, oldProcess) => {
{{ shapeOptions.find(s => s.value === nodeShape)?.label || 'Rectangle' }}
</div>
</div>
<!-- Size Controls for Shapes -->
<div v-if="selectedNodeData.data?.isShape" class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Width</label>
<input
v-model.number="selectedNodeData.data.width"
type="number"
min="50"
max="2000"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@input="updateShapeSize"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Height</label>
<input
v-model.number="selectedNodeData.data.height"
type="number"
min="50"
max="2000"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@input="updateShapeSize"
/>
</div>
</div>
</div>
</div>
</div>
@ -1905,8 +2014,8 @@ watch(() => processStore.currentProcess, async (newProcess, oldProcess) => {
</div>
</div>
<!-- Configuration Section -->
<div v-if="selectedNodeData.type !== 'start' && selectedNodeData.type !== 'end'" class="p-4 border-t border-gray-200">
<!-- Configuration Section (only for process nodes, not shapes) -->
<div v-if="selectedNodeData.type !== 'start' && selectedNodeData.type !== 'end' && !selectedNodeData.data?.isShape" class="p-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
<Icon name="material-symbols:settings" class="w-4 h-4 mr-2" />
Configuration
@ -1966,6 +2075,26 @@ watch(() => processStore.currentProcess, async (newProcess, oldProcess) => {
</RsButton>
</div>
</div>
<!-- Shape Information -->
<div v-if="selectedNodeData.data?.isShape" class="p-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
<Icon name="material-symbols:info" class="w-4 h-4 mr-2" />
Shape Information
</h4>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p class="text-sm text-blue-700">
<strong>Design Element:</strong> This is a decorative shape used for visual organization and layout.
It doesn't participate in the process flow and cannot be connected to other nodes.
</p>
<div class="mt-2 text-xs text-blue-600">
<div class="flex items-center">
<Icon name="material-symbols:palette" class="w-3 h-3 mr-1" />
Use the appearance section above to customize colors and styling
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -2851,6 +2980,188 @@ watch(() => processStore.currentProcess, async (newProcess, oldProcess) => {
}
}
/* Shape node styles */
:deep(.shape-node) {
border-radius: 4px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
user-select: none;
z-index: -10 !important; /* Always behind process nodes */
}
:deep(.shape-node.selected) {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.6);
z-index: -8 !important; /* Higher z-index when selected, but still behind process nodes */
}
/* Ensure process nodes always stay above shapes, even during drag operations */
:deep(.vue-flow__node:not([data-id*="swimlane"]):not([data-id*="rectangle-shape"]):not([data-id*="text-annotation"]):not([data-id*="process-group"]):not([data-id*="hexagon-shape"]):not([data-id*="trapezoid-shape"])) {
z-index: 10 !important; /* Process nodes always above shapes */
}
:deep(.vue-flow__node:not([data-id*="swimlane"]):not([data-id*="rectangle-shape"]):not([data-id*="text-annotation"]):not([data-id*="process-group"]):not([data-id*="hexagon-shape"]):not([data-id*="trapezoid-shape"]).selected) {
z-index: 15 !important; /* Selected process nodes even higher */
}
:deep(.vue-flow__node:not([data-id*="swimlane"]):not([data-id*="rectangle-shape"]):not([data-id*="text-annotation"]):not([data-id*="process-group"]):not([data-id*="hexagon-shape"]):not([data-id*="trapezoid-shape"]).dragging) {
z-index: 20 !important; /* Dragging process nodes on top */
}
/* Vue Flow state-specific z-index rules */
:deep(.vue-flow__node[data-id*="start"]),
:deep(.vue-flow__node[data-id*="end"]),
:deep(.vue-flow__node[data-id*="form"]),
:deep(.vue-flow__node[data-id*="api"]),
:deep(.vue-flow__node[data-id*="gateway"]),
:deep(.vue-flow__node[data-id*="script"]),
:deep(.vue-flow__node[data-id*="business-rule"]),
:deep(.vue-flow__node[data-id*="notification"]) {
z-index: 10 !important; /* All process node types above shapes */
}
:deep(.vue-flow__node[data-id*="start"].selected),
:deep(.vue-flow__node[data-id*="end"].selected),
:deep(.vue-flow__node[data-id*="form"].selected),
:deep(.vue-flow__node[data-id*="api"].selected),
:deep(.vue-flow__node[data-id*="gateway"].selected),
:deep(.vue-flow__node[data-id*="script"].selected),
:deep(.vue-flow__node[data-id*="business-rule"].selected),
:deep(.vue-flow__node[data-id*="notification"].selected) {
z-index: 15 !important; /* Selected process nodes even higher */
}
/* Handle Vue Flow's internal dragging state classes */
:deep(.vue-flow__node.dragging:not(.shape-node)) {
z-index: 25 !important; /* Dragging process nodes should be on top of everything */
}
/* Ensure edge connection during drag always works for process nodes */
:deep(.vue-flow.connecting .vue-flow__node:not(.shape-node)) {
z-index: 30 !important; /* During connection mode, process nodes are highest */
}
/* Shape nodes should never interfere with edge connections */
:deep(.shape-node .vue-flow__handle) {
display: none !important; /* Shapes should not have connection handles */
}
/* Ensure custom nodes (process nodes) maintain proper layering */
:deep(.custom-node:not(.shape-node)) {
position: relative;
z-index: 10 !important;
}
:deep(.custom-node:not(.shape-node).selected) {
z-index: 15 !important;
}
/* Ensure edges/connection lines stay above shapes */
:deep(.vue-flow__edges) {
z-index: 5 !important; /* Edges above shapes but below nodes */
}
:deep(.vue-flow__edge) {
z-index: 5 !important; /* Individual edges above shapes */
}
:deep(.vue-flow__edge.selected) {
z-index: 8 !important; /* Selected edges higher */
}
:deep(.vue-flow__connection) {
z-index: 12 !important; /* Active connection line above everything except dragging nodes */
}
:deep(.vue-flow__connection-path) {
z-index: 12 !important; /* Connection path above shapes and nodes */
}
/* Ensure edge paths and labels stay above shapes */
:deep(.vue-flow__edge-path) {
z-index: 5 !important; /* Edge paths above shapes */
}
:deep(.vue-flow__edge-text) {
z-index: 6 !important; /* Edge labels above edge paths and shapes */
}
:deep(.vue-flow__edge-textbg) {
z-index: 5 !important; /* Edge text background above shapes */
}
/* Ensure SVG elements in edges maintain proper z-index */
:deep(.vue-flow__edge svg) {
z-index: 5 !important;
}
:deep(.shape-node .shape-content) {
padding: 8px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
:deep(.shape-node .shape-label) {
font-weight: 500;
line-height: 1.2;
word-wrap: break-word;
max-width: 100%;
}
:deep(.shape-node .shape-description) {
font-size: 0.75rem;
opacity: 0.8;
margin-top: 4px;
line-height: 1.2;
word-wrap: break-word;
max-width: 100%;
}
/* Specific shape styling */
:deep(.shape-node.swimlane-horizontal) {
min-width: 600px;
min-height: 150px;
border-radius: 8px;
}
:deep(.shape-node.swimlane-vertical) {
min-width: 200px;
min-height: 400px;
border-radius: 8px;
}
:deep(.shape-node.rectangle-shape) {
min-width: 300px;
min-height: 200px;
border-radius: 4px;
}
:deep(.shape-node.text-annotation) {
min-width: 200px;
min-height: 80px;
border-radius: 4px;
font-style: italic;
}
:deep(.shape-node.process-group) {
min-width: 400px;
min-height: 300px;
border-radius: 12px;
padding: 16px;
}
:deep(.shape-node.process-group .shape-content) {
align-items: flex-start;
justify-content: flex-start;
}
/* Touch-friendly adjustments */
@media (hover: none) and (pointer: coarse) {
.component-item {