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:
parent
6a18615a84
commit
1ae0420656
|
|
@ -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,10 +128,11 @@ 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',
|
||||||
components: { draggable, OpenFormField, OpenFormButton, VueHcaptcha },
|
components: {draggable, OpenFormField, OpenFormButton, VueHcaptcha},
|
||||||
props: {
|
props: {
|
||||||
form: {
|
form: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|
@ -152,15 +154,15 @@ export default {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
defaultDataForm:{},
|
defaultDataForm: {},
|
||||||
adminPreview: { type: Boolean, default: false }, // If used in FormEditorPreview
|
adminPreview: {type: Boolean, default: false}, // If used in FormEditorPreview
|
||||||
darkMode: {
|
darkMode: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setup (props) {
|
setup(props) {
|
||||||
const recordsStore = useRecordsStore()
|
const recordsStore = useRecordsStore()
|
||||||
const workingFormStore = useWorkingFormStore()
|
const workingFormStore = useWorkingFormStore()
|
||||||
const dataForm = ref(useForm())
|
const dataForm = ref(useForm())
|
||||||
|
|
@ -169,32 +171,29 @@ export default {
|
||||||
dataForm,
|
dataForm,
|
||||||
recordsStore,
|
recordsStore,
|
||||||
workingFormStore,
|
workingFormStore,
|
||||||
|
draggingNewBlock: computed(() => workingFormStore.draggingNewBlock),
|
||||||
pendingSubmission: pendingSubmission(props.form)
|
pendingSubmission: pendingSubmission(props.form)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
data () {
|
data() {
|
||||||
return {
|
return {
|
||||||
currentFieldGroupIndex: 0,
|
currentFieldGroupIndex: 0,
|
||||||
/**
|
/**
|
||||||
* 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
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
hCaptchaSiteKey () {
|
hCaptchaSiteKey() {
|
||||||
return useRuntimeConfig().public.hCaptchaSiteKey
|
return useRuntimeConfig().public.hCaptchaSiteKey
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Create field groups (or Page) using page breaks if any
|
* Create field groups (or Page) using page breaks if any
|
||||||
*/
|
*/
|
||||||
fieldGroups () {
|
fieldGroups() {
|
||||||
if (!this.fields) return []
|
if (!this.fields) return []
|
||||||
const groups = []
|
const groups = []
|
||||||
let currentGroup = []
|
let currentGroup = []
|
||||||
|
|
@ -209,7 +208,7 @@ export default {
|
||||||
groups.push(currentGroup)
|
groups.push(currentGroup)
|
||||||
return groups
|
return groups
|
||||||
},
|
},
|
||||||
formProgress () {
|
formProgress() {
|
||||||
const requiredFields = this.fields.filter(field => field.required)
|
const requiredFields = this.fields.filter(field => field.required)
|
||||||
if (requiredFields.length === 0) {
|
if (requiredFields.length === 0) {
|
||||||
return 100
|
return 100
|
||||||
|
|
@ -219,10 +218,10 @@ export default {
|
||||||
return Math.round(progress)
|
return Math.round(progress)
|
||||||
},
|
},
|
||||||
currentFields: {
|
currentFields: {
|
||||||
get () {
|
get() {
|
||||||
return this.fieldGroups[this.currentFieldGroupIndex]
|
return this.fieldGroups[this.currentFieldGroupIndex]
|
||||||
},
|
},
|
||||||
set (val) {
|
set(val) {
|
||||||
// On re-order from the form, set the new order
|
// On re-order from the form, set the new order
|
||||||
// Add the previous groups and next to val, and set the properties on working form
|
// Add the previous groups and next to val, and set the properties on working form
|
||||||
const newFields = []
|
const newFields = []
|
||||||
|
|
@ -242,14 +241,14 @@ export default {
|
||||||
/**
|
/**
|
||||||
* Returns the page break block for the current group of fields
|
* Returns the page break block for the current group of fields
|
||||||
*/
|
*/
|
||||||
currentFieldsPageBreak () {
|
currentFieldsPageBreak() {
|
||||||
// Last block from current group
|
// Last block from current group
|
||||||
if (!this.currentFields?.length) return null
|
if (!this.currentFields?.length) return null
|
||||||
const block = this.currentFields[this.currentFields.length - 1]
|
const block = this.currentFields[this.currentFields.length - 1]
|
||||||
if (block && block.type === 'nf-page-break') return block
|
if (block && block.type === 'nf-page-break') return block
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
previousFieldsPageBreak () {
|
previousFieldsPageBreak() {
|
||||||
if (this.currentFieldGroupIndex === 0) return null
|
if (this.currentFieldGroupIndex === 0) return null
|
||||||
const previousFields = this.fieldGroups[this.currentFieldGroupIndex - 1]
|
const previousFields = this.fieldGroups[this.currentFieldGroupIndex - 1]
|
||||||
const block = previousFields[previousFields.length - 1]
|
const block = previousFields[previousFields.length - 1]
|
||||||
|
|
@ -260,13 +259,13 @@ export default {
|
||||||
* Returns true if we're on the last page
|
* Returns true if we're on the last page
|
||||||
* @returns {boolean}xs
|
* @returns {boolean}xs
|
||||||
*/
|
*/
|
||||||
isLastPage () {
|
isLastPage() {
|
||||||
return this.currentFieldGroupIndex === (this.fieldGroups.length - 1)
|
return this.currentFieldGroupIndex === (this.fieldGroups.length - 1)
|
||||||
},
|
},
|
||||||
isPublicFormPage () {
|
isPublicFormPage() {
|
||||||
return this.$route.name === 'forms-slug'
|
return this.$route.name === 'forms-slug'
|
||||||
},
|
},
|
||||||
dataFormValue () {
|
dataFormValue() {
|
||||||
// For get values instead of Id for select/multi select options
|
// For get values instead of Id for select/multi select options
|
||||||
const data = this.dataForm.data()
|
const data = this.dataForm.data()
|
||||||
const selectionFields = this.fields.filter((field) => {
|
const selectionFields = this.fields.filter((field) => {
|
||||||
|
|
@ -289,19 +288,19 @@ export default {
|
||||||
watch: {
|
watch: {
|
||||||
form: {
|
form: {
|
||||||
deep: true,
|
deep: true,
|
||||||
handler () {
|
handler() {
|
||||||
this.initForm()
|
this.initForm()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
deep: true,
|
deep: true,
|
||||||
handler () {
|
handler() {
|
||||||
this.initForm()
|
this.initForm()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dataFormValue: {
|
dataFormValue: {
|
||||||
deep: true,
|
deep: true,
|
||||||
handler () {
|
handler() {
|
||||||
if (this.isPublicFormPage && this.form && this.form.auto_save) {
|
if (this.isPublicFormPage && this.form && this.form.auto_save) {
|
||||||
this.pendingSubmission.set(this.dataFormValue)
|
this.pendingSubmission.set(this.dataFormValue)
|
||||||
}
|
}
|
||||||
|
|
@ -309,7 +308,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted () {
|
mounted() {
|
||||||
this.initForm()
|
this.initForm()
|
||||||
if (import.meta.client && window.location.href.includes('auto_submit=true')) {
|
if (import.meta.client && window.location.href.includes('auto_submit=true')) {
|
||||||
this.isAutoSubmit = true
|
this.isAutoSubmit = true
|
||||||
|
|
@ -318,7 +317,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
submitForm () {
|
submitForm() {
|
||||||
if (this.currentFieldGroupIndex !== this.fieldGroups.length - 1) {
|
if (this.currentFieldGroupIndex !== this.fieldGroups.length - 1) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +336,7 @@ export default {
|
||||||
/**
|
/**
|
||||||
* If more than one page, show first page with error
|
* If more than one page, show first page with error
|
||||||
*/
|
*/
|
||||||
onSubmissionFailure () {
|
onSubmissionFailure() {
|
||||||
this.isAutoSubmit = false
|
this.isAutoSubmit = false
|
||||||
if (this.fieldGroups.length > 1) {
|
if (this.fieldGroups.length > 1) {
|
||||||
// Find first mistake and show page
|
// Find first mistake and show page
|
||||||
|
|
@ -364,19 +363,19 @@ export default {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getSubmissionData () {
|
async getSubmissionData() {
|
||||||
if (!this.form || !this.form.editable_submissions || !this.form.submission_id) {
|
if (!this.form || !this.form.editable_submissions || !this.form.submission_id) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
await this.recordsStore.loadRecord(
|
await this.recordsStore.loadRecord(
|
||||||
opnFetch('/forms/' + this.form.slug + '/submissions/' + this.form.submission_id).then((data) => {
|
opnFetch('/forms/' + this.form.slug + '/submissions/' + this.form.submission_id).then((data) => {
|
||||||
return { submission_id: this.form.submission_id, id: this.form.submission_id,...data.data }
|
return {submission_id: this.form.submission_id, id: this.form.submission_id, ...data.data}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return this.recordsStore.getByKey(this.form.submission_id)
|
return this.recordsStore.getByKey(this.form.submission_id)
|
||||||
},
|
},
|
||||||
async initForm () {
|
async initForm() {
|
||||||
if(this.defaultDataForm){
|
if (this.defaultDataForm) {
|
||||||
this.dataForm = useForm(this.defaultDataForm)
|
this.dataForm = useForm(this.defaultDataForm)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -437,24 +436,42 @@ export default {
|
||||||
})
|
})
|
||||||
this.dataForm = useForm(formData)
|
this.dataForm = useForm(formData)
|
||||||
},
|
},
|
||||||
previousPage () {
|
previousPage() {
|
||||||
this.currentFieldGroupIndex -= 1
|
this.currentFieldGroupIndex -= 1
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({top: 0, behavior: 'smooth'})
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
nextPage () {
|
nextPage() {
|
||||||
this.currentFieldGroupIndex += 1
|
this.currentFieldGroupIndex += 1
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({top: 0, behavior: 'smooth'})
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,14 +93,22 @@
|
||||||
>
|
>
|
||||||
</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>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { computed } from 'vue'
|
import {computed} from 'vue'
|
||||||
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
||||||
import { default as _has } from 'lodash/has'
|
import {default as _has} from 'lodash/has'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'OpenFormField',
|
name: 'OpenFormField',
|
||||||
|
|
@ -166,7 +142,7 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
adminPreview: { type: Boolean, default: false } // If used in FormEditorPreview
|
adminPreview: {type: Boolean, default: false} // If used in FormEditorPreview
|
||||||
},
|
},
|
||||||
|
|
||||||
setup(props) {
|
setup(props) {
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue