drag and drop to add block (#404)

* drag and drop to add  block

* Change styling for drag & drop

* Improve dragging/reordering fields

* fix drag dropped bug

* Fix spacing between form elements

* fix sorting bug

* fix: move field

* fix page break bugs

* fix move and add logic implementation

* Changed cursor to grab

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Favour Olayinka 2024-05-13 13:47:59 +01:00 committed by GitHub
parent 6a18615a84
commit 1ae0420656
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 280 additions and 282 deletions

View File

@ -43,15 +43,16 @@
class="form-group flex flex-wrap w-full" class="form-group flex flex-wrap w-full"
> >
<draggable <draggable
v-model="currentFields" :list="currentFields"
group="form-elements"
item-key="id" item-key="id"
class="flex flex-wrap transition-all w-full" class="grid grid-cols-12 gap-x-3 relative transition-all w-full"
:class="{'-m-6 p-2 bg-gray-50 rounded-md':dragging}" :class="{'rounded-md bg-blue-50':draggingNewBlock}"
ghost-class="ghost-item" ghost-class="ghost-item"
handle=".draggable"
:animation="200" :animation="200"
@start="onDragStart" :disabled="!adminPreview"
@end="onDragEnd" handle=".handle"
@change="handleDragDropped"
> >
<template #item="{element}"> <template #item="{element}">
<open-form-field <open-form-field
@ -127,6 +128,7 @@ import VueHcaptcha from "@hcaptcha/vue3-hcaptcha"
import OpenFormField from './OpenFormField.vue' import OpenFormField from './OpenFormField.vue'
import {pendingSubmission} from "~/composables/forms/pendingSubmission.js" import {pendingSubmission} from "~/composables/forms/pendingSubmission.js"
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js" import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
import {computed} from "vue"
export default { export default {
name: 'OpenForm', name: 'OpenForm',
@ -169,6 +171,7 @@ export default {
dataForm, dataForm,
recordsStore, recordsStore,
workingFormStore, workingFormStore,
draggingNewBlock: computed(() => workingFormStore.draggingNewBlock),
pendingSubmission: pendingSubmission(props.form) pendingSubmission: pendingSubmission(props.form)
} }
}, },
@ -180,10 +183,6 @@ export default {
* Used to force refresh components by changing their keys * Used to force refresh components by changing their keys
*/ */
isAutoSubmit: false, isAutoSubmit: false,
/**
* If currently dragging a field
*/
dragging: false
} }
}, },
@ -450,11 +449,29 @@ export default {
isFieldHidden(field) { isFieldHidden(field) {
return (new FormLogicPropertyResolver(field, this.dataFormValue)).isHidden() return (new FormLogicPropertyResolver(field, this.dataFormValue)).isHidden()
}, },
onDragStart () { getTargetFieldIndex(currentFieldPageIndex){
this.dragging = true let targetIndex = 0;
if (this.currentFieldGroupIndex > 0) {
for (let i = 0; i < this.currentFieldGroupIndex; i++) {
targetIndex += this.fieldGroups[i].length;
}
targetIndex += currentFieldPageIndex;
} else {
targetIndex = currentFieldPageIndex
}
return targetIndex
}, },
onDragEnd () { handleDragDropped(data) {
this.dragging = false if (data.added) {
const targetIndex = this.getTargetFieldIndex(data.added.newIndex)
this.workingFormStore.addBlock(data.added.element, targetIndex)
}
if (data.moved) {
const oldTargetIndex = this.getTargetFieldIndex(data.moved.oldIndex)
const newTargetIndex = this.getTargetFieldIndex(data.moved.newIndex)
this.workingFormStore.moveField(oldTargetIndex, newTargetIndex)
}
} }
} }
} }

View File

@ -2,76 +2,44 @@
<div <div
v-if="!isFieldHidden" v-if="!isFieldHidden"
:id="'block-' + field.id" :id="'block-' + field.id"
:class="getFieldWidthClasses(field)" :class="[
getFieldWidthClasses(field),
{
'group/nffield hover:bg-gray-100/50 relative hover:z-10 w-[calc(100%+30px)] mx-[-15px] px-[15px] transition-colors hover:border-gray-200 dark:hover:bg-gray-900 border-dashed border border-transparent box-border dark:hover:border-blue-900 rounded-md':adminPreview,
'bg-blue-50 hover:!bg-blue-50 dark:bg-gray-800 rounded-md': beingEdited
}]"
>
<div
class="-m-[1px] w-full max-w-full mx-auto"
:class="{'relative transition-colors':adminPreview}"
> >
<div :class="getFieldClasses(field)">
<div <div
v-if="adminPreview" v-if="adminPreview"
class="absolute -translate-x-full top-0 bottom-0 opacity-0 group-hover/nffield:opacity-100 transition-opacity mb-4" class="absolute -translate-x-full -left-1 top-1 bottom-0 hidden group-hover/nffield:block"
> >
<div <div
class="flex flex-col bg-white rounded-md" class="flex flex-col -space-1 bg-white rounded-md shadow -mt-1"
:class="{ 'lg:flex-row': !fieldSideBarOpened, 'xl:flex-row': fieldSideBarOpened }" :class="{ 'lg:flex-row lg:-space-x-2': !fieldSideBarOpened, 'xl:flex-row xl:-space-x-1': fieldSideBarOpened }"
> >
<div <div
class="p-2 -mr-3 -mb-2 text-gray-300 hover:text-blue-500 cursor-pointer hidden xl:block" class="p-1 -mb-2 text-gray-300 hover:text-blue-500 cursor-pointer"
role="button" role="button"
:class="{ 'lg:block': !fieldSideBarOpened, 'xl:block': fieldSideBarOpened }"
@click.prevent="openAddFieldSidebar" @click.prevent="openAddFieldSidebar"
> >
<svg <Icon
xmlns="http://www.w3.org/2000/svg" name="heroicons:plus-16-solid"
fill="none" class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="3"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/> />
</svg>
</div> </div>
<div <div
class="p-2 text-gray-300 hover:text-blue-500 cursor-pointer" class="p-1 text-gray-300 hover:text-blue-500 cursor-pointer text-center"
role="button" role="button"
:class="{ 'lg:-mr-2': !fieldSideBarOpened, 'xl:-mr-2': fieldSideBarOpened }"
@click.prevent="editFieldOptions" @click.prevent="editFieldOptions"
> >
<svg <Icon
xmlns="http://www.w3.org/2000/svg" name="heroicons:cog-8-tooth-20-solid"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5" class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M11.828 2.25c-.916 0-1.699.663-1.85 1.567l-.091.549a.798.798 0 01-.517.608 7.45 7.45 0 00-.478.198.798.798 0 01-.796-.064l-.453-.324a1.875 1.875 0 00-2.416.2l-.243.243a1.875 1.875 0 00-.2 2.416l.324.453a.798.798 0 01.064.796 7.448 7.448 0 00-.198.478.798.798 0 01-.608.517l-.55.092a1.875 1.875 0 00-1.566 1.849v.344c0 .916.663 1.699 1.567 1.85l.549.091c.281.047.508.25.608.517.06.162.127.321.198.478a.798.798 0 01-.064.796l-.324.453a1.875 1.875 0 00.2 2.416l.243.243c.648.648 1.67.733 2.416.2l.453-.324a.798.798 0 01.796-.064c.157.071.316.137.478.198.267.1.47.327.517.608l.092.55c.15.903.932 1.566 1.849 1.566h.344c.916 0 1.699-.663 1.85-1.567l.091-.549a.798.798 0 01.517-.608 7.52 7.52 0 00.478-.198.798.798 0 01.796.064l.453.324a1.875 1.875 0 002.416-.2l.243-.243c.648-.648.733-1.67.2-2.416l-.324-.453a.798.798 0 01-.064-.796c.071-.157.137-.316.198-.478.1-.267.327-.47.608-.517l.55-.091a1.875 1.875 0 001.566-1.85v-.344c0-.916-.663-1.699-1.567-1.85l-.549-.091a.798.798 0 01-.608-.517 7.507 7.507 0 00-.198-.478.798.798 0 01.064-.796l.324-.453a1.875 1.875 0 00-.2-2.416l-.243-.243a1.875 1.875 0 00-2.416-.2l-.453.324a.798.798 0 01-.796.064 7.462 7.462 0 00-.478-.198.798.798 0 01-.517-.608l-.091-.55a1.875 1.875 0 00-1.85-1.566h-.344zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z"
clip-rule="evenodd"
/> />
</svg>
</div>
<div
class="px-2 xl:pl-0 lg:pr-1 lg:pt-2 pb-2 bg-white rounded-md text-gray-300 hover:text-gray-500 cursor-grab draggable"
:class="{ 'lg:pr-1 lg:pl-0': !fieldSideBarOpened, 'xl:-mr-2': fieldSideBarOpened }"
role="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</div> </div>
</div> </div>
</div> </div>
@ -125,6 +93,14 @@
> >
</div> </div>
</template> </template>
<div class="hidden group-hover/nffield:flex translate-x-full absolute right-0 top-0 h-full w-5 flex-col justify-center pl-1 pt-3">
<div class="flex items-center bg-gray-100 dark:bg-gray-800 border rounded-md h-12 text-gray-500 dark:text-gray-400 dark:border-gray-500 cursor-grab handle">
<Icon
name="clarity:drag-handle-line"
class="h-6 w-6 -ml-1 block shrink-0"
/>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -271,32 +247,18 @@ export default {
openAddFieldSidebar() { openAddFieldSidebar() {
this.workingFormStore.openAddFieldSidebar(this.field) this.workingFormStore.openAddFieldSidebar(this.field)
}, },
/**
* Get the right input component for the field/options combination
*/
getFieldClasses() {
let classes = ''
if (this.adminPreview) {
classes += '-mx-4 px-4 -my-1 py-1 group/nffield relative transition-colors'
if (this.beingEdited) {
classes += ' bg-blue-50 dark:bg-gray-800 rounded-md'
}
}
return classes
},
getFieldWidthClasses(field) { getFieldWidthClasses(field) {
if (!field.width || field.width === 'full') return 'w-full px-2' if (!field.width || field.width === 'full') return 'col-span-full'
else if (field.width === '1/2') { else if (field.width === '1/2') {
return 'w-full sm:w-1/2 px-2' return 'w-full sm:col-span-6 col-span-full'
} else if (field.width === '1/3') { } else if (field.width === '1/3') {
return 'w-full sm:w-1/3 px-2' return 'w-full sm:col-span-4 col-span-full'
} else if (field.width === '2/3') { } else if (field.width === '2/3') {
return 'w-full sm:w-2/3 px-2' return 'w-full sm:col-span-8 col-span-full'
} else if (field.width === '1/4') { } else if (field.width === '1/4') {
return 'w-full sm:w-1/4 px-2' return 'w-full sm:col-span-3 col-span-full'
} else if (field.width === '3/4') { } else if (field.width === '3/4') {
return 'w-full sm:w-3/4 px-2' return 'w-full sm:col-span-9 col-span-full'
} }
}, },
getFieldAlignClasses(field) { getFieldAlignClasses(field) {

View File

@ -21,7 +21,7 @@
/> />
</svg> </svg>
</button> </button>
<div class="font-semibold inline ml-2 truncate flex-grow truncate"> <div class="font-semibold inline ml-2 flex-grow truncate">
Add Block Add Block
</div> </div>
</div> </div>
@ -32,13 +32,22 @@
<p class="text-gray-500 uppercase text-xs font-semibold mb-2"> <p class="text-gray-500 uppercase text-xs font-semibold mb-2">
Input Blocks Input Blocks
</p> </p>
<div class="grid grid-cols-2 gap-2"> <draggable
:list="inputBlocks"
:group="{ name: 'form-elements', pull: 'clone', put: false }"
class="grid grid-cols-2 gap-2"
:sort="false"
:clone="handleInputClone"
ghost-class="ghost-item"
item-key="id"
@start="workingFormStore.draggingNewBlock=true"
@end="workingFormStore.draggingNewBlock=false"
>
<template #item="{element}">
<div <div
v-for="(block) in inputBlocks" class="bg-gray-50 border cursor-grab hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
:key="block.name"
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
role="button" role="button"
@click.prevent="addBlock(block.name)" @click.prevent="addBlock(element.name)"
> >
<div class="mx-auto"> <div class="mx-auto">
<svg <svg
@ -48,28 +57,36 @@
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
v-html="block.icon" v-html="element.icon"
/> />
</div> </div>
<p <p
class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1" class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1"
> >
{{ block.title }} {{ element.title }}
</p> </p>
</div> </div>
</div> </template>
</draggable>
</div> </div>
<div class="border-t mt-6"> <div class="border-t mt-6">
<p class="text-gray-500 uppercase text-xs font-semibold mb-2 mt-6"> <p class="text-gray-500 uppercase text-xs font-semibold mb-2 mt-6">
Layout Blocks Layout Blocks
</p> </p>
<div class="grid grid-cols-2 gap-2"> <draggable
:list="layoutBlocks"
:group="{ name: 'form-elements', pull: 'clone', put: false }"
class="grid grid-cols-2 gap-2"
:sort="false"
:clone="handleInputClone"
ghost-class="ghost-item"
item-key="id"
>
<template #item="{element}">
<div <div
v-for="(block) in layoutBlocks"
:key="block.name"
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col" class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
role="button" role="button"
@click.prevent="addBlock(block.name)" @click.prevent="addBlock(element.name)"
> >
<div class="mx-auto"> <div class="mx-auto">
<svg <svg
@ -79,28 +96,29 @@
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
v-html="block.icon" v-html="element.icon"
/> />
</div> </div>
<p <p
class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1" class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1"
> >
{{ block.title }} {{ element.title }}
</p> </p>
</div> </div>
</div> </template>
</draggable>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import clonedeep from "clone-deep" import draggable from 'vuedraggable'
import { computed } from "vue" import { computed } from "vue"
export default { export default {
name: "AddFormBlock", name: "AddFormBlock",
components: {}, components: {draggable},
props: {}, props: {},
setup() { setup() {
@ -115,7 +133,6 @@ export default {
data() { data() {
return { return {
blockForm: null,
inputBlocks: [ inputBlocks: [
{ {
name: "text", name: "text",
@ -219,126 +236,30 @@ export default {
}, },
computed: { computed: {
defaultBlockNames() {
return {
text: "Your name",
date: "Date",
url: "Link",
phone_number: "Phone Number",
number: "Number",
rating: "Rating",
scale: "Scale",
slider: "Slider",
email: "Email",
checkbox: "Checkbox",
select: "Select",
multi_select: "Multi Select",
files: "Files",
signature: "Signature",
"nf-text": "Text Block",
"nf-page-break": "Page Break",
"nf-divider": "Divider",
"nf-image": "Image",
"nf-code": "Code Block",
}
},
}, },
watch: {}, watch: {},
mounted() { mounted() {
this.reset() this.workingFormStore.resetBlockForm()
}, },
methods: { methods: {
closeSidebar() { closeSidebar() {
this.workingFormStore.closeAddFieldSidebar() this.workingFormStore.closeAddFieldSidebar()
}, },
reset() {
this.blockForm = useForm({
type: null,
name: null,
})
},
addBlock(type) { addBlock(type) {
this.blockForm.type = type this.workingFormStore.addBlock(type)
this.blockForm.name = this.defaultBlockNames[type]
const newBlock = this.prefillDefault(this.blockForm.data())
newBlock.id = this.generateUUID()
newBlock.hidden = false
if (["select", "multi_select"].includes(this.blockForm.type)) {
newBlock[this.blockForm.type] = { options: [] }
}
if (this.blockForm.type === "rating") {
newBlock.rating_max_value = 5
}
if (this.blockForm.type === "scale") {
newBlock.scale_min_value = 1
newBlock.scale_max_value = 5
newBlock.scale_step_value = 1
}
if (this.blockForm.type === "slider") {
newBlock.slider_min_value = 0
newBlock.slider_max_value = 50
newBlock.slider_step_value = 1
}
newBlock.help_position = "below_input"
if (
this.selectedFieldIndex === null ||
this.selectedFieldIndex === undefined
) {
const newFields = clonedeep(this.form.properties)
newFields.push(newBlock)
this.form.properties = newFields
this.workingFormStore.openSettingsForField(
this.form.properties.length - 1,
)
} else {
const newFields = clonedeep(this.form.properties)
newFields.splice(this.selectedFieldIndex + 1, 0, newBlock)
this.form.properties = newFields
this.workingFormStore.openSettingsForField(this.selectedFieldIndex + 1)
}
this.reset()
}, },
generateUUID() { handleInputClone(item) {
let d = new Date().getTime() // Timestamp return item.name
let d2 =
(typeof performance !== "undefined" &&
performance.now &&
performance.now() * 1000) ||
0 // Time in microseconds since page-load or 0 if unsupported
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
function (c) {
let r = Math.random() * 16 // random number between 0 and 16
if (d > 0) {
// Use timestamp until depleted
r = (d + r) % 16 | 0
d = Math.floor(d / 16)
} else {
// Use microseconds since page-load if supported
r = (d2 + r) % 16 | 0
d2 = Math.floor(d2 / 16)
} }
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16)
},
)
},
prefillDefault(data) {
if (data.type === "nf-text") {
data.content = "<p>This is a text block.</p>"
} else if (data.type === "nf-page-break") {
data.next_btn_text = "Next"
data.previous_btn_text = "Previous"
} else if (data.type === "nf-code") {
data.content =
'<div class="text-blue-500 italic">This is a code block.</div>'
} else if (data.type === "signature") {
data.help = "Draw your signature above"
}
return data
},
}, },
} }
</script> </script>
<style lang='scss' scoped>
.ghost-item {
@apply bg-blue-100 dark:bg-blue-900 rounded-md w-full col-span-full;
}
</style>

View File

@ -1,4 +1,28 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import clonedeep from "clone-deep"
import { generateUUID } from "~/lib/utils.js"
const defaultBlockNames = {
text: "Your name",
date: "Date",
url: "Link",
phone_number: "Phone Number",
number: "Number",
rating: "Rating",
scale: "Scale",
slider: "Slider",
email: "Email",
checkbox: "Checkbox",
select: "Select",
multi_select: "Multi Select",
files: "Files",
signature: "Signature",
"nf-text": "Text Block",
"nf-page-break": "Page Break",
"nf-divider": "Divider",
"nf-image": "Image",
"nf-code": "Code Block",
}
export const useWorkingFormStore = defineStore("working_form", { export const useWorkingFormStore = defineStore("working_form", {
state: () => ({ state: () => ({
@ -8,6 +32,8 @@ export const useWorkingFormStore = defineStore("working_form", {
selectedFieldIndex: null, selectedFieldIndex: null,
showEditFieldSidebar: null, showEditFieldSidebar: null,
showAddFieldSidebar: null, showAddFieldSidebar: null,
blockForm: null,
draggingNewBlock: false,
}), }),
actions: { actions: {
set(form) { set(form) {
@ -54,5 +80,77 @@ export const useWorkingFormStore = defineStore("working_form", {
this.showEditFieldSidebar = null this.showEditFieldSidebar = null
this.showAddFieldSidebar = null this.showAddFieldSidebar = null
}, },
resetBlockForm() {
this.blockForm = useForm({
type: null,
name: null,
})
},
prefillDefault(data) {
if (data.type === "nf-text") {
data.content = "<p>This is a text block.</p>"
} else if (data.type === "nf-page-break") {
data.next_btn_text = "Next"
data.previous_btn_text = "Previous"
} else if (data.type === "nf-code") {
data.content =
'<div class="text-blue-500 italic">This is a code block.</div>'
} else if (data.type === "signature") {
data.help = "Draw your signature above"
}
return data
},
addBlock(type, index = null) {
this.blockForm.type = type
this.blockForm.name = defaultBlockNames[type]
const newBlock = this.prefillDefault(this.blockForm.data())
newBlock.id = generateUUID()
newBlock.hidden = false
if (["select", "multi_select"].includes(this.blockForm.type)) {
newBlock[this.blockForm.type] = { options: [] }
}
if (this.blockForm.type === "rating") {
newBlock.rating_max_value = 5
}
if (this.blockForm.type === "scale") {
newBlock.scale_min_value = 1
newBlock.scale_max_value = 5
newBlock.scale_step_value = 1
}
if (this.blockForm.type === "slider") {
newBlock.slider_min_value = 0
newBlock.slider_max_value = 50
newBlock.slider_step_value = 1
}
newBlock.help_position = "below_input"
if (
this.selectedFieldIndex === null ||
this.selectedFieldIndex === undefined
) {
const newFields = clonedeep(this.content.properties)
newFields.push(newBlock)
this.content.properties = newFields
this.openSettingsForField(
this.form.properties.length - 1,
)
} else {
const fieldIndex = typeof index === "number" ? index : this.selectedFieldIndex + 1
const newFields = clonedeep(this.content.properties)
newFields.splice(fieldIndex, 0, newBlock)
this.content.properties = newFields
this.openSettingsForField(fieldIndex)
}
},
moveField(oldIndex, newIndex) {
const newFields = clonedeep(this.content.properties)
const field = newFields.splice(oldIndex, 1)[0];
newFields.splice(newIndex, 0, field);
this.content.properties = newFields
}
}, },
}) })