Initial commit

This commit is contained in:
Julien Nahum
2022-09-20 21:59:52 +02:00
commit f8e6cd4dd6
479 changed files with 77078 additions and 0 deletions

View File

@@ -0,0 +1,239 @@
<template>
<div v-if="form" class="open-complete-form">
<h1 v-if="!form.hide_title" class="mb-4 px-2" v-text="form.title" />
<div v-if="isPublicFormPage && form.is_password_protected">
<p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2">
This form is protected by a password.
</p>
<div class="form-group flex flex-wrap w-full">
<div class="relative mb-3 w-full px-2">
<text-input :form="passwordForm" name="password" native-type="password" label="Password" />
</div>
</div>
<div class="flex flex-wrap justify-center w-full text-center">
<v-button @click="passwordEntered">
Submit
</v-button>
</div>
</div>
<v-transition>
<div v-if="!form.is_password_protected && form.password && !hidePasswordDisabledMsg"
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
>
<div class="flex flex-grow">
<p class="mb-0 py-2 px-4 text-yellow-600">
We disabled the password protection for this form because you are an owner of it.
</p>
<v-button color="yellow" @click="hidePasswordDisabledMsg=true">
OK
</v-button>
</div>
</div>
</v-transition>
<div v-if="isPublicFormPage && form.is_closed"
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
>
<div class="flex-grow">
<p class="mb-0 py-2 px-4 text-yellow-600" v-html="form.closed_text" />
</div>
</div>
<div v-if="isPublicFormPage && form.max_number_of_submissions_reached"
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
>
<div class="flex-grow">
<p class="mb-0 py-2 px-4 text-yellow-600" v-html="form.max_submissions_reached_text" />
</div>
</div>
<div v-if="getFormCleaningsMsg"
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
>
<div class="flex-grow">
<p class="mb-0 py-2 px-4 text-yellow-600">
You're seeing this because you are an owner of this form. <br>
All your Pro features are de-activated when sharing this form: <br>
<span v-html="getFormCleaningsMsg" />
</p>
</div>
<div class="text-right">
<v-button color="yellow" shade="light" @click="form.cleanings=false">
Close
</v-button>
</div>
</div>
<transition
v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached))"
enter-active-class="duration-500 ease-out"
enter-class="translate-x-full opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="duration-500 ease-in"
leave-class="translate-x-0 opacity-100"
leave-to-class="translate-x-full opacity-0"
mode="out-in"
>
<div v-if="!submitted" key="form">
<p v-if="form.description && form.description !==''"
class="form-description mb-4 text-gray-700 dark:text-gray-300 whitespace-pre-wrap px-2"
v-html="form.description"
/>
<open-form v-if="form"
:form="form"
:loading="loading"
:fields="form.properties"
:theme="theme"
@submit="submitForm"
>
<template #submit-btn="{submitForm}">
<open-form-button :loading="loading" :theme="theme" :color="form.color" class="mt-2 px-8 mx-1"
@click="submitForm"
>
{{ form.submit_button_text }}
</open-form-button>
</template>
</open-form>
<p v-if="!form.no_branding" class="text-center w-full mt-2">
<a href="https://opnform.com"
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs"
target="_blank"
>Powered by OpnForm</a>
</p>
</div>
<div v-else key="submitted" class="px-2">
<p class="form-description text-gray-700 dark:text-gray-300 whitespace-pre-wrap" v-html="form.submitted_text " />
<open-form-button v-if="form.re_fillable" :theme="theme" :color="form.color" class="my-4" @click="restart">
{{ form.re_fill_button_text }}
</open-form-button>
<p v-if="!form.no_branding" class="mt-5">
<a target="_parent" href="https://opnform.com/" class="text-nt-blue hover:underline">Create your form for free with OpnForm</a>
</p>
</div>
</transition>
</div>
</template>
<script>
import Form from 'vform'
import OpenForm from './OpenForm'
import OpenFormButton from './OpenFormButton'
import { themes } from '~/config/form-themes'
import VButton from '../../common/Button'
import VTransition from '../../common/transitions/VTransition'
export default {
components: { VTransition, VButton, OpenFormButton, OpenForm },
props: {
form: { type: Object, required: true },
creating: { type: Boolean, default: false } // If true, fake form submit
},
data () {
return {
loading: false,
submitted: false,
themes: themes,
passwordForm: new Form({
password: null
}),
hidePasswordDisabledMsg: false
}
},
computed: {
isIframe () {
return window.location !== window.parent.location || window.frameElement
},
theme () {
return this.themes[this.themes.hasOwnProperty(this.form.theme) ? this.form.theme : 'default']
},
getFormCleaningsMsg () {
if (this.form.cleanings && Object.keys(this.form.cleanings).length > 0) {
let message = ''
Object.keys(this.form.cleanings).forEach((key) => {
const fieldName = key.charAt(0).toUpperCase() + key.slice(1)
let fieldInfo = '<br/>' + fieldName + "<br/><ul class='list-disc list-inside'>"
this.form.cleanings[key].forEach((msg) => {
fieldInfo = fieldInfo + '<li>' + msg + '</li>'
})
message = message + fieldInfo + '<ul/>'
})
return message
}
return false
},
isPublicFormPage () {
return this.$route.name === 'forms.show_public'
}
},
mounted () {
},
methods: {
submitForm (form, onFailure) {
if (this.creating) {
this.submitted = true
this.$emit('submitted', true)
return
}
this.loading = true
this.closeAlert()
form.post('/api/forms/' + this.form.slug + '/answer').then((response) => {
this.$logEvent('form_submission', {
workspace_id: this.form.workspace_id,
form_id: this.form.id
})
if (response.data.redirect && response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
this.loading = false
this.submitted = true
this.$emit('submitted', true)
}).catch((error) => {
if (error.response.data && error.response.data.message) {
this.alertError(error.response.data.message)
}
this.loading = false
onFailure()
})
},
restart () {
this.submitted = false
this.$emit('restarted', true)
},
passwordEntered () {
if (this.passwordForm.password !== '' && this.passwordForm.password !== null) {
this.$emit('password-entered', this.passwordForm.password)
} else {
this.addPasswordError('The Password field is required.')
}
},
addPasswordError (msg) {
this.passwordForm.errors.set('password', msg)
}
}
}
</script>
<style lang="scss">
.open-complete-form {
.form-description {
ol {
@apply list-decimal list-inside;
}
ul {
@apply list-disc list-inside;
}
}
}
</style>

View File

@@ -0,0 +1,398 @@
<template>
<form v-if="dataForm" @submit.prevent="">
<transition name="fade" mode="out-in" appear>
<template v-for="group, groupIndex in fieldGroups">
<div v-if="currentFieldGroupIndex===groupIndex" :key="groupIndex" class="form-group flex flex-wrap w-full">
<template v-for="field in group">
<component :is="getFieldComponents(field)" v-if="getFieldComponents(field)"
:key="field.id + formVersionId" :class="getFieldClasses(field)"
v-bind="inputProperties(field)" :required="isFieldRequired[field.id]"
/>
<template v-else>
<div v-if="field.type === 'nf-text' && field.content" :id="field.id" :key="field.id" class="nf-text w-full px-2 mb-3"
v-html="field.content"
/>
<div v-if="field.type === 'nf-divider'" :id="field.id" :key="field.id" class="border-b my-4 w-full mx-2" />
<div v-if="field.type === 'nf-image' && (field.image_block || !isPublicFormPage)" :id="field.id" :key="field.id" class="my-4 w-full px-2">
<div v-if="!field.image_block" class="p-4 border border-dashed">
Open <b>{{ field.name }}'s</b> block settings to upload image.
</div>
<img v-else :alt="field.name" :src="field.image_block" class="max-w-full">
</div>
</template>
</template>
</div>
</template>
</transition>
<!-- Captcha -->
<template v-if="form.use_captcha && isLastPage">
<div class="mb-3 px-2 mt-2 mx-auto w-max">
<vue-hcaptcha ref="hcaptcha" :sitekey="hCaptchaSiteKey" :theme="darkModeEnabled?'dark':'light'" />
<has-error :form="dataForm" field="h-captcha-response" />
</div>
</template>
<!-- Submit, Next and previous buttons -->
<div class="flex flex-wrap justify-center w-full">
<open-form-button v-if="currentFieldGroupIndex>0 && previousFieldsPageBreak && !loading" native-type="button"
:color="form.color" :theme="theme" class="mt-2 px-8 mx-1" @click="previousPage"
>
{{ previousFieldsPageBreak.previous_btn_text }}
</open-form-button>
<slot v-if="isLastPage" name="submit-btn" :submitForm="submitForm" />
<open-form-button v-else native-type="button" :color="form.color" :theme="theme" class="mt-2 px-8 mx-1"
@click="nextPage"
>
{{ currentFieldsPageBreak.next_btn_text }}
</open-form-button>
<div v-if="!currentFieldsPageBreak && !isLastPage">
Something is wrong with this form structure. If you're the form owner please contact us.
</div>
</div>
</form>
</template>
<script>
import Form from 'vform'
import OpenFormButton from './OpenFormButton'
import clonedeep from 'clone-deep'
import FormLogicPropertyResolver from '../../../forms/FormLogicPropertyResolver'
const VueHcaptcha = () => import('@hcaptcha/vue-hcaptcha')
export default {
name: 'OpenForm',
components: { OpenFormButton, VueHcaptcha },
props: {
form: {
type: Object,
required: true
},
theme: {
type: Object,
required: true
},
loading: {
type: Boolean,
required: true
},
showHidden: {
type: Boolean,
default: false
},
fields: {
type: Array,
required: true
}
},
data () {
return {
dataForm: null,
currentFieldGroupIndex: 0,
/**
* Used to force refresh components by changing their keys
*/
formVersionId: 1,
darkModeEnabled: document.body.classList.contains('dark')
}
},
computed: {
hCaptchaSiteKey: () => window.config.hCaptchaSiteKey,
actualFields () {
return this.fields.filter((field) => {
return this.showHidden || !this.isFieldHidden[field.id]
})
},
/**
* Create field groups (or Page) using page breaks if any
*/
fieldGroups () {
if (!this.actualFields) return []
const groups = []
let currentGroup = []
this.actualFields.forEach((field) => {
currentGroup.push(field)
if (field.type === 'nf-page-break') {
groups.push(currentGroup)
currentGroup = []
}
})
groups.push(currentGroup)
return groups
},
currentFields () {
return this.fieldGroups[this.currentFieldGroupIndex]
},
/**
* Returns the page break block for the current group of fields
*/
currentFieldsPageBreak () {
const block = this.currentFields[this.currentFields.length - 1]
if (block && block.type === 'nf-page-break') return block
return null
},
previousFieldsPageBreak () {
if (this.currentFieldGroupIndex === 0) return null
const previousFields = this.fieldGroups[this.currentFieldGroupIndex - 1]
const block = previousFields[previousFields.length - 1]
if (block && block.type === 'nf-page-break') return block
return null
},
/**
* Returns true if we're on the last page
* @returns {boolean}
*/
isLastPage () {
return this.currentFieldGroupIndex === (this.fieldGroups.length - 1)
},
fieldComponents () {
return {
text: 'TextInput',
number: 'TextInput',
select: 'SelectInput',
multi_select: 'SelectInput',
date: 'DateInput',
files: 'FileInput',
checkbox: 'CheckboxInput',
url: 'TextInput',
email: 'TextInput',
phone_number: 'TextInput',
}
},
isPublicFormPage () {
return this.$route.name === 'forms.show_public'
},
dataFormValue () {
// For get values instead of Id for select/multi select options
const data = this.dataForm.data()
const selectionFields = this.fields.filter((field) => {
return ['select', 'multi_select'].includes(field.type)
})
selectionFields.forEach((field) => {
if (data[field.id] !== undefined && data[field.id] !== null && Array.isArray(data[field.id])) {
data[field.id] = data[field.id].map(option_nfid => {
const tmpop = field[field.type].options.find((op) => { return (op.id === option_nfid) })
return (tmpop) ? tmpop.name : option_nfid
})
}
})
return data
},
isFieldHidden () {
const fieldsHidden = {}
this.fields.forEach((field) => {
fieldsHidden[field.id] = (new FormLogicPropertyResolver(field, this.dataFormValue)).isHidden()
})
return fieldsHidden
},
isFieldRequired () {
const fieldsRequired = {}
this.fields.forEach((field) => {
fieldsRequired[field.id] = (new FormLogicPropertyResolver(field, this.dataFormValue)).isRequired()
})
return fieldsRequired
}
},
watch: {
form: {
deep: true,
handler () {
this.initForm()
}
},
fields: {
deep: true,
handler () {
this.initForm()
}
},
theme: {
handler () {
this.formVersionId++
}
}
},
mounted () {
this.initForm()
},
methods: {
submitForm () {
if (this.currentFieldGroupIndex !== this.fieldGroups.length - 1) {
return
}
if (this.form.use_captcha) {
this.dataForm['h-captcha-response'] = document.getElementsByName('h-captcha-response')[0].value
this.$refs.hcaptcha.reset()
}
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
},
/**
* If more than one page, show first page with error
*/
onSubmissionFailure () {
if (this.fieldGroups.length > 1) {
// Find first mistake and show page
let pageChanged = false
this.fieldGroups.forEach((group, groupIndex) => {
group.forEach((field) => {
if (pageChanged) return
if (!pageChanged && this.dataForm.errors.has(field.id)) {
this.currentFieldGroupIndex = groupIndex
pageChanged = true
}
})
})
}
// Scroll to error
const elements = document.getElementsByClassName('has-error')
if (elements.length > 0) {
window.scroll({
top: window.scrollY + elements[0].getBoundingClientRect().top - 60,
behavior: 'smooth'
})
}
},
initForm () {
const formData = clonedeep(this.dataForm ? this.dataForm.data() : {})
let urlPrefill = null
if (this.isPublicFormPage && this.form.is_pro) {
urlPrefill = new URLSearchParams(window.location.search)
}
this.fields.forEach((field) => {
if (field.type.startsWith('nf-')) {
return
}
if (urlPrefill && urlPrefill.has(field.id)) {
// Url prefills
if (field.type === 'checkbox') {
if (urlPrefill.get(field.id) === 'false' || urlPrefill.get(field.id) === '0') {
formData[field.id] = false
} else if (urlPrefill.get(field.id) === 'true' || urlPrefill.get(field.id) === '1') {
formData[field.id] = true
}
} else {
formData[field.id] = urlPrefill.get(field.id)
}
} else if (urlPrefill && urlPrefill.has(field.id + '[]')) {
// Array url prefills
formData[field.id] = urlPrefill.getAll(field.id + '[]')
} else { // Default prefill if any
formData[field.id] = field.prefill
}
})
this.dataForm = new Form(formData)
},
/**
* Get the right input component for the field/options combination
*/
getFieldComponents (field) {
if (field.type === 'text' && field.multi_lines) {
return 'TextAreaInput'
}
if (field.type === 'url' && field.file_upload) {
return 'FileInput'
}
if (field.type === 'number' && field.is_rating && field.rating_max_value) {
return 'RatingInput'
}
if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) {
return 'FlatSelectInput'
}
return this.fieldComponents[field.type]
},
getFieldClasses (field) {
if (!field.width || field.width === 'full') return 'w-full px-2'
else if (field.width === '1/2') {
return 'w-full sm:w-1/2 px-2'
} else if (field.width === '1/3') {
return 'w-full sm:w-1/3 px-2'
} else if (field.width === '2/3') {
return 'w-full sm:w-2/3 px-2'
} else if (field.width === '1/4') {
return 'w-full sm:w-1/4 px-2'
} else if (field.width === '3/4') {
return 'w-full sm:w-3/4 px-2'
}
},
/**
* Get the right input component options for the field/options
*/
inputProperties (field) {
const inputProperties = {
key: field.id,
name: field.id,
form: this.dataForm,
label: (field.hide_field_name) ? null : field.name + (this.isFieldHidden[field.id] ? ' (Hidden Field)' : ''),
color: this.form.color,
placeholder: field.placeholder,
help: field.help,
uppercaseLabels: this.form.uppercase_labels,
theme: this.theme,
maxCharLimit: (field.max_char_limit) ? parseInt(field.max_char_limit) : 2000,
showCharLimit: field.show_char_limit || false
}
if (['select', 'multi_select'].includes(field.type)) {
inputProperties.options = (field.hasOwnProperty(field.type))
? field[field.type].options.map(option => {
return {
name: option.name,
value: option.name
}
})
: []
inputProperties.multiple = (field.type === 'multi_select')
inputProperties.allowCreation = (field.allow_creation === true)
inputProperties.searchable = (inputProperties.options.length > 4)
} else if (field.type === 'date') {
if (field.with_time) {
inputProperties.withTime = true
} else if (field.date_range) {
inputProperties.dateRange = true
}
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
inputProperties.multiple = (field.multiple !== undefined && field.multiple)
inputProperties.mbLimit = 5
inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : ""
} else if (field.type === 'number' && field.is_rating) {
inputProperties.numberOfStars = parseInt(field.rating_max_value)
}
return inputProperties
},
previousPage () {
this.currentFieldGroupIndex -= 1
return false
},
nextPage () {
this.currentFieldGroupIndex += 1
return false
}
}
}
</script>
<style lang="scss">
.nf-text {
ol {
@apply list-decimal list-inside;
}
ul {
@apply list-disc list-inside;
}
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<button :type="nativeType" :disabled="loading" :class="`py-${sizes['p-y']} px-${sizes['p-x']} text-${sizes['font']} ${theme.Button.body}`" :style="buttonStyle"
class="btn" @click="$emit('click',$event)"
>
<template v-if="!loading">
<slot />
</template>
<loader v-else class="h-6 w-6 text-white mx-auto" />
</button>
</template>
<script>
import { themes } from '~/config/form-themes'
export default {
name: 'OpenFormButton',
props: {
color: {
type: String,
required: true
},
size: {
type: String,
default: 'medium'
},
nativeType: {
type: String,
default: 'submit'
},
loading: {
type: Boolean,
default: false
},
theme: { type: Object, default: () => themes.default }
},
computed: {
buttonStyle () {
return {
backgroundColor: this.color,
color: this.getTextColor(this.color),
'--tw-ring-color': this.color
}
},
sizes () {
if (this.size === 'small') {
return {
font: 'sm',
'p-y': '1',
'p-x': '2'
}
}
return {
font: 'base',
'p-y': '2',
'p-x': '4'
}
}
},
methods: {
getTextColor (bgColor, lightColor = '#FFFFFF', darkColor = '#000000') {
const color = (bgColor.charAt(0) === '#') ? bgColor.substring(1, 7) : bgColor
const r = parseInt(color.substring(0, 2), 16) // hexToR
const g = parseInt(color.substring(2, 4), 16) // hexToG
const b = parseInt(color.substring(4, 6), 16) // hexToB
const uicolors = [r / 255, g / 255, b / 255]
const c = uicolors.map((col) => {
if (col <= 0.03928) {
return col / 12.92
}
return Math.pow((col + 0.055) / 1.055, 2.4)
})
const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2])
return (L > 0.45) ? darkColor : lightColor
}
}
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div
class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all"
>
<div class="flex items-center">
<p class="select-all text-nt-blue flex-grow">
{{ embedCode }}
</p>
<div class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer" @click="copyToClipboard">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'EmbedFormCode',
props: {
form: {
type: Object,
required: true
}
},
data () {
return {}
},
computed: {
embedCode () {
return '<iframe style="border:none;width:100%;" height="' + this.formHeight + 'px" src="' + this.form.share_url + '"></iframe>'
},
formHeight () {
let height = 200
if (!this.form.hide_title) {
height += 60
}
height += this.form.properties.filter((property) => {
return !property.hidden
}).length * 70
return height
}
},
watch: {},
mounted () {
},
methods: {
copyToClipboard () {
const str = this.embedCode
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
}
}
</script>

View File

@@ -0,0 +1,157 @@
<template>
<div v-if="form" id="form-editor" class="w-full flex border-t flex-grow relative overflow-x-hidden">
<!-- Form fields selection -->
<v-tour name="tutorial" :steps="steps" />
<div class="w-full md:w-1/2 lg:w-2/5 border-r relative overflow-y-scroll md:max-w-sm flex-shrink-0">
<div class="p-5 bg-blue-50 border-b text-nt-blue-dark md:hidden">
We suggest you create this form on a device with a larger screen such as computed. That will allow you
to preview your form changes.
</div>
<form-information />
<form-structure />
<form-customization />
<form-about-submission />
<form-notifications />
<form-security-privacy />
<form-custom-code />
<form-integrations />
</div>
<form-editor-preview />
<!-- Form Error Modal -->
<form-error-modal :show="showFormErrorModal"
:validation-error-response="validationErrorResponse"
@close="showFormErrorModal=false"
/>
</div>
<div v-else class="flex justify-center items-center">
<loader class="w-6 h-6" />
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import FormErrorModal from './form-components/FormErrorModal'
import FormInformation from './form-components/FormInformation'
import FormStructure from './form-components/FormStructure'
import FormCustomization from './form-components/FormCustomization'
import FormCustomCode from './form-components/FormCustomCode'
import FormAboutSubmission from './form-components/FormAboutSubmission'
import FormNotifications from './form-components/FormNotifications'
import FormIntegrations from './form-components/FormIntegrations'
import FormEditorPreview from './form-components/FormEditorPreview'
import FormSecurityPrivacy from './form-components/FormSecurityPrivacy'
export default {
name: 'FormEditor',
components: {
FormEditorPreview,
FormIntegrations,
FormNotifications,
FormAboutSubmission,
FormCustomCode,
FormCustomization,
FormStructure,
FormInformation,
FormErrorModal,
FormSecurityPrivacy
},
props: {
validationErrorResponse: {
required: false,
type: Object
},
},
data () {
return {
showFormErrorModal: false
}
},
computed: {
...mapGetters({
user: 'auth/user'
}),
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
steps () {
return [
{
target: '#v-step-0',
header: {
title: 'Welcome to the OpenForm Editor!'
},
content: 'Discover <strong>your form Editor</strong>!'
},
{
target: '#v-step-1',
header: {
title: 'Change your form fields'
},
content: 'Here you can decide which field to include or not, but also the ' +
'order you want your fields to be and so on. You also have custom options available for each field, just ' +
'click the blue cog.'
},
{
target: '#v-step-2',
header: {
title: 'Notifications, Customizations and more!'
},
content: 'Many more options are available: change colors, texts and receive a ' +
'notifications whenever someones submits your form.'
},
{
target: '.v-last-step',
header: {
title: 'Create your form'
},
content: 'Click this button when you\'re done to save your form!'
}
]
},
helpUrl: () => window.config.links.help
},
watch: {},
mounted () {
this.$emit('mounted')
this.startTour()
},
methods: {
startTour () {
if (!this.user.has_forms) {
this.$tours.tutorial.start()
}
},
showValidationErrors () {
this.showFormErrorModal = true
}
}
}
</script>
<style lang="scss">
.v-step {
color: white;
.v-step__header, .v-step__content {
color: white;
div {
color: white;
}
}
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<div>
<v-button class="w-full mb-5" @click="showAddBlock=true">
Add Block
</v-button>
<add-form-block-modal :form-blocks="formFields" :show="showAddBlock" @block-added="blockAdded"
@close="showAddBlock=false"
/>
<template v-if="selectedFieldIndex !== null">
<form-field-options-modal :field="formFields[selectedFieldIndex]"
:show="!isNotAFormField(formFields[selectedFieldIndex]) && showEditFieldModal"
:form="form" @close="closeInputOptionModal"
@remove-block="removeBlock(selectedFieldIndex)"
/>
<form-block-options-modal :field="formFields[selectedFieldIndex]"
:show="isNotAFormField(formFields[selectedFieldIndex]) && showEditFieldModal"
:form="form"
@remove-block="removeBlock(selectedFieldIndex)" @close="closeInputOptionModal"
/>
</template>
<draggable v-model="formFields"
class="border bg-white dark:bg-notion-dark-light border-nt-blue-light shadow rounded-md w-full mx-auto transition-colors overflow-hidden"
ghost-class="bg-nt-blue-lighter" handle=".draggable" :animation="200"
>
<div v-for="(field,index) in formFields" :key="field.id"
class="border-nt-blue-light w-full mx-auto transition-colors bg-white dark:bg-notion-dark-light"
:class="{'bg-gray-200 dark:bg-gray-800':field.hidden, 'border-b': (index!== formFields.length -1), 'bg-blue-50 dark:bg-blue-900':field && field.type==='nf-page-break'}"
>
<div v-if="field" class="flex items-center space-x-1 group py-2 pr-4">
<!-- Drag handler -->
<div class="cursor-move draggable p-2 -mr-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-400" 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>
<!-- Field name and type -->
<div class="flex flex-col flex-grow truncate">
<editable-div class="truncate" :value="field.name" @input="onChangeName(field, $event)">
<label class="cursor-pointer truncate w-full">
{{ field.name }}
</label>
<span v-if="field.required" class="text-red-500 required-dot">*</span>
<svg v-if="field.hidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
</editable-div>
<p class="text-xs text-gray-400 w-full truncate pl-2">
<span class="capitalize">{{ formatType(field) }}</span>
</p>
<template slot="popover">
<p class="text-white">
{{ field.name }}
</p>
</template>
</div>
<!-- Field options -->
<div class="flex-grow" v-if="['files'].includes(field.type) || field.type.startsWith('nf-')">
<pro-tag/>
</div>
<button v-if="!field.type.startsWith('nf-')"
class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-1 hidden md:group-hover:block"
:class="{'text-blue-500': !field.hidden, 'text-gray-500': field.hidden}"
@click="toggleHidden(field)"
>
<template v-if="!field.hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
<path fill-rule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clip-rule="evenodd"
/>
</svg>
</template>
<template v-else>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
clip-rule="evenodd"
/>
<path
d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z"
/>
</svg>
</template>
</button>
<button v-if="!field.type.startsWith('nf-')"
class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-1 hidden md:group-hover:block"
@click="toggleRequired(field)"
>
<div class="w-6 h-6 text-center font-bold text-3xl"
:class="{'text-red-500': field.required, 'text-gray-500': !field.required}"
>
*
</div>
</button>
<button class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-1"
@click="editOptions(index)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" viewBox="0 0 20 20"
fill="currentColor"
>
<path fill-rule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
</draggable>
</div>
</template>
<script>
import draggable from 'vuedraggable'
import FormFieldOptionsModal from '../fields/FormFieldOptionsModal'
import AddFormBlockModal from './form-components/AddFormBlockModal'
import FormBlockOptionsModal from '../fields/FormBlockOptionsModal'
import ProTag from '../../../common/ProTag'
import clonedeep from 'clone-deep'
import EditableDiv from '../../../common/EditableDiv'
export default {
name: 'FormFieldsEditor',
components: {
ProTag,
FormBlockOptionsModal,
AddFormBlockModal,
FormFieldOptionsModal,
draggable,
EditableDiv
},
data() {
return {
formFields: [],
selectedFieldIndex: null,
showEditFieldModal: false,
showAddBlock: false
}
},
computed: {
form: {
get() {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set(value) {
this.$store.commit('open/working_form/set', value)
}
},
},
watch: {
formFields: {
deep: true,
handler() {
this.$set(this.form, 'properties', this.formFields)
}
}
},
mounted() {
this.init()
},
methods: {
onChangeName(field, newName) {
this.$set(field, 'name', newName)
},
toggleHidden(field) {
this.$set(field, 'hidden', !field.hidden)
if (field.hidden) {
this.$set(field, 'required', false)
} else {
this.$set(field, 'generates_uuid', false)
this.$set(field, 'generates_auto_increment_id', false)
}
},
toggleRequired(field) {
this.$set(field, 'required', !field.required)
if (field.required) {
this.$set(field, 'hidden', false)
}
},
getDefaultFields() {
return [
{
"name": "Name",
"type": "text",
"hidden": false,
"required": true,
"id": this.generateUUID(),
},
{
"name": "Email",
"type": "email",
"hidden": false,
"id": this.generateUUID(),
},
{
"name": "Message",
"type": "text",
"hidden": false,
"multi_lines": true,
"id": this.generateUUID(),
}
];
},
init() {
if (this.$route.name === 'forms.create') { // Set Default fields
this.formFields = this.getDefaultFields()
} else {
this.formFields = clonedeep(this.form.properties).map((field) => {
// Add more field properties
field.placeholder = field.placeholder || null
field.prefill = field.prefill || null
field.help = field.help || null
return field
})
}
this.$set(this.form, 'properties', this.formFields)
},
generateUUID() {
let d = new Date().getTime()// Timestamp
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)
})
},
formatType(field) {
let type = field.type.replace('_', ' ')
if (!type.startsWith('nf')) {
type = type + ' Input'
} else {
type = type.replace('nf-', '')
}
if (field.generates_uuid || field.generates_auto_increment_id) {
type = type + ' - Auto ID'
}
return type
},
isNotAFormField(block) {
return block && block.type.startsWith('nf')
},
editOptions(index) {
this.selectedFieldIndex = index
this.showEditFieldModal = true
},
blockAdded(block) {
this.formFields.push(block)
},
removeBlock(blockIndex) {
this.closeInputOptionModal()
this.selectedFieldIndex = null
const newFields = clonedeep(this.formFields)
newFields.splice(blockIndex, 1)
this.$set(this, 'formFields', newFields)
},
closeInputOptionModal() {
this.showEditFieldModal = false
}
}
}
</script>
<style lang="scss">
.v-popover {
.trigger {
@apply truncate w-full;
}
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<div class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all">
<div v-if="!form.is_pro" class="relative">
<div class="absolute inset-0 z-10">
<div class="p-5 max-w-md mx-auto mt-5">
<p class="text-center">
You need a <pro-tag class="mx-1" /> subscription to access your form analytics.
</p>
<p class="mt-5 text-center">
<fancy-link :to="{name:'pricing'}">
Subscribe
</fancy-link>
</p>
</div>
</div>
<img :src="asset('img/pages/forms/blurred_graph.png')"
alt="Sample Graph"
class="mx-auto filter blur-md z-0"
>
</div>
<loader v-else-if="isLoading" class="h-6 w-6 text-nt-blue mx-auto" />
<LineChart v-else
:chart-options="chartOptions"
:chart-data="chartData"
/>
</div>
</template>
<script>
import axios from 'axios'
import { Line as LineChart } from 'vue-chartjs/legacy'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
CategoryScale,
PointElement
} from 'chart.js'
import ProTag from '../../../common/ProTag'
import FancyLink from '../../../common/FancyLink'
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
CategoryScale,
PointElement
)
export default {
name: 'FormStats',
components: {
FancyLink,
ProTag,
LineChart
},
props: {
form: {
type: Object,
required: true
}
},
data () {
return {
isLoading: true,
chartData: {
labels: [],
datasets: [
{
label: 'Form Views',
backgroundColor: 'rgba(59, 130, 246, 1)',
borderColor: 'rgba(59, 130, 246, 1)',
data: []
},
{
label: 'Form Submissions',
backgroundColor: 'rgba(16, 185, 129, 1)',
borderColor: 'rgba(16, 185, 129, 1)',
data: []
}
]
},
chartOptions: {
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
}
}
},
responsive: true,
maintainAspectRatio: false
}
}
},
mounted () {
this.getChartData()
},
methods: {
getChartData () {
if (!this.form || !this.form.is_pro) { return null }
this.isLoading = true
axios.get('/api/open/workspaces/' + this.form.workspace_id + '/form-stats/' + this.form.id).then((response) => {
const statsData = response.data
if (statsData && statsData.views !== undefined) {
this.chartData.labels = Object.keys(statsData.views)
this.chartData.datasets[0].data = statsData.views
this.chartData.datasets[1].data = statsData.submissions
this.isLoading = false
}
})
}
}
}
</script>

View File

@@ -0,0 +1,127 @@
<template>
<div
class="my-4 w-full mx-auto">
<h3 class="font-semibold mb-4">
Form Submissions <span v-if="form && !isLoading && tableData.length > 0"
class="text-right text-xs uppercase mb-2">
- <a :href="exportUrl" target="_blank">Export as CSV</a>
</span>
</h3>
<loader v-if="!form || isLoading" class="h-6 w-6 text-nt-blue mx-auto"/>
<div v-else>
<scroll-shadow
ref="shadows"
class="border max-h-full h-full notion-database-renderer"
:shadow-top-offset="0"
:hide-scrollbar="true"
>
<open-table
ref="table"
class="max-h-full"
:data="tableData"
:loading="isLoading"
@resize="dataChanged()"
>
</open-table>
</scroll-shadow>
</div>
</div>
</template>
<script>
import axios from 'axios'
import ScrollShadow from '../../../common/ScrollShadow'
import OpenTable from '../../tables/OpenTable'
import clonedeep from "clone-deep";
export default {
name: 'FormSubmissions',
components: {ScrollShadow, OpenTable},
props: {},
data() {
return {
formInitDone: false,
isLoading: false,
tableData: [],
currentPage: 1,
fullyLoaded: false,
}
},
mounted() {
this.initFormStructure()
this.getSubmissionsData()
},
computed: {
form: {
get() {
return this.$store.state['open/working_form'].content
},
set(value) {
this.$store.commit('open/working_form/set', value)
}
},
tableStructure() {
if (!this.form) {
return []
}
let tmp = this.form.properties.filter(property => !property.hasOwnProperty('hidden') || !property.hidden)
tmp.push({
"name": "Create Date",
"id": "create_date",
"type": "date"
});
return tmp
},
exportUrl() {
if (!this.form) {
return ''
}
return '/api/open/forms/' + this.form.id + '/submissions/export'
}
},
methods: {
initFormStructure() {
if (!this.form || this.formInitDone) {
return
}
// Add a "created at" column
const columns = clonedeep(this.form.properties)
columns.push({
"name": "Created at",
"id": "created_at",
"type": "date",
"width": 140,
})
this.$set(this.form, 'properties', columns)
this.formInitDone = true
},
getSubmissionsData() {
if (!this.form || this.fullyLoaded) {
return
}
this.isLoading = true
axios.get('/api/open/forms/' + this.form.id + '/submissions?page=' + this.currentPage).then((response) => {
const resData = response.data;
this.tableData = this.tableData.concat(resData.data.map((record) => record.data))
if (this.currentPage < resData.meta.last_page) {
this.currentPage += 1
this.getSubmissionsData()
} else {
this.isLoading = false
this.fullyLoaded = true
}
}).catch((error) => {
console.error(error)
this.isLoading = false
})
},
dataChanged() {
this.$refs.shadows.toggleShadow()
this.$refs.shadows.calcDimensions()
},
},
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div
class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light shadow rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all"
>
<div class="flex items-center">
<p class="select-all flex-grow break-all" v-html="preFillUrl" />
<div class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer" @click="copyToClipboard">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'FormUrlPrefill',
props: {
form: {
type: Object,
required: true
},
formData: {
type: Object,
required: true
}
},
data () {
return {}
},
computed: {
preFillUrl () {
const url = this.form.share_url
const uriComponents = new URLSearchParams()
this.form.properties.filter((property) => {
return this.formData.hasOwnProperty(property.id) && this.formData[property.id] !== null
}).forEach((property) => {
if (Array.isArray(this.formData[property.id])) {
this.formData[property.id].forEach((value) => {
uriComponents.append(property.id + '[]', value)
})
} else {
uriComponents.append(property.id, this.formData[property.id])
}
})
return url + '?' + uriComponents
}
},
watch: {},
mounted () {
},
methods: {
getPropertyUriComponent (property) {
const prefillValue = encodeURIComponent(this.formData[property.id])
return encodeURIComponent(property.id) + '=' + prefillValue
},
copyToClipboard () {
const str = this.preFillUrl
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
}
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all">
<div class="flex items-center">
<p class="select-all text-nt-blue flex-grow truncate">
<a v-if="link" :href="form.share_url" target="_blank">
{{ form.share_url }}
</a>
<span v-else>
{{ form.share_url }}
</span>
</p>
<div class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer" @click="copyToClipboard(form.share_url)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ShareFormUrl',
props: {
form: {
type: Object,
required: true
},
link: {
type: Boolean,
default: false
}
},
data () {
return {
}
},
computed: {
},
watch: {
},
mounted () {
},
methods: {
copyToClipboard (str) {
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
}
}
</script>

View File

@@ -0,0 +1,310 @@
<template>
<modal :show="show" @close="close">
<p class="text-gray-500 uppercase text-xs font-semibold mb-2">Input Blocks</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<!-- Text Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('text')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Text Input</p>
</div>
<!-- Date Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('date')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Date Input</p>
</div>
<!-- Url Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('url')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">URL Input</p>
</div>
<!-- Phone Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('phone_number')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Phone Input</p>
</div>
<!-- email Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('email')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"/>
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Email Input</p>
</div>
<!-- checkbox Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('checkbox')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Checkbox Input</p>
</div>
<!-- select Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('select')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Select Input</p>
</div>
<!-- multiselect Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('multi_select')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold">Multi-select Input</p>
</div>
<!-- number Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('number')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Number Input</p>
</div>
<!-- files Input -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('files')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">File Input</p>
</div>
</div>
<p class="text-gray-500 uppercase text-xs font-semibold mb-2 mt-6">Layout Blocks</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<!-- Text Block -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('nf-text')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Text Block</p>
</div>
<!-- Page Break Block -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('nf-page-break')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold">Page-break Block</p>
</div>
<!-- Divider Block -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('nf-divider')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Divider block</p>
</div>
<!-- Image Block -->
<div
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
role="button" @click.prevent="addBlock('nf-image')"
>
<div class="mx-auto py-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Image Block</p>
</div>
</div>
<div class="flex justify-end mt-4">
<v-button color="gray" shade="light" @click="close">
Close
</v-button>
</div>
</modal>
</template>
<script>
import Form from 'vform'
import VButton from '../../../../common/Button'
export default {
name: 'AddFormBlockModal',
components: {VButton},
props: {
formBlocks: {
type: Array,
required: true
},
show: {
type: Boolean,
required: true
}
},
data() {
return {
blockForm: null
}
},
computed: {
defaultBlockNames() {
return {
'text': 'Your name',
'date': 'Date',
'url': 'Link',
'phone_number': 'Phone Number',
'number': 'Number',
'email': 'Email',
'checkbox': 'Checkbox',
'select': 'Select',
'multi_select': 'Multi Select',
'files': 'Files',
'nf-text': 'Text Block',
'nf-page-break': 'Page Break',
'nf-divider': 'Divider',
'nf-image': 'Image',
}
}
},
watch: {},
mounted() {
this.reset()
},
methods: {
reset() {
this.blockForm = new Form({
type: null,
name: null
})
},
addBlock(type) {
this.blockForm.type = type
this.blockForm.name = this.defaultBlockNames[type]
const data = this.prefillDefault(this.blockForm.data())
data.id = this.generateUUID()
data.hidden = false
if (['select', 'multi_select'].includes(this.blockForm.type)) {
data[this.blockForm.type] = {'options': []}
}
this.$emit('block-added', data)
this.close()
},
generateUUID() {
let d = new Date().getTime()// Timestamp
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'
}
return data
},
close() {
this.$emit('close')
this.reset()
}
}
}
</script>

View File

@@ -0,0 +1,219 @@
<template>
<collapse class="p-5 w-full" :default-value="true">
<template #title>
<h3 class="font-semibold text-lg relative">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
About Submission
</h3>
</template>
<text-input name="submit_button_text" class="mt-4"
:form="form"
label="Text of submit button"
:required="true"
/>
<select-input :form="submissionOptions" name="databaseAction" label="Database Submission Action"
:options="[
{name:'Create new record (default)', value:'create'},
{name:'Update Record (if any)', value:'update'}
]" :required="true" help="Create a new record or update an existing one"
>
<template #selected="{option,optionName}">
<div class="flex items-center truncate mr-6">
{{ optionName }}
<pro-tag v-if="option === 'update'" class="ml-2" />
</div>
</template>
<template #option="{option, selected}">
<span class="flex hover:text-white">
<p class="flex-grow hover:text-white">
{{ option.name }} <template v-if="option.value === 'update'"><pro-tag /></template>
</p>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</span>
</span>
</template>
</select-input>
<v-transition>
<div v-if="submissionOptions.databaseAction == 'update' && filterableFields.length">
<select-input v-if="filterableFields.length" :form="form" name="database_fields_update"
label="Properties to check on update" :options="filterableFields" :required="true"
:multiple="true"
/>
<div class="-mt-3 mb-3 text-gray-400 dark:text-gray-500">
<small>If the submission has the same value(s) as a previous one for the selected
column(s), we will update it, instead of creating a new one.
<a href="#" @click.prevent="$getCrisp().push(['do', 'helpdesk:article:open', ['en', 'how-to-update-a-page-on-form-submission-1t1jwmn']])">More info here.</a>
</small>
</div>
</div>
</v-transition>
<select-input :form="submissionOptions" name="submissionMode" label="Post Submission Action"
:options="[
{name:'Show Success page', value:'default'},
{name:'Redirect', value:'redirect'}
]" :required="true" help="Show a message, or redirect to a URL"
>
<template #selected="{option,optionName}">
<div class="flex items-center truncate mr-6">
{{ optionName }}
<pro-tag v-if="option === 'redirect'" class="ml-2" />
</div>
</template>
<template #option="{option, selected}">
<span class="flex hover:text-white">
<p class="flex-grow hover:text-white">
{{ option.name }} <template v-if="option.value === 'redirect'"><pro-tag /></template>
</p>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</span>
</span>
</template>
</select-input>
<template v-if="submissionOptions.submissionMode === 'redirect'">
<text-input name="redirect_url"
:form="form"
label="Redirect URL"
:required="true" help="On submit, redirects to that URL"
/>
</template>
<template v-else>
<pro-tag class="float-right" />
<checkbox-input name="use_captcha" :form="form" class="mt-4"
label="Protect your form with a Captcha"
help="If enabled we will make sure respondant is a human"
/>
<checkbox-input name="re_fillable" :form="form" class="mt-4"
label="Allow users to fill the form again"
/>
<text-input v-if="form.re_fillable" name="re_fill_button_text"
:form="form"
label="Text of re-start button"
:required="true"
/>
<rich-text-area-input name="submitted_text"
:form="form"
label="Text after submission"
:required="false"
/>
<date-input :with-time="true" name="closes_at"
:form="form"
label="Closing date"
help="If filled, then the form won't accept submissions after the given date"
:required="false"
/>
<rich-text-area-input v-if="form.closes_at" name="closed_text"
:form="form"
label="Closed form text"
help="This message will be shown when the form will be closed"
:required="false"
/>
<text-input name="max_submissions_count" native-type="number" :min="1" :form="form"
label="Max number of submissions"
help="If filled, the form will only accept X number of submissions"
:required="false"
/>
<rich-text-area-input v-if="form.max_submissions_count && form.max_submissions_count > 0" name="max_submissions_reached_text"
:form="form"
label="Max Submissions reached text"
help="This message will be shown when the form will have the maximum number of submissions"
:required="false"
/>
</template>
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse'
import ProTag from '../../../../common/ProTag'
import VTransition from '../../../../common/transitions/VTransition'
export default {
components: { Collapse, ProTag, VTransition },
props: {
},
data () {
return {
submissionOptions: {}
}
},
computed: {
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
/**
* Used for the update record on submission. Lists all visible fields on which you can filter records to update
* on submission instead of creating
*/
filterableFields () {
if (this.submissionOptions.databaseAction !== 'update') return []
return this.form.properties.filter((field) => {
return !field.hidden && window.config.notion.database_filterable_types.includes(field.type)
}).map((field) => {
const fieldName = (field.name !== field.notion_name) ? (field.name + ' (' + field.notion_name + ')') : field.name
return {
name: fieldName,
value: field.id
}
})
}
},
watch: {
form: {
handler () {
if (this.form) {
this.submissionOptions = {
submissionMode: this.form.redirect_url ? 'redirect' : 'default',
databaseAction: this.form.database_fields_update ? 'update' : 'create'
}
}
},
deep: true
},
submissionOptions: {
deep: true,
handler: function (val) {
if (val.submissionMode === 'default') {
this.$set(this.form, 'redirect_url', null)
}
if (val.databaseAction === 'create') {
this.$set(this.form, 'database_fields_update', null)
}
}
}
},
mounted () {
},
methods: {
}
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<collapse class="p-5 w-full border-b" :default-value="false">
<template #title>
<h3 class="font-semibold text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
Custom Code
<pro-tag />
</h3>
</template>
<p class="mt-4">
The code will be injected in the <b>head</b> section of your form page. <a href="#" class="text-gray-500"
@click.prevent="$getCrisp().push(['do', 'helpdesk:article:open', ['en', 'how-to-inject-custom-code-in-my-form-1amadj3']])"
>Click
here to get an example CSS code.</a>
</p>
<code-input name="custom_code" class="mt-4"
:form="form" help="Custom code cannot be previewed in our editor. Please test your code using
your actual form page (save changes beforehand)."
label="Custom Code"
/>
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse'
import ProTag from '../../../../common/ProTag'
import CodeInput from '../../../../forms/CodeInput'
export default {
components: { Collapse, ProTag, CodeInput },
props: {
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
}
},
watch: {},
mounted () {
},
methods: {
}
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<collapse class="p-5 w-full border-b" :default-value="true">
<template #title>
<h3 class="font-semibold text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Customization
<pro-tag />
</h3>
</template>
<select-input name="theme" class="mt-4"
:options="[
{name:'Default',value:'default'},
{name:'Simple',value:'simple'},
{name:'Notion',value:'notion'},
]"
:form="form" label="Form Theme"
/>
<div class="-mt-3 mb-3 text-gray-400 dark:text-gray-500">
<small>
Need another theme? <a href="#" @click.prevent="openChat">Send us some suggestions!</a>
</small>
</div>
<select-input name="width" class="mt-4"
:options="[
{name:'Centered',value:'centered'},
{name:'Full Width',value:'full'},
]"
:form="form" label="Form Width" help="Useful when embedding your form"
/>
<image-input name="cover_picture" class="mt-4"
:form="form" label="Cover Picture" help="Not visible when form is embedded"
:required="false"
/>
<image-input name="logo_picture" class="mt-4"
:form="form" label="Logo" help="Not visible when form is embedded"
:required="false"
/>
<select-input name="dark_mode" class="mt-4"
help="To see changes, save your form and open it"
:options="[
{name:'Auto - use Device System Preferences',value:'auto'},
{name:'Light Mode',value:'light'},
{name:'Dark Mode',value:'dark'}
]"
:form="form" label="Dark Mode"
/>
<color-input name="color" class="mt-4"
:form="form"
label="Color (for buttons & inputs border)"
/>
<checkbox-input name="hide_title" :form="form" class="mt-4"
label="Hide Title"
/>
<checkbox-input name="no_branding" :form="form" class="mt-4"
label="Remove OpnForm Branding"
/>
<checkbox-input name="uppercase_labels" :form="form" class="mt-4"
label="Uppercase Input Labels"
/>
<checkbox-input name="transparent_background" :form="form" class="mt-4"
label="Transparent Background" help="Only applies when form is embedded"
/>
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse'
import ProTag from '../../../../common/ProTag'
export default {
components: { Collapse, ProTag },
props: {
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
}
},
watch: {},
mounted () {
},
methods: {
openChat () {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
}
}
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<!-- Form Preview (desktop only) -->
<div
class="bg-gray-100 dark:bg-notion-dark-light hidden md:flex flex-grow p-5 flex-col items-center overflow-y-scroll shadow-inner"
>
<p class="mb-4 mt-2 text-center text-gray-400">
Preview Full Page
<v-switch v-model="previewEmbed" class="inline px-2" />
Preview Embed
</p>
<p class="font-semibold">
<span v-if="creating" class="font-normal text-gray-400">Answers won't really be saved</span>
<span v-if="previewFormSubmitted && !form.re_fillable">
<a href="#" @click.prevent="$refs['form-preview'].restart()">Restart Form
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-nt-blue inline" viewBox="0 0 20 20"
fill="currentColor"
>
<path fill-rule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clip-rule="evenodd"
/>
</svg>
</a>
</span>
</p>
<div class="border rounded-lg bg-white dark:bg-notion-dark w-full block shadow-sm transition-all"
:class="{'max-w-lg':previewEmbed,'max-w-5xl':!previewEmbed}"
>
<transition enter-active-class="linear duration-100 overflow-hidden"
enter-class="max-h-0"
enter-to-class="max-h-56"
leave-active-class="linear duration-100 overflow-hidden"
leave-class="max-h-56"
leave-to-class="max-h-0"
>
<div v-if="!previewEmbed && (form.logo_picture || form.cover_picture)">
<div v-if="form.cover_picture">
<div id="cover-picture"
class="max-h-56 rounded-t-lg w-full overflow-hidden flex items-center justify-center"
>
<img alt="Cover Picture" :src="coverPictureSrc(form.cover_picture)" class="w-full">
</div>
</div>
<div v-if="form.logo_picture" class="w-full mx-auto p-5 relative"
:class="{'pt-20':!form.cover_picture, 'max-w-lg': form && (form.width === 'centered')}"
>
<img alt="Logo Picture" :src="coverPictureSrc(form.logo_picture)"
:class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}"
class="w-20 h-20 absolute left-5 transition-all"
>
</div>
</div>
</transition>
<open-complete-form ref="form-preview" class="w-full mx-auto py-5 px-3" :class="{'max-w-lg': form && (form.width === 'centered')}"
:creating="creating"
:form="form"
@restarted="previewFormSubmitted=false"
@submitted="previewFormSubmitted=true"
/>
</div>
</div>
</template>
<script>
import VSwitch from '../../../../forms/components/VSwitch'
import OpenCompleteForm from '../../OpenCompleteForm'
export default {
components: { OpenCompleteForm, VSwitch },
props: {
},
data () {
return {
previewFormSubmitted: false,
previewEmbed: false
}
},
computed: {
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
creating () { // returns true if we are creating a form
return !this.form.hasOwnProperty('id')
}
},
watch: {},
mounted () {
},
methods: {
coverPictureSrc (val) {
try {
// Is valid url
new URL(val)
} catch (_) {
// Is file
return URL.createObjectURL(val)
}
return val
}
}
}
</script>

View File

@@ -0,0 +1,40 @@
<template>
<modal :show="show" @close="$emit('close')">
<div class="-mx-5">
<h2 class="text-red-400 text-2xl font-bold mb-4 px-4">
Error saving your form
</h2>
<div v-if="validationErrorResponse" class="p-4 border-b border-t">
<p v-if="validationErrorResponse.message" v-text="validationErrorResponse.message" />
<ul class="list-disc list-inside">
<li v-for="err, key in validationErrorResponse.errors" :key="key">
{{ Array.isArray(err)?err[0]:err }}
</li>
</ul>
</div>
<div class="px-4 pt-4 text-right">
<v-button color="gray" shade="light" @click="$emit('close')">
Close
</v-button>
</div>
</div>
</modal>
</template>
<script>
export default {
name: 'FormErrorModal',
components: {},
props: {
show: { type: Boolean, required: true },
validationErrorResponse: { type: Object, required: false }
},
data: () => ({}),
computed: {},
methods: {}
}
</script>

View File

@@ -0,0 +1,154 @@
<template>
<collapse class="p-5 w-full border-b" :default-value="true">
<template #title class="test">
<h3 id="v-step-0" class="font-semibold text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Information
</h3>
</template>
<text-input name="title" class="mt-4"
:form="form"
label="Title of your form"
:required="true"
/>
<rich-text-area-input name="description"
:form="form"
label="Description"
:required="false"
/>
<select-input name="tags" label="Tags" :form="form" class="mt-3 mb-6"
help="To organize your forms (hidden to respondents)"
placeholder="Select Tag(s)" :multiple="true" :allowCreation="true"
:options="allTagsOptions"
/>
<button
v-if="copyFormOptions.length > 0"
class="group mt-3 cursor-pointer relative w-full rounded-lg border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full py-2 px-4 bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100"
@click.prevent="showCopyFormSettingsModal=true"
>
Copy another form's settings
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -mt-1 text-nt-blue inline" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
</button>
<modal :show="showCopyFormSettingsModal" @close="showCopyFormSettingsModal=false">
<div class="-m-4 sm:-mx-6">
<div class="p-4 border-b">
<h2 class="text-2xl font-bold z-10 truncate -mt-2 text-nt-blue">
Copy Settings from another form
</h2>
</div>
<div class="p-4">
<p class="text-gray-600">
If you already have another form that you like to use as a base for this form, you can do that here.
Select another form, confirm, and we will copy all of the other form settings (except the form structure)
to this form.
</p>
<select-input v-model="copyFormId" name="copy_form_id"
label="Copy settings from" class="mt-3 mb-6"
placeholder="Choose a form" :searchable="copyFormOptions.length > 5"
:options="copyFormOptions"
/>
<div class="flex justify-between">
<v-button color="blue" shade="light" @click="copySettings">
Confirm & Copy settings
</v-button>
<v-button color="gray" shade="light" class="ml-1" @click="showCopyFormSettingsModal=false">
Close
</v-button>
</div>
</div>
</div>
</modal>
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse'
import SelectInput from '../../../../forms/SelectInput'
import { mapState } from 'vuex'
import clonedeep from 'clone-deep'
export default {
components: { SelectInput, Collapse },
props: {},
data () {
return {
showCopyFormSettingsModal: false,
copyFormId: null
}
},
computed: {
copyFormOptions () {
return this.forms.filter((form) => {
return this.form.id !== form.id
}).map((form) => {
return {
name: form.title,
value: form.id
}
})
},
...mapState({
forms: state => state['open/forms'].content
}),
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
allTagsOptions () {
return this.$store.getters['open/forms/getAllTags'].map((tagname) => {
return {
name: tagname,
value: tagname
}
})
}
},
watch: {},
mounted () {
},
methods: {
copySettings () {
if (this.copyFormId == null) return
const copyForm = clonedeep(this.forms.find((form) => form.id === this.copyFormId))
if (!copyForm) return
// Clean copy from form
['title', 'description', 'properties', 'cleanings', 'views_count', 'submissions_count', 'workspace', 'workspace_id', 'updated_at',
'share_url', 'slug', 'notion_database_url', 'id', 'database_id', 'database_fields_update', 'creator',
'created_at', 'deleted_at'].forEach((property) => {
if (copyForm.hasOwnProperty(property)) {
delete copyForm[property]
}
})
// Apply changes
Object.keys(copyForm).forEach((property) => {
this.form[property] = copyForm[property]
})
this.showCopyFormSettingsModal = false
}
}
}
</script>

View File

@@ -0,0 +1,73 @@
<template>
<collapse class="p-5 w-full border-b">
<template #title>
<h3 class="font-semibold text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
Integrations
<pro-tag />
</h3>
</template>
<text-input name="webhook_url" class="mt-4"
:form="form" help="We will post form submissions to this endpoint."
label="Webhook URL"
/>
<p>
<span class="text-uppercase font-semibold text-blue-500">NEW</span> - our Zapier integration is available for
beta testers! During the beta, <b>you don't need a Pro subscription</b> to try it out.
</p>
<p class="w-full text-center mt-5">
<a :href="zapierUrl" target="_blank">
<v-button color="gray" shade="lighter">
<svg class="h-5 w-5 inline text-yellow-500" fill="currentColor" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
>
<path
d="M318 256c0 19-4 36-10 52-16 7-34 10-52 10-19 0-36-3-52-9-7-17-10-34-10-53 0-18 3-36 10-52 16-6 33-10 52-10 18 0 36 4 52 10 6 16 10 34 10 52zm182-41H355l102-102c-8-11-17-22-26-32-10-9-21-18-32-26L297 157V12c-13-2-27-3-41-3s-28 1-41 3v145L113 55c-12 8-22 17-32 26-10 10-19 21-27 32l102 102H12s-3 27-3 41 1 28 3 41h144L54 399c16 23 36 43 59 59l102-102v144c13 2 27 3 41 3s28-1 41-3V356l102 102c11-8 22-17 32-27 9-10 18-20 26-32L355 297h145c2-13 3-27 3-41s-1-28-3-41z"
/>
</svg>
Zapier Integration
</v-button>
</a>
</p>
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse'
import ProTag from '../../../../common/ProTag'
export default {
components: { Collapse, ProTag },
props: {
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
zapierUrl: () => window.config.links.zapier_integration
},
watch: {},
mounted () {
},
methods: {
}
}
</script>

View File

@@ -0,0 +1,93 @@
<template>
<collapse class="p-5 w-full border-t border-b" :default-value="true">
<template #title>
<h3 id="v-step-2" class="font-semibold text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Notifications
<pro-tag />
</h3>
</template>
<checkbox-input name="notifies" :form="form" class="mt-4"
label="Receive email notifications on submission"
/>
<text-area-input v-if="form.notifies" name="notification_emails" :form="form" class="mt-4"
label="Notification Emails" help="Add one email per line"
/>
<checkbox-input :disabled="emailSubmissionConfirmationField===null" name="send_submission_confirmation"
:form="form" class="mt-4"
label="Send submission confirmation" :help="emailSubmissionConfirmationHelp"
/>
<text-input v-if="form.send_submission_confirmation" name="notification_sender"
:form="form" class="mt-4"
label="Confirmation Email Sender Name" help="Emails will be sent from our email address but you can customize the name of the Sender"
/>
<text-input v-if="form.send_submission_confirmation" name="notification_subject"
:form="form" class="mt-4"
label="Confirmation email subject" help="Subject of the confirmation email that will be sent"
/>
<rich-text-area-input v-if="form.send_submission_confirmation" name="notification_body"
:form="form" class="mt-4"
label="Confirmation email content" help="Content of the confirmation email that will be sent"
/>
<checkbox-input v-if="form.send_submission_confirmation" name="notifications_include_submission"
:form="form" class="mt-4"
label="Include submission data" help="If enabled the confirmation email will contain form submission answers"
/>
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse'
import ProTag from '../../../../common/ProTag'
export default {
components: { Collapse, ProTag },
props: {
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
emailSubmissionConfirmationField () {
const emailFields = this.form.properties.filter((field) => {
return field.type === 'email' && !field.hidden
})
if (emailFields.length === 1) return emailFields[0]
return null
},
emailSubmissionConfirmationHelp () {
if (this.emailSubmissionConfirmationField) {
return 'Confirmation will be sent to the email in the "' + this.emailSubmissionConfirmationField.name + '" field.'
}
return 'Only available if your form contains 1 email field.'
}
},
watch: {
emailSubmissionConfirmationField (val) {
if (val === null) {
this.$set(this.form, 'send_submission_confirmation', false)
}
}
},
mounted () {
},
methods: {
}
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<collapse class="p-5 w-full border-b" :default-value="false">
<template #title>
<h3 id="v-step-2" class="font-semibold text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Security & Privacy
</h3>
</template>
<checkbox-input name="can_be_indexed" :form="form" class="mt-4"
label="Indexable by Google"
help="If enabled, your form can appear in the search results of Google"
/>
<pro-tag class="float-right" />
<text-input name="password" :form="form" class="mt-4"
label="Form Password" help="Leave empty to disable password"
/>
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse'
import ProTag from '../../../../common/ProTag'
export default {
components: { Collapse, ProTag },
props: {
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
}
},
watch: {
},
mounted () {
},
methods: {
}
}
</script>

View File

@@ -0,0 +1,49 @@
<template>
<collapse class="p-5 w-full border-b" :default-value="true">
<template #title>
<div class="flex">
<h3 id="v-step-1" class="font-semibold block text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg> Form Structure
</h3>
</div>
</template>
<form-fields-editor class="mt-5" />
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse'
import FormFieldsEditor from '../FormFieldsEditor'
export default {
components: { Collapse, FormFieldsEditor },
props: {
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.$store.state['open/working_form'].content
},
/* We add a setter */
set (value) {
this.$store.commit('open/working_form/set', value)
}
}
},
watch: {},
mounted () {
},
methods: {
}
}
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div v-if="isMounted" class="flex flex-wrap">
<div class="w-full font-semibold text-gray-700 dark:text-gray-300 mb-2">
{{ property.name }}
</div>
<SelectInput v-model="content.operator" class="w-full" :options="operators"
:name="'operator_'+property.id" placeholder="Comparison operator"
@input="operatorChanged()"
/>
<template v-if="hasInput">
<component :is="inputComponentData.component" v-model="content.value" class="w-full"
:name="'value_'+property.id" v-bind="inputComponentData" placeholder="Filter Value"
@input="$emit('input',castContent(content))"
/>
</template>
</div>
</template>
<script>
import OpenFilters from '../../../../../../data/open_filters.json'
export default {
components: { },
props: {
value: { required: true }
},
data () {
return {
content: { ...this.value },
available_filters: OpenFilters,
isMounted: false,
hasInput: false,
inputComponent: {
text: 'TextInput',
number: 'TextInput',
select: 'SelectInput',
multi_select: 'SelectInput',
date: 'DateInput',
files: 'FileInput',
checkbox: 'CheckboxInput',
url: 'TextInput',
email: 'TextInput',
phone_number: 'TextInput',
}
}
},
computed: {
// Return type of input, and props for that input
inputComponentData () {
const componentData = {
component: this.inputComponent[this.property.type],
name: this.property.id,
required: true
}
if (['select', 'multi_select'].includes(this.property.type)) {
componentData.multiple = (this.property.type == 'multi_select')
componentData.options = this.property[this.property.type].options.map(option => {
return {
name: option.name,
value: option.name
}
})
} else if (this.property.type === 'date') {
// componentData.withTime = true
} else if (this.property.type === 'checkbox') {
componentData.label = this.property.name
}
return componentData
},
operators () {
return Object.keys(this.available_filters[this.property.type].comparators).map(key => {
return {
value: key,
name: this.optionFilterNames(key, this.property.type)
}
})
}
},
mounted () {
if (!this.content.operator) {
this.content.operator = this.operators[0].value
this.operatorChanged()
} else {
this.hasInput = this.needsInput()
}
this.content.property_meta = {
id: this.property.id,
type: this.property.type,
}
this.isMounted = true
},
methods: {
castContent (content) {
if (this.property.type === 'number' && content.value) {
content.value = Number(content.value)
}
const operator = this.selectedOperator()
if (operator.expected_type === 'boolean') {
content.value = Boolean(content.value)
}
return content
},
operatorChanged () {
if (!this.content.operator) {
return
}
const operator = this.selectedOperator()
const operatorFormat = operator.format
this.hasInput = this.needsInput()
if (operator.expected_type === 'boolean' && operatorFormat.type === 'enum' && operatorFormat.values.length === 1) {
this.content.value = operator.format.values[0]
} else if (operator.expected_type === 'object' && operatorFormat.type === 'empty' && operatorFormat.values === '{}') {
this.content.value = {}
} else if (typeof this.content.value === 'boolean' || typeof this.content.value === 'object') {
this.content.value = null
}
this.$emit('input', this.castContent(this.content))
},
needsInput () {
const operator = this.selectedOperator()
if (!operator) {
return false
}
const operatorFormat = operator.format
if (!operatorFormat) return true
if (operator.expected_type === 'boolean' && operatorFormat.type === 'enum' && operatorFormat.values.length === 1) {
return false
} else if (operator.expected_type === 'object' && operatorFormat.type === 'empty' && operatorFormat.values === '{}') {
return false
}
return true
},
selectedOperator () {
if (!this.content.operator) {
return null
}
return this.available_filters[this.property.type].comparators[this.content.operator]
},
optionFilterNames (key, propertyType) {
if (propertyType === 'checkbox') {
return {
equals: 'Is checked',
does_not_equal: 'Is not checked'
}[key]
}
return key.split('_').map(function (item) {
return item.charAt(0).toUpperCase() + item.substring(1)
}).join(' ')
}
}
}
</script>

View File

@@ -0,0 +1,118 @@
<template>
<query-builder v-model="query" :rules="rules" :config="config" @input="onChange">
<template #groupOperator="props">
<div class="query-builder-group-slot__group-selection flex items-center px-5 border-b py-1 mb-1 flex">
<p class="mr-2 font-semibold">
Operator
</p>
<select-input
wrapper-class="relative"
:value="props.currentOperator"
:options="props.operators"
emit-key="identifier"
option-key="identifier"
name="operator-input"
margin-bottom=""
@input="props.updateCurrentOperator($event)"
/>
</div>
</template>
<template #groupControl="props">
<group-control-slot :group-ctrl="props" />
</template>
<template #rule="ruleCtrl">
<component
:is="ruleCtrl.ruleComponent"
:value="ruleCtrl.ruleData"
@input="ruleCtrl.updateRuleData"
/>
</template>
</query-builder>
</template>
<script>
import QueryBuilder from 'query-builder-vue'
import ColumnCondition from './ColumnCondition'
import Vue from 'vue'
import GroupControlSlot from './GroupControlSlot'
export default {
components: {
GroupControlSlot,
QueryBuilder,
ColumnCondition
},
props: {
form: { type: Object, required: true },
value: { required: false }
},
data () {
return {
query: this.value
}
},
computed: {
rules () {
return this.form.properties.filter((property) => {
return !property.type.startsWith('nf-')
}).map((property) => {
const workspaceId = this.form.workspace_id
const formSlug = this.form.slug
return {
identifier: property.id,
name: property.name,
component: (function () {
return Vue.extend(ColumnCondition).extend({
computed: {
property () {
return property
},
viewContext () {
return {
form_slug: formSlug,
workspace_id: workspaceId
}
}
}
})
})()
}
})
},
config () {
return {
operators: [
{
name: 'And',
identifier: 'and'
},
{
name: 'Or',
identifier: 'or'
}
],
rules: this.rules,
colors: ['#ef4444', '#22c55e', '#f97316', '#0ea5e9', '#8b5cf6', '#ec4899']
}
}
},
watch: {
value () {
this.query = this.value
}
},
methods: {
onChange () {
this.$emit('input', this.query)
}
}
}
</script>

View File

@@ -0,0 +1,184 @@
<template>
<div v-if="logic" :key="resetKey" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Logic
<pro-tag />
</h3>
<p class="text-gray-400 mb-5">
Add some logic to this block. Start by adding some conditions, and then add some actions.
</p>
<div class="relative">
<v-button size="small" @click="showCopyFormModal=true">
Copy from...
</v-button>
<v-button color="red" shade="light" size="small" class="ml-1" @click="clearAll">
Clear All
</v-button>
</div>
<h5 class="font-semibold mt-4">
1. Conditions
</h5>
<condition-editor ref="filter-editor" v-model="logic.conditions" class="mt-4 border-t border" :form="form" />
<h5 class="font-semibold mt-4">
2. Actions
</h5>
<select-input :key="resetKey" v-model="logic.actions" name="actions"
:multiple="true" class="mt-4" placeholder="Actions..."
help="Action(s) triggerred when above conditions are true"
:options="actionOptions"
@input="onActionInput"
/>
<modal :show="showCopyFormModal" @close="showCopyFormModal">
<h3 class="font-semibold block text-lg">
Copy logic from another field
</h3>
<p class="text-gray-400 mb-5">
Select another field/block to copy its logic and apply it to "{{ field.name }}".
</p>
<select-input v-model="copyFrom" name="copy_from" emit-key="value"
label="Copy logic from" placeholder="Choose a field/block..."
:options="copyFromOptions" :searchable="copyFromOptions && copyFromOptions.options > 5"
/>
<div class="flex justify-between mb-6">
<v-button color="blue" shade="light" @click="copyLogic">
Confirm & Copy
</v-button>
<v-button color="gray" shade="light" class="ml-1" @click="showCopyFormModal=false">
Close
</v-button>
</div>
</modal>
</div>
</template>
<script>
import ProTag from '../../../../common/ProTag'
import ConditionEditor from './ConditionEditor'
import Modal from '../../../../Modal'
import SelectInput from '../../../../forms/SelectInput'
import clonedeep from 'clone-deep'
export default {
name: 'FormBlockLogicEditor',
components: { SelectInput, Modal, ProTag, ConditionEditor },
props: {
field: {
type: Object,
required: false
},
form: {
type: Object,
required: false
}
},
data () {
return {
resetKey: 0,
logic: this.field.logic || {
conditions: null,
actions: []
},
showCopyFormModal: false,
copyFrom: null
}
},
computed: {
copyFromOptions () {
return this.form.properties.filter((field) => {
return field.id !== this.field.id
}).map((field) => {
return { name: field.name, value: field.id }
})
},
actionOptions () {
if (['nf-text', 'nf-page-break', 'nf-divider', 'nf-image'].includes(this.field.type)) {
return [{ name: 'Hide Block', value: 'hide-block' }]
}
if (this.field.hidden) {
return [
{ name: 'Show Block', value: 'show-block' },
{ name: 'Require answer', value: 'require-answer' }
]
} else {
return [
{ name: 'Hide Block', value: 'hide-block' },
(this.field.required
? { name: 'Make it optional', value: 'make-it-optional' }
: {
name: 'Require answer',
value: 'require-answer'
})
]
}
}
},
watch: {
logic: {
handler () {
this.$set(this.field, 'logic', this.logic)
},
deep: true
},
'field.required': {
handler () {
this.cleanConditions()
},
deep: true
}
},
mounted () {
if (!this.field.hasOwnProperty('logic')) {
this.$set(this.field, 'logic', this.logic)
}
},
methods: {
clearAll () {
this.$set(this.logic, 'conditions', null)
this.$set(this.logic, 'actions', [])
this.refreshActions()
},
onActionInput () {
if (this.logic.actions.length >= 2) {
if (this.logic.actions[1] === 'require-answer' && this.logic.actions[0] === 'hide-block') {
this.$set(this.logic, 'actions', ['require-answer'])
} else if (this.logic.actions[1] === 'hide-block' && this.logic.actions[0] === 'require-answer') {
this.$set(this.logic, 'actions', ['hide-block'])
}
this.refreshActions()
}
},
cleanConditions () {
if (this.required && this.logic.actions.includes('require-answer')) {
this.$set(this.logic, 'actions', this.logic.actions.filter((action) => action !== 'require-answer'))
} else if (!this.required && this.logic.actions.includes('make-it-optional')) {
this.$set(this.logic, 'actions', this.logic.actions.filter((action) => action !== 'make-it-optional'))
}
this.resetKey++
},
refreshActions () {
this.resetKey++
},
copyLogic () {
if (this.copyFrom) {
const property = this.form.properties.find((property) => {
return property.id === this.copyFrom
})
if (property && property.logic) {
this.$set(this, 'logic', clonedeep(property.logic))
this.cleanConditions()
}
}
this.showCopyFormModal = false
}
}
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex px-4 py-1">
<select-input ref="ruleSelect" v-model="selectedRule" class="flex-grow mr-1"
wrapper-class="relative" placeholder="Add condition on input field"
:options="groupCtrl.rules" margin-bottom=""
emit-key="identifier"
option-key="identifier"
name="group-control-slot-rule"
/>
<v-button class="ml-1" color="blue" size="small" :disabled="selectedRule === ''" @click="addRule">
Add Condition
</v-button>
<v-button class="ml-1" color="green" size="small" @click="groupCtrl.newGroup">
Add Group
</v-button>
</div>
</template>
<script>
export default {
components: {},
props: { groupCtrl: { type: Object, required: true } },
data () {
return {
selectedRule: null
}
},
methods: {
addRule () {
if (this.selectedRule) {
this.groupCtrl.addRule(this.selectedRule)
this.$refs.ruleSelect.content = null
this.selectedRule = null
}
}
}
}
</script>

View File

@@ -0,0 +1,178 @@
<template>
<modal :show="show" @close="close">
<div v-if="field">
<div class="flex">
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue flex-grow">
Configure "<span class="truncate">{{ field.name }}</span>" block
</h2>
<div>
<v-button color="red" size="small" @click="removeBlock">
Remove Block
</v-button>
</div>
</div>
<div v-if="field.type == 'nf-text'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<rich-text-area-input name="content"
:form="field"
label="Content"
:required="false"
/>
</div>
<div v-else-if="field.type == 'nf-page-break'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<text-input name="next_btn_text"
:form="field"
label="Text of next button"
:required="true"
/>
<text-input name="previous_btn_text"
:form="field"
label="Text of previous button"
help="Shown on the next page"
:required="true"
/>
</div>
<div v-else-if="field.type == 'nf-page-body-input'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<div class="-mx-4 sm:-mx-6 p-5 pt-0 border-b">
<h3 class="font-semibold block text-lg">
General
</h3>
<p class="text-gray-400 mb-5">
Exclude this field or make it required.
</p>
<v-checkbox v-model="field.hidden" class="mb-3"
:name="field.id+'_hidden'"
@input="onFieldHiddenChange"
>
Hidden
</v-checkbox>
<v-checkbox v-model="field.required"
:name="field.id+'_required'"
@input="onFieldRequiredChange"
>
Required
</v-checkbox>
</div>
<div class="-mx-4 sm:-mx-6 p-5">
<h3 class="font-semibold block text-lg">
Customization
<pro-tag/>
</h3>
<p class="text-gray-400 mb-5">
Change your form field name, pre-fill a value, add hints.
</p>
<text-input name="name" class="mt-4"
:form="field" :required="true"
label="Field Name"
/>
<text-area-input name="prefill" class="mt-4"
:form="field"
label="Pre-filled value"
/>
<!-- Placeholder -->
<text-input name="placeholder" class="mt-4"
:form="field"
label="Empty Input Text (Placeholder)"
/>
<!-- Help -->
<text-input name="help" class="mt-4"
:form="field"
label="Field Help"
help="Your field help will be shown below the field, just like this message."
/>
</div>
</div>
<div v-else-if="field.type == 'nf-divider'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<text-input name="name" class="mt-4"
:form="field" :required="true"
label="Field Name"
/>
</div>
<div v-else-if="field.type == 'nf-image'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<text-input name="name" class="mt-4"
:form="field" :required="true"
label="Field Name"
/>
<image-input name="image_block" class="mt-4"
:form="field" label="Upload Image" :required="false"
/>
</div>
<div v-else class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<p>No settings found.</p>
</div>
<!-- Logic Block -->
<form-block-logic-editor :form="form" :field="field" v-model="form"/>
<div class="pt-5 text-right">
<v-button color="gray" shade="light" @click="close">
Close
</v-button>
</div>
</div>
<div v-else class="text-center p-10">
Field not found.
</div>
</modal>
</template>
<script>
import ProTag from '../../../common/ProTag'
import FormBlockLogicEditor from '../components/form-logic-components/FormBlockLogicEditor'
export default {
name: 'FormBlockOptionsModal',
components: {ProTag, FormBlockLogicEditor},
props: {
field: {
type: Object,
required: false
},
form: {
type: Object,
required: false
},
show: {
type: Boolean,
required: false
}
},
data() {
return {}
},
computed: {},
watch: {},
mounted() {
},
methods: {
close() {
this.$emit('close')
},
removeBlock() {
this.close()
this.$emit('remove-block', this.field)
},
onFieldRequiredChange(val) {
this.$set(this.field, 'required', val)
if (this.field.required) {
this.$set(this.field, 'hidden', false)
}
},
onFieldHiddenChange(val) {
this.$set(this.field, 'hidden', val)
if (this.field.hidden) {
this.$set(this.field, 'required', false)
}
}
}
}
</script>

View File

@@ -0,0 +1,437 @@
<template>
<modal :show="show" @close="close">
<div v-if="field">
<div class="flex">
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue flex-grow">
Configure "<span class="truncate">{{ field.name }}</span>" block
</h2>
<div>
<v-button color="red" size="small" @click="removeBlock">
Remove Block
</v-button>
</div>
</div>
<!-- General -->
<div class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<h3 class="font-semibold block text-lg">
General
</h3>
<p class="text-gray-400 mb-5">
Exclude this field or make it required.
</p>
<v-checkbox v-model="field.hidden" class="mb-3"
:name="field.id+'_hidden'"
@input="onFieldHiddenChange"
>
Hidden
</v-checkbox>
<v-checkbox v-model="field.required" class="mb-3"
:name="field.id+'_required'"
@input="onFieldRequiredChange"
>
Required
</v-checkbox>
</div>
<!-- File Uploads -->
<div v-if="field.type === 'files'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
File uploads
</h3>
<v-checkbox v-model="field.multiple" class="mt-4"
:name="field.id+'_multiple'"
>
Allow multiple files
</v-checkbox>
<text-input name="allowed_file_types" class="mt-4" :form="field"
label="Allowed file types" placeholder="jpg,jpeg,png,gif"
help="Comma separated values, leave blank to allow all file types"
/>
</div>
<!-- Number Options -->
<div v-if="field.type === 'number'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Number Options
<pro-tag />
</h3>
<v-checkbox v-model="field.is_rating" class="mt-4"
:name="field.id+'_is_rating'" @input="initRating"
>
Rating
</v-checkbox>
<p class="text-gray-400 mb-5">
If enabled then this field will be star rating input.
</p>
<text-input v-if="field.is_rating" name="rating_max_value" native-type="number" :min="1" class="mt-4"
:form="field" required
label="Max rating value"
/>
</div>
<!-- Text Options -->
<div v-if="field.type === 'text' && displayBasedOnAdvanced" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Text Options
</h3>
<p class="text-gray-400 mb-5">
Keep it simple or make it a multi-lines input.
</p>
<v-checkbox v-model="field.multi_lines"
:name="field.id+'_multi_lines'"
@input="$set(field,'multi_lines',$event)"
>
Multi-lines input
</v-checkbox>
</div>
<!-- Date Options -->
<div v-if="field.type === 'date'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Date Options
<pro-tag />
</h3>
<v-checkbox v-model="field.date_range" class="mt-4"
:name="field.id+'_date_range'"
@input="onFieldDateRangeChange"
>
Date Range
</v-checkbox>
<p class="text-gray-400 mb-5">
Adds an end date. This cannot be used with the time option yet.
</p>
<v-checkbox v-model="field.with_time"
:name="field.id+'_with_time'"
@input="onFieldWithTimeChange"
>
Date with time
</v-checkbox>
<p class="text-gray-400 mb-5">
Include time. Or not. This cannot be used with the date range option yet.
</p>
<select-input v-if="field.with_time" name="timezone" class="mt-4"
:form="field" :options="timezonesOptions"
label="Timezone" :searchable="true"
help="Make sure to select correct timezone. Leave blank otherwise."
/>
</div>
<!-- select/multiselect Options -->
<div v-if="['select','multi_select'].includes(field.type)" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Select Options
<pro-tag />
</h3>
<p class="text-gray-400 mb-5">
Advanced options for your select/multiselect fields.
</p>
<text-area-input v-model="optionsText" :name="field.id+'_options_text'" class="mt-4"
@input="onFieldOptionsChange"
label="Set selection options"
help="Add one option per line"
/>
<v-checkbox v-model="field.allow_creation"
name="allow_creation" @input="onFieldAllowCreationChange" help=""
>
Allow respondent to create new options
</v-checkbox>
<v-checkbox v-model="field.without_dropdown" class="mt-4"
name="without_dropdown" @input="onFieldWithoutDropdownChange" help=""
>
Always show all select options
</v-checkbox>
<p class="text-gray-400 mb-5">Options won't be in a dropdown anymore, but will all be visible</p>
</div>
<!-- Customization - Placeholder, Prefill, Relabel, Field Help -->
<div v-if="displayBasedOnAdvanced" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Customization
<pro-tag />
</h3>
<p class="text-gray-400 mb-5">
Change your form field name, pre-fill a value, add hints.
</p>
<text-input name="name" class="mt-4"
:form="field" :required="true"
label="Field Name"
/>
<v-checkbox v-model="field.hide_field_name" class="mb-3"
:name="field.id+'_hide_field_name'"
>
Hide field name
</v-checkbox>
<!-- Pre-fill depends on type -->
<v-checkbox v-if="field.type=='checkbox'" v-model="field.prefill" class="mt-4"
:name="field.id+'_prefill'"
@input="$set(field,'prefill',$event)"
>
Pre-filled value
</v-checkbox>
<select-input v-else-if="['select','multi_select'].includes(field.type)" name="prefill" class="mt-4"
:form="field" :options="prefillSelectsOptions"
label="Pre-filled value"
:multiple="field.type==='multi_select'"
/>
<text-area-input v-else-if="field.type === 'text' && field.multi_lines"
name="prefill" class="mt-4"
:form="field"
label="Pre-filled value"
/>
<text-input v-else-if="field.type!=='files'" name="prefill" class="mt-4"
:form="field"
label="Pre-filled value"
/>
<div v-if="['select','multi_select'].includes(field.type)" class="-mt-3 mb-3 text-gray-400 dark:text-gray-500">
<small>
A problem? <a href="#" @click.prevent="field.prefill=null">Click here to clear your pre-fill</a>
</small>
</div>
<!-- Placeholder -->
<text-input v-if="hasPlaceholder" name="placeholder" class="mt-4"
:form="field"
label="Empty Input Text (Placeholder)"
/>
<!-- Help -->
<text-input name="help" class="mt-4"
:form="field"
label="Field Help"
help="Your field help will be shown below the field, just like this message."
/>
<select-input name="width" class="mt-4"
:options="[
{name:'Full',value:'full'},
{name:'1/2 (half width)',value:'1/2'},
{name:'1/3 (a third of the width)',value:'1/3'},
{name:'2/3 (two thirds of the width)',value:'2/3'},
{name:'1/4 (a quarter of the width)',value:'1/4'},
{name:'3/4 (three quarters of the width)',value:'3/4'},
]"
:form="field" label="Field Width"
/>
<template v-if="['text','number','url','email','phone_number'].includes(field.type)">
<text-input v-model="field.max_char_limit" name="max_char_limit" native-type="number" :min="1" :max="2000" :form="field"
label="Max character limit"
help="Maximum character limit of 2000"
:required="false"
/>
<checkbox-input name="show_char_limit" :form="field" class="mt-4"
label="Always show character limit"
/>
</template>
</div>
<!-- Advanced Options -->
<div v-if="field.type === 'text'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Advanced Options
<pro-tag />
</h3>
<v-checkbox v-model="field.generates_uuid"
:name="field.id+'_generates_uuid'"
@input="onFieldGenUIdChange"
>
Generates a unique id on submission
</v-checkbox>
<p class="text-gray-400 mb-5">
If you enable this, we will hide this field and fill it a unique id (UUID format) on each new form submission
</p>
<v-checkbox v-model="field.generates_auto_increment_id"
:name="field.id+'_generates_auto_increment_id'"
@input="onFieldGenAutoIdChange"
>
Generates an auto-incremented id on submission
</v-checkbox>
<p class="text-gray-400 mb-5">
If you enable this, we will hide this field and fill it a unique number on each new form submission
</p>
</div>
<!-- Logic Block -->
<form-block-logic-editor v-model="form" :form="form" :field="field" />
<div class="pt-5 text-right">
<v-button color="red" @click="removeBlock">
Remove Field
</v-button>
<v-button color="gray" shade="light" @click="close">
Close
</v-button>
</div>
</div>
<div v-else class="text-center p-10">
Field not found.
</div>
</modal>
</template>
<script>
import VButton from '../../../common/Button'
import ProTag from '../../../common/ProTag'
import TextInput from '../../../forms/TextInput'
import TextAreaInput from '../../../forms/TextAreaInput'
import timezones from '../../../../../data/timezones.json'
import FormBlockLogicEditor from '../components/form-logic-components/FormBlockLogicEditor'
export default {
name: 'FormFieldOptionsModal',
components: { TextAreaInput, TextInput, ProTag, VButton, FormBlockLogicEditor },
props: {
field: {
type: Object,
required: false
},
form: {
type: Object,
required: false
},
show: {
type: Boolean,
required: false
}
},
data () {
return {
typesWithoutPlaceholder: ['date', 'checkbox', 'files']
}
},
computed: {
hasPlaceholder () {
return !this.typesWithoutPlaceholder.includes(this.field.type)
},
prefillSelectsOptions () {
if (!['select', 'multi_select'].includes(this.field.type)) return {}
return this.field[this.field.type].options.map(option => {
return {
name: option.name,
value: option.id
}
})
},
timezonesOptions () {
if (this.field.type !== 'date') return []
return timezones.map((timezone) => {
return {
name: timezone.text,
value: timezone.utc[0]
}
})
},
displayBasedOnAdvanced () {
if (this.field.generates_uuid || this.field.generates_auto_increment_id) {
return false
}
return true
},
optionsText(){
return this.field[this.field.type].options.map(option => {
return option.name
}).join("\n")
}
},
watch: {},
mounted () {
if(['text','number','url','email','phone_number'].includes(this.field.type) && !this.field.max_char_limit){
this.field.max_char_limit = 2000
}
},
methods: {
close () {
this.$emit('close')
},
removeBlock () {
this.close()
this.$emit('remove-block', this.field)
},
onFieldRequiredChange (val) {
this.$set(this.field, 'required', val)
if (this.field.required) {
this.$set(this.field, 'hidden', false)
}
},
onFieldHiddenChange (val) {
this.$set(this.field, 'hidden', val)
if (this.field.hidden) {
this.$set(this.field, 'required', false)
} else {
this.$set(this.field, 'generates_uuid', false)
this.$set(this.field, 'generates_auto_increment_id', false)
}
},
onFieldDateRangeChange (val) {
this.$set(this.field, 'date_range', val)
if (this.field.date_range) {
this.$set(this.field, 'with_time', false)
}
},
onFieldWithTimeChange (val) {
this.$set(this.field, 'with_time', val)
if (this.field.with_time) {
this.$set(this.field, 'date_range', false)
}
},
onFieldGenUIdChange (val) {
this.$set(this.field, 'generates_uuid', val)
if (this.field.generates_uuid) {
this.$set(this.field, 'generates_auto_increment_id', false)
this.$set(this.field, 'hidden', true)
}
},
onFieldGenAutoIdChange (val) {
this.$set(this.field, 'generates_auto_increment_id', val)
if (this.field.generates_auto_increment_id) {
this.$set(this.field, 'generates_uuid', false)
this.$set(this.field, 'hidden', true)
}
},
initRating () {
if (this.field.is_rating && !this.field.rating_max_value) {
this.$set(this.field, 'rating_max_value', 5)
}
},
onFieldOptionsChange (val) {
const vals = (val) ? val.trim().split("\n") : []
const tmpOpts = vals.map(name => {
return {
name: name,
id: name
}
})
this.$set(this.field, this.field.type, {'options': tmpOpts})
},
onFieldAllowCreationChange (val) {
this.$set(this.field, 'allow_creation', val)
if(this.field.allow_creation){
this.$set(this.field, 'without_dropdown', false)
}
},
onFieldWithoutDropdownChange (val) {
this.$set(this.field, 'without_dropdown', val)
if(this.field.without_dropdown){
this.$set(this.field, 'allow_creation', false)
}
},
}
}
</script>