Enhance ComponentPreview and FormBuilder Components with Drag-and-Drop Functionality

- Updated ComponentPreview.vue to implement a draggable container for nested components, allowing users to reorder fields within repeating groups.
- Enhanced the user interface with visual feedback during drag-and-drop actions, improving usability and interaction.
- Modified FormBuilderComponents.vue to include default properties for nested components, facilitating better organization and management of form fields.
- Improved FormBuilderFieldSettingsModal.vue to provide clearer information about repeating group containers and their functionalities.
- Refactored event handling for component updates and deletions to support nested components within sections and repeating groups, ensuring consistent data management.
- Updated styles across components to enhance the visual experience during drag-and-drop operations and improve overall aesthetics.
This commit is contained in:
Afiq 2025-08-07 10:28:57 +08:00
parent f86fe87fc5
commit 415ac5a0d1
5 changed files with 305 additions and 200 deletions

View File

@ -144,24 +144,81 @@
{{ component.props.help }}
</div>
<!-- Default group preview (in edit mode) -->
<!-- Builder mode - show draggable container -->
<div v-if="!isPreview" class="repeating-groups space-y-4">
<div class="group-item border border-gray-200 rounded-md p-3 bg-gray-50">
<div class="flex justify-between items-center mb-3">
<h4 class="text-sm font-medium text-gray-700">Item 1</h4>
<button type="button" class="text-red-500 hover:text-red-700 text-sm">
{{ component.props.removeText || 'Remove' }}
<!-- Container for draggable fields -->
<div
class="repeating-group-container border-2 border-dashed border-gray-300 rounded-md p-4 min-h-[100px] bg-gray-50"
:class="{
'border-blue-400 bg-blue-50': sectionDropStates[component.id]?.isDraggingOver
}"
@dragover.prevent="handleSectionDragOver($event, component.id)"
@dragleave="handleSectionDragLeave($event, component.id)"
@drop="handleSectionDrop($event, component.id)"
@dragenter.prevent="handleSectionDragEnter($event, component.id)"
>
<div v-if="component.props.children && component.props.children.length > 0" class="nested-components">
<draggable
v-model="component.props.children"
group="form-components"
item-key="id"
handle=".drag-handle"
ghost-class="ghost"
animation="300"
class="grid grid-cols-12 gap-2"
@end="onNestedDragEnd"
@add="onNestedComponentAdd"
>
<template #item="{ element: childElement, index: childIndex }">
<div
class="form-component relative border rounded-md overflow-hidden transition-all duration-200 bg-white"
:class="'border-gray-200 hover:border-blue-300 hover:shadow-md'"
:style="{
gridColumn: childElement.props.gridColumn || 'span 6'
}"
>
<!-- Component actions -->
<div class="component-actions absolute right-1 top-1 flex space-x-1 z-10">
<button
class="p-1 text-gray-400 hover:text-blue-600 rounded"
title="Component settings"
@click.stop="openNestedComponentSettings(childElement)"
>
<Icon name="heroicons:cog-6-tooth" class="w-3 h-3" />
</button>
<button
class="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Drag to reorder"
>
<span class="drag-handle cursor-move">
<Icon name="material-symbols:drag-indicator" class="w-3 h-3" />
</span>
</button>
<button
class="p-1 text-gray-400 hover:text-red-500 rounded"
title="Remove from group"
@click.stop="removeFromSection(component.id, childIndex)"
>
<Icon name="material-symbols:close" class="w-3 h-3" />
</button>
</div>
<div class="space-y-3">
<template v-for="(field, fieldIndex) in component.props.fields" :key="fieldIndex">
<FormKit :type="field.type" :label="field.label" :placeholder="field.placeholder"
:name="`${field.name}_1`" :options="field.options" disabled />
<div class="p-2">
<component-preview :component="childElement" :is-preview="false" />
</div>
</div>
</template>
</draggable>
</div>
<!-- Empty state -->
<div v-else class="text-center py-8">
<Icon name="material-symbols:add-circle-outline" class="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p class="text-sm text-gray-500">Drag fields here to add them to this repeating group</p>
</div>
</div>
<!-- Add Item button for preview -->
<button type="button"
class="inline-flex items-center px-3 py-1.5 border border-blue-600 text-blue-600 bg-white hover:bg-blue-50 rounded text-sm">
<Icon name="material-symbols:add-circle-outline" class="w-4 h-4 mr-1" />
@ -169,7 +226,7 @@
</button>
</div>
<!-- Functional groups (in preview mode) -->
<!-- Preview mode - show functional repeating groups -->
<div v-else class="repeating-groups space-y-4">
<div v-for="(group, groupIndex) in (safeGetField(component.props.name, previewFormData) || [])" :key="groupIndex"
class="group-item border border-gray-200 rounded-md p-3 bg-gray-50">
@ -181,12 +238,17 @@
</button>
</div>
<div class="space-y-3">
<template v-for="(field, fieldIndex) in component.props.fields" :key="fieldIndex">
<FormKit :type="field.type" :label="field.label" :placeholder="field.placeholder"
:name="`${component.props.name}.${groupIndex}.${field.name}`" :options="field.options"
:value="group[field.name]"
@input="updateGroupField(component.props.name, groupIndex, field.name, $event)" />
<div class="grid grid-cols-12 gap-2">
<!-- Render children components for each group item -->
<template v-for="(child, childIndex) in component.props.children" :key="childIndex">
<div
class="form-component"
:style="{
gridColumn: child.props.gridColumn || 'span 6'
}"
>
<component-preview :component="child" :is-preview="true" />
</div>
</template>
</div>
</div>
@ -462,9 +524,16 @@
<!-- In preview mode, show only the nested components (no placeholder) -->
<div v-if="isPreview" class="section-fields">
<!-- Render nested components if they exist -->
<div v-if="component.props.children && component.props.children.length > 0" class="space-y-3">
<div v-if="component.props.children && component.props.children.length > 0" class="grid grid-cols-12 gap-2">
<template v-for="(childComponent, childIndex) in component.props.children" :key="childIndex">
<div
class="form-component"
:style="{
gridColumn: childComponent.props.gridColumn || 'span 6'
}"
>
<component-preview :component="childComponent" :is-preview="true" />
</div>
</template>
</div>
<!-- Show subtle indication for empty sections in preview mode (optional) -->
@ -579,6 +648,7 @@ import { useFormBuilderStore } from '~/stores/formBuilder';
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
import { safeGetField } from '~/composables/safeGetField';
import { onMounted, onUnmounted, watch, computed } from 'vue';
import draggable from 'vuedraggable';
const props = defineProps({
component: {
@ -613,6 +683,9 @@ const sectionDropStates = ref({});
const showNestedSettingsModal = ref(false);
const selectedNestedComponent = ref(null);
// Track selected component ID for highlighting
const selectedComponentId = ref(null);
// Track timers and DOM elements for cleanup
let lightbox = null;
let importInput = null;
@ -1323,18 +1396,18 @@ const removeFromSection = (sectionId, childIndex) => {
}
};
const handleSectionDragOver = (event, sectionId) => {
const handleSectionDragOver = (event, containerId) => {
event.preventDefault();
event.stopPropagation();
// Initialize section drop state if it doesn't exist
if (!sectionDropStates.value[sectionId]) {
sectionDropStates.value[sectionId] = { isDraggingOver: false };
// Initialize container drop state if it doesn't exist
if (!sectionDropStates.value[containerId]) {
sectionDropStates.value[containerId] = { isDraggingOver: false };
}
sectionDropStates.value[sectionId].isDraggingOver = true;
sectionDropStates.value[containerId].isDraggingOver = true;
};
const handleSectionDragLeave = (event, sectionId) => {
const handleSectionDragLeave = (event, containerId) => {
event.preventDefault();
event.stopPropagation();
@ -1347,18 +1420,70 @@ const handleSectionDragLeave = (event, sectionId) => {
event.clientY > rect.bottom
);
if (isOutside && sectionDropStates.value[sectionId]) {
sectionDropStates.value[sectionId].isDraggingOver = false;
if (isOutside && sectionDropStates.value[containerId]) {
sectionDropStates.value[containerId].isDraggingOver = false;
}
};
const handleSectionDrop = (event, sectionId) => {
// Container drag handling functions
const handleContainerDragStart = (event) => {
console.log('Container drag started:', event);
};
const handleContainerDragEnd = (event) => {
console.log('Container drag ended:', event);
// The draggable component automatically updates the array order
// We just need to update the parent container to trigger reactivity
if (props.component) {
formStore.updateComponent(props.component);
}
};
// Nested component selection
const selectNestedComponent = (nestedComponent) => {
if (!nestedComponent || !nestedComponent.id) return;
selectedComponentId.value = nestedComponent.id;
selectedNestedComponent.value = nestedComponent;
// Emit the selection event to parent
emit('select-nested-component', nestedComponent);
console.log('Selected nested component:', nestedComponent);
};
// Delete nested component
const deleteNestedComponent = (componentId) => {
if (!componentId || !props.component) return;
// Find the component in the children array
const childIndex = props.component.props.children.findIndex(child => child.id === componentId);
if (childIndex !== -1) {
// Remove the component
const deletedComponent = props.component.props.children.splice(childIndex, 1)[0];
// Update the container to trigger reactivity
formStore.updateComponent(props.component);
// Clear selection if the deleted component was selected
if (selectedComponentId.value === componentId) {
selectedComponentId.value = null;
selectedNestedComponent.value = null;
}
console.log('Deleted nested component:', deletedComponent);
}
};
const handleSectionDrop = (event, containerId) => {
event.preventDefault();
event.stopPropagation();
// Reset drag state
if (sectionDropStates.value[sectionId]) {
sectionDropStates.value[sectionId].isDraggingOver = false;
if (sectionDropStates.value[containerId]) {
sectionDropStates.value[containerId].isDraggingOver = false;
}
try {
@ -1384,7 +1509,7 @@ const handleSectionDrop = (event, sectionId) => {
name: componentData.name,
props: {
...componentData.defaultProps,
gridColumn: 'span 6', // Default to half width in sections
gridColumn: 'span 6', // Default to half width in containers
width: '50%',
// Ensure the component has a proper label
label: componentData.defaultProps.label || componentData.name || `${componentData.type.charAt(0).toUpperCase() + componentData.type.slice(1)} Field`,
@ -1392,41 +1517,42 @@ const handleSectionDrop = (event, sectionId) => {
}
};
// Find the target section
const section = formStore.formComponents.find(comp => comp.id === sectionId);
if (section) {
// Find the target container (section or repeating-group)
const container = formStore.formComponents.find(comp => comp.id === containerId);
if (container && (container.type === 'form-section' || container.type === 'repeating-group')) {
// Initialize children array if it doesn't exist
if (!section.props.children) {
section.props.children = [];
if (!container.props.children) {
container.props.children = [];
}
// Add the component to the section
section.props.children.push(newComponent);
// Add the component to the container
container.props.children.push(newComponent);
// Update the section in the form store
formStore.updateComponent(section);
// Update the container in the form store
formStore.updateComponent(container);
// Record the action in history
formStore.recordHistory('add_component_to_section', {
formStore.recordHistory('add_component_to_container', {
componentType: newComponent.type,
componentName: newComponent.name,
sectionId: sectionId
containerId: containerId,
containerType: container.type
});
console.log('Component added to section:', newComponent);
console.log('Component added to container:', newComponent);
}
} catch (error) {
console.error('Error dropping component into section:', error);
console.error('Error dropping component into container:', error);
}
};
const handleSectionDragEnter = (event, sectionId) => {
const handleSectionDragEnter = (event, containerId) => {
event.preventDefault();
event.stopPropagation();
// Initialize section drop state if it doesn't exist
if (!sectionDropStates.value[sectionId]) {
sectionDropStates.value[sectionId] = { isDraggingOver: false };
// Initialize container drop state if it doesn't exist
if (!sectionDropStates.value[containerId]) {
sectionDropStates.value[containerId] = { isDraggingOver: false };
}
};
@ -1445,20 +1571,20 @@ const closeNestedSettingsModal = () => {
const saveNestedComponentSettings = (updatedComponent) => {
if (!updatedComponent || !selectedNestedComponent.value) return;
// Find the parent section and update the nested component
const parentSection = formStore.formComponents.find(comp =>
comp.type === 'form-section' &&
// Find the parent container (section or repeating-group) and update the nested component
const parentContainer = formStore.formComponents.find(comp =>
(comp.type === 'form-section' || comp.type === 'repeating-group') &&
comp.props.children &&
comp.props.children.some(child => child.id === updatedComponent.id)
);
if (parentSection) {
const childIndex = parentSection.props.children.findIndex(child => child.id === updatedComponent.id);
if (parentContainer) {
const childIndex = parentContainer.props.children.findIndex(child => child.id === updatedComponent.id);
if (childIndex !== -1) {
// Update the nested component
parentSection.props.children[childIndex] = { ...updatedComponent };
// Update the section to trigger reactivity
formStore.updateComponent(parentSection);
parentContainer.props.children[childIndex] = { ...updatedComponent };
// Update the container to trigger reactivity
formStore.updateComponent(parentContainer);
console.log('Updated nested component:', updatedComponent);
}
@ -1658,6 +1784,65 @@ const getButtonSizeClass = (size) => {
border-color: #93c5fd;
}
.repeating-group-container {
transition: all 0.2s ease-in-out;
min-height: 100px;
}
.repeating-group-container:hover {
border-color: #93c5fd;
background-color: #f0f9ff;
}
/* Nested Component Styles */
.nested-component {
transition: all 0.2s ease-in-out;
margin-bottom: 0.75rem;
}
.nested-component:hover {
border-color: #93c5fd;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nested-component-actions {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.nested-component:hover .nested-component-actions {
opacity: 1;
}
.draggable-children-container {
min-height: 20px;
}
/* Ghost class for drag preview */
.ghost {
opacity: 0.5;
background: #c1d5db;
}
/* Form component styles for nested components */
.form-component {
transition: all 0.2s ease-in-out;
}
.form-component:hover {
border-color: #93c5fd;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.component-actions {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.form-component:hover .component-actions {
opacity: 1;
}
/* Dynamic List Component */
.dynamic-list-container {
width: 100%;
@ -1773,6 +1958,19 @@ const getButtonSizeClass = (size) => {
opacity: 1;
}
/* Preview mode grid styles */
.repeating-groups .grid,
.section-fields .grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 0.5rem;
}
.repeating-groups .form-component,
.section-fields .form-component {
width: 100%;
}
.component-actions:hover {
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);

View File

@ -508,11 +508,17 @@ const availableComponents = [
maxItems: 10,
buttonText: 'Add Person',
removeText: 'Remove',
fields: [
{ type: 'text', name: 'name', label: 'Name', placeholder: 'Enter name' },
{ type: 'number', name: 'age', label: 'Age', placeholder: 'Enter age' },
{ type: 'email', name: 'email', label: 'Email', placeholder: 'Enter email' }
]
width: '100%',
gridColumn: 'span 12',
showPlaceholder: true, // Whether to show the placeholder in builder mode
children: [], // Array to hold nested components (draggable fields)
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
conditions: [],
action: 'show',
operator: 'and'
}
}
},
{

View File

@ -1337,117 +1337,32 @@ if (name && email) {
/>
</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>
<!-- Container Information -->
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
<div class="flex items-start">
<Icon name="material-symbols:info" class="w-5 h-5 text-blue-600 mr-2 mt-0.5" />
<div>
<h4 class="font-medium text-sm text-blue-800 mb-1">Repeating Group Container</h4>
<p class="text-xs text-blue-700">
This is a container component. Drag and drop fields into it in the form builder to create the repeating group structure.
Each field you add will be repeated for every group item.
</p>
</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>
<!-- Children Count -->
<div class="border rounded-md p-3 bg-gray-50">
<div class="flex items-center justify-between">
<div>
<h5 class="text-sm font-medium text-gray-700">Group Fields</h5>
<p class="text-xs text-gray-500 mt-1">
{{ configModel.children?.length || 0 }} field(s) in this repeating group
</p>
</div>
<div class="text-sm text-gray-500">
<Icon name="material-symbols:drag-indicator" class="w-4 h-4" />
</div>
</div>
</div>
</div>

View File

@ -59,11 +59,8 @@ function handleChange(event) {
}
.switch-input:disabled + .switch-label .switch-track {
@apply opacity-50 cursor-not-allowed;
}
.switch-input:disabled + .switch-label {
@apply cursor-not-allowed;
@apply opacity-50;
cursor: not-allowed;
}
.switch-thumb {
@ -98,20 +95,9 @@ function handleChange(event) {
/* Readonly styles for Switch */
.switch-input:disabled + .switch-label .switch-track {
@apply opacity-50 cursor-not-allowed;
@apply opacity-50;
cursor: not-allowed;
}
.switch-input:disabled + .switch-label {
@apply cursor-not-allowed;
}
/* Additional readonly styling */
.switch-label.cursor-not-allowed {
cursor: not-allowed !important;
}
.switch-label.cursor-not-allowed .switch-track {
opacity: 0.8;
background-color: #f3f4f6;
}
</style>

View File

@ -2334,17 +2334,17 @@ const handleUpdateComponent = (updatedComponent) => {
// Check if this is a nested component inside a section
let foundInSection = false;
// Look for the component in section children
// Look for the component in section or repeating-group children
formStore.formComponents.forEach(component => {
if (component.type === 'form-section' && component.props.children) {
if ((component.type === 'form-section' || component.type === 'repeating-group') && component.props.children) {
const nestedIndex = component.props.children.findIndex(child => child.id === updatedComponent.id);
if (nestedIndex !== -1) {
// Update the nested component
component.props.children[nestedIndex] = updatedComponent;
// Update the entire section to trigger reactivity
// Update the entire container to trigger reactivity
formStore.updateComponent(component);
foundInSection = true;
console.log('Updated nested component in section:', updatedComponent);
console.log('Updated nested component in container:', updatedComponent);
return;
}
}
@ -2366,14 +2366,14 @@ const handleDeleteComponent = (id) => {
// Check if this is a nested component inside a section
let foundInSection = false;
// Look for the component in section children
// Look for the component in section or repeating-group children
formStore.formComponents.forEach(component => {
if (component.type === 'form-section' && component.props.children) {
if ((component.type === 'form-section' || component.type === 'repeating-group') && component.props.children) {
const nestedIndex = component.props.children.findIndex(child => child.id === id);
if (nestedIndex !== -1) {
// Remove the nested component
const deletedComponent = component.props.children.splice(nestedIndex, 1)[0];
// Update the entire section to trigger reactivity
// Update the entire container to trigger reactivity
formStore.updateComponent(component);
foundInSection = true;
@ -2383,7 +2383,7 @@ const handleDeleteComponent = (id) => {
formStore.selectedComponentId = null;
}
console.log('Deleted nested component from section:', deletedComponent);
console.log('Deleted nested component from container:', deletedComponent);
return;
}
}