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> </span>
</button> </button>
<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" class="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Resize component" title="Resize component"
@click.stop="toggleResizeMode(element)" @click.stop="toggleResizeMode(element)"
> >
<Icon name="material-symbols:resize" class="w-4 h-4" /> <Icon name="material-symbols:resize" class="w-4 h-4" />
</button> </button> -->
<button <button
class="p-1 text-gray-400 hover:text-red-500 rounded" class="p-1 text-gray-400 hover:text-red-500 rounded"
title="Delete component" title="Delete component"
@click.stop="deleteComponent(element.id)" @click.stop="showDeleteModal(element.id)"
> >
<Icon name="material-symbols:delete-outline" class="w-4 h-4" /> <Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button> </button>
@ -101,6 +108,32 @@
</template> </template>
</draggable> </draggable>
</div> </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> </div>
</template> </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 selectedComponentId = ref(null);
const resizeMode = ref(false); const resizeMode = ref(false);
@ -123,6 +156,10 @@ const resizing = ref(false);
const initialWidth = ref(0); const initialWidth = ref(0);
const initialX = ref(0); const initialX = ref(0);
// Delete modal state
const showDeleteConfirmModal = ref(false);
const componentToDelete = ref(null);
// Watch for changes in formComponents // Watch for changes in formComponents
watch(() => props.formComponents, (newComponents) => { watch(() => props.formComponents, (newComponents) => {
// If the currently selected component is no longer in the list, deselect it // If the currently selected component is no longer in the list, deselect it
@ -155,13 +192,35 @@ const selectComponent = (component) => {
emit('select-component', componentCopy); emit('select-component', componentCopy);
}; };
// Handle component deletion // Handle component duplication
const deleteComponent = (id) => { const duplicateComponent = (component) => {
if (selectedComponentId.value === id) { emit('duplicate-component', component);
selectedComponentId.value = null; };
resizeMode.value = false;
// 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);
} }
emit('delete-component', id); showDeleteConfirmModal.value = false;
componentToDelete.value = null;
}; };
// Handle nested component selection from sections // Handle nested component selection from sections

View File

@ -2918,7 +2918,7 @@ if (this.element.querySelector('.file-upload')) {
<div class="flex space-x-3"> <div class="flex space-x-3">
<RsButton <RsButton
@click="handleReset" @click="showResetConfirmation"
variant="warning-outline" variant="warning-outline"
> >
<Icon name="heroicons:arrow-path" class="w-4 h-4 mr-1" /> <Icon name="heroicons:arrow-path" class="w-4 h-4 mr-1" />
@ -2937,6 +2937,32 @@ if (this.element.querySelector('.file-upload')) {
</template> </template>
</RsModal> </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> </template>
<script setup> <script setup>
@ -2970,6 +2996,7 @@ const configModel = ref({
// Type changing state // Type changing state
const pendingTypeChange = ref(null) const pendingTypeChange = ref(null)
const showTypeChangePreview = ref(false) const showTypeChangePreview = ref(false)
const showResetModal = ref(false)
// Component info helpers // Component info helpers
const modalTitle = computed(() => { const modalTitle = computed(() => {
@ -3756,6 +3783,19 @@ const handleSave = () => {
handleClose() handleClose()
} }
const showResetConfirmation = () => {
showResetModal.value = true;
};
const cancelReset = () => {
showResetModal.value = false;
};
const confirmReset = () => {
handleReset();
showResetModal.value = false;
};
const handleReset = () => { const handleReset = () => {
if (props.component) { if (props.component) {
// Reset to default props from the component definition // Reset to default props from the component definition

View File

@ -371,6 +371,21 @@ const formatCurrentCode = async () => {
const debouncedFormatCode = useDebounceFn(formatCurrentCode, 300); 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 // Fullscreen functionality
const isFullscreen = ref(false); const isFullscreen = ref(false);
const fullscreenContainer = ref(null); const fullscreenContainer = ref(null);

View File

@ -330,6 +330,7 @@
@select-nested-component="handleSelectNestedComponent" @select-nested-component="handleSelectNestedComponent"
@move-component="handleMoveComponent" @move-component="handleMoveComponent"
@delete-component="handleDeleteComponent" @delete-component="handleDeleteComponent"
@duplicate-component="handleDuplicateComponent"
@update-component="handleUpdateComponent" @update-component="handleUpdateComponent"
@optimize-layout="handleOptimizeLayout" @optimize-layout="handleOptimizeLayout"
@insert-component-at-index="handleInsertComponentAtIndex" @insert-component-at-index="handleInsertComponentAtIndex"
@ -426,16 +427,9 @@
<Icon name="heroicons:adjustments-horizontal" class="w-4 h-4 mr-1.5" /> <Icon name="heroicons:adjustments-horizontal" class="w-4 h-4 mr-1.5" />
Full Settings Full Settings
</button> </button>
<button <button
@click="duplicateComponent" @click="showDeleteConfirmation"
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"
class="action-btn danger" class="action-btn danger"
title="Delete this component" title="Delete this component"
> >
@ -1288,6 +1282,84 @@
@restored="handleFormRestored" @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 --> <!-- Help Modal -->
<RsModal v-model="showHelpModal" title="How to Use Form Builder" size="xl" position="center"> <RsModal v-model="showHelpModal" title="How to Use Form Builder" size="xl" position="center">
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
@ -1980,11 +2052,11 @@ const handleJsonImport = (event) => {
} else { } else {
// Confirm before replacing current form // Confirm before replacing current form
if (formStore.formComponents.length > 0) { if (formStore.formComponents.length > 0) {
if (!confirm("This will replace your current form content. Continue?")) { // Store the pending import for later confirmation
// Reset file input pendingJsonImport.value = importedJson;
event.target.value = ''; fileInputToReset.value = event.target;
return; showReplaceContentModal.value = true;
} return;
} }
// Validate imported JSON // Validate imported JSON
@ -2400,13 +2472,157 @@ const handleDeleteComponent = (id) => {
} }
console.log('Deleted nested component from container:', deletedComponent); console.log('Deleted nested component from container:', deletedComponent);
toast.success('Component deleted successfully');
} }
} else { } else {
// If not found in sections, delete as a main component // If not found in sections, delete as a main component
formStore.deleteComponent(id); 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) => { const handleDragOver = (event) => {
// Always prevent default to enable drop // Always prevent default to enable drop
event.preventDefault(); event.preventDefault();
@ -2812,9 +3028,9 @@ function handleClickOutside(event) {
const applyFormTemplate = (template) => { const applyFormTemplate = (template) => {
// Confirm if there's already content in the form // Confirm if there's already content in the form
if (formStore.formComponents.length > 0) { if (formStore.formComponents.length > 0) {
if (!confirm("This will replace your current form content. Continue?")) { pendingTemplate.value = template;
return; showReplaceContentModal.value = true;
} return;
} }
try { try {
@ -2822,8 +3038,11 @@ const applyFormTemplate = (template) => {
console.log('Template components:', template.components ? template.components.length : 0); console.log('Template components:', template.components ? template.components.length : 0);
// Set form name if it's a new form or user allows overwrite // 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); 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) // Reset the form components and form ID (to ensure this becomes a new form)
@ -3336,44 +3555,9 @@ const openFullSettingsModal = () => {
showFieldSettings.value = true; 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) => { const handleConditionalLogicGenerated = (script) => {
// Add the generated script to the form's custom script // Add the generated script to the form's custom script