Initial commit
This commit is contained in:
52
resources/js/components/open/NotionPage.vue
Normal file
52
resources/js/components/open/NotionPage.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<notion-renderer v-if="!loading" :block-map="blockMap" />
|
||||
<div v-else class="my-10 py-20 flex items-center justify-center">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NotionRenderer, getPageBlocks } from 'vue-notion'
|
||||
|
||||
export default {
|
||||
name: 'NotionPage',
|
||||
components: { NotionRenderer },
|
||||
props: {
|
||||
pageId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
blockMap: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
apiUrl: () => window.config.notion.worker
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
// get Notion blocks from the API via a Notion pageId
|
||||
this.loading = true
|
||||
getPageBlocks(this.pageId, this.apiUrl).then((blocks) => {
|
||||
this.blockMap = blocks
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "vue-notion/src/styles.css"; /* optional Notion-like styles */
|
||||
|
||||
.notion-blue {
|
||||
@apply text-nt-blue;
|
||||
}
|
||||
</style>
|
||||
239
resources/js/components/open/forms/OpenCompleteForm.vue
Normal file
239
resources/js/components/open/forms/OpenCompleteForm.vue
Normal 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>
|
||||
398
resources/js/components/open/forms/OpenForm.vue
Normal file
398
resources/js/components/open/forms/OpenForm.vue
Normal 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>
|
||||
84
resources/js/components/open/forms/OpenFormButton.vue
Normal file
84
resources/js/components/open/forms/OpenFormButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
157
resources/js/components/open/forms/components/FormEditor.vue
Normal file
157
resources/js/components/open/forms/components/FormEditor.vue
Normal 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>
|
||||
@@ -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>
|
||||
121
resources/js/components/open/forms/components/FormStats.vue
Normal file
121
resources/js/components/open/forms/components/FormStats.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
369
resources/js/components/open/tables/OpenTable.vue
Normal file
369
resources/js/components/open/tables/OpenTable.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<table :id="'table-'+tableHash" ref="table"
|
||||
class="notion-table n-table whitespace-no-wrap bg-white dark:bg-notion-dark-light relative"
|
||||
>
|
||||
<thead :id="'table-header-'+tableHash" ref="header"
|
||||
class="n-table-head top-0"
|
||||
:class="{'absolute': data.length !== 0}"
|
||||
style="will-change: transform; transform: translate3d(0px, 0px, 0px)"
|
||||
>
|
||||
<tr class="n-table-row overflow-x-hidden">
|
||||
<resizable-th v-for="col, index in form.properties" :id="'table-head-cell-' + col.id" :key="col.id"
|
||||
scope="col" :allow-resize="allowResize" :width="(col.width ? col.width + 'px':'auto')"
|
||||
class="n-table-cell p-0 relative"
|
||||
@resize-width="resizeCol(col, $event)"
|
||||
>
|
||||
<p
|
||||
:class="{'border-r': index < form.properties.length - 1 || hasActions}"
|
||||
class="bg-gray-50 dark:bg-notion-dark truncate sticky top-0 border-b border-gray-200 dark:border-gray-800 px-4 py-2 text-gray-500 font-semibold tracking-wider uppercase text-xs"
|
||||
>
|
||||
{{ col.name }}
|
||||
</p>
|
||||
</resizable-th>
|
||||
<th v-if="hasActions" class="n-table-cell p-0 relative" style="width: 91px">
|
||||
<p
|
||||
class="bg-gray-50 dark:bg-notion-dark truncate sticky top-0 border-b border-gray-200 dark:border-gray-800 px-4 py-2 text-gray-500 font-semibold tracking-wider uppercase text-xs">
|
||||
Actions
|
||||
</p>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="data.length > 0" class="n-table-body bg-white dark:bg-notion-dark-light">
|
||||
<tr v-if="$slots.hasOwnProperty('actions')"
|
||||
:id="'table-actions-'+tableHash"
|
||||
ref="actions-row"
|
||||
class="action-row absolute w-full"
|
||||
style="will-change: transform; transform: translate3d(0px, 32px, 0px)"
|
||||
>
|
||||
<td :colspan="form.properties.length" class="p-1">
|
||||
<slot name="actions"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="row, index in data" :key="row.id" class="n-table-row" :class="{'first':index===0}">
|
||||
<td v-for="col, colIndex in form.properties"
|
||||
:key="col.id"
|
||||
:style="{width: col.width + 'px'}"
|
||||
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 overflow-hidden"
|
||||
:class="[{'border-b': index !== data.length - 1, 'border-r': colIndex !== form.properties.length - 1 || hasActions},
|
||||
colClasses(col)]"
|
||||
>
|
||||
<component :is="fieldComponents[col.type]" class="border-gray-100 dark:border-gray-900"
|
||||
:property="col" :value="row[col.id]"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loading" class="n-table-row border-t bg-gray-50 dark:bg-gray-900">
|
||||
<td :colspan="form.properties.length" class="p-8 w-full">
|
||||
<loader class="w-4 h-4 mx-auto"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else key="body-content" class="n-table-body">
|
||||
<tr class="n-table-row loader w-full">
|
||||
<td :colspan="form.properties.length" class="n-table-cell w-full p-8">
|
||||
<loader v-if="loading" class="w-4 h-4 mx-auto"/>
|
||||
<p v-else class="text-gray-500 text-center">
|
||||
No data found.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OpenText from './components/OpenText'
|
||||
import OpenUrl from './components/OpenUrl'
|
||||
import OpenSelect from './components/OpenSelect'
|
||||
import OpenDate from './components/OpenDate'
|
||||
import OpenFile from './components/OpenFile'
|
||||
import OpenCheckbox from './components/OpenCheckbox'
|
||||
import ResizableTh from './components/ResizableTh'
|
||||
import clonedeep from 'clone-deep'
|
||||
|
||||
const cyrb53 = function (str, seed = 0) {
|
||||
let h1 = 0xdeadbeef ^ seed
|
||||
let h2 = 0x41c6ce57 ^ seed
|
||||
for (let i = 0, ch; i < str.length; i++) {
|
||||
ch = str.charCodeAt(i)
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761)
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677)
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
|
||||
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {ResizableTh},
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowResize: {
|
||||
required: false,
|
||||
default: true,
|
||||
type: Boolean
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
tableHash: null,
|
||||
skip: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get() {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
hasActions() {
|
||||
return false
|
||||
},
|
||||
fieldComponents() {
|
||||
return {
|
||||
text: OpenText,
|
||||
number: OpenText,
|
||||
select: OpenSelect,
|
||||
multi_select: OpenSelect,
|
||||
date: OpenDate,
|
||||
files: OpenFile,
|
||||
checkbox: OpenCheckbox,
|
||||
url: OpenUrl,
|
||||
email: OpenText,
|
||||
phone_number: OpenText,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'form.properties': {
|
||||
handler() {
|
||||
this.onStructureChange()
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
data() {
|
||||
this.$nextTick(() => {
|
||||
this.handleScroll()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const parent = document.getElementById('table-page')
|
||||
this.tableHash = cyrb53(JSON.stringify(this.form.properties))
|
||||
parent.addEventListener('scroll', this.handleScroll, {passive: true})
|
||||
window.addEventListener('resize', this.handleScroll)
|
||||
this.onStructureChange()
|
||||
this.handleScroll()
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
const parent = document.getElementById('table-page')
|
||||
parent.removeEventListener('scroll', this.handleScroll)
|
||||
window.removeEventListener('resize', this.handleScroll)
|
||||
},
|
||||
|
||||
methods: {
|
||||
colClasses(col) {
|
||||
let colAlign, colColor, colFontWeight, colWrap
|
||||
|
||||
// Column align
|
||||
colAlign = `text-${col.alignment ? col.alignment : 'left'}`
|
||||
|
||||
// Column color
|
||||
colColor = null
|
||||
if (!col.hasOwnProperty('color') || col.color === 'default') {
|
||||
colColor = 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
colColor = `text-${col.color}`
|
||||
|
||||
// Column font weight
|
||||
if (col.hasOwnProperty('bold') && col.bold) {
|
||||
colFontWeight = 'font-semibold'
|
||||
}
|
||||
|
||||
// Column wrapping
|
||||
if (!col.hasOwnProperty('wrap_text') || !col.wrap_text) {
|
||||
colWrap = 'truncate'
|
||||
}
|
||||
|
||||
return [colAlign, colColor, colWrap, colFontWeight]
|
||||
},
|
||||
onStructureChange() {
|
||||
if (this.form.properties) {
|
||||
this.$nextTick(() => {
|
||||
this.form.properties.forEach(col => {
|
||||
if (!col.hasOwnProperty('width')) {
|
||||
if (this.allowResize && this.form !== null && document.getElementById('table-head-cell-' + col.id)) {
|
||||
// Within editor
|
||||
this.resizeCol(col, document.getElementById('table-head-cell-' + col.id).offsetWidth)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
resizeCol(col, width) {
|
||||
if (!this.form) return
|
||||
const columns = clonedeep(this.form.properties)
|
||||
const index = this.form.properties.findIndex(c => c.id === col.id)
|
||||
columns[index].width = width
|
||||
this.$set(this.form, 'properties', columns)
|
||||
this.$nextTick(() => {
|
||||
this.$emit('resize')
|
||||
})
|
||||
},
|
||||
handleScroll() {
|
||||
const parent = document.getElementById('table-page')
|
||||
const posTop = parent.getBoundingClientRect().top
|
||||
const tablePosition = Math.max(0, posTop - this.$refs.table.getBoundingClientRect().top)
|
||||
const tableHeader = document.getElementById('table-header-' + this.tableHash)
|
||||
|
||||
// Set position of table header
|
||||
if (tableHeader) {
|
||||
tableHeader.style.transform = `translate3d(0px, ${tablePosition}px, 0px)`
|
||||
if (tablePosition > 0) {
|
||||
tableHeader.classList.add('border-t')
|
||||
} else {
|
||||
tableHeader.classList.remove('border-t')
|
||||
}
|
||||
}
|
||||
|
||||
// Set position of actions row
|
||||
if (this.$slots.hasOwnProperty('actions')) {
|
||||
const tableActionsRow = document.getElementById('table-actions-' + this.tableHash)
|
||||
if (tableActionsRow) {
|
||||
if (tablePosition > 100) {
|
||||
tableActionsRow.style.transform = `translate3d(0px, ${tablePosition + 33}px, 0px)`
|
||||
} else {
|
||||
const parentContainer = document.getElementById('table-page')
|
||||
tableActionsRow.style.transform = `translate3d(0px, ${parentContainer.offsetHeight + (posTop - this.$refs.table.getBoundingClientRect().top) - 35}px, 0px)`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.n-table {
|
||||
.n-table-head {
|
||||
height: 33px;
|
||||
|
||||
.resize-handler {
|
||||
height: 33px;
|
||||
width: 5px;
|
||||
margin-left: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.n-table-row {
|
||||
display: flex;
|
||||
|
||||
&.first, &.loader {
|
||||
margin-top: 33px;
|
||||
}
|
||||
}
|
||||
|
||||
.n-table-cell {
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.notion-table {
|
||||
|
||||
td {
|
||||
&.text-gray {
|
||||
color: #787774;
|
||||
}
|
||||
|
||||
&.text-brown {
|
||||
color: #9f6b53;
|
||||
}
|
||||
|
||||
&.text-orange {
|
||||
color: #d9730d;
|
||||
}
|
||||
|
||||
&.text-yellow {
|
||||
color: #cb912f;
|
||||
}
|
||||
|
||||
&.text-green {
|
||||
color: #448361;
|
||||
}
|
||||
|
||||
&.text-blue {
|
||||
color: #337ea9;
|
||||
}
|
||||
|
||||
&.text-purple {
|
||||
color: #9065b0;
|
||||
}
|
||||
|
||||
&.text-pink {
|
||||
color: #c14c8a;
|
||||
}
|
||||
|
||||
&.text-red {
|
||||
color: #d44c47;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.notion-table {
|
||||
td {
|
||||
&.text-gray {
|
||||
color: #9b9b9b;
|
||||
}
|
||||
|
||||
&.text-brown {
|
||||
color: #ba856f;
|
||||
}
|
||||
|
||||
&.text-orange {
|
||||
color: #c77d48;
|
||||
}
|
||||
|
||||
&.text-yellow {
|
||||
color: #ca9849;
|
||||
}
|
||||
|
||||
&.text-green {
|
||||
color: #529e72;
|
||||
}
|
||||
|
||||
&.text-blue {
|
||||
color: #5e87c9;
|
||||
}
|
||||
|
||||
&.text-purple {
|
||||
color: #9d68d3;
|
||||
}
|
||||
|
||||
&.text-pink {
|
||||
color: #d15796;
|
||||
}
|
||||
|
||||
&.text-red {
|
||||
color: #df5452;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<svg v-if="value===true" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mx-auto" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<svg v-else-if="value===false" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mx-auto" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
35
resources/js/components/open/tables/components/OpenDate.vue
Normal file
35
resources/js/components/open/tables/components/OpenDate.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<span v-if="valueIsObject">
|
||||
<template v-if="value[0]">{{ value[0] }}</template>
|
||||
<template v-if="value[1]"><b>to</b> {{ value[1] }}</template>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
valueIsObject () {
|
||||
if (typeof this.value === 'object' && this.value !== null) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
71
resources/js/components/open/tables/components/OpenFile.vue
Normal file
71
resources/js/components/open/tables/components/OpenFile.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<p class="text-xs">
|
||||
<span v-for="file in value" :key="file.file_url"
|
||||
class="whitespace-nowrap rounded-md transition-colors hover:decoration-none"
|
||||
:class="{'open-file text-gray-700 dark:text-gray-300 truncate':!isImage(file.file_url), 'open-file-img':isImage(file.file_url)}"
|
||||
>
|
||||
<a class="text-gray-700 dark:text-gray-300" :href="file.file_url" target="_blank"
|
||||
rel="nofollow"
|
||||
>
|
||||
<div v-if="isImage(file.file_url)" class="w-8 h-8">
|
||||
<img class="object-cover h-full w-full rounded" :src="file.file_url">
|
||||
</div>
|
||||
<span v-else
|
||||
class="py-1 px-2"
|
||||
>
|
||||
<a :href="file.file_url" target="_blank" download>{{ displayedFileName(file.file_name) }}</a>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {},
|
||||
mounted() {
|
||||
},
|
||||
|
||||
methods: {
|
||||
isImage(url) {
|
||||
return ['png', 'gif', 'jpg', 'jpeg', 'tif'].some((suffix) => {
|
||||
return url && url.endsWith(suffix)
|
||||
})
|
||||
},
|
||||
displayedFileName(fileName) {
|
||||
const extension = fileName.substr(fileName.lastIndexOf(".") + 1)
|
||||
const filename = fileName.substr(0, fileName.lastIndexOf("."))
|
||||
|
||||
if (filename.length > 12) {
|
||||
return filename.substr(0, 12) + '(...).' + extension
|
||||
}
|
||||
return filename + '.' + extension
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.open-file {
|
||||
max-width: 120px;
|
||||
background-color: #e3e2e0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.open-file {
|
||||
background-color: #5a5a5a;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<span class="-mb-2" v-if="value">
|
||||
<template v-if="valueIsObject">
|
||||
<open-tag v-for="val,index in value" :key="index" :opt="val" />
|
||||
</template>
|
||||
<open-tag v-else :opt="value" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OpenTag from './OpenTag'
|
||||
|
||||
export default {
|
||||
components: { OpenTag },
|
||||
props: {
|
||||
value: {
|
||||
type: Object | null,
|
||||
required: true
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
valueIsObject () {
|
||||
if (typeof this.value === 'object' && this.value !== null) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
101
resources/js/components/open/tables/components/OpenTag.vue
Normal file
101
resources/js/components/open/tables/components/OpenTag.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<span :id="opt"
|
||||
class="py-1 px-2 mb-1 open-tag default mr-2 text-gray-700 dark:text-gray-300 text-xs whitespace-nowrap rounded-md transition-colors"
|
||||
>
|
||||
{{ opt }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
opt: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.open-tag {
|
||||
display: inline-block;
|
||||
&.gray {
|
||||
background-color: #e3e2e0;
|
||||
}
|
||||
&.light-gray,&.default {
|
||||
background-color: #e3e2e080;
|
||||
}
|
||||
&.brown {
|
||||
background-color: #eee0da;
|
||||
}
|
||||
&.orange {
|
||||
background-color: #fadec9;
|
||||
}
|
||||
&.yellow {
|
||||
background-color: #fdecc8;
|
||||
}
|
||||
&.green {
|
||||
background-color: #dbeddb;
|
||||
}
|
||||
&.blue {
|
||||
background-color: #d3e5ef;
|
||||
}
|
||||
&.purple {
|
||||
background-color: #e8deee;
|
||||
}
|
||||
&.pink {
|
||||
background-color: #f5e0e9;
|
||||
}
|
||||
&.red {
|
||||
background-color: #ffe2dd;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.open-tag {
|
||||
&.gray {
|
||||
background-color: #5a5a5a;
|
||||
}
|
||||
&.light-gray,&.default {
|
||||
background-color: #ffffff21;
|
||||
}
|
||||
&.brown {
|
||||
background-color: #603b2c;
|
||||
}
|
||||
&.orange {
|
||||
background-color: #854c1d;
|
||||
}
|
||||
&.yellow {
|
||||
background-color: #89632a;
|
||||
}
|
||||
&.green {
|
||||
background-color: #2b593f;
|
||||
}
|
||||
&.blue {
|
||||
background-color: #28456c;
|
||||
}
|
||||
&.purple {
|
||||
background-color: #492f64;
|
||||
}
|
||||
&.pink {
|
||||
background-color: #69314c;
|
||||
}
|
||||
&.red {
|
||||
background-color: #6e3630;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
99
resources/js/components/open/tables/components/OpenText.vue
Normal file
99
resources/js/components/open/tables/components/OpenText.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<span v-if="!valueIsObject">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<span
|
||||
v-for="(item, i) in value.responseData"
|
||||
:key="i"
|
||||
:class="{
|
||||
'font-semibold': item.annotations.bold && !item.annotations.code,
|
||||
italic: item.annotations.italic,
|
||||
'line-through': item.annotations.strikethrough,
|
||||
underline: item.annotations.underline,
|
||||
'bg-pink-100 py-1 px-2 rounded-lg text-pink-500': item.annotations.code,
|
||||
'font-serif': item.type == 'equation',
|
||||
}"
|
||||
:style="{
|
||||
color:
|
||||
item.annotations.color != 'default'
|
||||
? getColor(item.annotations.color)
|
||||
: null,
|
||||
'background-color':
|
||||
item.annotations.color != 'default' &&
|
||||
item.annotations.color.split('_')[1]
|
||||
? getBgColor(item.annotations.color.split('_')[0])
|
||||
: 'none',
|
||||
}"
|
||||
>
|
||||
<a
|
||||
v-if="item.href"
|
||||
:href="item.href"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
class="text-blue-600 underline"
|
||||
>{{ item.plain_text }}</a>
|
||||
<span v-else-if="!item.href">{{ item.plain_text }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
valueIsObject () {
|
||||
if (
|
||||
typeof this.value === 'object' &&
|
||||
!Array.isArray(this.value) &&
|
||||
this.value !== null
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
getColor (color) {
|
||||
return {
|
||||
red: '#e03e3e',
|
||||
gray: '#9b9a97',
|
||||
brown: '#64473a',
|
||||
orange: '#d9730d',
|
||||
yellow: '#dfab01',
|
||||
teal: '#0f7b6c',
|
||||
blue: '#0b6e99',
|
||||
purple: '#6940a5',
|
||||
pink: '#ad1a72'
|
||||
}[color]
|
||||
},
|
||||
getBgColor (color) {
|
||||
return {
|
||||
red: '#fbe4e4',
|
||||
gray: '#ebeced',
|
||||
brown: '#e9e5e3',
|
||||
orange: '#faebdd',
|
||||
yellow: '#fbf3db',
|
||||
teal: '#ddedea',
|
||||
blue: '#ddebf1',
|
||||
purple: '#eae4f2',
|
||||
pink: '#f4dfeb'
|
||||
}[color]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
26
resources/js/components/open/tables/components/OpenUrl.vue
Normal file
26
resources/js/components/open/tables/components/OpenUrl.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<a class="text-gray-700 dark:text-gray-300 hover:underline" :href="value" target="_blank" rel="nofollow">{{ value }}</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<th ref="th" :style="{width: width}">
|
||||
<slot />
|
||||
<div v-if="allowResize" class="absolute right-0 top-0 w-0 z-10">
|
||||
<div class="resize-handler bg-transparent cursor-move hover:bg-blue-500 opacity-80 transition-colors"
|
||||
@mousedown="mouseDownHandler"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
allowResize: {
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
x: 0,
|
||||
w: 0
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
mouseDownHandler (e) {
|
||||
// Get the current mouse position
|
||||
this.x = e.clientX
|
||||
|
||||
// Calculate the dimension of element
|
||||
const styles = window.getComputedStyle(this.$refs.th)
|
||||
this.w = parseInt(styles.width, 10)
|
||||
|
||||
// Attach the listeners to `document`
|
||||
document.addEventListener('mousemove', this.mouseMoveHandler)
|
||||
document.addEventListener('mouseup', this.mouseUpHandler)
|
||||
},
|
||||
mouseMoveHandler (e) {
|
||||
// How far the mouse has been moved
|
||||
const dx = e.clientX - this.x
|
||||
|
||||
// Adjust the dimension of element
|
||||
this.$emit('resize-width', this.w + dx)
|
||||
},
|
||||
mouseUpHandler () {
|
||||
// Remove the handlers of `mousemove` and `mouseup`
|
||||
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
||||
document.removeEventListener('mouseup', this.mouseUpHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user