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:
parent
0060ad38b7
commit
9883504a7c
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
};
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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';
|
||||
|
Loading…
x
Reference in New Issue
Block a user