Enhance FormBuilderFieldSettingsModal with Repeating Group Configuration

- Added configuration options for repeating groups, including minimum and maximum item limits, button text settings, and dynamic field management.
- Implemented functionality to add and remove fields within the repeating group, improving user experience for form customization.
- Updated component type and description mappings to include the new repeating group feature, ensuring consistency across the application.
This commit is contained in:
Md Afiq Iskandar 2025-07-23 18:37:59 +08:00
parent 0060ad38b7
commit 9883504a7c
6 changed files with 960 additions and 22 deletions

View File

@ -693,6 +693,173 @@
</div>
</template>
<!-- Repeating Group Configuration -->
<template v-if="component.type === 'repeating-group'">
<div class="space-y-6">
<!-- Group Limits -->
<div class="grid grid-cols-2 gap-4">
<FormKit
type="number"
label="Minimum Items"
name="minItems"
v-model="configModel.minItems"
help="Minimum number of groups required"
:classes="{ outer: 'field-wrapper' }"
:min="0"
placeholder="1"
/>
<FormKit
type="number"
label="Maximum Items"
name="maxItems"
v-model="configModel.maxItems"
help="Maximum number of groups allowed"
:classes="{ outer: 'field-wrapper' }"
:min="1"
placeholder="10"
/>
</div>
<!-- Button Text Settings -->
<div class="grid grid-cols-2 gap-4">
<FormKit
type="text"
label="Add Button Text"
name="buttonText"
v-model="configModel.buttonText"
help="Text for the add button"
:classes="{ outer: 'field-wrapper' }"
placeholder="Add Item"
/>
<FormKit
type="text"
label="Remove Button Text"
name="removeText"
v-model="configModel.removeText"
help="Text for the remove button"
:classes="{ outer: 'field-wrapper' }"
placeholder="Remove"
/>
</div>
<!-- Group Fields Management -->
<div class="space-y-4">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Group Fields</h5>
<div class="border rounded-md p-3 bg-gray-50 space-y-3">
<div v-for="(field, index) in (configModel.fields || [])" :key="index" class="border p-3 rounded bg-white">
<div class="flex justify-between items-center mb-3">
<h4 class="font-medium text-sm text-gray-800">Field {{ index + 1 }}</h4>
<button
@click="removeGroupField(index)"
class="text-red-500 hover:text-red-700 p-1"
type="button"
title="Remove field"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<FormKit
type="select"
label="Field Type"
:options="[
{ label: 'Text', value: 'text' },
{ label: 'Number', value: 'number' },
{ label: 'Email', value: 'email' },
{ label: 'Textarea', value: 'textarea' },
{ label: 'Select', value: 'select' },
{ label: 'Date', value: 'date' },
{ label: 'Time', value: 'time' },
{ label: 'Checkbox', value: 'checkbox' },
{ label: 'Radio', value: 'radio' }
]"
v-model="field.type"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Field Name"
v-model="field.name"
:classes="{ outer: 'field-wrapper' }"
placeholder="e.g., name, age, email"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<FormKit
type="text"
label="Field Label"
v-model="field.label"
:classes="{ outer: 'field-wrapper' }"
placeholder="e.g., Full Name, Age, Email"
/>
<FormKit
type="text"
label="Placeholder"
v-model="field.placeholder"
:classes="{ outer: 'field-wrapper' }"
placeholder="e.g., Enter your name"
/>
</div>
<!-- Options for select/radio/checkbox fields -->
<div v-if="['select', 'radio', 'checkbox'].includes(field.type)" class="mt-3">
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Options</label>
<div class="border rounded-md p-2 bg-gray-50 space-y-2">
<div v-for="(option, optionIndex) in (field.options || [])" :key="optionIndex" class="flex items-center space-x-2">
<input
type="text"
v-model="field.options[optionIndex].label"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
placeholder="Option label"
/>
<input
type="text"
v-model="field.options[optionIndex].value"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
placeholder="Option value"
/>
<button
@click="removeFieldOption(field, optionIndex)"
class="text-red-500 hover:text-red-700 p-1"
type="button"
>
<Icon name="material-symbols:delete-outline" class="w-3 h-3" />
</button>
</div>
<button
@click="addFieldOption(field)"
class="text-sm text-blue-600 hover:text-blue-800 flex items-center"
type="button"
>
<Icon name="material-symbols:add-circle-outline" class="w-3 h-3 mr-1" />
Add Option
</button>
</div>
</div>
</div>
</div>
<button
@click="addGroupField"
class="w-full text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center py-2 border border-dashed border-blue-300 rounded-md hover:bg-blue-50"
type="button"
>
<Icon name="material-symbols:add-circle-outline" class="w-4 h-4 mr-1" />
Add Field to Group
</button>
</div>
</div>
</div>
</template>
<!-- Form Section Configuration -->
<template v-if="component.type === 'form-section'">
<div class="space-y-6">
@ -1463,7 +1630,8 @@ const getComponentIcon = (type) => {
'form-section': 'material-symbols:view-module-outline',
'info-display': 'heroicons:information-circle',
'dynamic-list': 'heroicons:list-bullet',
'repeating-table': 'heroicons:table-cells'
'repeating-table': 'heroicons:table-cells',
'repeating-group': 'heroicons:list-bullet'
}
return icons[type] || 'heroicons:square-3-stack-3d'
}
@ -1498,7 +1666,8 @@ const getComponentTypeName = (type) => {
'form-section': 'Form Section',
'info-display': 'Information Display',
'dynamic-list': 'Dynamic List',
'repeating-table': 'Data Table'
'repeating-table': 'Data Table',
'repeating-group': 'Repeating Group'
}
return names[type] || 'Form Field'
}
@ -1533,7 +1702,8 @@ const getComponentDescription = (type) => {
'form-section': 'Visual container to group related form fields into sections',
'info-display': 'Read-only information display in organized format',
'dynamic-list': 'Dynamic list for displaying and managing items',
'repeating-table': 'Structured table for collecting multiple records with forms'
'repeating-table': 'Structured table for collecting multiple records with forms',
'repeating-group': 'Collect multiple entries of the same data structure'
}
return descriptions[type] || 'Configure this form field'
}
@ -1558,14 +1728,14 @@ const showField = (fieldName) => {
if (!props.component) return false
const fieldConfig = {
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'dynamic-list'],
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
value: ['heading', 'paragraph', 'hidden'],
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table'],
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table', 'repeating-group'],
options: ['select', 'checkbox', 'radio'],
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table']
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group']
}
return fieldConfig[fieldName]?.includes(props.component.type) || false
@ -1574,7 +1744,7 @@ const showField = (fieldName) => {
const hasOptions = computed(() => showField('options'))
const hasSpecificSettings = computed(() => {
if (!props.component) return false
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'button', 'form-section', 'dynamic-list', 'repeating-table']
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group']
return specificTypes.includes(props.component.type)
})
@ -2031,6 +2201,42 @@ const removeNestedComponent = (index) => {
}
}
// Repeating group field management
const addGroupField = () => {
if (!configModel.value.fields) {
configModel.value.fields = []
}
configModel.value.fields.push({
type: 'text',
name: `field_${configModel.value.fields.length + 1}`,
label: `Field ${configModel.value.fields.length + 1}`,
placeholder: 'Enter value'
})
}
const removeGroupField = (index) => {
if (configModel.value.fields) {
configModel.value.fields.splice(index, 1)
}
}
const addFieldOption = (field) => {
if (!field.options) {
field.options = []
}
field.options.push({
label: `Option ${field.options.length + 1}`,
value: `option_${field.options.length + 1}`
})
}
const removeFieldOption = (field, optionIndex) => {
if (field.options) {
field.options.splice(optionIndex, 1)
}
}
// Type changing methods
const handleTypeChange = (newType) => {
if (newType === props.component.type) return

View File

@ -1,5 +1,5 @@
<script setup>
const emits = defineEmits(["update:modelValue"]);
const emits = defineEmits(["update:modelValue", "close"]);
const props = defineProps({
title: {
type: String,
@ -65,11 +65,15 @@ const props = defineProps({
const closeModal = () => {
emits("update:modelValue", false);
emits("close");
};
const validateCancelCallback = () => {
if (props.cancelCallback == "() => {}") closeModal();
else props.cancelCallback();
else {
props.cancelCallback();
emits("close");
}
};
watch(
@ -131,7 +135,7 @@ watch(
>
<rs-button
v-if="!$slots.footer && !cancelOnly"
@click="okCallback"
@click="() => { okCallback(); emits('close'); }"
>{{ okTitle }}</rs-button
>
</div>

View File

@ -1225,6 +1225,72 @@ defineExpose({
get flowInstance() {
return flowInstance;
},
// Provide direct access to nodes and edges
get nodes() {
return nodes.value;
},
get edges() {
return edges.value;
},
// Add method to get selected elements
getSelectedElements: () => {
try {
// First try to get selection from Vue Flow instance
if (flowInstance) {
const selection = flowInstance.getSelection();
if (selection && (selection.nodes?.length > 0 || selection.edges?.length > 0)) {
return {
nodes: selection.nodes || [],
edges: selection.edges || []
};
}
}
// Fallback: check for selected property on nodes and edges
const selectedNodes = nodes.value.filter(node => node.selected === true);
const selectedEdges = edges.value.filter(edge => edge.selected === true);
// If still no selection, try DOM-based detection
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
// Look for selected nodes in the DOM
const selectedNodeElements = document.querySelectorAll('.vue-flow__node.selected');
const selectedEdgeElements = document.querySelectorAll('.vue-flow__edge.selected');
// Convert DOM elements to node/edge data
const domSelectedNodes = [];
const domSelectedEdges = [];
selectedNodeElements.forEach(element => {
const nodeId = element.getAttribute('data-id');
if (nodeId) {
const node = nodes.value.find(n => n.id === nodeId);
if (node) {
domSelectedNodes.push(node);
}
}
});
selectedEdgeElements.forEach(element => {
const edgeId = element.getAttribute('data-id');
if (edgeId) {
const edge = edges.value.find(e => e.id === edgeId);
if (edge) {
domSelectedEdges.push(edge);
}
}
});
if (domSelectedNodes.length > 0 || domSelectedEdges.length > 0) {
return { nodes: domSelectedNodes, edges: domSelectedEdges };
}
}
return { nodes: selectedNodes, edges: selectedEdges };
} catch (error) {
console.warn('Error getting selected elements:', error);
return { nodes: [], edges: [] };
}
}
});
// Update an existing node

View File

@ -2369,7 +2369,7 @@ const getDefaultIcon = (type) => {
'file': 'material-symbols:upload-file-outline',
'button': 'material-symbols:smart-button',
'image-preview': 'material-symbols:image-outline',
'repeating-group': 'material-symbols:playlist-add-outline',
'repeating-group': 'heroicons:list-bullet',
'dynamic-list': 'material-symbols:format-list-bulleted'
};

View File

@ -79,6 +79,52 @@ const navigationConfirmed = ref(false);
const isSaving = ref(false);
const isAddingComponent = ref(false); // Flag to prevent canvas reactivity issues during component addition
// Add clipboard state for copy/paste functionality
const clipboard = ref({
nodes: [],
edges: [],
type: null, // 'nodes', 'edges', or 'mixed'
timestamp: null
});
// Load clipboard from localStorage on mount
const loadClipboardFromStorage = () => {
try {
const stored = localStorage.getItem('processBuilderClipboard');
if (stored) {
const parsed = JSON.parse(stored);
// Only restore if it's recent (within 24 hours)
const isRecent = parsed.timestamp && (Date.now() - parsed.timestamp) < 24 * 60 * 60 * 1000;
if (isRecent) {
clipboard.value = parsed;
}
}
} catch (error) {
console.warn('Failed to load clipboard from storage:', error);
}
};
// Save clipboard to localStorage
const saveClipboardToStorage = () => {
try {
localStorage.setItem('processBuilderClipboard', JSON.stringify(clipboard.value));
} catch (error) {
console.warn('Failed to save clipboard to storage:', error);
}
};
// Storage event handler for cross-tab clipboard sync
const handleStorageEvent = (event) => {
if (event.key === 'processBuilderClipboard' && event.newValue) {
try {
const parsed = JSON.parse(event.newValue);
clipboard.value = parsed;
} catch (error) {
console.warn('Failed to parse clipboard from storage event:', error);
}
}
};
// Add a ref for the ProcessFlowCanvas component
const processFlowCanvas = ref(null);
@ -168,7 +214,344 @@ const onPaneClickMobile = () => {
}
};
// Keyboard shortcuts for panel toggles
// Copy/paste functions
const copySelectedElements = () => {
const copiedNodes = [];
const copiedEdges = [];
// Get all selected elements from Vue Flow
let selectedNodes = [];
let selectedEdges = [];
if (processFlowCanvas.value) {
try {
// Try the new getSelectedElements method first
if (processFlowCanvas.value.getSelectedElements) {
const selection = processFlowCanvas.value.getSelectedElements();
selectedNodes = selection.nodes || [];
selectedEdges = selection.edges || [];
}
// If no selection found, try direct access to nodes and edges
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected === true);
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected === true);
}
}
// If still no selection found, try DOM-based detection as fallback
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
// Look for selected nodes in the DOM
const selectedNodeElements = document.querySelectorAll('.vue-flow__node.selected');
const selectedEdgeElements = document.querySelectorAll('.vue-flow__edge.selected');
// Convert DOM elements to node/edge data
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
selectedNodeElements.forEach(element => {
const nodeId = element.getAttribute('data-id');
if (nodeId) {
const node = processFlowCanvas.value.nodes.find(n => n.id === nodeId);
if (node) {
selectedNodes.push(node);
}
}
});
selectedEdgeElements.forEach(element => {
const edgeId = element.getAttribute('data-id');
if (edgeId) {
const edge = processFlowCanvas.value.edges.find(e => e.id === edgeId);
if (edge) {
selectedEdges.push(edge);
}
}
});
}
}
} catch (error) {
console.warn('Failed to get selected elements from Vue Flow:', error);
}
}
// If no multiple selection, fall back to single selection
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
// Copy selected nodes
if (selectedNodeData.value) {
const nodeCopy = JSON.parse(JSON.stringify(selectedNodeData.value));
// Remove any temporary properties
delete nodeCopy.selected;
delete nodeCopy.dragging;
copiedNodes.push(nodeCopy);
}
// Copy selected edges
if (selectedEdgeData.value) {
const edgeCopy = JSON.parse(JSON.stringify(selectedEdgeData.value));
// Remove any temporary properties
delete edgeCopy.selected;
copiedEdges.push(edgeCopy);
}
} else {
// Handle multiple selection
for (const node of selectedNodes) {
const nodeCopy = JSON.parse(JSON.stringify(node));
// Remove any temporary properties
delete nodeCopy.selected;
delete nodeCopy.dragging;
copiedNodes.push(nodeCopy);
}
for (const edge of selectedEdges) {
const edgeCopy = JSON.parse(JSON.stringify(edge));
// Remove any temporary properties
delete edgeCopy.selected;
copiedEdges.push(edgeCopy);
}
}
// Store in clipboard
clipboard.value = {
nodes: copiedNodes,
edges: copiedEdges,
type: copiedNodes.length > 0 && copiedEdges.length > 0 ? 'mixed' :
copiedNodes.length > 0 ? 'nodes' :
copiedEdges.length > 0 ? 'edges' : null,
timestamp: Date.now()
};
// Save to localStorage
saveClipboardToStorage();
// Show feedback
const totalElements = copiedNodes.length + copiedEdges.length;
if (totalElements > 0) {
toast.success(`Copied ${totalElements} element${totalElements > 1 ? 's' : ''} to clipboard`);
} else {
toast.warning('No elements selected to copy');
}
};
const clearClipboard = () => {
clipboard.value = {
nodes: [],
edges: [],
type: null,
timestamp: null
};
// Clear from localStorage
try {
localStorage.removeItem('processBuilderClipboard');
} catch (error) {
console.warn('Failed to clear clipboard from storage:', error);
}
toast.info('Clipboard cleared');
};
// Debug function to help understand selection state
const debugSelection = () => {
console.log('=== DEBUG SELECTION STATE ===');
if (!processFlowCanvas.value) {
console.log('ProcessFlowCanvas not available');
return;
}
console.log('ProcessFlowCanvas available, checking properties...');
console.log('Available methods:', Object.keys(processFlowCanvas.value));
if (!processFlowCanvas.value.flowInstance) {
console.log('Flow instance not available');
console.log('Trying to access flowInstance directly...');
// Try different ways to access the flow instance
try {
const flowInstance = processFlowCanvas.value.flowInstance;
console.log('Direct access result:', flowInstance);
} catch (error) {
console.log('Direct access error:', error);
}
// Check if we can access nodes and edges directly
try {
const nodes = processFlowCanvas.value.nodes;
const edges = processFlowCanvas.value.edges;
console.log('Direct nodes access:', nodes);
console.log('Direct edges access:', edges);
} catch (error) {
console.log('Direct nodes/edges access error:', error);
}
return;
}
const flowInstance = processFlowCanvas.value.flowInstance;
// Check Vue Flow selection
try {
const selection = flowInstance.getSelection();
console.log('Vue Flow selection:', selection);
} catch (error) {
console.log('Error getting Vue Flow selection:', error);
}
// Check all nodes and edges
const allNodes = flowInstance.getNodes();
const allEdges = flowInstance.getEdges();
console.log('All nodes:', allNodes.length);
console.log('All edges:', allEdges.length);
// Check for selected property
const selectedNodes = allNodes.filter(node => node.selected);
const selectedEdges = allEdges.filter(edge => edge.selected);
console.log('Nodes with selected=true:', selectedNodes.length);
console.log('Edges with selected=true:', selectedEdges.length);
// Check DOM
const domSelectedNodes = document.querySelectorAll('.vue-flow__node.selected');
const domSelectedEdges = document.querySelectorAll('.vue-flow__edge.selected');
console.log('DOM selected nodes:', domSelectedNodes.length);
console.log('DOM selected edges:', domSelectedEdges.length);
// Show details of selected elements
if (selectedNodes.length > 0) {
console.log('Selected nodes:', selectedNodes.map(n => ({ id: n.id, type: n.type, selected: n.selected })));
}
if (selectedEdges.length > 0) {
console.log('Selected edges:', selectedEdges.map(e => ({ id: e.id, source: e.source, target: e.target, selected: e.selected })));
}
console.log('=== END DEBUG ===');
};
const pasteElements = async () => {
if (!clipboard.value.type || !processStore.currentProcess) {
toast.warning('No elements in clipboard to paste');
return;
}
try {
const pastedNodes = [];
const pastedEdges = [];
const nodeIdMap = new Map(); // Map old IDs to new IDs
// Process nodes first
if (clipboard.value.nodes.length > 0) {
for (const node of clipboard.value.nodes) {
const newNode = JSON.parse(JSON.stringify(node));
// Generate new ID
const oldId = newNode.id;
const newId = `${newNode.type}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
newNode.id = newId;
// Offset position to avoid overlap - use a more sophisticated offset
const baseX = newNode.position?.x || 100;
const baseY = newNode.position?.y || 100;
const offsetX = 100 + (pastedNodes.length * 80); // Larger offset for better visibility
const offsetY = 100 + (pastedNodes.length * 60); // Different Y offset to create a diagonal pattern
newNode.position = {
x: baseX + offsetX,
y: baseY + offsetY
};
// Update label to indicate it's a copy
if (newNode.label && !newNode.label.includes(' (Copy)')) {
newNode.label = `${newNode.label} (Copy)`;
}
if (newNode.data?.label && !newNode.data.label.includes(' (Copy)')) {
newNode.data.label = `${newNode.data.label} (Copy)`;
}
// Store ID mapping for edge updates
nodeIdMap.set(oldId, newId);
// Add to store
await processStore.addNode(newNode);
pastedNodes.push(newNode);
}
}
// Process edges after nodes
if (clipboard.value.edges.length > 0) {
for (const edge of clipboard.value.edges) {
const newEdge = JSON.parse(JSON.stringify(edge));
// Generate new ID
newEdge.id = `edge_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
// Update source and target IDs if they were copied nodes
if (nodeIdMap.has(newEdge.source)) {
newEdge.source = nodeIdMap.get(newEdge.source);
}
if (nodeIdMap.has(newEdge.target)) {
newEdge.target = nodeIdMap.get(newEdge.target);
}
// Only add edge if both source and target nodes exist
const sourceExists = processStore.currentProcess.nodes.some(n => n.id === newEdge.source);
const targetExists = processStore.currentProcess.nodes.some(n => n.id === newEdge.target);
if (sourceExists && targetExists) {
await processStore.addEdge(newEdge);
pastedEdges.push(newEdge);
} else {
// Show warning for orphaned edges
const missingNodes = [];
if (!sourceExists) missingNodes.push(newEdge.source);
if (!targetExists) missingNodes.push(newEdge.target);
console.warn(`Skipping edge ${newEdge.id}: missing nodes ${missingNodes.join(', ')}`);
}
}
}
// Show success message
const totalPasted = pastedNodes.length + pastedEdges.length;
const skippedEdges = clipboard.value.edges.length - pastedEdges.length;
if (totalPasted > 0) {
let message = `Pasted ${totalPasted} element${totalPasted > 1 ? 's' : ''} successfully`;
if (skippedEdges > 0) {
message += ` (${skippedEdges} edge${skippedEdges > 1 ? 's' : ''} skipped - missing nodes)`;
}
toast.success(message);
// Select the first pasted node if any
if (pastedNodes.length > 0) {
onNodeSelected(pastedNodes[0]);
// Fit view to show all pasted nodes
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
nextTick(() => {
try {
processFlowCanvas.value.fitView({
padding: 0.1,
includeHiddenNodes: false,
minZoom: 0.5,
maxZoom: 1.5
});
} catch (error) {
console.warn('Failed to fit view:', error);
}
});
}
}
} else if (skippedEdges > 0) {
toast.warning(`${skippedEdges} edge${skippedEdges > 1 ? 's' : ''} could not be pasted - missing connected nodes`);
}
} catch (error) {
console.error('Error pasting elements:', error);
toast.error('Failed to paste elements');
}
};
// Keyboard shortcuts for panel toggles and copy/paste
const handleKeyboardShortcuts = (event) => {
// Only handle shortcuts when no input is focused
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
@ -192,6 +575,18 @@ const handleKeyboardShortcuts = (event) => {
event.preventDefault();
toggleBothPanels();
}
// Ctrl/Cmd + C: Copy selected elements
if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
event.preventDefault();
copySelectedElements();
}
// Ctrl/Cmd + V: Paste elements
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
event.preventDefault();
pasteElements();
}
};
// Handle node highlighting from variable usage
@ -243,7 +638,7 @@ const components = [
{
type: 'start',
label: 'Start Point',
icon: 'play_circle_filled',
icon: 'play-circle-rounded',
iconColor: 'text-green-500',
data: { description: 'Process starts here' }
},
@ -336,7 +731,7 @@ const components = [
{
type: 'end',
label: 'End Point',
icon: 'stop_circle',
icon: 'stop-circle',
iconColor: 'text-red-500',
data: { description: 'Process completes here' }
}
@ -461,8 +856,8 @@ const getNodeIcon = (nodeType) => {
'business-rule': 'rule',
'notification': 'notifications',
'subprocess': 'hub',
'start': 'play_circle_filled',
'end': 'stop_circle',
'start': 'play-circle-rounded',
'end': 'stop-circle',
'swimlane-horizontal': 'view-stream',
'swimlane-vertical': 'view-column',
'rectangle-shape': 'rectangle',
@ -1113,6 +1508,9 @@ const goToManage = () => {
// Add events for beforeunload
onMounted(() => {
// Load clipboard from localStorage
loadClipboardFromStorage();
// Check if there's a process ID in the URL query parameters
const processId = route.query.id;
@ -1146,6 +1544,9 @@ onMounted(() => {
// Add keyboard shortcuts listener
window.addEventListener('keydown', handleKeyboardShortcuts);
// Add storage event listener for cross-tab clipboard sync
window.addEventListener('storage', handleStorageEvent);
// Initial screen size check
checkScreenSize();
});
@ -1164,6 +1565,7 @@ onUnmounted(() => {
window.removeEventListener('resize', checkScreenSize);
window.removeEventListener('highlightNode', handleNodeHighlight);
window.removeEventListener('keydown', handleKeyboardShortcuts);
window.removeEventListener('storage', handleStorageEvent);
// Clear highlight timeout if it exists
if (highlightTimeout.value) {
@ -2036,6 +2438,112 @@ const canShowNodeActions = computed(() => {
);
});
// Add computed properties for copy/paste functionality
const canCopy = computed(() => {
// Check for multiple selections first
if (processFlowCanvas.value) {
try {
let selectedNodes = [];
let selectedEdges = [];
// Try the new getSelectedElements method first
if (processFlowCanvas.value.getSelectedElements) {
const selection = processFlowCanvas.value.getSelectedElements();
selectedNodes = selection.nodes || [];
selectedEdges = selection.edges || [];
}
// If no selection found, try direct access
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected);
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected);
}
}
// If there are multiple selections, any selection is copyable
if (selectedNodes.length > 0 || selectedEdges.length > 0) {
return true;
}
} catch (error) {
console.warn('Failed to check multiple selections:', error);
}
}
// Fall back to single selection check
// Can copy edges
if (selectedEdgeData.value) return true;
// Can copy any nodes (including start/end)
if (selectedNodeData.value) {
return true;
}
return false;
});
const canPaste = computed(() => {
return clipboard.value.type && processStore.currentProcess;
});
const clipboardInfo = computed(() => {
if (!clipboard.value.type) return null;
const nodeCount = clipboard.value.nodes.length;
const edgeCount = clipboard.value.edges.length;
if (clipboard.value.type === 'mixed') {
return `${nodeCount} node${nodeCount > 1 ? 's' : ''}, ${edgeCount} edge${edgeCount > 1 ? 's' : ''}`;
} else if (clipboard.value.type === 'nodes') {
return `${nodeCount} node${nodeCount > 1 ? 's' : ''}`;
} else if (clipboard.value.type === 'edges') {
return `${edgeCount} edge${edgeCount > 1 ? 's' : ''}`;
}
return null;
});
// Add computed property to show current selection info
const currentSelectionInfo = computed(() => {
if (!processFlowCanvas.value || !processFlowCanvas.value.flowInstance) return null;
try {
let selectedNodes = [];
let selectedEdges = [];
// Try the new getSelectedElements method first
if (processFlowCanvas.value.getSelectedElements) {
const selection = processFlowCanvas.value.getSelectedElements();
selectedNodes = selection.nodes || [];
selectedEdges = selection.edges || [];
}
// If no selection found, try direct access
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected);
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected);
}
}
if (selectedNodes.length === 0 && selectedEdges.length === 0) return null;
let info = '';
if (selectedNodes.length > 0) {
info += `${selectedNodes.length} node${selectedNodes.length > 1 ? 's' : ''}`;
}
if (selectedEdges.length > 0) {
if (info) info += ', ';
info += `${selectedEdges.length} edge${selectedEdges.length > 1 ? 's' : ''}`;
}
return info;
} catch (error) {
console.warn('Failed to get current selection info:', error);
return null;
}
});
const nodeZIndex = computed({
get: () => {
return selectedNodeData.value?.data?.isShape ? selectedNodeData.value.data.zIndex || 0 : 0;
@ -2334,6 +2842,54 @@ const sendToBack = () => {
</RsButton>
</div>
<!-- Copy/Paste actions -->
<div class="flex items-center mr-1 md:mr-2 border-r border-gray-200 pr-1 md:pr-2">
<RsButton
@click="copySelectedElements"
variant="secondary"
size="sm"
class="mr-1"
:disabled="!canCopy"
:title="`Copy selected elements (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
>
<Icon name="material-symbols:content-copy" class="mr-0 md:mr-1" />
<span class="hidden md:inline">Copy</span>
</RsButton>
<RsButton
@click="pasteElements"
variant="secondary"
size="sm"
:disabled="!canPaste"
:title="`Paste elements (Ctrl+V)${clipboardInfo ? ' - ' + clipboardInfo : ''}`"
>
<Icon name="material-symbols:content-paste" class="mr-0 md:mr-1" />
<span class="hidden md:inline">Paste</span>
</RsButton>
<!-- Debug button (temporary) -->
<RsButton
@click="debugSelection"
variant="tertiary"
size="sm"
class="ml-1"
title="Debug selection state"
>
<Icon name="material-symbols:bug-report" class="w-4 h-4" />
</RsButton>
<!-- Selection indicator -->
<div v-if="currentSelectionInfo && !clipboardInfo" class="ml-2 px-2 py-1 bg-green-100 text-green-700 text-xs rounded-md border border-green-200 hidden sm:block" :title="`Selected: ${currentSelectionInfo}. Press Ctrl+C to copy.`">
<Icon name="material-symbols:select-all" class="w-3 h-3 mr-1" />
{{ currentSelectionInfo }}
</div>
<!-- Clipboard indicator -->
<div v-if="clipboardInfo" class="ml-2 px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-md border border-blue-200 hidden sm:block" :title="`Clipboard contains: ${clipboardInfo}. You can paste this in any process tab.`">
<Icon name="material-symbols:content-paste" class="w-3 h-3 mr-1" />
{{ clipboardInfo }}
</div>
</div>
<!-- Secondary actions -->
@ -2361,6 +2917,21 @@ const sendToBack = () => {
<span>Export</span>
</button>
<!-- Clipboard Actions -->
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
Clipboard
</div>
<div v-if="clipboardInfo" class="px-4 py-2 text-xs text-gray-600 bg-gray-50 border-l-2 border-blue-400">
<div class="flex items-center">
<Icon name="material-symbols:content-paste" class="w-3 h-3 mr-1" />
<span>{{ clipboardInfo }} ready to paste</span>
</div>
</div>
<button @click="clearClipboard(); showDropdown = false" :disabled="!clipboardInfo" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center disabled:opacity-50">
<Icon name="mdi:clipboard-remove-outline" class="mr-2 w-4 h-4" />
<span>Clear Clipboard</span>
</button>
<!-- Settings -->
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
Settings
@ -2479,7 +3050,87 @@ const sendToBack = () => {
</div>
<div class="flex-1 overflow-y-auto">
<!-- Show variable manager when no node is selected -->
<VariableManager v-if="!selectedNodeData" :key="`variables-${variablesUpdateKey}`" />
<VariableManager v-if="!selectedNodeData && !selectedEdgeData" :key="`variables-${variablesUpdateKey}`" />
<!-- Show edge properties when an edge is selected -->
<div v-else-if="selectedEdgeData" class="flex flex-col h-full">
<!-- Edge Header -->
<div class="p-4 border-b border-gray-200 bg-gray-50">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-lg flex items-center justify-center text-white text-sm font-medium bg-blue-500">
<Icon name="material-symbols:timeline" class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900">Connection</h3>
<p class="text-xs text-gray-500">{{ selectedEdgeData.id }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<RsButton
@click="copySelectedElements"
variant="secondary-text"
size="sm"
icon
:title="`Copy Edge (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
class="modern-icon-btn"
>
<Icon name="material-symbols:content-copy" />
</RsButton>
<RsButton
@click="deleteEdge"
variant="danger-text"
size="sm"
icon
:title="'Delete Edge'"
class="modern-icon-btn"
>
<Icon name="material-symbols:delete" />
</RsButton>
</div>
</div>
</div>
<!-- Edge Properties -->
<div class="flex-1 overflow-y-auto">
<div class="p-4 space-y-4">
<div>
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
<Icon name="material-symbols:edit" class="w-4 h-4 mr-2" />
Edge Properties
</h4>
<!-- Edge Label -->
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Label</label>
<input
v-model="selectedEdgeData.label"
type="text"
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"
placeholder="Enter edge label"
@input="updateEdgeLabel(selectedEdgeData.label)"
/>
</div>
<!-- Source Node -->
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Source Node</label>
<div class="px-3 py-2 text-sm bg-gray-100 border border-gray-300 rounded-md">
{{ selectedEdgeData.sourceNode?.label || selectedEdgeData.source }}
</div>
</div>
<!-- Target Node -->
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Target Node</label>
<div class="px-3 py-2 text-sm bg-gray-100 border border-gray-300 rounded-md">
{{ selectedEdgeData.targetNode?.label || selectedEdgeData.target }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Show node properties when a node is selected -->
<div v-else class="flex flex-col h-full">
@ -2500,6 +3151,17 @@ const sendToBack = () => {
</div>
</div>
<div v-if="canShowNodeActions" class="flex items-center gap-2">
<RsButton
@click="copySelectedElements"
variant="secondary-text"
size="sm"
icon
:disabled="!canCopy"
:title="`Copy Node (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
class="modern-icon-btn"
>
<Icon name="material-symbols:content-copy" />
</RsButton>
<RsButton
@click="duplicateNode"
variant="secondary-text"
@ -2508,7 +3170,7 @@ const sendToBack = () => {
:title="'Duplicate Node'"
class="modern-icon-btn"
>
<Icon name="material-symbols:content-copy" />
<Icon name="material-symbols:content-copy-outline" />
</RsButton>
<RsButton
@click="deleteNode"

View File

@ -177,11 +177,11 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
if (nodeId.includes('start-')) {
nodeType = 'start';
label = 'Start Point';
icon = 'play_circle_filled';
icon = 'play-circle-rounded';
} else if (nodeId.includes('end-')) {
nodeType = 'end';
label = 'End Point';
icon = 'stop_circle';
icon = 'stop-circle';
} else if (nodeId.includes('form-')) {
nodeType = 'form';
label = 'Form';