I am working on a drag-and-drop form customizer where users can add, reorder, and remove fields using Alpine.js and Sortable.js. However, I am encountering two issues that I cannot seem to fix:
Duplicate Fields: When a user reorders the fields, duplicate fields are created in the list. For example, if I reorder an already existing field (like "First Name"), it is added again in the list.
Empty Fields: When reordering a field (e.g., moving "Mobile" to a new position), an empty field appears in the list.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drag and Drop Form Customizer</title>
<script src="/@tailwindcss/browser@4"></script>
<link href="output.css" rel="stylesheet">
<script defer src="/[email protected]/dist/cdn.min.js"></script>
<script defer src="/[email protected]/Sortable.min.js"></script>
<style>
.sortable-chosen { background: #f0f0f0; }
.sortable-ghost { opacity: 0.5; }
</style>
</head>
<body>
<div x-data="customizer" class="grid grid-cols-1 gap-4 p-4 lg:grid-cols-3">
<!-- Available Fields -->
<div x-show="!isPreview">
<div>
<p class="py-4 font-bold">Header</p>
<ul class="grid grid-cols-1 gap-4 p-4 text-sm font-semibold bg-gray-100 rounded-md lg:grid-cols-2">
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Company Logo</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Company Name</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Address</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Mobile No.</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Email</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>GST Number</p>
</li>
</ul>
</div>
<div>
<p class="py-4 font-bold">Available Fields</p>
<div id="availableFields" class="grid grid-cols-2 gap-2 p-4 pt-4 bg-gray-100 rounded-md"
x-ref="availableFields"
@drop="dropField($event, 'available')"
@dragover.prevent
@dragenter.prevent>
<template x-for="field in availableFields" :key="field">
<button class="w-full px-2 py-1 text-sm font-semibold bg-white border cursor-grab"
draggable="true"
@dragstart="dragField($event, field)"
x-text="field"></button>
</template>
</div>
</div>
</div>
<!-- Customize Sections -->
<div class="col-span-2">
<div class="flex items-center w-full gap-4 px-4 py-2 bg-gray-100">
<p class="font-bold">Customize</p>
<button class="text-blue-600" @click="togglePreview()" x-text="isPreview ? 'Close Preview' : 'Preview'"></button>
</div>
<!-- Sections -->
<template x-for="(section, index) in sections" :key="index">
<div :class="isPreview ? 'bg-transparent' : 'border p-4 mt-4 bg-white shadow-md'">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<template x-if="!section.isEditing">
<p class="font-bold" x-text="section.name"></p>
</template>
<template x-if="section.isEditing">
<input type="text" class="px-2 py-1 border" x-model="section.name">
</template>
<button class="text-blue-600" x-show="!isPreview" @click="toggleEdit(index)">
<span x-show="!section.isEditing">Edit</span>
<span x-show="section.isEditing">✔</span>
</button>
</div>
<div class="flex items-center gap-4" x-show="!isPreview">
<button class="text-blue-600" @click="toggleLayout(index)">Single</button>
<button class="text-blue-600" @click="toggleLayout(index, true)">Double</button>
<button class="text-red-600" @click="removeSection(index)">Delete</button>
</div>
</div>
<!-- Droppable Area -->
<div :class="[
section.layout === 'double' ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-1 gap-2',
section.isDraggingOver ? 'bg-green-100 border-2 border-green-500 border-dotted p-4' : 'border bg-gray-50'
]"
class="mt-2 p-2 min-h-[50px]"
@dragover.prevent="setDraggingOver(index, true)"
@dragenter.prevent="setDraggingOver(index, true)"
@dragleave="setDraggingOver(index, false)"
@drop="dropField($event, index)"
x-ref="sortableSection"
x-init="Sortable.create($refs.sortableSection, {
animation: 150,
onEnd: (evt) => {
if (evt.oldIndex !== evt.newIndex) {
let movedField = sections[index].fields.splice(evt.oldIndex, 1)[0];
sections[index].fields.splice(evt.newIndex, 0, movedField);
}
}
})">
<template x-for="(field, fieldIndex) in section.fields" :key="field">
<div class="flex items-center justify-between p-2 bg-white border cursor-grab"
:class="isPreview ? 'border-none' : 'border'"
draggable="true">
<p x-text="field"></p>
<button class="text-red-600" @click="!isPreview && removeField(index, field)" x-show="!isPreview">✖</button>
</div>
</template>
</div>
</div>
</template>
<!-- Add Section Button -->
<div class="mt-4" x-show="!isPreview">
<button @click="addSection()" class="px-4 py-2 text-xs font-semibold text-blue-600 bg-blue-100 border-blue-600 rounded-md">Add Section</button>
</div>
</div>
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("customizer", () => ({
availableFields: [
"Employee ID", "First Name", "Last Name", "Email", "Mobile", "Date of Birth", "Gender", "Address",
"City", "State", "Postal Code", "Country", "Department", "Designation", "Joining Date", "Salary",
"Employment Type", "Work Shift", "Reporting Manager", "Status"
],
originalOrder: [
"Employee ID", "First Name", "Last Name", "Email", "Mobile", "Date of Birth", "Gender", "Address",
"City", "State", "Postal Code", "Country", "Department", "Designation", "Joining Date", "Salary",
"Employment Type", "Work Shift", "Reporting Manager", "Status"
],
sections: [],
isPreview: false,
togglePreview() {
this.isPreview = !this.isPreview;
},
toggleEdit(index) {
this.sections[index].isEditing = !this.sections[index].isEditing;
},
addSection() {
this.sections.push({
name: `Section ${this.sections.length + 1}`,
isEditing: false,
layout: "single",
fields: [],
isDraggingOver: false
});
},
removeSection(index) {
this.sections[index].fields.forEach(field => this.restoreFieldOrder(field));
this.sections.splice(index, 1);
},
removeField(sectionIndex, field) {
this.restoreFieldOrder(field);
this.sections[sectionIndex].fields = this.sections[sectionIndex].fields.filter(f => f !== field);
},
toggleLayout(index, double = false) {
this.sections[index].layout = double ? "double" : "single";
},
dragField(event, field) {
event.dataTransfer.setData("text/plain", field);
},
dropField(event, target) {
if (this.isPreview) return;
const field = event.dataTransfer.getData("text/plain");
if (target === "available") {
if (!this.availableFields.includes(field)) {
this.restoreFieldOrder(field);
}
} else {
const section = this.sections[target];
if (!section.fields.includes(field)) {
section.fields.push(field);
}
this.availableFields = this.availableFields.filter(f => f !== field);
}
this.setDraggingOver(target, false);
},
setDraggingOver(index, status) {
this.sections[index].isDraggingOver = status;
},
restoreFieldOrder(field) {
this.availableFields = [...new Set([...this.originalOrder.filter(f => !this.availableFields.includes(f)), ...this.availableFields])];
}
}));
});
</script>
</body>
</html>
I am working on a drag-and-drop form customizer where users can add, reorder, and remove fields using Alpine.js and Sortable.js. However, I am encountering two issues that I cannot seem to fix:
Duplicate Fields: When a user reorders the fields, duplicate fields are created in the list. For example, if I reorder an already existing field (like "First Name"), it is added again in the list.
Empty Fields: When reordering a field (e.g., moving "Mobile" to a new position), an empty field appears in the list.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drag and Drop Form Customizer</title>
<script src="https://unpkg/@tailwindcss/browser@4"></script>
<link href="output.css" rel="stylesheet">
<script defer src="https://cdn.jsdelivr/npm/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr/npm/[email protected]/Sortable.min.js"></script>
<style>
.sortable-chosen { background: #f0f0f0; }
.sortable-ghost { opacity: 0.5; }
</style>
</head>
<body>
<div x-data="customizer" class="grid grid-cols-1 gap-4 p-4 lg:grid-cols-3">
<!-- Available Fields -->
<div x-show="!isPreview">
<div>
<p class="py-4 font-bold">Header</p>
<ul class="grid grid-cols-1 gap-4 p-4 text-sm font-semibold bg-gray-100 rounded-md lg:grid-cols-2">
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Company Logo</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Company Name</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Address</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Mobile No.</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Email</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>GST Number</p>
</li>
</ul>
</div>
<div>
<p class="py-4 font-bold">Available Fields</p>
<div id="availableFields" class="grid grid-cols-2 gap-2 p-4 pt-4 bg-gray-100 rounded-md"
x-ref="availableFields"
@drop="dropField($event, 'available')"
@dragover.prevent
@dragenter.prevent>
<template x-for="field in availableFields" :key="field">
<button class="w-full px-2 py-1 text-sm font-semibold bg-white border cursor-grab"
draggable="true"
@dragstart="dragField($event, field)"
x-text="field"></button>
</template>
</div>
</div>
</div>
<!-- Customize Sections -->
<div class="col-span-2">
<div class="flex items-center w-full gap-4 px-4 py-2 bg-gray-100">
<p class="font-bold">Customize</p>
<button class="text-blue-600" @click="togglePreview()" x-text="isPreview ? 'Close Preview' : 'Preview'"></button>
</div>
<!-- Sections -->
<template x-for="(section, index) in sections" :key="index">
<div :class="isPreview ? 'bg-transparent' : 'border p-4 mt-4 bg-white shadow-md'">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<template x-if="!section.isEditing">
<p class="font-bold" x-text="section.name"></p>
</template>
<template x-if="section.isEditing">
<input type="text" class="px-2 py-1 border" x-model="section.name">
</template>
<button class="text-blue-600" x-show="!isPreview" @click="toggleEdit(index)">
<span x-show="!section.isEditing">Edit</span>
<span x-show="section.isEditing">✔</span>
</button>
</div>
<div class="flex items-center gap-4" x-show="!isPreview">
<button class="text-blue-600" @click="toggleLayout(index)">Single</button>
<button class="text-blue-600" @click="toggleLayout(index, true)">Double</button>
<button class="text-red-600" @click="removeSection(index)">Delete</button>
</div>
</div>
<!-- Droppable Area -->
<div :class="[
section.layout === 'double' ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-1 gap-2',
section.isDraggingOver ? 'bg-green-100 border-2 border-green-500 border-dotted p-4' : 'border bg-gray-50'
]"
class="mt-2 p-2 min-h-[50px]"
@dragover.prevent="setDraggingOver(index, true)"
@dragenter.prevent="setDraggingOver(index, true)"
@dragleave="setDraggingOver(index, false)"
@drop="dropField($event, index)"
x-ref="sortableSection"
x-init="Sortable.create($refs.sortableSection, {
animation: 150,
onEnd: (evt) => {
if (evt.oldIndex !== evt.newIndex) {
let movedField = sections[index].fields.splice(evt.oldIndex, 1)[0];
sections[index].fields.splice(evt.newIndex, 0, movedField);
}
}
})">
<template x-for="(field, fieldIndex) in section.fields" :key="field">
<div class="flex items-center justify-between p-2 bg-white border cursor-grab"
:class="isPreview ? 'border-none' : 'border'"
draggable="true">
<p x-text="field"></p>
<button class="text-red-600" @click="!isPreview && removeField(index, field)" x-show="!isPreview">✖</button>
</div>
</template>
</div>
</div>
</template>
<!-- Add Section Button -->
<div class="mt-4" x-show="!isPreview">
<button @click="addSection()" class="px-4 py-2 text-xs font-semibold text-blue-600 bg-blue-100 border-blue-600 rounded-md">Add Section</button>
</div>
</div>
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("customizer", () => ({
availableFields: [
"Employee ID", "First Name", "Last Name", "Email", "Mobile", "Date of Birth", "Gender", "Address",
"City", "State", "Postal Code", "Country", "Department", "Designation", "Joining Date", "Salary",
"Employment Type", "Work Shift", "Reporting Manager", "Status"
],
originalOrder: [
"Employee ID", "First Name", "Last Name", "Email", "Mobile", "Date of Birth", "Gender", "Address",
"City", "State", "Postal Code", "Country", "Department", "Designation", "Joining Date", "Salary",
"Employment Type", "Work Shift", "Reporting Manager", "Status"
],
sections: [],
isPreview: false,
togglePreview() {
this.isPreview = !this.isPreview;
},
toggleEdit(index) {
this.sections[index].isEditing = !this.sections[index].isEditing;
},
addSection() {
this.sections.push({
name: `Section ${this.sections.length + 1}`,
isEditing: false,
layout: "single",
fields: [],
isDraggingOver: false
});
},
removeSection(index) {
this.sections[index].fields.forEach(field => this.restoreFieldOrder(field));
this.sections.splice(index, 1);
},
removeField(sectionIndex, field) {
this.restoreFieldOrder(field);
this.sections[sectionIndex].fields = this.sections[sectionIndex].fields.filter(f => f !== field);
},
toggleLayout(index, double = false) {
this.sections[index].layout = double ? "double" : "single";
},
dragField(event, field) {
event.dataTransfer.setData("text/plain", field);
},
dropField(event, target) {
if (this.isPreview) return;
const field = event.dataTransfer.getData("text/plain");
if (target === "available") {
if (!this.availableFields.includes(field)) {
this.restoreFieldOrder(field);
}
} else {
const section = this.sections[target];
if (!section.fields.includes(field)) {
section.fields.push(field);
}
this.availableFields = this.availableFields.filter(f => f !== field);
}
this.setDraggingOver(target, false);
},
setDraggingOver(index, status) {
this.sections[index].isDraggingOver = status;
},
restoreFieldOrder(field) {
this.availableFields = [...new Set([...this.originalOrder.filter(f => !this.availableFields.includes(f)), ...this.availableFields])];
}
}));
});
</script>
</body>
</html>
Share
Improve this question
edited Feb 17 at 10:07
rozsazoltan
8,2555 gold badges17 silver badges38 bronze badges
asked Feb 17 at 8:53
R. ElavarasanR. Elavarasan
153 bronze badges
1 Answer
Reset to default 0What i found out is that it takes the text and from that it creates a new one this is than including the cross.
what i changed is when retrieving an plain text have an regex execute which is the following:
let field = event.dataTransfer.getData("text/plain").trim();
field = field.replace(/\s+\W+/g, '');
Now what it tries to input is the same as already exists.
Note:
The filter function on where it needs to be sorted does not work and did not work in your example i did NOT fix this for you since it is not what was asked + it would take extra time.
full example preview with in my oppinion some best practice changes:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drag and Drop Form Customizer</title>
<script src="https://unpkg/@tailwindcss/browser@4"></script>
<link href="output.css" rel="stylesheet">
<script defer src="https://cdn.jsdelivr/npm/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr/npm/[email protected]/Sortable.min.js"></script>
<style>
.sortable-chosen { background: #f0f0f0; }
.sortable-ghost { opacity: 0.5; }
</style>
</head>
<body>
<div x-data="customizer" class="grid grid-cols-1 gap-4 p-4 lg:grid-cols-3">
<!-- Available Fields -->
<div x-show="!isPreview">
<div>
<p class="py-4 font-bold">Header</p>
<ul class="grid grid-cols-1 gap-4 p-4 text-sm font-semibold bg-gray-100 rounded-md lg:grid-cols-2">
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Company Logo</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Company Name</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Address</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Mobile No.</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>Email</p>
</li>
<li class="flex items-center gap-2">
<input class="w-4 h-4" type="checkbox">
<p>GST Number</p>
</li>
</ul>
</div>
<div>
<p class="py-4 font-bold">Available Fields</p>
<div id="availableFields" class="grid grid-cols-2 gap-2 p-4 pt-4 bg-gray-100 rounded-md"
x-ref="availableFields"
@drop="dropField($event, 'available')"
@dragover.prevent
@dragenter.prevent>
<template x-for="field in availableFields" :key="field">
<button class="w-full px-2 py-1 text-sm font-semibold bg-white border cursor-grab"
draggable="true"
@dragstart="dragField($event, field)"
x-text="field"></button>
</template>
</div>
</div>
</div>
<!-- Customize Sections -->
<div class="col-span-2">
<div class="flex items-center w-full gap-4 px-4 py-2 bg-gray-100">
<p class="font-bold">Customize</p>
<button class="text-blue-600" @click="togglePreview()" x-text="isPreview ? 'Close Preview' : 'Preview'"></button>
</div>
<!-- Sections -->
<template x-for="(section, index) in sections" :key="index">
<div :class="isPreview ? 'bg-transparent' : 'border p-4 mt-4 bg-white shadow-md'">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<template x-if="!section.isEditing">
<p class="font-bold" x-text="section.name"></p>
</template>
<template x-if="section.isEditing">
<input type="text" class="px-2 py-1 border" x-model="section.name">
</template>
<button class="text-blue-600" x-show="!isPreview" @click="toggleEdit(index)">
<span x-show="!section.isEditing">Edit</span>
<span x-show="section.isEditing">✔</span>
</button>
</div>
<div class="flex items-center gap-4" x-show="!isPreview">
<button class="text-blue-600" @click="toggleLayout(index)">Single</button>
<button class="text-blue-600" @click="toggleLayout(index, true)">Double</button>
<button class="text-red-600" @click="removeSection(index)">Delete</button>
</div>
</div>
<div :class="[
section.layout === 'double' ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-1 gap-2',
section.isDraggingOver ? 'bg-green-100 border-2 border-green-500 border-dotted p-4' : 'border bg-gray-50'
]"
class="mt-2 p-2 min-h-[50px]"
@dragover.prevent="setDraggingOver(index, true)"
@dragenter.prevent="setDraggingOver(index, true)"
@dragleave="setDraggingOver(index, false)"
@drop="dropField($event, index)"
x-ref="sortableSection"
x-init="Sortable.create($refs.sortableSection, {
animation: 150,
onEnd: (evt) => {
if (evt.oldIndex !== evt.newIndex) {
let movedField = sections[index].fields.splice(evt.oldIndex, 1)[0];
sections[index].fields.splice(evt.newIndex, 0, movedField);
}
}
})">
<template x-for="(field, fieldIndex) in section.fields" :key="field">
<div class="flex items-center justify-between p-2 bg-white border cursor-grab"
:class="isPreview ? 'border-none' : 'border'"
draggable="true">
<p x-text="field"></p>
<button class="text-red-600" @click="!isPreview && removeField(index, field)" x-show="!isPreview">✖</button>
</div>
</template>
</div>
</div>
</template>
<!-- Add Section Button -->
<div class="mt-4" x-show="!isPreview">
<button @click="addSection()" class="px-4 py-2 text-xs font-semibold text-blue-600 bg-blue-100 border-blue-600 rounded-md">Add Section</button>
</div>
</div>
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("customizer", () => ({
availableFields: [
"Employee ID", "First Name", "Last Name", "Email", "Mobile", "Date of Birth", "Gender", "Address",
"City", "State", "Postal Code", "Country", "Department", "Designation", "Joining Date", "Salary",
"Employment Type", "Work Shift", "Reporting Manager", "Status"
],
originalOrder: [
"Employee ID", "First Name", "Last Name", "Email", "Mobile", "Date of Birth", "Gender", "Address",
"City", "State", "Postal Code", "Country", "Department", "Designation", "Joining Date", "Salary",
"Employment Type", "Work Shift", "Reporting Manager", "Status"
],
sections: [],
isPreview: false,
togglePreview() {
this.isPreview = !this.isPreview;
},
toggleEdit(index) {
this.sections[index].isEditing = !this.sections[index].isEditing;
},
addSection() {
this.sections.push({
name: `Section ${this.sections.length + 1}`,
isEditing: false,
layout: "single",
fields: [],
isDraggingOver: false
});
},
removeSection(index) {
this.sections[index].fields.forEach(field => this.restoreFieldOrder(field));
this.sections.splice(index, 1);
},
removeField(sectionIndex, field) {
this.restoreFieldOrder(field);
this.sections[sectionIndex].fields = this.sections[sectionIndex].fields.filter(f => f !== field);
},
toggleLayout(index, double = false) {
this.sections[index].layout = double ? "double" : "single";
},
dragField(event, field) {
event.dataTransfer.setData("text/plain", field.trim());
},
dropField(event, target) {
if (this.isPreview) return;
let field = event.dataTransfer.getData("text/plain").trim();
field = field.replace(/\s+\W+/g, '');
if (target === "available") {
if (!this.availableFields.includes(field)) {
this.restoreFieldOrder(field);
}
} else {
const section = this.sections[target];
if (!section.fields.includes(field)) { // Voorkomt duplicaten
section.fields.push(field);
}
// this.availableFields = this.availableFields.filter(f => f !== field);
}
this.setDraggingOver(target, false);
},
setDraggingOver(index, status) {
this.sections[index].isDraggingOver = status;
},
restoreFieldOrder(field) {
this.availableFields = [...new Set([...this.originalOrder.filter(f => !this.availableFields.includes(f)), ...this.availableFields])];
}
}));
});
</script>
</body>