Form Editor v2.5 (#599)

* Form Editor v2.5

* Remove log debug

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala 2024-10-23 14:04:51 +05:30 committed by GitHub
parent 97c4b9db5b
commit 8a1282f4b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 169 additions and 87 deletions

View File

@ -19,6 +19,7 @@
</div> </div>
<div <div
v-else v-else
:style="inputStyle"
class="flex flex-col w-full items-center justify-center transition-colors duration-40" class="flex flex-col w-full items-center justify-center transition-colors duration-40"
:class="[ :class="[
{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled, {'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
@ -29,12 +30,18 @@
theme.fileInput.spacing.horizontal, theme.fileInput.spacing.horizontal,
theme.fileInput.spacing.vertical, theme.fileInput.spacing.vertical,
theme.fileInput.fontSize, theme.fileInput.fontSize,
theme.fileInput.minHeight theme.fileInput.minHeight,
{'border-red-500 border-2':hasError},
'focus:outline-none focus:ring-2'
]" ]"
tabindex="0"
role="button"
:aria-label="multiple ? 'Choose files or drag here' : 'Choose a file or drag here'"
@dragover.prevent="uploadDragoverEvent=true" @dragover.prevent="uploadDragoverEvent=true"
@dragleave.prevent="uploadDragoverEvent=false" @dragleave.prevent="uploadDragoverEvent=false"
@drop.prevent="onUploadDropEvent" @drop.prevent="onUploadDropEvent"
@click="openFileUpload" @click="openFileUpload"
@keydown.enter.prevent="openFileUpload"
> >
<div class="flex w-full items-center justify-center"> <div class="flex w-full items-center justify-center">
<div <div

View File

@ -19,7 +19,7 @@
'!cursor-not-allowed !bg-gray-200': disabled, '!cursor-not-allowed !bg-gray-200': disabled,
}, },
]" ]"
class="resize-y" class="resize-y block"
:name="name" :name="name"
:style="inputStyle" :style="inputStyle"
:placeholder="placeholder" :placeholder="placeholder"
@ -63,7 +63,6 @@ export default {
props: { props: {
...inputProps, ...inputProps,
maxCharLimit: {type: Number, required: false, default: null}, maxCharLimit: {type: Number, required: false, default: null},
showCharLimit: {type: Boolean, required: false, default: false},
}, },
setup(props, context) { setup(props, context) {

View File

@ -31,6 +31,8 @@
:maxlength="maxCharLimit" :maxlength="maxCharLimit"
@change="onChange" @change="onChange"
@keydown.enter.prevent="onEnterPress" @keydown.enter.prevent="onEnterPress"
@focus="onFocus"
@blur="onBlur"
> >
<template <template
@ -74,7 +76,6 @@ export default {
max: {type: Number, required: false, default: null}, max: {type: Number, required: false, default: null},
autocomplete: {type: [Boolean, String, Object], default: null}, autocomplete: {type: [Boolean, String, Object], default: null},
maxCharLimit: {type: Number, required: false, default: null}, maxCharLimit: {type: Number, required: false, default: null},
showCharLimit: {type: Boolean, required: false, default: false},
pattern: {type: String, default: null}, pattern: {type: String, default: null},
}, },

View File

@ -4,7 +4,7 @@
class="input-label" class="input-label"
:class="[ :class="[
theme.default.label, theme.default.label,
{ 'uppercase text-xs': uppercaseLabels, 'text-sm': !uppercaseLabels }, { 'uppercase text-xs': uppercaseLabels, 'text-sm/none': !uppercaseLabels },
]" ]"
> >
<slot> <slot>

View File

@ -45,7 +45,8 @@
<has-error <has-error
v-if="hasValidation && form" v-if="hasValidation && form"
:form="form" :form="form"
:field="name" :field-id="name"
:field-name="label"
/> />
</slot> </slot>
</div> </div>

View File

@ -10,7 +10,11 @@
:class="[ :class="[
theme.SelectInput.input, theme.SelectInput.input,
theme.SelectInput.borderRadius, theme.SelectInput.borderRadius,
{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed dark:!bg-gray-600 !bg-gray-200': disabled }, {
'!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed dark:!bg-gray-600 !bg-gray-200': disabled,
'focus-within:ring-2 focus-within:ring-opacity-100 focus-within:border-transparent': !hasError
},
inputClass inputClass
]" ]"
> >
@ -19,12 +23,14 @@
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded="true" aria-expanded="true"
aria-labelledby="listbox-label" aria-labelledby="listbox-label"
class="cursor-pointer w-full flex-grow relative" class="cursor-pointer w-full flex-grow relative focus:outline-none"
:class="[ :class="[
theme.SelectInput.spacing.horizontal, theme.SelectInput.spacing.horizontal,
theme.SelectInput.spacing.vertical theme.SelectInput.spacing.vertical
]" ]"
@click="toggleDropdown" @click="toggleDropdown"
@focus="onFocus"
@blur="onBlur"
> >
<div <div
class="flex items-center" class="flex items-center"
@ -237,12 +243,13 @@ export default {
allowCreation: {type: Boolean, default: false}, allowCreation: {type: Boolean, default: false},
disabled: {type: Boolean, default: false} disabled: {type: Boolean, default: false}
}, },
emits: ['update:modelValue', 'update-options'], emits: ['update:modelValue', 'update-options', 'focus', 'blur'],
data() { data() {
return { return {
isOpen: false, isOpen: false,
searchTerm: '', searchTerm: '',
defaultValue: this.modelValue ?? null defaultValue: this.modelValue ?? null,
isFocused: false
} }
}, },
computed: { computed: {
@ -311,11 +318,26 @@ export default {
} }
return this.modelValue === value return this.modelValue === value
}, },
onFocus(event) {
this.isFocused = true
this.$emit('focus', event)
},
onBlur(event) {
this.isFocused = false
this.$emit('blur', event)
},
toggleDropdown() { toggleDropdown() {
if (this.disabled) { if (this.disabled) {
this.isOpen = false this.isOpen = false
} else { } else {
this.isOpen = !this.isOpen this.isOpen = !this.isOpen
if (this.isOpen) {
this.onFocus()
} else {
this.onBlur()
}
} }
if (!this.isOpen) { if (!this.isOpen) {
this.searchTerm = '' this.searchTerm = ''

View File

@ -88,6 +88,14 @@ export function useFormInput(props, context, options = {}) {
return wrapperProps return wrapperProps
}) })
const onFocus = (event) => {
context.emit('focus', event)
}
const onBlur = (event) => {
context.emit('blur', event)
}
// Watch for changes in props.modelValue and update the local content // Watch for changes in props.modelValue and update the local content
watch( watch(
() => props.modelValue, () => props.modelValue,
@ -104,5 +112,7 @@ export function useFormInput(props, context, options = {}) {
hasValidation, hasValidation,
hasError, hasError,
inputWrapperProps, inputWrapperProps,
onFocus,
onBlur,
} }
} }

View File

@ -2,7 +2,7 @@
<transition name="fade"> <transition name="fade">
<div <div
v-if="errorMessage" v-if="errorMessage"
class="has-error text-sm text-red-500 -bottom-3" class="has-error text-xs text-red-500 mt-1"
v-html="errorMessage" v-html="errorMessage"
/> />
</transition> </transition>
@ -10,42 +10,57 @@
<script> <script>
export default { export default {
name: "HasError", name: 'HasError',
props: { props: {
form: { form: {
type: Object, type: Object,
required: true, required: true,
}, },
field: { fieldId: {
type: String, type: String,
required: true, required: true,
}, },
fieldName: {
type: String,
required: false,
},
}, },
computed: { computed: {
errorMessage() { errorMessage() {
if (!this.form || !this.form.errors || !this.form.errors.any()) if (!this.form.errors || !this.form.errors.any())
return null return null
const subErrorsKeys = Object.keys(this.form.errors.all()).filter( const subErrorsKeys = Object.keys(this.form.errors.all()).filter(
(key) => { (key) => {
return key.startsWith(this.field) && key !== this.field return key.startsWith(this.fieldId) && key !== this.fieldId
}, },
) )
const baseError = let baseError
this.form.errors.get(this.field) ?? = this.form.errors.get(this.fieldId)
(subErrorsKeys.length ? "This field has some errors:" : null) ?? (subErrorsKeys.length ? 'This field has some errors:' : null)
// If no error and no sub errors, return // If no error and no sub errors, return
if (!baseError) return null if (!baseError)
return null
return `<p class="text-red-500">${baseError}</p><ul class="list-disc list-inside">${subErrorsKeys.map( // Check if baseError starts with "The {field.name} field" and replace if necessary
(key) => { if (baseError.startsWith(`The ${this.fieldName} field`)) {
return "<li>" + this.getSubError(key) + "</li>" baseError = baseError.replace(`The ${this.fieldName} field`, 'This field')
}, }
)}</ul>`
const coreError = `<p class='text-red-500'>${baseError}</p>`
if (subErrorsKeys.length) {
return coreError + `<ul class='list-disc list-inside'>${subErrorsKeys.map(
(key) => {
return `<li>${this.getSubError(key)}</li>`
},
)}</ul>`
}
return coreError
}, },
}, },
methods: { methods: {
getSubError(subErrorKey) { getSubError(subErrorKey) {
return this.form.errors.get(subErrorKey).replace(subErrorKey, "item") return this.form.errors.get(subErrorKey).replace(subErrorKey, 'item')
}, },
}, },
} }

View File

@ -15,9 +15,7 @@
v-if="!editing" v-if="!editing"
:content="content" :content="content"
> >
<label class="cursor-pointer truncate w-full"> {{ content }}
{{ content }}
</label>
</slot> </slot>
<div <div
v-if="editing" v-if="editing"
@ -55,7 +53,11 @@ const divHeight = ref(0)
const parentRef = ref(null) // Ref for parent element const parentRef = ref(null) // Ref for parent element
const editInputRef = ref(null) // Ref for edit input element const editInputRef = ref(null) // Ref for edit input element
const startEditing = () => { const startEditing = (event) => {
if (editing.value || (event.type === 'keydown' && event.target !== parentRef.value)) {
return
}
if (parentRef.value) { if (parentRef.value) {
divHeight.value = parentRef.value.offsetHeight divHeight.value = parentRef.value.offsetHeight
editing.value = true editing.value = true

View File

@ -10,12 +10,19 @@
:href="getFontUrl" :href="getFontUrl"
> >
<h1 <template v-if="!isHideTitle">
v-if="!isHideTitle" <EditableTag
class="mb-4 px-2" v-if="adminPreview"
:class="{'mt-4':isEmbedPopup}" v-model="form.title"
v-text="form.title" element="h1"
/> class="mb-2"
/>
<h1
v-else
class="mb-2 px-2"
v-text="form.title"
/>
</template>
<div v-if="isPublicFormPage && form.is_password_protected"> <div v-if="isPublicFormPage && form.is_password_protected">
<p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2"> <p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2">

View File

@ -88,7 +88,7 @@
/> />
<has-error <has-error
:form="dataForm" :form="dataForm"
field="h-captcha-response" field-id="h-captcha-response"
/> />
</div> </div>
</template> </template>

View File

@ -19,7 +19,7 @@
> >
<div <div
v-if="adminPreview" v-if="adminPreview"
class="absolute translate-y-full lg:translate-y-0 -bottom-1.5 left-1/2 -translate-x-1/2 lg:-translate-x-full lg:-left-1 lg:top-1 lg:bottom-0 hidden group-hover/nffield:block" class="absolute translate-y-full lg:translate-y-0 -bottom-1 left-1/2 -translate-x-1/2 lg:-translate-x-full lg:-left-1 lg:top-1 lg:bottom-0 hidden group-hover/nffield:block"
> >
<div <div
class="flex lg:flex-col bg-gray-100 dark:bg-gray-800 border rounded-md" class="flex lg:flex-col bg-gray-100 dark:bg-gray-800 border rounded-md"
@ -68,7 +68,7 @@
v-if="field.type === 'nf-text' && field.content" v-if="field.type === 'nf-text' && field.content"
:id="field.id" :id="field.id"
:key="field.id" :key="field.id"
class="nf-text w-full mb-3" class="nf-text w-full my-1.5"
:class="[getFieldAlignClasses(field)]" :class="[getFieldAlignClasses(field)]"
v-html="field.content" v-html="field.content"
/> />
@ -76,7 +76,7 @@
v-if="field.type === 'nf-code' && field.content" v-if="field.type === 'nf-code' && field.content"
:id="field.id" :id="field.id"
:key="field.id" :key="field.id"
class="nf-code w-full px-2 mb-3" class="nf-code w-full px-2 my-1.5"
v-html="field.content" v-html="field.content"
/> />
<div <div
@ -91,6 +91,7 @@
:key="field.id" :key="field.id"
class="my-4 w-full px-2" class="my-4 w-full px-2"
:class="[getFieldAlignClasses(field)]" :class="[getFieldAlignClasses(field)]"
@dblclick="editFieldOptions"
> >
<div <div
v-if="!field.image_block" v-if="!field.image_block"
@ -115,7 +116,7 @@
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" 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 <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" 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 min-h-[40px]"
> >
<Icon <Icon
name="clarity:drag-handle-line" name="clarity:drag-handle-line"

View File

@ -4,6 +4,13 @@
id="form-editor" id="form-editor"
class="relative flex w-full flex-col grow max-h-screen" class="relative flex w-full flex-col grow max-h-screen"
> >
<!-- Loading overlay -->
<div
v-if="updateFormLoading"
class="absolute inset-0 bg-white bg-opacity-70 z-50 flex items-center justify-center"
>
<loader class="h-6 w-6 text-blue-500" />
</div>
<div <div
class="border-b bg-white md:hidden fixed inset-0 w-full z-50 flex flex-col items-center justify-center" class="border-b bg-white md:hidden fixed inset-0 w-full z-50 flex flex-col items-center justify-center"
> >
@ -175,6 +182,7 @@ export default {
mounted() { mounted() {
this.$emit("mounted") this.$emit("mounted")
this.workingFormStore.activeTab = 0
useAmplitude().logEvent('form_editor_viewed') useAmplitude().logEvent('form_editor_viewed')
this.appStore.hideNavbar() this.appStore.hideNavbar()
if (!this.isEdit) { if (!this.isEdit) {
@ -292,6 +300,8 @@ export default {
slug: this.createdFormSlug, slug: this.createdFormSlug,
new_form: response.users_first_form, new_form: response.users_first_form,
}, },
}).then(() => {
this.updateFormLoading = false
}) })
}) })
.catch((error) => { .catch((error) => {
@ -304,8 +314,6 @@ export default {
) )
captureException(error) captureException(error)
} }
})
.finally(() => {
this.updateFormLoading = false this.updateFormLoading = false
}) })
}, },

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="w-full border-b p-2 flex gap-x-3 items-center bg-white"> <div class="w-full border-b p-2 flex gap-x-2 items-center bg-white">
<a <a
v-if="backButton" v-if="backButton"
href="#" href="#"
@ -13,38 +13,39 @@
</a> </a>
<EditableTag <UTabs
v-model="form.title" id="form-editor-navbar-tabs"
element="h3" v-model="activeTab"
class="font-medium py-1 text-md w-48 text-gray-500 truncate" :items="[
{ label: 'Build' },
{ label: 'Design'},
{ label: 'Settings'}
]"
/> />
<UBadge
v-if="form.visibility == 'draft'"
color="yellow"
variant="soft"
label="Draft"
/>
<UBadge
v-else-if="form.visibility == 'closed'"
color="gray"
variant="soft"
label="Closed"
/>
<UndoRedo />
<div class="flex-grow flex justify-center"> <div class="flex-grow flex justify-center">
<UTabs <EditableTag
v-model="activeTab" id="form-editor-title"
:items="[ v-model="form.title"
{ label: 'Build' }, element="h3"
{ label: 'Design'}, class="font-medium py-1 text-md w-48 text-gray-500 truncate form-editor-title"
{ label: 'Settings'} />
]" <UBadge
v-if="form.visibility == 'draft'"
color="yellow"
variant="soft"
label="Draft"
/>
<UBadge
v-else-if="form.visibility == 'closed'"
color="gray"
variant="soft"
label="Closed"
/> />
</div> </div>
<UndoRedo />
<div <div
class="flex items-stretch gap-x-2" class="flex items-stretch gap-x-2"
> >

View File

@ -52,7 +52,7 @@
<UTooltip :text="element.hidden ? 'Show Block' : 'Hide Block'"> <UTooltip :text="element.hidden ? 'Show Block' : 'Hide Block'">
<button <button
class="hidden cursor-pointer rounded p-1 transition-colors hover:bg-nt-blue-lighter items-center justify-center" class="hidden !cursor-pointer rounded p-1 transition-colors hover:bg-nt-blue-lighter items-center justify-center"
:class="{ :class="{
'text-gray-300 hover:text-blue-500 md:group-hover:flex': !element.hidden, 'text-gray-300 hover:text-blue-500 md:group-hover:flex': !element.hidden,
'text-gray-300 hover:text-gray-500 md:flex': element.hidden, 'text-gray-300 hover:text-gray-500 md:flex': element.hidden,

View File

@ -18,7 +18,7 @@
</div> </div>
<div class="py-2 px-4"> <div class="py-2 px-4">
<p class="text-sm font-medium my-2"> <p class="text-gray-500 text-xs font-medium my-2">
Input Blocks Input Blocks
</p> </p>
<draggable <draggable
@ -34,7 +34,7 @@
> >
<template #item="{element}"> <template #item="{element}">
<div <div
class="flex hover:bg-gray-50 rounded-md items-center gap-2 p-2" class="flex hover:bg-gray-50 rounded-md items-center gap-2 p-2 group"
role="button" role="button"
@click.prevent="addBlock(element.name)" @click.prevent="addBlock(element.name)"
> >

View File

@ -22,7 +22,7 @@
<div class="bg-red-500 rounded-full w-2.5 h-2.5" /> <div class="bg-red-500 rounded-full w-2.5 h-2.5" />
<div class="bg-yellow-500 rounded-full w-2.5 h-2.5" /> <div class="bg-yellow-500 rounded-full w-2.5 h-2.5" />
<div class="bg-green-500 rounded-full w-2.5 h-2.5" /> <div class="bg-green-500 rounded-full w-2.5 h-2.5" />
<p class="text-sm text-gray-500/70 text-sm ml-4"> <p class="text-sm text-gray-500/70 text-sm ml-4 select-none">
Form Preview Form Preview
</p> </p>
<div class="flex-grow" /> <div class="flex-grow" />

View File

@ -99,7 +99,7 @@
<has-error <has-error
v-if="hasValidation" v-if="hasValidation"
:form="form" :form="form"
:field="name" :field-id="name"
/> />
</div> </div>
</template> </template>

View File

@ -53,7 +53,7 @@
<div <div
v-if="field.type == 'nf-text'" v-if="field.type == 'nf-text'"
class="border-t py-2" class="border-t mt-4"
> >
<rich-text-area-input <rich-text-area-input
class="mx-4" class="mx-4"
@ -85,7 +85,7 @@
<div <div
v-else-if="field.type == 'nf-image'" v-else-if="field.type == 'nf-image'"
class="border-t py-2" class="border-t mt-4"
> >
<image-input <image-input
name="image_block" name="image_block"

View File

@ -7,7 +7,7 @@
:class="[ :class="[
option.class ? (typeof option.class === 'function' ? option.class(isSelected(option.name)) : option.class) : {}, option.class ? (typeof option.class === 'function' ? option.class(isSelected(option.name)) : option.class) : {},
{ {
'border-blue-500': isSelected(option.name), 'border-blue-500 bg-blue-50': isSelected(option.name),
'hover:bg-gray-100 border-gray-300': !isSelected(option.name) 'hover:bg-gray-100 border-gray-300': !isSelected(option.name)
} }
]" ]"

View File

@ -5,9 +5,9 @@ export const themes = {
default: { default: {
default: { default: {
wrapper: { wrapper: {
sm: 'relative mb-2', sm: 'relative my-1',
md: 'relative mb-3', md: 'relative my-1.5',
lg: 'relative mb-3', lg: 'relative my-1.5',
}, },
label: 'text-gray-700 dark:text-gray-300 font-medium', label: 'text-gray-700 dark:text-gray-300 font-medium',
input: input:
@ -156,9 +156,9 @@ export const themes = {
simple: { simple: {
default: { default: {
wrapper: { wrapper: {
sm: 'relative mb-2', sm: 'relative my-1',
md: 'relative mb-3', md: 'relative my-1.5',
lg: 'relative mb-3', lg: 'relative my-1.5',
}, },
label: 'text-gray-700 dark:text-gray-300 font-medium', label: 'text-gray-700 dark:text-gray-300 font-medium',
input: input:
@ -301,9 +301,9 @@ export const themes = {
notion: { notion: {
default: { default: {
wrapper: { wrapper: {
sm: 'relative mb-2', sm: 'relative my-1',
md: 'relative mb-3', md: 'relative my-1.5',
lg: 'relative mb-3', lg: 'relative my-1.5',
}, },
label: 'text-gray-900 dark:text-gray-100 mb-1 block mt-4', label: 'text-gray-900 dark:text-gray-100 mb-1 block mt-4',
input: input:

View File

@ -23,7 +23,7 @@ body.dark * {
} }
h1 { h1 {
@apply text-3xl sm:text-4xl font-semibold; @apply text-2xl sm:text-3xl font-extrabold;
} }
h2 { h2 {

View File

@ -73,6 +73,14 @@ export const useWorkingFormStore = defineStore("working_form", {
}, },
prefillDefault(data) { prefillDefault(data) {
// If a field already has this name, we need to make it unique with a number at the end
let baseName = data.name
let counter = 1
while (this.content.properties.some(prop => prop.name === data.name)) {
counter++
data.name = `${baseName} ${counter}`
}
if (data.type === "nf-text") { if (data.type === "nf-text") {
data.content = "<p>This is a text block.</p>" data.content = "<p>This is a text block.</p>"
} else if (data.type === "nf-page-break") { } else if (data.type === "nf-page-break") {