Implement Duplicate Component Feature and Enhance Delete Confirmation in FormBuilder

- Added a duplicate component button in FormBuilderCanvas.vue, allowing users to easily duplicate existing components.
- Introduced a delete confirmation modal in both FormBuilderCanvas.vue and FormBuilderFieldSettingsModal.vue to prevent accidental deletions, enhancing user experience.
- Updated delete functionality to utilize the confirmation modal, ensuring users are aware of the consequences of their actions.
- Refactored related methods to support the new duplication and confirmation features, maintaining clean and organized code.
- Enhanced user feedback with toast notifications upon successful deletion and duplication of components.
This commit is contained in:
Afiq 2025-08-07 20:37:28 +08:00
parent 8d6184fd8b
commit 72a70972fb
4 changed files with 363 additions and 65 deletions

View File

@ -64,16 +64,23 @@
</span>
</button>
<button
class="p-1 text-gray-400 hover:text-blue-500 rounded"
title="Duplicate component"
@click.stop="duplicateComponent(element)"
>
<Icon name="heroicons:document-duplicate" class="w-4 h-4" />
</button>
<!-- <button
class="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Resize component"
@click.stop="toggleResizeMode(element)"
>
<Icon name="material-symbols:resize" class="w-4 h-4" />
</button>
</button> -->
<button
class="p-1 text-gray-400 hover:text-red-500 rounded"
title="Delete component"
@click.stop="deleteComponent(element.id)"
@click.stop="showDeleteModal(element.id)"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
@ -101,6 +108,32 @@
</template>
</draggable>
</div>
<!-- Delete Confirmation Modal -->
<RsModal v-model="showDeleteConfirmModal" title="Confirm Delete" size="md" position="center">
<div class="p-4">
<div class="flex items-center mb-4">
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3 flex-shrink-0" />
<div>
<p class="text-gray-600 font-medium mb-1">Delete Component</p>
<p class="text-gray-600 text-sm">
Are you sure you want to delete this component? This action cannot be undone and will permanently remove the component from your form.
</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="cancelDelete" variant="tertiary">
Cancel
</RsButton>
<RsButton @click="confirmDelete" variant="danger">
<Icon name="material-symbols:delete" class="w-4 h-4 mr-1" />
Delete
</RsButton>
</div>
</template>
</RsModal>
</div>
</template>
@ -115,7 +148,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['select-component', 'move-component', 'delete-component', 'update-component', 'optimize-layout', 'select-nested-component', 'insert-component-at-index']);
const emit = defineEmits(['select-component', 'move-component', 'delete-component', 'update-component', 'optimize-layout', 'select-nested-component', 'insert-component-at-index', 'duplicate-component']);
const selectedComponentId = ref(null);
const resizeMode = ref(false);
@ -123,6 +156,10 @@ const resizing = ref(false);
const initialWidth = ref(0);
const initialX = ref(0);
// Delete modal state
const showDeleteConfirmModal = ref(false);
const componentToDelete = ref(null);
// Watch for changes in formComponents
watch(() => props.formComponents, (newComponents) => {
// If the currently selected component is no longer in the list, deselect it
@ -155,13 +192,35 @@ const selectComponent = (component) => {
emit('select-component', componentCopy);
};
// Handle component deletion
const deleteComponent = (id) => {
// Handle component duplication
const duplicateComponent = (component) => {
emit('duplicate-component', component);
};
// Show delete confirmation modal
const showDeleteModal = (id) => {
componentToDelete.value = id;
showDeleteConfirmModal.value = true;
};
// Cancel delete operation
const cancelDelete = () => {
showDeleteConfirmModal.value = false;
componentToDelete.value = null;
};
// Confirm delete operation
const confirmDelete = () => {
if (componentToDelete.value) {
const id = componentToDelete.value;
if (selectedComponentId.value === id) {
selectedComponentId.value = null;
resizeMode.value = false;
}
emit('delete-component', id);
}
showDeleteConfirmModal.value = false;
componentToDelete.value = null;
};
// Handle nested component selection from sections

View File

@ -2918,7 +2918,7 @@ if (this.element.querySelector('.file-upload')) {
<div class="flex space-x-3">
<RsButton
@click="handleReset"
@click="showResetConfirmation"
variant="warning-outline"
>
<Icon name="heroicons:arrow-path" class="w-4 h-4 mr-1" />
@ -2937,6 +2937,32 @@ if (this.element.querySelector('.file-upload')) {
</template>
</RsModal>
<!-- Reset Confirmation Modal -->
<RsModal v-model="showResetModal" title="Confirm Reset" size="md" position="center">
<div class="p-4">
<div class="flex items-center mb-4">
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3 flex-shrink-0" />
<div>
<p class="text-gray-600 font-medium mb-1">Reset to Default Settings</p>
<p class="text-gray-600 text-sm">
Are you sure you want to reset this component to its default settings? This action will overwrite all current configuration and cannot be undone.
</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="cancelReset" variant="tertiary">
Cancel
</RsButton>
<RsButton @click="confirmReset" variant="warning">
<Icon name="heroicons:arrow-path" class="w-4 h-4 mr-1" />
Reset to Default
</RsButton>
</div>
</template>
</RsModal>
</template>
<script setup>
@ -2970,6 +2996,7 @@ const configModel = ref({
// Type changing state
const pendingTypeChange = ref(null)
const showTypeChangePreview = ref(false)
const showResetModal = ref(false)
// Component info helpers
const modalTitle = computed(() => {
@ -3756,6 +3783,19 @@ const handleSave = () => {
handleClose()
}
const showResetConfirmation = () => {
showResetModal.value = true;
};
const cancelReset = () => {
showResetModal.value = false;
};
const confirmReset = () => {
handleReset();
showResetModal.value = false;
};
const handleReset = () => {
if (props.component) {
// Reset to default props from the component definition

View File

@ -371,6 +371,21 @@ const formatCurrentCode = async () => {
const debouncedFormatCode = useDebounceFn(formatCurrentCode, 300);
// Helper functions for format button
const getFormatButtonTitle = () => {
const langName = props.language === 'json' ? 'JSON' : props.language.toUpperCase();
return `Format ${langName} code (Shift+Alt+F)`;
};
const getFormatButtonIcon = () => {
return 'material-symbols:auto-fix-high';
};
const getFormatButtonText = () => {
const langName = props.language === 'json' ? 'JSON' : props.language.toUpperCase();
return `Format ${langName}`;
};
// Fullscreen functionality
const isFullscreen = ref(false);
const fullscreenContainer = ref(null);

View File

@ -330,6 +330,7 @@
@select-nested-component="handleSelectNestedComponent"
@move-component="handleMoveComponent"
@delete-component="handleDeleteComponent"
@duplicate-component="handleDuplicateComponent"
@update-component="handleUpdateComponent"
@optimize-layout="handleOptimizeLayout"
@insert-component-at-index="handleInsertComponentAtIndex"
@ -426,16 +427,9 @@
<Icon name="heroicons:adjustments-horizontal" class="w-4 h-4 mr-1.5" />
Full Settings
</button>
<button
@click="duplicateComponent"
class="action-btn secondary"
title="Duplicate this component"
>
<Icon name="heroicons:document-duplicate" class="w-4 h-4 mr-1.5" />
Duplicate
</button>
<button
@click="deleteComponent"
@click="showDeleteConfirmation"
class="action-btn danger"
title="Delete this component"
>
@ -1288,6 +1282,84 @@
@restored="handleFormRestored"
/>
<!-- Delete Confirmation Modal for Settings Panel -->
<RsModal v-model="showDeleteModal" title="Confirm Delete" size="md" position="center">
<div class="p-4">
<div class="flex items-center mb-4">
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3 flex-shrink-0" />
<div>
<p class="text-gray-600 font-medium mb-1">Delete Component</p>
<p class="text-gray-600 text-sm">
Are you sure you want to delete this component? This action cannot be undone and will permanently remove the component from your form.
</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="cancelDelete" variant="tertiary">
Cancel
</RsButton>
<RsButton @click="confirmDelete" variant="danger">
<Icon name="material-symbols:delete" class="w-4 h-4 mr-1" />
Delete
</RsButton>
</div>
</template>
</RsModal>
<!-- Replace Content Confirmation Modal -->
<RsModal v-model="showReplaceContentModal" title="Confirm Replace Content" size="md" position="center">
<div class="p-4">
<div class="flex items-center mb-4">
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3 flex-shrink-0" />
<div>
<p class="text-gray-600 font-medium mb-1">Replace Current Form Content</p>
<p class="text-gray-600 text-sm">
This will replace your current form content. Any unsaved changes will be lost. Are you sure you want to continue?
</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="cancelReplaceContent" variant="tertiary">
Cancel
</RsButton>
<RsButton @click="confirmReplaceContent" variant="warning">
<Icon name="material-symbols:swap-horiz" class="w-4 h-4 mr-1" />
Replace Content
</RsButton>
</div>
</template>
</RsModal>
<!-- Update Form Name Confirmation Modal -->
<RsModal v-model="showUpdateFormNameModal" title="Update Form Name" size="md" position="center">
<div class="p-4">
<div class="flex items-center mb-4">
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3 flex-shrink-0" />
<div>
<p class="text-gray-600 font-medium mb-1">Update Form Name</p>
<p class="text-gray-600 text-sm">
Do you want to update the form name to match the template name "{{ pendingFormNameUpdate }}"?
</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="cancelUpdateFormName" variant="tertiary">
Keep Current Name
</RsButton>
<RsButton @click="confirmUpdateFormName" variant="primary">
<Icon name="material-symbols:edit" class="w-4 h-4 mr-1" />
Update Name
</RsButton>
</div>
</template>
</RsModal>
<!-- Help Modal -->
<RsModal v-model="showHelpModal" title="How to Use Form Builder" size="xl" position="center">
<div class="p-6 space-y-6">
@ -1980,12 +2052,12 @@ const handleJsonImport = (event) => {
} else {
// Confirm before replacing current form
if (formStore.formComponents.length > 0) {
if (!confirm("This will replace your current form content. Continue?")) {
// Reset file input
event.target.value = '';
// Store the pending import for later confirmation
pendingJsonImport.value = importedJson;
fileInputToReset.value = event.target;
showReplaceContentModal.value = true;
return;
}
}
// Validate imported JSON
if (!importedJson.formName || !Array.isArray(importedJson.components)) {
@ -2400,13 +2472,157 @@ const handleDeleteComponent = (id) => {
}
console.log('Deleted nested component from container:', deletedComponent);
toast.success('Component deleted successfully');
}
} else {
// If not found in sections, delete as a main component
formStore.deleteComponent(id);
toast.success('Component deleted successfully');
}
};
const handleDuplicateComponent = (component) => {
if (!component) return;
// Create a deep copy of the component props to avoid reference issues
const duplicatedProps = JSON.parse(JSON.stringify(component.props));
// Update name to avoid conflicts
if (duplicatedProps.name) {
duplicatedProps.name = duplicatedProps.name + '_copy';
}
// Update label to indicate it's a copy
if (duplicatedProps.label) {
duplicatedProps.label = duplicatedProps.label + ' (Copy)';
}
// Create the component definition for duplication
// Note: We don't set an ID here as the store's addComponent method will generate a new unique ID
const componentToDuplicate = {
type: component.type,
name: getComponentTypeName(component.type) + ' (Copy)',
category: getDefaultCategory(component.type),
icon: getComponentIcon(component.type),
defaultProps: duplicatedProps
};
// The addComponent method in the store will automatically assign a new unique ID
formStore.addComponent(componentToDuplicate);
toast.success('Component duplicated successfully');
};
// Delete confirmation modal state
const showDeleteModal = ref(false);
// Replace content confirmation modal state
const showReplaceContentModal = ref(false);
const pendingJsonImport = ref(null);
const pendingTemplate = ref(null);
const fileInputToReset = ref(null);
// Update form name confirmation modal state
const showUpdateFormNameModal = ref(false);
const pendingFormNameUpdate = ref('');
const showDeleteConfirmation = () => {
if (!formStore.selectedComponent) return;
showDeleteModal.value = true;
};
const cancelDelete = () => {
showDeleteModal.value = false;
};
const confirmDelete = () => {
if (formStore.selectedComponent) {
formStore.deleteComponent(formStore.selectedComponent.id);
toast.success('Component deleted successfully');
}
showDeleteModal.value = false;
};
// Replace content confirmation functions
const confirmReplaceContent = () => {
try {
if (pendingJsonImport.value) {
// Handle JSON import
if (!pendingJsonImport.value.formName || !Array.isArray(pendingJsonImport.value.components)) {
throw new Error('Invalid form JSON structure');
}
formStore.formName = pendingJsonImport.value.formName || 'Imported Form';
formStore.formDescription = pendingJsonImport.value.formDescription || '';
formStore.formComponents = [];
formStore.currentFormId = null;
pendingJsonImport.value.components.forEach(component => {
formStore.addComponent(component);
});
if (pendingJsonImport.value.customScript) {
formStore.formCustomScript = pendingJsonImport.value.customScript;
}
if (pendingJsonImport.value.customCSS) {
formStore.formCustomCSS = pendingJsonImport.value.customCSS;
}
toast.success('JSON imported successfully');
} else if (pendingTemplate.value) {
// Handle template application
const template = pendingTemplate.value;
formStore.formComponents = [];
formStore.currentFormId = null;
if (template.components && Array.isArray(template.components)) {
template.components.forEach(component => {
formStore.addComponent(component);
});
}
toast.success(`Template "${template.name}" applied successfully`);
}
} catch (error) {
console.error('Error replacing content:', error);
toast.error('Failed to replace content: ' + error.message);
} finally {
// Reset state
showReplaceContentModal.value = false;
pendingJsonImport.value = null;
pendingTemplate.value = null;
if (fileInputToReset.value) {
fileInputToReset.value.value = '';
fileInputToReset.value = null;
}
}
};
const cancelReplaceContent = () => {
showReplaceContentModal.value = false;
pendingJsonImport.value = null;
pendingTemplate.value = null;
if (fileInputToReset.value) {
fileInputToReset.value.value = '';
fileInputToReset.value = null;
}
};
// Update form name confirmation functions
const confirmUpdateFormName = () => {
if (pendingFormNameUpdate.value) {
formStore.setFormName(pendingFormNameUpdate.value);
}
showUpdateFormNameModal.value = false;
pendingFormNameUpdate.value = '';
};
const cancelUpdateFormName = () => {
showUpdateFormNameModal.value = false;
pendingFormNameUpdate.value = '';
};
const handleDragOver = (event) => {
// Always prevent default to enable drop
event.preventDefault();
@ -2812,18 +3028,21 @@ function handleClickOutside(event) {
const applyFormTemplate = (template) => {
// Confirm if there's already content in the form
if (formStore.formComponents.length > 0) {
if (!confirm("This will replace your current form content. Continue?")) {
pendingTemplate.value = template;
showReplaceContentModal.value = true;
return;
}
}
try {
console.log('Applying template:', template.name);
console.log('Template components:', template.components ? template.components.length : 0);
// Set form name if it's a new form or user allows overwrite
if (formStore.formName === 'New Form' || confirm("Update the form name to match the template?")) {
if (formStore.formName === 'New Form') {
formStore.setFormName(template.name);
} else {
pendingFormNameUpdate.value = template.name;
showUpdateFormNameModal.value = true;
}
// Reset the form components and form ID (to ensure this becomes a new form)
@ -3336,44 +3555,9 @@ const openFullSettingsModal = () => {
showFieldSettings.value = true;
};
// Duplicate component
const duplicateComponent = () => {
if (!formStore.selectedComponent) return;
const originalComponent = formStore.selectedComponent;
const duplicatedProps = { ...originalComponent.props };
// Update name to avoid conflicts
if (duplicatedProps.name) {
duplicatedProps.name = duplicatedProps.name + '_copy';
}
// Update label to indicate it's a copy
if (duplicatedProps.label) {
duplicatedProps.label = duplicatedProps.label + ' (Copy)';
}
const componentToDuplicate = {
type: originalComponent.type,
name: getComponentTypeName(originalComponent.type) + ' (Copy)',
category: getDefaultCategory(originalComponent.type),
icon: getComponentIcon(originalComponent.type),
defaultProps: duplicatedProps
};
formStore.addComponent(componentToDuplicate);
toast.success('Component duplicated successfully');
};
// Delete component
const deleteComponent = () => {
if (!formStore.selectedComponent) return;
if (confirm('Are you sure you want to delete this component?')) {
formStore.deleteComponent(formStore.selectedComponent.id);
toast.success('Component deleted successfully');
}
};
const handleConditionalLogicGenerated = (script) => {
// Add the generated script to the form's custom script