Work in progress

This commit is contained in:
Julien Nahum 2023-12-09 15:47:03 +01:00
parent f970557b76
commit 1f853e8178
315 changed files with 34058 additions and 25 deletions

24
client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

75
client/README.md Normal file
View File

@ -0,0 +1,75 @@
# Nuxt 3 Minimal Starter
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm run dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm run build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm run preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

83
client/app.vue Normal file
View File

@ -0,0 +1,83 @@
<template>
<div id="app" class="bg-white dark:bg-notion-dark">
<ServiceCrisp/>
<transition enter-active-class="linear duration-200 overflow-hidden"
enter-from-class="max-h-0"
enter-to-class="max-h-screen"
leave-active-class="linear duration-200 overflow-hidden"
leave-from-class="max-h-screen"
leave-to-class="max-h-0"
>
<div v-if="announcement && !isIframe" class="bg-nt-blue text-white text-center p-3 relative">
<a class="text-white font-semibold" href="" target="_blank">🚨
OpnForm beta is over 🚨</a>
<div role="button" class="text-white absolute right-0 top-0 p-3 cursor-pointer" @click="announcement=false">
<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="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</transition>
<transition name="page" mode="out-in">
<NuxtLayout>
<NuxtPage/>
</NuxtLayout>
</transition>
<ToolsStopImpersonation/>
<!-- <notifications />-->
</div>
</template>
<script>
import {computed} from 'vue'
import {useAppStore} from '~/stores/app'
export default {
el: '#app',
name: 'OpnForm',
components: {},
setup() {
const appStore = useAppStore()
return {
layout: computed(() => appStore.layout),
isIframe: useIsIframe()
}
},
data: () => ({
metaTitle: 'OpnForm',
metaDescription: 'Create beautiful forms for free. Unlimited fields, unlimited submissions. It\'s free and it takes less than 1 minute to create your first form.',
announcement: false,
alert: {
type: null,
autoClose: 0,
message: '',
confirmationProceed: null,
confirmationCancel: null
},
navbarHidden: false
}),
computed: {
isOnboardingPage() {
return this.$route.name === 'onboarding'
},
},
methods: {
workspaceAdded() {
this.$router.push({name: 'home'})
},
hideNavbar(hidden = true) {
this.navbarHidden = hidden
}
}
}
</script>

View File

@ -0,0 +1,46 @@
<template>
<input-wrapper v-bind="inputWrapperProps">
<template #label>
<span />
</template>
<v-checkbox :id="id?id:name" v-model="compVal" :disabled="disabled?true:null" :name="name">
<slot name="label">
{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span>
</slot>
</v-checkbox>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import VCheckbox from './components/VCheckbox.vue'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'CheckboxInput',
components: { InputWrapper, VCheckbox },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
mounted () {
this.compVal = !!this.compVal
}
}
</script>

View File

@ -0,0 +1,65 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<template #help>
<slot name="help" />
</template>
<div
:class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]"
>
<codemirror :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
:options="cmOptions"
:style="inputStyle" :name="name"
:placeholder="placeholder"
/>
</div>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'CodeInput',
components: { InputWrapper, codemirror },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () {
return {
cmOptions: {
// codemirror options
tabSize: 4,
mode: 'text/html',
theme: 'default',
lineNumbers: true,
line: true
}
}
}
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<input-wrapper v-bind="inputWrapperProps">
<template #label>
<span />
</template>
<div class="flex items-center">
<input :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
type="color" class="mr-2"
:name="name"
>
<slot name="label">
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
</slot>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import InputWrapper from './components/InputWrapper.vue'
import { inputProps, useFormInput } from './useFormInput.js'
export default {
name: 'ColorInput',
components: { InputWrapper },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
}
}
</script>

View File

@ -0,0 +1,190 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<div v-if="!dateRange" class="flex">
<input :id="id?id:name" v-model="fromDate" :type="useTime ? 'datetime-local' : 'date'" :class="inputClasses"
:disabled="disabled?true:null"
:style="inputStyle" :name="name" data-date-format="YYYY-MM-DD"
:min="setMinDate" :max="setMaxDate"
>
</div>
<div v-else :class="inputClasses">
<div class="flex -mx-2">
<p class="text-gray-900 px-4">
From
</p>
<input :id="id?id:name" v-model="fromDate" :type="useTime ? 'datetime-local' : 'date'" :disabled="disabled?true:null"
:style="inputStyle" :name="name" data-date-format="YYYY-MM-DD"
class="flex-grow border-transparent focus:outline-none "
:min="setMinDate" :max="setMaxDate"
>
<p class="text-gray-900 px-4">
To
</p>
<input v-if="dateRange" :id="id?id:name" v-model="toDate" :type="useTime ? 'datetime-local' : 'date'"
:disabled="disabled?true:null"
:style="inputStyle" :name="name" class="flex-grow border-transparent focus:outline-none"
:min="setMinDate" :max="setMaxDate"
>
</div>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import { fixedClasses } from '../../plugins/config/vue-tailwind/datePicker.js'
export default {
name: 'DateInput',
components: { InputWrapper },
mixins: [],
props: {
...inputProps,
withTime: { type: Boolean, default: false },
dateRange: { type: Boolean, default: false },
disablePastDates: { type: Boolean, default: false },
disableFutureDates: { type: Boolean, default: false }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data: () => ({
fixedClasses: fixedClasses,
fromDate: null,
toDate: null
}),
computed: {
inputClasses () {
let str = 'border border-gray-300 dark:bg-notion-dark-light dark:border-gray-600 dark:placeholder-gray-500 dark:text-gray-300 flex-1 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-opacity-100 placeholder-gray-400 px-4 py-2 rounded-lg shadow-sm text-base text-black text-gray-700'
str += this.dateRange ? ' w-50' : ' w-full'
str += this.disabled ? ' !cursor-not-allowed !bg-gray-200' : ''
return str
},
useTime () {
return this.withTime && !this.dateRange
},
setMinDate () {
if (this.disablePastDates) {
return new Date().toISOString().split('T')[0]
}
return false
},
setMaxDate () {
if (this.disableFutureDates) {
return new Date().toISOString().split('T')[0]
}
return false
}
},
watch: {
color: {
handler () {
this.setInputColor()
},
immediate: true
},
fromDate: {
handler (val) {
if (this.dateRange) {
if (!Array.isArray(this.compVal)) {
this.compVal = []
}
this.compVal[0] = this.dateToUTC(val)
} else {
this.compVal = this.dateToUTC(val)
}
},
immediate: false
},
toDate: {
handler (val) {
if (this.dateRange) {
if (!Array.isArray(this.compVal)) {
this.compVal = [null]
}
this.compVal[1] = this.dateToUTC(val)
} else {
this.compVal = null
}
},
immediate: false
}
},
mounted () {
if (this.compVal) {
if (Array.isArray(this.compVal)) {
this.fromDate = this.compVal[0] ?? null
this.toDate = this.compVal[1] ?? null
} else {
this.fromDate = this.dateToLocal(this.compVal)
}
}
this.fixedClasses.input = this.theme.default.input
this.setInputColor()
},
methods: {
/**
* Pressing enter won't submit form
* @param event
* @returns {boolean}
*/
onEnterPress (event) {
event.preventDefault()
return false
},
setInputColor () {
if (this.$refs.datepicker) {
const dateInput = this.$refs.datepicker.$el.getElementsByTagName('input')[0]
dateInput.style.setProperty('--tw-ring-color', this.color)
}
},
dateToUTC (val) {
if (!val) {
return null
}
if (!this.useTime) {
return val
}
return new Date(val).toISOString()
},
dateToLocal (val) {
if (!val) {
return null
}
const dateObj = new Date(val)
let dateStr = dateObj.getFullYear() + '-' +
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
String(dateObj.getDate()).padStart(2, '0')
if (this.useTime) {
dateStr += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' +
String(dateObj.getMinutes()).padStart(2, '0')
}
return dateStr
}
}
}
</script>

View File

@ -0,0 +1,239 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<div class="flex w-full items-center justify-center transition-colors duration-40"
:class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
[theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent,
['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading}, theme.fileInput.input]"
@dragover.prevent="uploadDragoverEvent=true"
@dragleave.prevent="uploadDragoverEvent=false"
@drop.prevent="onUploadDropEvent"
@click="openFileUpload"
>
<div
v-if="loading"
class="text-gray-600 dark:text-gray-400"
>
<loader class="mx-auto h-6 w-6" />
<p class="mt-2 text-center text-sm text-gray-500">
Uploading your file...
</p>
</div>
<template v-else>
<div class="text-center">
<input
ref="actual-input"
class="hidden"
:multiple="multiple"
type="file"
:name="name"
:accept="acceptExtensions"
@change="manualFileUpload"
>
<div v-if="files.length" class="flex flex-wrap items-center justify-center gap-4">
<uploaded-file
v-for="file in files"
:key="file.url"
:file="file"
:theme="theme"
@remove="clearFile(file)"
/>
</div>
<template v-else>
<div class="text-gray-500 w-full flex justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
</div>
<p class="mt-2 text-sm text-gray-500 font-semibold select-none">
Click to choose {{ multiple ? 'file(s)' : 'a file' }} or drag here
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-600 select-none">
Size limit: {{ mbLimit }}MB per file
</p>
</template>
</div>
</template>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import axios from 'axios'
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import UploadedFile from './components/UploadedFile.vue'
export default {
name: 'FileInput',
components: { InputWrapper, UploadedFile },
mixins: [],
props: {
...inputProps,
multiple: { type: Boolean, default: true },
mbLimit: { type: Number, default: 5 },
accept: { type: String, default: '' },
moveToFormAssets: { type: Boolean, default: false }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data: () => ({
files: [],
uploadDragoverEvent: false,
loading: false
}),
computed: {
currentUrl () {
return this.form[this.name]
},
acceptExtensions () {
if (!this.accept) {
return null
}
return this.accept
.split(',')
.map((i) => {
return '.' + i.trim()
})
.join(',')
}
},
watch: {
files: {
deep: true,
handler (files) {
this.compVal = files.map((file) => file.url)
}
}
},
async created () {
if (typeof this.compVal === 'string' || this.compVal instanceof String) {
await this.getFileFromUrl(this.compVal).then((fileObj) => {
this.files = [{
file: fileObj,
url: this.compVal,
src: this.getFileSrc(fileObj)
}]
})
} else if (this.compVal && this.compVal.length > 0) {
const tmpFiles = []
for (let i = 0; i < this.compVal.length; i++) {
await this.getFileFromUrl(this.compVal[i]).then((fileObj) => {
tmpFiles.push({
file: fileObj,
url: this.compVal[i],
src: this.getFileSrc(fileObj)
})
})
}
this.files = tmpFiles
}
},
methods: {
clearAll () {
this.files = []
},
clearFile (index) {
this.files.splice(index, 1)
},
onUploadDropEvent (e) {
this.uploadDragoverEvent = false
this.droppedFiles(e.dataTransfer.files)
},
droppedFiles (droppedFiles) {
if (!droppedFiles || this.disabled) return
for (let i = 0; i < droppedFiles.length; i++) {
this.uploadFileToServer(droppedFiles.item(i))
}
},
openFileUpload () {
if (this.disabled) return
this.$refs['actual-input'].click()
},
manualFileUpload (e) {
const files = e.target.files
for (let i = 0; i < files.length; i++) {
this.uploadFileToServer(files.item(i))
}
},
uploadFileToServer (file) {
if (this.disabled) return
this.loading = true
this.storeFile(file)
.then((response) => {
if (!this.multiple) {
this.files = []
}
if (this.moveToFormAssets) {
// Move file to permanent storage for form assets
axios.post('/api/open/forms/assets/upload', {
type: 'files',
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
}).then(moveFileResponse => {
this.files.push({
file: file,
url: moveFileResponse.data.url,
src: this.getFileSrc(file)
})
this.loading = false
}).catch((error) => {
this.loading = false
})
} else {
this.files.push({
file: file,
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension,
src: this.getFileSrc(file)
})
this.loading = false
}
})
.catch((error) => {
this.clearAll()
this.loading = false
})
},
async getFileFromUrl (url, defaultType = 'image/jpeg') {
const response = await fetch(url)
const data = await response.blob()
const name = url.replace(/^.*(\\|\/|\:)/, '')
return new File([data], name, {
type: data.type || defaultType
})
},
getFileSrc (file) {
if (file.type && file.type.split('/')[0] === 'image') {
return URL.createObjectURL(file)
}
return null
}
}
}
</script>

View File

@ -0,0 +1,96 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
<div v-for="(option, index) in options" v-else :key="option[optionKey]" role="button"
:class="[theme.default.input,'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 flex',{ 'mb-2': index !== options.length,'!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
@click="onSelect(option[optionKey])"
>
<p class="flex-grow">
{{ option[displayKey] }}
</p>
<div v-if="isSelected(option[optionKey])" class="flex items-center">
<svg :color="color" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
/**
* Options: {name,value} objects
*/
export default {
name: 'FlatSelectInput',
components: { InputWrapper },
props: {
...inputProps,
options: { type: Array, required: true },
optionKey: { type: String, default: 'value' },
emitKey: { type: String, default: 'value' },
displayKey: { type: String, default: 'name' },
loading: { type: Boolean, default: false },
multiple: { type: Boolean, default: false }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () {
return {}
},
computed: {},
methods: {
onSelect (value) {
if (this.disabled) {
return
}
if (this.multiple) {
const emitValue = Array.isArray(this.compVal) ? [...this.compVal] : []
// Already in value, remove it
if (this.isSelected(value)) {
this.compVal = emitValue.filter((item) => {
return item !== value
})
return
}
// Otherwise add value
emitValue.push(value)
this.compVal = emitValue
} else {
this.compVal = (this.compVal === value) ? null : value
}
},
isSelected (value) {
if (!this.compVal) return false
if (this.multiple) {
return this.compVal.includes(value)
}
return this.compVal === value
}
}
}
</script>

View File

@ -0,0 +1,215 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<span class="inline-block w-full rounded-md shadow-sm">
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
class="cursor-pointer relative w-full" :class="[theme.default.input,{'ring-red-500 ring-2': hasValidation && form.errors.has(name)}]"
:style="inputStyle" @click.prevent="showUploadModal=true"
>
<div v-if="currentUrl==null" class="h-6 text-gray-600 dark:text-gray-400">
Upload image <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<div v-else class="h-6 text-gray-600 dark:text-gray-400 flex">
<div class="flex-grow">
<img :src="currentUrl" class="h-6 rounded shadow-md">
</div>
<a href="#" class="hover:text-nt-blue flex" @click.prevent="clearUrl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg></a>
</div>
</button>
</span>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
<!-- Modal -->
<modal :show="showUploadModal" @close="showUploadModal=false">
<h2 class="text-lg font-semibold">
Upload an image
</h2>
<div class="max-w-3xl mx-auto lg:max-w-none">
<div class="sm:mt-5 sm:grid sm:grid-cols-1 sm:gap-4 sm:items-start sm:pt-5">
<div class="mt-2 sm:mt-0 sm:col-span-2 mb-5">
<div
v-cloak
class="w-full flex justify-center items-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md h-128"
@dragover.prevent="onUploadDragoverEvent($event)"
@drop.prevent="onUploadDropEvent($event)"
>
<div v-if="loading" class="text-gray-600 dark:text-gray-400">
<loader class="h-6 w-6 mx-auto m-10" />
<p class="text-center mt-6">
Uploading your file...
</p>
</div>
<template v-else>
<div
class="absolute rounded-full bg-gray-100 h-20 w-20 z-10 transition-opacity duration-500 ease-in-out"
:class="{
'opacity-100': uploadDragoverTracking,
'opacity-0': !uploadDragoverTracking
}"
/>
<div class="relative z-20 text-center">
<input ref="actual-input" class="hidden" type="file" :name="name"
accept="image/png, image/gif, image/jpeg, image/bmp, image/svg+xml" @change="manualFileUpload"
>
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-24 w-24 text-gray-200" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p class="mt-5 text-sm text-gray-600">
<button
type="button"
class="font-semibold text-nt-blue hover:text-nt-blue-dark focus:outline-none focus:underline transition duration-150 ease-in-out"
@click="openFileUpload"
>
Upload your image,
</button>
use drag and drop or paste it
</p>
<p class="mt-1 text-xs text-gray-500">
.jpg, .jpeg, .png, .bmp, .gif, .svg up to 5mb
</p>
</div>
</template>
</div>
</div>
</div>
</div>
</modal>
</input-wrapper>
</template>
<script>
import axios from 'axios'
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import Modal from '../global/Modal.vue'
export default {
name: 'ImageInput',
components: { InputWrapper, Modal },
mixins: [],
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data: () => ({
showUploadModal: false,
file: [],
uploadDragoverTracking: false,
uploadDragoverEvent: false,
loading: false
}),
computed: {
currentUrl () {
return this.compVal
}
},
watch: {
showUploadModal: {
handler (val) {
if (process.server) return
document.removeEventListener('paste', this.onUploadPasteEvent)
if (this.showUploadModal) {
document.addEventListener('paste', this.onUploadPasteEvent)
}
}
}
},
methods: {
clearUrl () {
this.form[this.name] = null
},
onUploadDragoverEvent (e) {
this.uploadDragoverEvent = true
this.uploadDragoverTracking = true
},
onUploadDropEvent (e) {
this.uploadDragoverEvent = false
this.uploadDragoverTracking = false
this.droppedFiles(e.dataTransfer.files)
},
onUploadPasteEvent (e) {
if (!this.showUploadModal) return
this.uploadDragoverEvent = false
this.uploadDragoverTracking = false
this.droppedFiles(e.clipboardData.files)
},
droppedFiles (droppedFiles) {
if (!droppedFiles) return
this.file = droppedFiles[0]
this.uploadFileToServer()
},
openFileUpload () {
this.$refs['actual-input'].click()
},
manualFileUpload (e) {
this.file = e.target.files[0]
this.uploadFileToServer()
},
uploadFileToServer () {
this.loading = true
// Store file in s3
this.storeFile(this.file).then(response => {
// Move file to permanent storage for form assets
axios.post('/api/open/forms/assets/upload', {
url: this.file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
}).then(moveFileResponse => {
if (!this.multiple) {
this.files = []
}
this.compVal = moveFileResponse.data.url
this.showUploadModal = false
this.loading = false
}).catch((error) => {
this.compVal = null
this.showUploadModal = false
this.loading = false
})
}).catch((error) => {
this.compVal = null
this.showUploadModal = false
this.loading = false
})
}
}
}
</script>

View File

@ -0,0 +1,145 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<div :id="id ? id : name" :name="name" :style="inputStyle" class="flex items-center">
<v-select v-model="selectedCountryCode" class="w-[130px]" dropdown-class="w-[300px]" input-class="rounded-r-none"
:data="countries"
:disabled="(disabled || countries.length===1)?true:null" :searchable="true" :search-keys="['name']" :option-key="'code'" :color="color"
:has-error="hasValidation && form.errors.has(name)"
:placeholder="'Select a country'" :uppercase-labels="true" :theme="theme" @update:model-value="onChangeCountryCode"
>
<template #option="props">
<div class="flex items-center space-x-2 hover:text-white">
<country-flag size="normal" class="!-mt-[9px]" :country="props.option.code" />
<span class="grow">{{ props.option.name }}</span>
<span>{{ props.option.dial_code }}</span>
</div>
</template>
<template #selected="props">
<div class="flex items-center space-x-2 justify-center overflow-hidden">
<country-flag size="normal" class="!-mt-[9px]" :country="props.option.code" />
<span>{{ props.option.dial_code }}</span>
</div>
</template>
</v-select>
<input v-model="inputVal" type="text" class="inline-flex-grow !border-l-0 !rounded-l-none" :disabled="disabled?true:null"
:class="[theme.default.input, { '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200': disabled }]"
:placeholder="placeholder" :style="inputStyle" @update:model-value="onInput"
>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import countryCodes from '../../../data/country_codes.json'
import CountryFlag from 'vue-country-flag-next'
import parsePhoneNumber from 'libphonenumber-js'
export default {
phone: 'PhoneInput',
components: { InputWrapper, CountryFlag },
props: {
...inputProps,
canOnlyCountry: { type: Boolean, default: false },
unavailableCountries: { type: Array, default: () => [] }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () {
return {
selectedCountryCode: null,
inputVal: null
}
},
computed: {
countries () {
return countryCodes.filter((item) => {
return !this.unavailableCountries.includes(item.code)
})
}
},
watch: {
inputVal: {
handler (val) {
if (val && val.startsWith('0')) {
val = val.substring(1)
}
if (this.canOnlyCountry) {
this.compVal = (val) ? this.selectedCountryCode.code + this.selectedCountryCode.dial_code + val : this.selectedCountryCode.code + this.selectedCountryCode.dial_code
} else {
this.compVal = (val) ? this.selectedCountryCode.code + this.selectedCountryCode.dial_code + val : null
}
}
},
selectedCountryCode (newVal, oldVal) {
if (this.compVal && newVal && oldVal) {
this.compVal = this.compVal.replace(oldVal.code + oldVal.dial_code, newVal.code + newVal.dial_code)
}
}
},
mounted () {
if (this.compVal) {
if (!this.compVal.startsWith('+')) {
this.selectedCountryCode = this.getCountryBy(this.compVal.substring(2, 0))
}
const phoneObj = parsePhoneNumber(this.compVal)
if (phoneObj !== undefined && phoneObj) {
if (!this.selectedCountryCode && phoneObj.country !== undefined && phoneObj.country) {
this.selectedCountryCode = this.getCountryBy(phoneObj.country)
}
this.inputVal = phoneObj.nationalNumber
}
}
if (!this.selectedCountryCode) {
this.selectedCountryCode = this.getCountryBy()
}
if (!this.selectedCountryCode || this.countries.length === 1) {
this.selectedCountryCode = this.countries[0]
}
},
methods: {
getCountryBy (code = 'US', type = 'code') {
if (!code) code = 'US' // Default US
return this.countries.find((item) => {
return item[type] === code
}) ?? null
},
onInput (event) {
this.inputVal = event.target.value.replace(/[^0-9]/g, '')
},
onChangeCountryCode () {
if (!this.selectedCountryCode && this.countries.length > 0) {
this.selectedCountryCode = this.countries[0]
}
if (this.canOnlyCountry && (this.inputVal === null || this.inputVal === '' || !this.inputVal)) {
this.compVal = this.selectedCountryCode.code + this.selectedCountryCode.dial_code
}
}
}
}
</script>

View File

@ -0,0 +1,85 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<div class="stars-outer">
<div v-for="i in numberOfStars" :key="i"
class="cursor-pointer inline-block text-gray-200 dark:text-gray-800"
:class="{'!text-yellow-400 active-star':i<=compVal, '!text-yellow-200 !dark:text-yellow-800 hover-star':i>compVal && i<=hoverRating, '!cursor-not-allowed':disabled}"
role="button" @click="setRating(i)"
@mouseenter="onMouseHover(i)"
@mouseleave="hoverRating = -1"
>
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
</div>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'RatingInput',
components: { InputWrapper },
props: {
...inputProps,
numberOfStars: { type: Number, default: 5 }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () {
return {
hoverRating: -1
}
},
mounted () {
if (!this.compVal) this.compVal = 0
},
updated () {
if (!this.compVal) {
this.compVal = 0
}
},
methods: {
onMouseHover (i) {
this.hoverRating = (this.disabled) ? -1 : i
},
setRating (val) {
if (this.disabled) {
return
}
if (this.compVal === val) {
this.compVal = 0
} else {
this.compVal = val
}
}
}
}
</script>

View File

@ -0,0 +1,81 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<vue-editor :id="id?id:name" ref="editor" v-model="compVal" :disabled="disabled?true:null"
:placeholder="placeholder" :class="[{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }, theme.RichTextAreaInput.input]"
:editor-toolbar="editorToolbar" class="rich-editor resize-y"
:style="inputStyle"
/>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import { VueEditor, Quill } from 'vue3-editor'
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion')
export default {
name: 'RichTextAreaInput',
components: { InputWrapper, VueEditor },
props: {
...inputProps,
editorToolbar: {
type: Array,
default: () => {
return [
[{ header: 1 }, { header: 2 }],
['bold', 'italic', 'underline', 'link'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }]
]
}
}
},
setup (props, context) {
return {
...useFormInput(props, context)
}
}
}
</script>
<style lang="scss">
.rich-editor {
.ql-container {
border-bottom: 0px !important;
border-right: 0px !important;
border-left: 0px !important;
.ql-editor {
min-height: 100px !important;
}
}
.ql-toolbar {
border-top: 0px !important;
border-right: 0px !important;
border-left: 0px !important;
}
.ql-snow .ql-toolbar .ql-picker-item.ql-selected, .ql-snow .ql-toolbar .ql-picker-item:hover, .ql-snow .ql-toolbar .ql-picker-label.ql-active, .ql-snow .ql-toolbar .ql-picker-label:hover, .ql-snow .ql-toolbar button.ql-active, .ql-snow .ql-toolbar button:focus, .ql-snow .ql-toolbar button:hover, .ql-snow.ql-toolbar .ql-picker-item.ql-selected, .ql-snow.ql-toolbar .ql-picker-item:hover, .ql-snow.ql-toolbar .ql-picker-label.ql-active, .ql-snow.ql-toolbar .ql-picker-label:hover, .ql-snow.ql-toolbar button.ql-active, .ql-snow.ql-toolbar button:focus, .ql-snow.ql-toolbar button:hover {
@apply text-nt-blue;
}
}
</style>

View File

@ -0,0 +1,107 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<div class="rectangle-outer grid grid-cols-5 gap-2">
<div v-for="i in scaleList" :key="i"
:class="[{'font-semibold':compVal===i},theme.ScaleInput.button, compVal!==i ? unselectedButtonClass: '']"
:style="btnStyle(i===compVal)"
role="button" @click="setScale(i)"
>
{{ i }}
</div>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'ScaleInput',
components: { InputWrapper },
props: {
...inputProps,
minScale: { type: Number, default: 1 },
maxScale: { type: Number, default: 5 },
stepScale: { type: Number, default: 1 }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () {
return {}
},
computed: {
scaleList () {
const list = []
for (let i = this.minScale; i <= this.maxScale; i += this.stepScale) {
list.push(i)
}
return list
},
unselectedButtonClass () {
return this.theme.ScaleInput.unselectedButton
},
textColor () {
const color = (this.color.charAt(0) === '#') ? this.color.substring(1, 7) : this.color
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.55) ? '#000000' : '#FFFFFF'
}
},
mounted () {
if (this.compVal && typeof this.compVal === 'string') {
this.compVal = parseInt(this.compVal)
}
},
methods: {
btnStyle (isSelected) {
if (!isSelected) return {}
return {
color: this.textColor,
backgroundColor: this.color
}
},
setScale (val) {
if (this.disabled) {
return
}
if (this.compVal === val) {
this.compVal = null
} else {
this.compVal = val
}
}
}
}
</script>

View File

@ -0,0 +1,131 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<v-select v-model="compVal"
:data="finalOptions"
:label="label"
:option-key="optionKey"
:emit-key="emitKey"
:required="required"
:multiple="multiple"
:searchable="searchable"
:loading="loading"
:color="color"
:placeholder="placeholder"
:uppercase-labels="uppercaseLabels"
:theme="theme"
:has-error="hasValidation && form.errors.has(name)"
:allow-creation="allowCreation"
:disabled="disabled?true:null"
:help="help"
:help-position="helpPosition"
@update-options="updateOptions"
>
<template #selected="{option}">
<slot name="selected" :option="option" :optionName="getOptionName(option)">
<template v-if="multiple">
<div class="flex items-center truncate mr-6">
<span v-for="(item,index) in option" :key="item" class="truncate">
<span v-if="index!==0">, </span>
{{ getOptionName(item) }}
</span>
</div>
</template>
<template v-else>
<div class="flex items-center truncate mr-6">
<div>{{ getOptionName(option) }}</div>
</div>
</template>
</slot>
</template>
<template #option="{option, selected}">
<slot name="option" :option="option" :selected="selected">
<span class="flex group-hover:text-white">
<p class="flex-grow group-hover:text-white">
{{ option.name }}
</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>
</slot>
</template>
</v-select>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
/**
* Options: {name,value} objects
*/
export default {
name: 'SelectInput',
components: { InputWrapper },
props: {
...inputProps,
options: { type: Array, required: true },
optionKey: { type: String, default: 'value' },
emitKey: { type: String, default: 'value' },
displayKey: { type: String, default: 'name' },
loading: { type: Boolean, default: false },
multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: false },
allowCreation: { type: Boolean, default: false }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
data () {
return {
additionalOptions: []
}
},
computed: {
finalOptions () {
return this.options.concat(this.additionalOptions)
}
},
methods: {
getOptionName (val) {
const option = this.finalOptions.find((optionCandidate) => {
return optionCandidate[this.optionKey] === val
})
if (option) return option[this.displayKey]
return null
},
updateOptions (newItem) {
if (newItem) {
this.additionalOptions.push(newItem)
}
}
}
}
</script>

View File

@ -0,0 +1,62 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<VueSignaturePad ref="signaturePad"
:class="[theme.default.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
height="150px"
:name="name"
:options="{ onEnd }"
/>
<template #bottom_after_help>
<small :class="theme.default.help">
<a :class="theme.default.help" href="#" @click.prevent="clear">Clear</a>
</small>
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import { VueSignaturePad } from 'vue-signature-pad'
export default {
name: 'SignatureInput',
components: { InputWrapper, VueSignaturePad },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
methods: {
clear () {
this.$refs.signaturePad.clearSignature()
this.onEnd()
},
onEnd () {
if (this.disabled) {
this.$refs.signaturePad.clearSignature()
} else {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
this.form[this.name] = (!isEmpty && data) ? data : null
}
}
}
}
</script>

View File

@ -0,0 +1,56 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<textarea :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
:class="[theme.default.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
class="resize-y"
:name="name" :style="inputStyle"
:placeholder="placeholder"
:maxlength="maxCharLimit"
/>
<template v-if="maxCharLimit && showCharLimit" #bottom_after_help>
<small :class="theme.default.help">
{{ charCount }}/{{ maxCharLimit }}
</small>
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'TextAreaInput',
components: { InputWrapper },
mixins: [],
props: {
...inputProps,
maxCharLimit: { type: Number, required: false, default: null },
showCharLimit: { type: Boolean, required: false, default: false }
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
computed: {
charCount () {
return (this.compVal) ? this.compVal.length : 0
}
}
}
</script>

View File

@ -0,0 +1,76 @@
<template>
<input-wrapper
v-bind="inputWrapperProps"
>
<template #label>
<slot name="label" />
</template>
<input :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
:type="nativeType"
:pattern="pattern"
:style="inputStyle"
:class="[theme.default.input, { '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200': disabled }]"
:name="name" :accept="accept"
:placeholder="placeholder" :min="min" :max="max" :maxlength="maxCharLimit"
@change="onChange" @keydown.enter.prevent="onEnterPress"
>
<template v-if="maxCharLimit && showCharLimit" #bottom_after_help>
<small :class="theme.default.help">
{{ charCount }}/{{ maxCharLimit }}
</small>
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'TextInput',
components: { InputWrapper },
props: {
...inputProps,
nativeType: { type: String, default: 'text' },
accept: { type: String, default: null },
min: { type: Number, required: false, default: null },
max: { type: Number, required: false, default: null },
maxCharLimit: { type: Number, required: false, default: null },
showCharLimit: { type: Boolean, required: false, default: false },
pattern: { type: String, default: null }
},
setup (props, context) {
const onChange = (event) => {
if (props.nativeType !== 'file') return
const file = event.target.files[0]
// eslint-disable-next-line vue/no-mutating-props
props.form[props.name] = file
}
const onEnterPress = (event) => {
event.preventDefault()
return false
}
return {
...useFormInput(props, context, props.nativeType === 'file' ? 'file-' : null),
onEnterPress,
onChange
}
},
computed: {
charCount () {
return (this.compVal) ? this.compVal.length : 0
}
}
}
</script>

View File

@ -0,0 +1,46 @@
<template>
<input-wrapper v-bind="inputWrapperProps">
<template #label>
<span />
</template>
<div class="flex">
<v-switch :id="id?id:name" v-model="compVal" class="inline-block mr-2" :disabled="disabled?true:null" />
<slot name="label">
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
</slot>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import { inputProps, useFormInput } from './useFormInput.js'
import VSwitch from './components/VSwitch.vue'
import InputWrapper from './components/InputWrapper.vue'
export default {
name: 'ToggleSwitchInput',
components: { InputWrapper, VSwitch },
props: {
...inputProps
},
setup (props, context) {
return {
...useFormInput(props, context)
}
},
mounted () {
this.compVal = !!this.compVal
}
}
</script>

View File

@ -0,0 +1,21 @@
<template>
<div class="flex mb-1 input-help">
<small :class="theme.default.help" class="grow flex">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<slot name="after-help">
<small class="flex-grow" />
</slot>
</div>
</template>
<script>
export default {
name: 'InputHelp',
props: {
theme: { type: Object, required: true },
help: { type: String, required: false }
}
}
</script>

View File

@ -0,0 +1,25 @@
<template>
<label :for="nativeFor"
class="input-label"
:class="[theme.default.label,{'uppercase text-xs': uppercaseLabels, 'text-sm': !uppercaseLabels}]"
>
<slot>
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</slot>
</label>
</template>
<script>
export default {
name: 'InputLabel',
props: {
nativeFor: { type: String, default: null },
theme: { type: Object, required: true },
uppercaseLabels: { type: Boolean, default: false },
required: { type: Boolean, default: false },
label: { type: String, required: true }
}
}
</script>

View File

@ -0,0 +1,54 @@
<template>
<div :class="wrapperClass" :style="inputStyle">
<slot name="label">
<input-label v-if="label && !hideFieldName"
:label="label"
:theme="theme"
:required="required"
:native-for="id?id:name"
:uppercase-labels="uppercaseLabels"
/>
</slot>
<slot v-if="help && helpPosition==='above_input'" name="help">
<input-help :help="help" :theme="theme" />
</slot>
<slot />
<slot v-if="(help && helpPosition==='below_input') || $slots.bottom_after_help" name="help">
<input-help :help="help" :theme="theme">
<template #after-help>
<slot name="bottom_after_help" />
</template>
</input-help>
</slot>
<slot name="error">
<has-error v-if="hasValidation && form" :form="form" :field="name" />
</slot>
</div>
</template>
<script>
import InputLabel from './InputLabel.vue'
import InputHelp from './InputHelp.vue'
export default {
name: 'InputWrapper',
components: { InputLabel, InputHelp },
props: {
id: { type: String, required: false },
name: { type: String, required: false },
label: { type: String, required: false },
form: { type: Object, required: false },
theme: { type: Object, required: true },
wrapperClass: { type: String, required: false },
inputStyle: { type: Object, required: false },
help: { type: String, required: false },
helpPosition: { type: String, default: 'below_input' },
uppercaseLabels: { type: Boolean, default: true },
hideFieldName: { type: Boolean, default: true },
required: { type: Boolean, default: false },
hasValidation: { type: Boolean, default: true }
}
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<div
:class="[theme.fileInput.uploadedFile, 'overflow-hidden']"
:title="file.file.name"
>
<div v-if="file.src && !isImageHide" class="h-20 overflow-hidden flex">
<img class="block object-cover object-center w-full" :src="file.src" @error="isImageHide=true">
</div>
<div v-else class="h-20 flex items-center justify-center">
<svg class="w-10 h-10 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="0.8" stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
</div>
<div class="flex gap-2 items-center border-t py-1 px-2">
<p class="flex-grow text-left truncate text-gray-500 text-xs">
{{ file.file.name }}
</p>
<a
href="javascript:void(0);"
class="flex text-gray-400 rounded hover:bg-neutral-50 hover:text-red-500 dark:text-gray-600 p-1"
role="button"
title="Remove"
@click.stop="$emit('remove')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</a>
</div>
</div>
</template>
<script>
export default {
name: 'UploadedFile',
props: {
file: { default: null },
theme: { type: Object }
},
data: () => ({
isImageHide: false
}),
computed: {}
}
</script>

View File

@ -0,0 +1,72 @@
<template>
<div class="flex items-center">
<input
:id="id || name"
:name="name"
:checked="internalValue"
type="checkbox"
:class="sizeClasses"
class="rounded border-gray-500 cursor-pointer"
:disabled="disabled?true:null"
@click="handleClick"
>
<label :for="id || name" class="text-gray-700 dark:text-gray-300 ml-2" :class="{'!cursor-not-allowed':disabled}">
<slot />
</label>
</div>
</template>
<script setup>
import { ref, watch, onMounted, defineProps, defineEmits, defineOptions } from 'vue'
defineOptions({
name: 'VCheckbox'
})
const props = defineProps({
id: { type: String, default: null },
name: { type: String, default: 'checkbox' },
modelValue: { type: [Boolean, String], default: false },
checked: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
sizeClasses: { type: String, default: 'w-4 h-4' }
})
const emit = defineEmits(['update:modelValue', 'click'])
const internalValue = ref(props.modelValue)
watch(() => props.modelValue, val => {
internalValue.value = val
})
watch(() => props.checked, val => {
internalValue.value = val
})
watch(() => internalValue.value, (val, oldVal) => {
if (val === 0 || val === '0') val = false
if (val === 1 || val === '1') val = true
if (val !== oldVal) {
emit('update:modelValue', val)
}
})
if ('checked' in props) {
internalValue.value = props.checked
}
onMounted(() => {
emit('update:modelValue', internalValue.value)
})
const handleClick = (e) => {
emit('click', e)
if (!e.isPropagationStopped) {
internalValue.value = e.target.checked
emit('update:modelValue', internalValue.value)
}
}
</script>

View File

@ -0,0 +1,224 @@
<template>
<div class="v-select relative">
<span class="inline-block w-full rounded-md">
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
class="cursor-pointer"
:style="inputStyle"
:class="[theme.SelectInput.input,{'py-2': !multiple || loading,'py-1': multiple, '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200': disabled}, inputClass]"
@click.stop="toggleDropdown"
>
<div :class="{'h-6': !multiple, 'min-h-8': multiple && !loading}">
<transition name="fade" mode="out-in">
<loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
<div v-else-if="modelValue" key="value" class="flex" :class="{'min-h-8': multiple}">
<slot name="selected" :option="modelValue" />
</div>
<div v-else key="placeholder">
<slot name="placeholder">
<div class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
:class="{'py-1': multiple && !loading}"
>
{{ placeholder }}
</div>
</slot>
</div>
</transition>
</div>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</button>
</span>
<collapsible v-model="isOpen"
class="absolute mt-1 rounded-md bg-white dark:bg-notion-dark-light shadow-xl z-10"
:class="dropdownClass"
>
<ul tabindex="-1" role="listbox"
class="rounded-md text-base leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative"
:class="{'max-h-42 py-1': !isSearchable,'max-h-48 pb-1': isSearchable}"
>
<div v-if="isSearchable" class="px-2 pt-2 sticky top-0 bg-white dark-bg-notion-dark-light z-10">
<text-input v-model="searchTerm" name="search" :color="color" :theme="theme"
placeholder="Search..."
/>
</div>
<div v-if="loading" class="w-full py-2 flex justify-center">
<loader class="h-6 w-6 text-nt-blue mx-auto" />
</div>
<template v-if="filteredOptions.length > 0">
<li v-for="item in filteredOptions" :key="item[optionKey]" role="option" :style="optionStyle"
:class="{'px-3 pr-9': multiple, 'px-3': !multiple}"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
@click="select(item)"
>
<slot name="option" :option="item" :selected="isSelected(item)" />
</li>
</template>
<p v-else-if="!loading && !(allowCreation && searchTerm)" class="w-full text-gray-500 text-center py-2">
{{ (allowCreation ? 'Type something to add an option' : 'No option available') }}.
</p>
<li v-if="allowCreation && searchTerm" role="option" :style="optionStyle"
:class="{'px-3 pr-9': multiple, 'px-3': !multiple}"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
@click="createOption(searchTerm)"
>
Create <b class="px-1 bg-gray-300 rounded group-hover-text-black">{{ searchTerm }}</b>
</li>
</ul>
</collapsible>
</div>
</template>
<script>
import Collapsible from '~/components/global/transitions/Collapsible.vue'
import { themes } from '../../../config/form-themes'
import TextInput from '../TextInput.vue'
import debounce from 'debounce'
import Fuse from 'fuse.js'
export default {
name: 'VSelect',
components: { Collapsible, TextInput },
directives: {},
props: {
data: Array,
modelValue: { default: null },
inputClass: { type: String, default: null },
dropdownClass: { type: String, default: 'w-full' },
loading: { type: Boolean, default: false },
required: { type: Boolean, default: false },
multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: false },
hasError: { type: Boolean, default: false },
remote: { type: Function, default: null },
searchKeys: { type: Array, default: () => ['name'] },
optionKey: { type: String, default: 'id' },
emitKey: { type: String, default: null },
color: { type: String, default: '#3B82F6' },
placeholder: { type: String, default: null },
uppercaseLabels: { type: Boolean, default: true },
theme: { type: Object, default: () => themes.default },
allowCreation: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
},
data () {
return {
isOpen: false,
searchTerm: '',
defaultValue: this.modelValue ?? null
}
},
computed: {
optionStyle () {
return {
'--bg-form-color': this.color
}
},
inputStyle () {
return {
'--tw-ring-color': this.color
}
},
debouncedRemote () {
if (this.remote) {
return debounce(this.remote, 300)
}
return null
},
filteredOptions () {
if (!this.data) return []
if (!this.searchable || this.remote || this.searchTerm === '') {
return this.data
}
// Fuse search
const fuzeOptions = {
keys: this.searchKeys
}
const fuse = new Fuse(this.data, fuzeOptions)
return fuse.search(this.searchTerm).map((res) => {
return res.item
})
},
isSearchable () {
return this.searchable || this.remote !== null || this.allowCreation
}
},
watch: {
searchTerm (val) {
if (!this.debouncedRemote) return
if ((this.remote && val) || (val === '' && !this.modelValue) || (val === '' && this.isOpen)) {
return this.debouncedRemote(val)
}
}
},
methods: {
isSelected (value) {
if (!this.modelValue) return false
if (this.emitKey && value[this.emitKey]) {
value = value[this.emitKey]
}
if (this.multiple) {
return this.modelValue.includes(value)
}
return this.modelValue === value
},
toggleDropdown () {
if (this.disabled) {
this.isOpen = false
}
this.isOpen = !this.isOpen
if (!this.isOpen) {
this.searchTerm = ''
}
},
select (value) {
if (!this.multiple) {
// Close after select
this.toggleDropdown()
}
if (this.emitKey) {
value = value[this.emitKey]
}
if (this.multiple) {
const emitValue = Array.isArray(this.modelValue) ? [...this.modelValue] : []
if (this.isSelected(value)) {
this.$emit('update:modelValue', emitValue.filter((item) => {
if (this.emitKey) {
return item !== value
}
return item[this.optionKey] !== value && item[this.optionKey] !== value[this.optionKey]
}))
return
}
emitValue.push(value)
this.$emit('update:modelValue', emitValue)
} else {
if (this.modelValue === value) {
this.$emit('update:modelValue', this.defaultValue ?? null)
} else {
this.$emit('update:modelValue', value)
}
}
},
createOption (newOption) {
if (newOption) {
const newItem = {
name: newOption,
value: newOption
}
this.$emit('update-options', newItem)
this.select(newItem)
}
}
}
}
</script>

View File

@ -0,0 +1,22 @@
<template>
<div role="button" @click="onClick">
<div class="inline-flex items-center h-6 w-12 p-1 bg-gray-300 border rounded-full cursor-pointer focus:outline-none transition-all transform ease-in-out duration-100" :class="{'bg-nt-blue': modelValue}">
<div class="inline-block h-4 w-4 rounded-full bg-white shadow transition-all transform ease-in-out duration-150 rounded-2xl scale-100" :class="{'translate-x-5.5': modelValue}" />
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const { modelValue, disabled } = defineProps({
modelValue: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const onClick = () => {
if (disabled) return
emit('update:modelValue', !modelValue)
}
</script>

55
client/components/forms/index.js vendored Normal file
View File

@ -0,0 +1,55 @@
import { defineAsyncComponent } from 'vue'
import HasError from './validation/HasError.vue'
import AlertError from './validation/AlertError.vue'
import AlertSuccess from './validation/AlertSuccess.vue'
import VCheckbox from './components/VCheckbox.vue'
import TextInput from './TextInput.vue'
import TextAreaInput from './TextAreaInput.vue'
import VSelect from './components/VSelect.vue'
import CheckboxInput from './CheckboxInput.vue'
import SelectInput from './SelectInput.vue'
import ColorInput from './ColorInput.vue'
import FileInput from './FileInput.vue'
import ImageInput from './ImageInput.vue'
import RatingInput from './RatingInput.vue'
import FlatSelectInput from './FlatSelectInput.vue'
import ToggleSwitchInput from './ToggleSwitchInput.vue'
import ScaleInput from './ScaleInput.vue'
export function registerComponents (app) {
[
HasError,
AlertError,
AlertSuccess,
VCheckbox,
VSelect,
CheckboxInput,
ColorInput,
TextInput,
SelectInput,
TextAreaInput,
FileInput,
ImageInput,
RatingInput,
FlatSelectInput,
ToggleSwitchInput,
ScaleInput
].forEach(Component => {
Component.name ? app.component(Component.name, Component) : app.component(Component.name, Component)
})
// Register async components
app.component('SignatureInput', defineAsyncComponent(() =>
import('./SignatureInput.vue')
))
app.component('RichTextAreaInput', defineAsyncComponent(() =>
import('./RichTextAreaInput.vue')
))
app.component('PhoneInput', defineAsyncComponent(() =>
import('./PhoneInput.vue')
))
app.component('DateInput', defineAsyncComponent(() =>
import('./DateInput.vue')
))
}

88
client/components/forms/useFormInput.js vendored Normal file
View File

@ -0,0 +1,88 @@
import { ref, computed, watch } from 'vue'
import { themes } from '~/config/form-themes.js'
export const inputProps = {
id: { type: String, default: null },
name: { type: String, required: true },
label: { type: String, required: false },
form: { type: Object, required: false },
theme: { type: Object, default: () => themes.default },
modelValue: { required: false },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: null },
uppercaseLabels: { type: Boolean, default: false },
hideFieldName: { type: Boolean, default: false },
help: { type: String, default: null },
helpPosition: { type: String, default: 'below_input' },
color: { type: String, default: '#3B82F6' },
wrapperClass: { type: String, default: 'relative mb-3' }
}
export function useFormInput (props, context, formPrefixKey = null) {
const content = ref(props.modelValue)
const inputStyle = computed(() => {
return {
'--tw-ring-color': props.color
}
})
const hasValidation = computed(() => {
return props.form !== null && props.form !== undefined && props.form.hasOwnProperty('errors')
})
const hasError = computed(() => {
return hasValidation && props.form?.errors?.has(name)
})
const compVal = computed({
get: () => {
if (props.form) {
return props.form[(formPrefixKey || '') + props.name]
}
return content.value
},
set: (val) => {
if (props.form) {
props.form[(formPrefixKey || '') + props.name] = val
} else {
content.value = val
}
if (hasValidation.value) {
props.form.errors.clear(props.name)
}
context.emit('update:modelValue', compVal.value)
}
})
const inputWrapperProps = computed(() => {
const wrapperProps = {}
Object.keys(inputProps).forEach((key) => {
if (!['modelValue', 'disabled', 'placeholder', 'color'].includes(key)) {
wrapperProps[key] = props[key]
}
})
return wrapperProps
})
// Watch for changes in props.modelValue and update the local content
watch(
() => props.modelValue,
(newValue) => {
if (content.value !== newValue) {
content.value = newValue
}
}
)
return {
compVal,
inputStyle,
hasValidation,
hasError,
inputWrapperProps
}
}

View File

@ -0,0 +1,21 @@
export default {
props: {
form: {
type: Object,
required: true
},
dismissible: {
type: Boolean,
default: true
}
},
methods: {
dismiss () {
if (this.dismissible) {
this.form.clear()
}
}
}
}

View File

@ -0,0 +1,29 @@
<template>
<div v-if="form.errors.any()" class="alert alert-danger alert-dismissible" role="alert">
<button v-if="dismissible" type="button" class="close" aria-label="Close" @click="dismiss">
<span aria-hidden="true">&times;</span>
</button>
<slot>
<div v-if="form.errors.has('error')" v-html="form.errors.get('error')"/>
<div v-else v-html="message"/>
</slot>
</div>
</template>
<script>
import Alert from './Alert.js'
export default {
name: 'AlertError',
extends: Alert,
props: {
message: {
type: String,
default: 'There were some problems with your input.'
}
}
}
</script>

View File

@ -0,0 +1,37 @@
<template>
<transition name="fade">
<div v-if="form.successful" class="bg-green-200 border-green-600 text-green-600 border-l-4 p-4 relative rounded-lg"
role="alert">
<button v-if="dismissible"
type="button"
@click.prevent="dismiss()"
class="absolute right-2 top-0 -mr-1 flex-shrink-0 flex p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 sm:-mr-2">
<span class="sr-only">
Dismiss
</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="h-6 w-6 text-green-500"
viewBox="0 0 1792 1792">
<path
d="M1490 1322q0 40-28 68l-136 136q-28 28-68 28t-68-28l-294-294-294 294q-28 28-68 28t-68-28l-136-136q-28-28-28-68t28-68l294-294-294-294q-28-28-28-68t28-68l136-136q28-28 68-28t68 28l294 294 294-294q28-28 68-28t68 28l136 136q28 28 28 68t-28 68l-294 294 294 294q28 28 28 68z">
</path>
</svg>
</button>
<p class="font-bold">
Success
</p>
<div v-html="message"/>
</div>
</transition>
</template>
<script>
import Alert from './Alert.js'
export default {
name: 'AlertSuccess',
extends: Alert,
props: {
message: { type: String, default: '' }
}
}
</script>

View File

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

View File

@ -0,0 +1,89 @@
<template>
<section class="sticky flex items-center inset-x-0 top-0 z-20 py-3 bg-white border-b border-gray-200">
<div class="hidden md:flex flex-grow">
<slot name="left" />
</div>
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
<div class="flex items-center justify-center space-x-4">
<div v-if="displayHome" class="flex items-center">
<router-link class="text-gray-400 hover:text-gray-500" :to="{ name: (authenticated) ? 'home' : 'index' }">
<svg class="flex-shrink-0 w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z"
clip-rule="evenodd"
/>
</svg>
<span class="sr-only">Home</span>
</router-link>
<svg class="flex-shrink-0 w-5 h-5 text-gray-400 ml-4" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true"
>
<path fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
</div>
<div v-for="(item,index) in path" :key="index" class="flex items-center">
<router-link v-if="item.route" class="text-sm font-semibold text-gray-500 hover:text-gray-700 truncate"
:to="item.route"
>
{{ item.label }}
</router-link>
<div v-else class="text-sm font-semibold sm:w-full w-36 text-blue-500 truncate">
{{ item.label }}
</div>
<div v-if="index!==path.length-1">
<svg class="flex-shrink-0 w-5 h-5 text-gray-400 ml-4" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true"
>
<path fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
</div>
<div class="hidden md:flex flex-grow justify-end">
<slot name="right" />
</div>
</section>
</template>
<script>
import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth';
export default {
name: 'Breadcrumb',
props: {
/**
* route: Route object
* label: Label
*/
path: { type: Array }
},
setup () {
const authStore = useAuthStore()
return {
authenticated : computed(() => authStore.check)
}
},
data () {
return {
displayHome: true
}
},
computed: {},
mounted () {},
methods: {}
}
</script>

View File

@ -0,0 +1,27 @@
<template>
<div class="flex flex-col w-full bg-white rounded-lg shadow"
:class="{'px-4 py-8 sm:px-6 md:px-8 lg:px-10':padding}"
>
<div v-if="title" class="self-center mb-6 text-xl font-light text-gray-900 sm:text-3xl font-bold dark:text-white">
{{ title }}
</div>
<slot />
</div>
</template>
<script>
export default {
name: 'Card',
props: {
padding: {
type: Boolean,
default: true
},
title: {
type: String,
default: null
}
}
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<div>
<div class="w-full relative">
<div class="cursor-pointer" @click="trigger">
<slot name="title" />
</div>
<div class="text-gray-400 hover:text-gray-600 absolute -right-2 -top-1 cursor-pointer p-2" @click="trigger">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition transform duration-500"
:class="{'rotate-180':showContent}" viewBox="0 0 20 20" fill="currentColor"
>
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<VTransition>
<div v-if="showContent" class="w-full">
<slot />
</div>
</VTransition>
</div>
</template>
<script setup>
import VTransition from './transitions/VTransition.vue'
import { ref, defineProps, defineEmits } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: null }
})
const showContent = ref(props.modelValue)
const emit = defineEmits()
const trigger = () => {
showContent.value = !showContent.value
emit('update:modelValue', showContent.value)
}
</script>

View File

@ -0,0 +1,54 @@
<template>
<div class="relative">
<slot name="trigger"
:toggle="toggle"
:open="open"
:close="close"
/>
<collapsible v-model="isOpen" :class="dropdownClass">
<div class="py-1 " role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
<slot />
</div>
</collapsible>
</div>
</template>
<script>
import { ref } from 'vue'
import Collapsible from './transitions/Collapsible.vue'
export default {
name: 'Dropdown',
components: { Collapsible },
directives: {},
props: {
dropdownClass: {
type: String,
default: 'origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-20'
}
},
setup () {
const isOpen = ref(false)
const open = () => {
isOpen.value = true
}
const close = () => {
isOpen.value = false
}
const toggle = () => {
isOpen.value = !isOpen.value
}
return {
isOpen,
open,
close,
toggle
}
}
}
</script>

View File

@ -0,0 +1,72 @@
<template>
<div ref="parentRef"
tabindex="0"
:class="{
'hover:bg-gray-100 dark:hover:bg-gray-800 rounded px-2 cursor-pointer': !editing
}"
class="relative"
:style="{ height: editing ? divHeight + 'px' : 'auto' }"
@focus="startEditing"
>
<slot v-if="!editing" :content="content">
<label class="cursor-pointer truncate w-full">
{{ content }}
</label>
</slot>
<div v-if="editing" class="absolute inset-0 border-2 transition-colors"
:class="{ 'border-transparent': !editing, 'border-blue-500': editing }"
>
<input ref="editInputRef" v-model="content"
class="absolute inset-0 focus:outline-none bg-white transition-colors"
:class="[{'bg-blue-50': editing}, contentClass]" @blur="editing = false" @keyup.enter="editing = false"
@input="handleInput"
>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, defineProps, defineEmits } from 'vue'
const props = defineProps({
modelValue: { type: String, required: true },
textAlign: { type: String, default: 'left' },
contentClass: { type: String, default: '' }
})
const emit = defineEmits()
const content = ref(props.modelValue)
const editing = ref(false)
const divHeight = ref(0)
const parentRef = ref(null) // Ref for parent element
const editInputRef = ref(null) // Ref for edit input element
const startEditing = () => {
if (parentRef.value) {
divHeight.value = parentRef.value.offsetHeight
editing.value = true
nextTick(() => {
if (editInputRef.value) {
editInputRef.value.focus()
}
})
}
}
const handleInput = () => {
emit('update:modelValue', content.value)
}
// Watch for changes in props.modelValue and update the local content
watch(() => props.modelValue, (newValue) => {
content.value = newValue
})
// Wait until the component is mounted to set the initial divHeight
onMounted(() => {
if (parentRef.value) {
divHeight.value = parentRef.value.offsetHeight
}
})
</script>

View File

@ -0,0 +1,13 @@
<template>
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
<script>
export default {
name: 'Loader',
props: {}
}
</script>

View File

@ -0,0 +1,180 @@
<template>
<portal to="modals" :order="portalOrder">
<transition @leave="(el,done) => motions.backdrop.leave(done)">
<div v-if="show" v-motion="'backdrop'" :variants="motionFadeIn"
class="fixed z-30 top-0 inset-0 px-4 sm:px-0 flex items-top justify-center bg-gray-700/75 w-full h-screen overflow-y-scroll"
:class="{'backdrop-blur-sm':backdropBlur}"
@click.self="close"
>
<div ref="content" v-motion="'body'" :variants="motionSlideBottom"
class="self-start bg-white dark:bg-notion-dark w-full relative p-4 md:p-6 my-6 rounded-xl shadow-xl"
:class="maxWidthClass"
>
<div v-if="closeable" class="absolute top-4 right-4">
<button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click.prevent="close">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="sm:flex sm:flex-col sm:items-start">
<div v-if="$scopedSlots.hasOwnProperty('icon')" class="flex w-full justify-center mb-4">
<div class="w-14 h-14 rounded-full flex justify-center items-center"
:class="'bg-'+iconColor+'-100 text-'+iconColor+'-600'"
>
<slot name="icon"/>
</div>
</div>
<div class="mt-3 text-center sm:mt-0 w-full">
<h2 v-if="$scopedSlots.hasOwnProperty('title')"
class="text-2xl font-semibold text-center text-gray-900"
>
<slot name="title"/>
</h2>
</div>
</div>
<div class="w-full">
<slot/>
</div>
<div v-if="$scopedSlots.hasOwnProperty('footer')" class="px-6 py-4 bg-gray-100 text-right">
<slot name="footer"/>
</div>
</div>
</div>
</transition>
</portal>
</template>
<script>
import {useMotions} from '@vueuse/motion'
export default {
name: 'Modal',
props: {
show: {
default: false
},
backdropBlur: {
type: Boolean,
default: false
},
iconColor: {
default: 'blue'
},
maxWidth: {
default: '2xl'
},
closeable: {
default: true
},
portalOrder: {
default: 1
}
},
setup(props) {
useHead({
bodyAttrs: {
class: {
'overflow-hidden': props.show
}
}
})
const closeOnEscape = (e) => {
if (e.key === 'Escape' && this.show) {
this.close()
}
}
onMounted(() => {
if (process.server) return
document.addEventListener('keydown', closeOnEscape)
})
onBeforeUnmount(() => {
if (process.server) return
document.removeEventListener('keydown', closeOnEscape)
})
return {
motions: useMotions(),
}
},
computed: {
maxWidthClass() {
return {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl'
}[this.maxWidth]
},
motionFadeIn() {
return {
initial: {
opacity: 0,
transition: {
delay: 100,
duration: 200,
ease: 'easeIn'
}
},
enter: {
opacity: 1,
transition: {
duration: 200
}
}
}
},
motionSlideBottom() {
return {
initial: {
y: 150,
opacity: 0,
transition: {
ease: 'easeIn',
duration: 200
}
},
enter: {
y: 0,
opacity: 1,
transition: {
duration: 250,
ease: 'easeOut',
delay: 100
}
}
}
}
},
watch: {
show(newVal, oldVal) {
if (newVal !== oldVal) {
if (!newVal) {
this.motions.body.apply('initial')
this.motions.backdrop.apply('initial')
}
}
}
},
methods: {
close() {
if (this.closeable) {
this.$emit('close')
}
}
}
}
</script>

View File

@ -0,0 +1,216 @@
<template>
<nav v-if="hasNavbar" class="bg-white dark:bg-notion-dark border-b">
<div class="max-w-7xl mx-auto px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center">
<router-link :to="{ name: user ? 'home' : 'index' }" class="flex-shrink-0 font-semibold hover:no-underline flex items-center">
<img src="/img/logo.svg" alt="notion tools logo" class="w-8 h-8">
<span
class="ml-2 text-md hidden sm:inline text-black dark:text-white"
>
OpnForm</span>
</router-link>
<workspace-dropdown class="ml-6" />
</div>
<div v-if="showAuth" class="hidden md:block ml-auto relative">
<router-link v-if="$route.name !== 'templates'" :to="{name:'templates'}"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
>
Templates
</router-link>
<router-link v-if="$route.name !== 'ai-form-builder'" :to="{name:'ai-form-builder'}"
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
>
AI Form Builder
</router-link>
<router-link v-if="paidPlansEnabled && (user===null || (user && workspace && !workspace.is_pro)) && $route.name !== 'pricing'" :to="{name:'pricing'}"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
>
<span v-if="user">Upgrade</span>
<span v-else>Pricing</span>
</router-link>
<a v-if="hasCrisp" href="#"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1" @click.prevent="openCrisp"
>
Help
</a>
<a v-else :href="helpUrl"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1" target="_blank"
>
Help
</a>
</div>
<div v-if="showAuth" class="hidden md:block pl-5 border-gray-300 border-r h-5" />
<div v-if="showAuth" class="block">
<div class="flex items-center">
<div class="ml-3 mr-4 relative">
<div class="relative inline-block text-left">
<dropdown v-if="user" dusk="nav-dropdown">
<template #trigger="{toggle}">
<button id="dropdown-menu-button" type="button"
class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500"
dusk="nav-dropdown-button" @click.stop="toggle()"
>
<img :src="user.photo_url" class="rounded-full w-6 h-6">
<p class="ml-2 hidden sm:inline">
{{ user.name }}
</p>
</button>
</template>
<router-link v-if="userOnboarded" :to="{ name: 'home' }"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
My Forms
</router-link>
<router-link v-if="userOnboarded" :to="{ name: 'my_templates' }"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
My Templates
</router-link>
<router-link :to="{ name: 'settings.profile' }"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
>
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</router-link>
<a href="#"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@click.prevent="logout"
>
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Logout
</a>
</dropdown>
<div v-else class="flex gap-2">
<router-link v-if="$route.name !== 'login'" :to="{ name: 'login' }"
class="text-gray-600 dark:text-white hover:text-gray-800 dark:hover:text-white px-0 sm:px-3 py-2 rounded-md text-sm"
active-class="text-gray-800 dark:text-white"
>
Login
</router-link>
<v-button v-track.nav_create_form_click size="small" :to="{ name: 'forms.create.guest' }" color="outline-blue" :arrow="true">
Create a form
</v-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</nav>
</template>
<script>
import { computed } from 'vue'
import Dropdown from '~/components/global/Dropdown.vue'
import WorkspaceDropdown from './WorkspaceDropdown.vue'
export default {
components: {
WorkspaceDropdown,
Dropdown
},
setup () {
const authStore = useAuthStore()
const formsStore = useFormsStore()
const workspacesStore = useWorkspacesStore()
const {openCrisp} = useCrisp()
return {
authStore,
formsStore,
workspacesStore,
openCrisp,
config: useConfig(),
user: computed(() => authStore.user),
isIframe: useIsIframe(),
}
},
computed: {
helpUrl: () => this.config.links.help_url,
form () {
if (this.$route.name && this.$route.name.startsWith('forms.show_public')) {
return this.formsStore.getBySlug(this.$route.params.slug)
}
return null
},
workspace () {
return this.workspacesStore.getCurrent()
},
paidPlansEnabled () {
return this.config.paid_plans_enabled
},
showAuth () {
return this.$route.name && !this.$route.name.startsWith('forms.show_public')
},
hasNavbar () {
if (this.isIframe) return false
if (this.$route.name && this.$route.name.startsWith('forms.show_public')) {
if (this.form) {
// If there is a cover, or if branding is hidden remove nav
if (this.form.cover_picture || this.form.no_branding) {
return false
}
} else {
return false
}
}
return !this.$root.navbarHidden
},
userOnboarded () {
return this.user && this.user.workspaces_count > 0
},
hasCrisp () {
return this.config.crisp_website_id
}
},
methods: {
async logout () {
// Log out the user.
await this.authStore.logout()
// Reset store
this.workspacesStore.resetState()
this.formsStore.resetState()
// Redirect to login.
this.$router.push({ name: 'login' })
},
}
}
</script>

View File

@ -0,0 +1,139 @@
<template>
<div class="fixed top-0 bottom-24 right-0 flex px-4 items-start justify-end z-50 pointer-events-none">
<notification v-slot="{ notifications, close }">
<div class="relative pointer-events-auto" v-for="notification in notifications" :key="notification.id">
<div
v-if="notification.type==='success'"
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
>
<div class="flex justify-center items-center w-12 bg-green-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="-mx-3 py-2 px-4">
<div class="mx-3">
<span class="text-green-500 font-semibold pr-6">{{notification.title}}</span>
<p class="text-gray-600 text-sm">{{notification.text}}</p>
</div>
</div>
</div>
<div
v-if="notification.type==='info'"
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
>
<div class="flex justify-center items-center w-12 bg-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="-mx-3 py-2 px-4">
<div class="mx-3">
<span class="text-blue-500 font-semibold pr-6">{{notification.title}}</span>
<p class="text-gray-600 text-sm">T{{notification.text}}</p>
</div>
</div>
</div>
<div
v-if="notification.type==='error'"
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
>
<div class="flex justify-center items-center w-12 bg-red-500">
<svg
class="h-6 w-6 fill-current text-white"
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"
/>
</svg>
</div>
<div class="-mx-3 py-2 px-4">
<div class="mx-3">
<span class="text-red-500 font-semibold pr-6">{{notification.title}}</span>
<p class="text-gray-600 text-sm">{{notification.text}}</p>
</div>
</div>
</div>
<div
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
v-if="notification.type==='warning'"
>
<div class="flex justify-center items-center w-12 bg-yellow-500">
<svg
class="h-6 w-6 fill-current text-white"
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"
/>
</svg>
</div>
<div class="-mx-3 py-2 px-4">
<div class="mx-3">
<span class="text-yellow-500 font-semibold pr-6">{{notification.title}}</span>
<p class="text-gray-600 text-sm">{{notification.text}}</p>
</div>
</div>
</div>
<div
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
v-if="notification.type==='confirm'"
>
<div class="flex justify-center items-center w-12 bg-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6 text-white">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm11.378-3.917c-.89-.777-2.366-.777-3.255 0a.75.75 0 01-.988-1.129c1.454-1.272 3.776-1.272 5.23 0 1.513 1.324 1.513 3.518 0 4.842a3.75 3.75 0 01-.837.552c-.676.328-1.028.774-1.028 1.152v.75a.75.75 0 01-1.5 0v-.75c0-1.279 1.06-2.107 1.875-2.502.182-.088.351-.199.503-.331.83-.727.83-1.857 0-2.584zM12 18a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd" />
</svg>
</div>
<div class="-mx-3 py-2 px-4">
<div class="mx-3">
<span class="text-blue-500 font-semibold pr-6">{{notification.title}}</span>
<p class="text-gray-600 text-sm">{{notification.text}}</p>
<div class="w-full flex gap-2 mt-1">
<v-button color="blue" size="small" @click.prevent="notification.success();close(notification.id)">Yes</v-button>
<v-button color="gray" shade="light" size="small" @click.prevent="notification.failure();close(notification.id)">No</v-button>
</div>
</div>
</div>
</div>
<button @click="close(notification.id)" class="absolute top-0 right-0 px-2 py-2 cursor-pointer">
<svg
class="fill-current h-6 w-6 text-gray-300 hover:text-gray-500"
role="button"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<title>Close</title>
<path
d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"
/>
</svg>
</button>
</div>
</notification>
</div>
</template>
<script>
export default {
name: 'Notifications',
data() {
return {}
},
computed: {},
mounted() {
},
methods: {}
}
</script>

View File

@ -0,0 +1,36 @@
<template>
<notion-renderer :block-map="blockMap"/>
</template>
<script>
import {NotionRenderer} from 'vue-notion'
export default {
name: 'NotionPage',
components: {NotionRenderer},
props: {
pageId: {
type: String,
required: true
}
},
async setup(props) {
const apiUrl = useConfig().notion.worker
const {data} = await useFetch(`${apiUrl}/page/${props.pageId}`)
return {
apiUrl: useConfig().notion.worker,
blockMap: data,
}
}
}
</script>
<style lang="scss">
@import "styles.css";
.notion-blue {
@apply text-nt-blue;
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<div class="inline" v-if="shouldDisplayProTag">
<div class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold cursor-pointer"
@click.prevent="showPremiumModal=true"
>
PRO
</div>
<modal :show="showPremiumModal" @close="showPremiumModal=false">
<h2 class="text-nt-blue">
OpnForm PRO
</h2>
<h4 v-if="user && user.is_subscribed" class="text-center mt-5">
We're happy to have you as a Pro customer. If you're having any issue with OpnForm, or if you have a
feature request, please <a href="mailto:contact@opnform.com">contact us</a>.
</h4>
<div v-if="!user || !user.is_subscribed" class="mt-4">
<p>
All the features with a<span
class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold mx-1"
>
PRO
</span> tag are available in the Pro plan of OpnForm. <b>You can play around and try all Pro features
within
the form editor, but you can't use them in your real forms</b>. You can subscribe now to gain unlimited access
to
all our pro features!
</p>
</div>
<div class="my-4 text-center">
<v-button color="white" @click="showPremiumModal=false">
Close
</v-button>
</div>
</modal>
</div>
</template>
<script>
import { computed } from 'vue'
import Modal from './Modal.vue'
import { useAuthStore } from '../../stores/auth';
import { useWorkspacesStore } from '../../stores/workspaces';
import PricingTable from "../pages/pricing/PricingTable.vue";
export default {
name: 'ProTag',
components: {PricingTable, Modal},
props: {},
setup () {
const authStore = useAuthStore()
const workspacesStore = useWorkspacesStore()
return {
user : computed(() => authStore.user),
currentWorkSpace : computed(() => workspacesStore.getCurrent())
}
},
data() {
return {
showPremiumModal: false,
checkoutLoading: false
}
},
computed: {
shouldDisplayProTag() {
if (!this.$config.paid_plans_enabled) return false
if (!this.user || !this.currentWorkSpace) return true
return !(this.currentWorkSpace.is_pro)
},
},
mounted() {
},
methods: {
openChat() {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
},
}
}
</script>

View File

@ -0,0 +1,185 @@
<template>
<div class="scroll-shadow max-w-full" :class="[$style.wrap,{'w-max':!shadow.left && !shadow.right}]">
<div
ref="scrollContainer"
:class="[$style['scroll-container'],{'no-scrollbar':hideScrollbar}]"
:style="{ width: width?width:'auto', height }"
@scroll.passive="toggleShadow"
>
<slot />
<span :class="[$style['shadow-top'], shadow.top && $style['is-active']]" :style="{
top: shadowTopOffset+'px',
}"
/>
<span :class="[$style['shadow-right'], shadow.right && $style['is-active']]" />
<span :class="[$style['shadow-bottom'], shadow.bottom && $style['is-active']]" />
<span :class="[$style['shadow-left'], shadow.left && $style['is-active']]" />
</div>
</div>
</template>
<script>
function newResizeObserver (callback) {
// Skip this feature for browsers which
// do not support ResizeObserver.
// https://caniuse.com/#search=resizeobserver
if (typeof ResizeObserver === 'undefined') return
return new ResizeObserver(e => e.map(callback))
}
export default {
name: 'ScrollShadow',
props: {
hideScrollbar: {
type: Boolean,
default: false
},
shadowTopOffset: {
type: Number,
default: 0
}
},
data () {
return {
width: undefined,
height: undefined,
shadow: {
top: false,
right: false,
bottom: false,
left: false
},
debounceTimeout: null,
scrollContainerObserver: null,
wrapObserver: null
}
},
mounted () {
window.addEventListener('resize', this.calcDimensions)
// Check if shadows are necessary after the element is resized.
const scrollContainerObserver = newResizeObserver(this.toggleShadow)
if (scrollContainerObserver) {
scrollContainerObserver.observe(this.$refs.scrollContainer)
}
// Recalculate the container dimensions when the wrapper is resized.
this.wrapObserver = newResizeObserver(this.calcDimensions)
if (this.wrapObserver) {
this.wrapObserver.observe(this.$el)
}
},
unmounted () {
window.removeEventListener('resize', this.calcDimensions)
// Cleanup when the component is unmounted.
this.wrapObserver.disconnect()
this.scrollContainerObserver.disconnect()
},
methods: {
async calcDimensions () {
// Reset dimensions for correctly recalculating parent dimensions.
this.width = undefined
this.height = undefined
await this.$nextTick()
this.width = `${this.$el.clientWidth}px`
this.height = `${this.$el.clientHeight}px`
},
// Check if shadows are needed.
toggleShadow () {
const hasHorizontalScrollbar =
this.$refs.scrollContainer.clientWidth <
this.$refs.scrollContainer.scrollWidth
const hasVerticalScrollbar =
this.$refs.scrollContainer.clientHeight <
this.$refs.scrollContainer.scrollHeight
const scrolledFromLeft =
this.$refs.scrollContainer.offsetWidth +
this.$refs.scrollContainer.scrollLeft
const scrolledFromTop =
this.$refs.scrollContainer.offsetHeight +
this.$refs.scrollContainer.scrollTop
const scrolledToTop = this.$refs.scrollContainer.scrollTop === 0
const scrolledToRight =
scrolledFromLeft >= this.$refs.scrollContainer.scrollWidth
const scrolledToBottom =
scrolledFromTop >= this.$refs.scrollContainer.scrollHeight
const scrolledToLeft = this.$refs.scrollContainer.scrollLeft === 0
this.$nextTick(() => {
this.shadow.top = hasVerticalScrollbar && !scrolledToTop
this.shadow.right = hasHorizontalScrollbar && !scrolledToRight
this.shadow.bottom = hasVerticalScrollbar && !scrolledToBottom
this.shadow.left = hasHorizontalScrollbar && !scrolledToLeft
})
}
}
}
</script>
<style lang="scss" module>
.wrap {
overflow: hidden;
position: relative;
}
.scroll-container {
overflow: auto;
}
.shadow-top,
.shadow-right,
.shadow-bottom,
.shadow-left {
position: absolute;
border-radius: 6em;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.shadow-top,
.shadow-bottom {
right: 0;
left: 0;
height: 1em;
border-top-right-radius: 0;
border-top-left-radius: 0;
background-image: linear-gradient(rgba(#555, 0.1) 0%, rgba(#FFF, 0) 100%);
}
.shadow-top {
top: 0;
}
.shadow-bottom {
bottom: 0;
transform: rotate(180deg);
}
.shadow-right,
.shadow-left {
top: 0;
bottom: 0;
width: 1em;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
background-image: linear-gradient(90deg, rgba(#555, 0.1) 0%, rgba(#FFF, 0) 100%);
}
.shadow-right {
right: 0;
transform: rotate(180deg);
}
.shadow-left {
left: 0;
}
.is-active {
opacity: 1;
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<div class="py-4" :class="{'border-b-2':borderBottom}">
<div class="uppercase tracking-wide text-xs font-bold dark:text-gray-400 text-gray-500 mb-1 leading-tight">
Step: {{ Math.min(current + 1, steps.length) }} of {{ steps.length }}
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
<div class="flex-1">
<div class="text-lg font-bold dark:text-gray-300 text-gray-700 leading-tight">
{{ steps[current] ? steps[current] : 'Complete!' }}
</div>
</div>
<div class="flex items-center md:w-64">
<div class="w-full bg-gray-100 dark:bg-gray-700 rounded-full mr-2">
<div class="rounded-full bg-nt-blue text-xs leading-none h-2 text-center text-white transition-all"
:style="{'width': parseInt(current / steps.length * 100) +'%', 'min-width': '8px'}"
/>
</div>
<div class="text-xs w-10 text-gray-600 dark:text-gray-400" v-text="parseInt(current / steps.length * 100) +'%'" />
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Steps',
props: {
steps: {
type: Array,
required: true
},
borderBottom: {
type: Boolean,
default: true
},
current: {
type: Number,
default: 0
}
}
}
</script>

View File

@ -0,0 +1,185 @@
<template>
<a v-if="href" :class="btnClasses" :href="href" :target="target">
<slot />
</a>
<button v-else-if="!to" :type="nativeType" :disabled="loading?true:null" :class="btnClasses"
@click="onClick($event)"
>
<template v-if="!loading">
<span class="no-underline mx-auto">
<slot />
</span>
<svg v-if="arrow" class="ml-2 w-3 h-3 inline" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L11 1M11 1H1M11 1V11" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<loader v-else class="h-6 w-6 mx-auto" :class="`text-${colorShades['text']}`" />
</button>
<router-link v-else :class="btnClasses" :to="to" :target="target">
<span class="no-underline mx-auto">
<slot />
</span>
<svg v-if="arrow" class="ml-2 w-3 h-3 inline" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L11 1M11 1H1M11 1V11" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</router-link>
</template>
<script>
export default {
name: 'VButton',
props: {
color: {
type: String,
default: 'blue'
},
size: {
type: String,
default: 'medium'
},
nativeType: {
type: String,
default: null
},
loading: {
type: Boolean,
default: false
},
arrow: {
type: Boolean,
default: false
},
to: {
type: Object,
default: null
},
href: {
type: String,
default: null
},
target: {
type: String,
default: '_self'
}
},
computed: {
btnClasses () {
const sizes = this.sizes
const colorShades = this.colorShades
return `v-btn ${sizes['p-y']} ${sizes['p-x']}
${colorShades?.main} ${colorShades?.hover} ${colorShades?.ring} ${colorShades['ring-offset']}
${colorShades?.text} transition ease-in duration-200 text-center text-${sizes?.font} font-medium focus:outline-none focus:ring-2
focus:ring-offset-2 rounded-lg flex items-center hover:no-underline`
},
colorShades () {
if (this.color === 'blue') {
return {
main: 'bg-blue-600',
hover: 'hover:bg-blue-700',
ring: 'focus:ring-blue-500',
'ring-offset': 'focus:ring-offset-blue-200',
text: 'text-white'
}
} else if (this.color === 'outline-blue') {
return {
main: 'bg-transparent border border-blue-600',
hover: 'hover:bg-blue-600',
ring: 'focus:ring-blue-500',
'ring-offset': 'focus:ring-offset-blue-200',
text: 'text-blue-600 hover:text-white'
}
} else if (this.color === 'outline-gray') {
return {
main: 'bg-transparent border border-gray-300',
hover: 'hover:bg-gray-500',
ring: 'focus:ring-gray-500',
'ring-offset': 'focus:ring-offset-gray-200',
text: 'text-gray-500 hover:text-white'
}
} else if (this.color === 'red') {
return {
main: 'bg-red-600',
hover: 'hover:bg-red-700',
ring: 'focus:ring-red-500',
'ring-offset': 'focus:ring-offset-red-200',
text: 'text-white'
}
} else if (this.color === 'gray') {
return {
main: 'bg-gray-600',
hover: 'hover:bg-gray-700',
ring: 'focus:ring-gray-500',
'ring-offset': 'focus:ring-offset-gray-200',
text: 'text-white'
}
} else if (this.color === 'light-gray') {
return {
main: 'bg-gray-50 border border-gray-300',
hover: 'hover:bg-gray-100',
ring: 'focus:ring-gray-500',
'ring-offset': 'focus:ring-offset-gray-300',
text: 'text-gray-700'
}
} else if (this.color === 'green') {
return {
main: 'bg-green-600',
hover: 'hover:bg-green-700',
ring: 'focus:ring-green-500',
'ring-offset': 'focus:ring-offset-green-200',
text: 'text-white'
}
} else if (this.color === 'yellow') {
return {
main: 'bg-yellow-600',
hover: 'hover:bg-yellow-700',
ring: 'focus:ring-yellow-500',
'ring-offset': 'focus:ring-offset-yellow-200',
text: 'text-white'
}
} else if (this.color === 'white') {
return {
main: 'bg-transparent border border-gray-300',
hover: 'hover:bg-gray-200',
ring: 'focus:ring-white-500',
'ring-offset': 'focus:ring-offset-white-200',
text: 'text-gray-700'
}
}
console.error('Unknown color')
},
sizes () {
if (this.size === 'small') {
return {
font: 'sm',
'p-y': 'py-1',
'p-x': 'px-2'
}
}
return {
font: 'base',
'p-y': 'py-2',
'p-x': 'px-4'
}
}
},
methods: {
onClick (event) {
this.$emit('click', event)
}
}
}
</script>

View File

@ -0,0 +1,104 @@
<template>
<dropdown v-if="user && workspaces && workspaces.length > 1" ref="dropdown"
dropdown-class="origin-top-left absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
dusk="workspace-dropdown"
>
<template v-if="workspace" #trigger="{toggle}">
<div class="flex items-center cursor group" role="button" @click.stop="toggle()">
<div class="rounded-full h-8 8">
<img v-if="isUrl(workspace.icon)"
:src="workspace.icon"
:alt="workspace.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
>
<div v-else class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
v-text="workspace.icon"
/>
</div>
<p class="hidden group-hover:underline lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200">
{{ workspace.name }}
</p>
</div>
</template>
<template v-for="worksp in workspaces" :key="worksp.id">
<a href="#"
class="px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
:class="{'bg-blue-100 dark:bg-blue-900':workspace.id === worksp.id}" @click.prevent="switchWorkspace(worksp)"
>
<div class="rounded-full h-8 w-8 flex-shrink-0" role="button">
<img v-if="isUrl(worksp.icon)"
:src="worksp.icon"
:alt="worksp.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
>
<div v-else class="rounded-full flex-shrink-0 pt-1 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
v-text="worksp.icon"
/>
</div>
<p class="ml-4 truncate">{{ worksp.name }}</p>
</a>
</template>
</dropdown>
</template>
<script>
import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth.js'
import { useFormsStore } from '../../stores/forms.js'
import { useWorkspacesStore } from '../../stores/workspaces.js'
import Dropdown from '~/components/global/Dropdown.vue'
export default {
name: 'WorkspaceDropdown',
components: {
Dropdown
},
setup () {
const authStore = useAuthStore()
const formsStore = useFormsStore()
const workspacesStore = useWorkspacesStore()
return {
formsStore,
workspacesStore,
user: computed(() => authStore.user),
workspaces: computed(() => workspacesStore.content),
loading: computed(() => workspacesStore.loading)
}
},
computed: {
workspace () {
return this.workspacesStore.getCurrent()
}
},
watch: {
},
mounted () {
},
methods: {
switchWorkspace (workspace) {
this.workspacesStore.setCurrentId(workspace.id)
this.$refs.dropdown.close()
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' })
}
this.formsStore.load(workspace.id)
},
isUrl (str) {
try {
new URL(str)
} catch (_) {
return false
}
return true
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,59 @@
<template>
<transition @leave="(el,done) => motions.collapsible.leave(done)">
<div
v-if="modelValue"
key="dropdown"
v-motion="'collapsible'"
v-on-click-outside.bubble="close"
:variants="motionCollapse"
>
<slot />
</div>
</transition>
</template>
<script>
import { vOnClickOutside } from '@vueuse/components'
import { useMotions } from '@vueuse/motion'
export default {
name: 'Collapsible',
directives: {
onClickOutside: vOnClickOutside
},
props: {
modelValue: { type: Boolean },
closeOnClickAway: { type: Boolean, default: true }
},
setup () {
return {
motions: useMotions()
}
},
computed: {
motionCollapse () {
return {
enter: {
opacity: 1,
y: 0,
height: 'auto',
transition: { duration: 150, ease: 'easeOut' }
},
initial: {
opacity: 0,
y: -10,
height: 0,
transition: { duration: 75, ease: 'easeIn' }
}
}
}
},
methods: {
close () {
if (this.closeOnClickAway) {
this.$emit('update:modelValue', false)
}
}
}
}
</script>

View File

@ -0,0 +1,19 @@
<template>
<transition v-if="name=='slideInUp'"
enter-active-class="linear duration-300 overflow-hidden"
enter-from-class="max-h-0"
enter-to-class="max-h-screen"
leave-active-class="linear duration-300 overflow-hidden"
leave-from-class="max-h-screen"
leave-to-class="max-h-0"
>
<slot />
</transition>
</template>
<script>
export default {
name: 'VTransition',
props: { name: { default: 'slideInUp' } }
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<div class="flex items-center justify-center space-x-1">
<button v-track.delete_record_click
class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-red-700"
@click="onDeleteClick"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-4 h-4"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
</template>
<script>
import axios from 'axios'
export default {
components: { },
props: {
form: {
type: Object,
required: true
},
structure: {
type: Array,
default: () => []
},
rowid: {
type: Number,
default: () => {}
}
},
data () {
return {
}
},
computed: {
},
mounted () {
},
methods: {
onDeleteClick () {
this.alertConfirm('Do you really want to delete this record?', this.deleteRecord)
},
async deleteRecord () {
axios.delete('/api/open/forms/' + this.form.id + '/records/' + this.rowid + '/delete').then(async (response) => {
if (response.data.type === 'success') {
this.$emit('deleted')
this.alertSuccess(response.data.message)
} else {
this.alertError('Something went wrong!')
}
}).catch((error) => {
this.alertError(error.response.data.message)
})
}
}
}
</script>

View File

@ -0,0 +1,46 @@
<template>
<collapse class="p-4 w-full border-b" v-model="show">
<template #title>
<div class="flex items-center pr-8">
<div class="mr-3" :class="{'text-blue-600':show, 'text-gray-500':!show}">
<slot name="icon" />
</div>
<h3 id="v-step-2" class="font-semibold flex-grow">
{{ name }}
</h3>
<pro-tag v-if="hasProTag" />
</div>
</template>
<slot />
</collapse>
</template>
<script>
import Collapse from '~/components/global/Collapse.vue'
import ProTag from '~/components/global/ProTag.vue'
export default {
name: 'EditorOptionsPanel',
components: { Collapse, ProTag },
props: {
name: {
type: String,
required: true
},
hasProTag: {
type: Boolean,
default: false
},
alreadyOpened: {
type: Boolean,
default: false
}
},
data () {
return {
show: this.alreadyOpened
}
}
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<transition @leave="(el,done) => motions.slide.leave(done)">
<div v-if="show" v-motion-slide-right="'slide'"
class="absolute shadow-lg shadow-gray-800/30 top-0 h-[calc(100vh-53px)] right-0 lg:shadow-none lg:relative bg-white w-full md:w-1/2 lg:w-2/5 border-l overflow-y-scroll md:max-w-[20rem] flex-shrink-0 z-50"
>
<slot />
</div>
</transition>
</template>
<script>
import { useMotions } from '@vueuse/motion'
export default {
name: 'EditorRightSidebar',
props: {
show: {
type: Boolean,
default: false
}
},
setup (props) {
return {
motions: useMotions()
}
}
}
</script>

View File

@ -0,0 +1,261 @@
<template>
<div v-if="form" class="open-complete-form">
<h1 v-if="!isHideTitle" class="mb-4 px-2" :class="{'mt-4':isEmbedPopup}" 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 || form.visibility=='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>
<form-cleanings v-if="!adminPreview" :hideable="true" class="mb-4 mx-2" :form="form" :specify-form-owner="true" />
<transition
v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached && form.visibility!='closed'))"
enter-active-class="duration-500 ease-out"
enter-from-class="translate-x-full opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="duration-500 ease-in"
leave-from-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"
:admin-preview="adminPreview"
@submit="submitForm"
>
<template #submit-btn="{submitForm}">
<open-form-button :loading="loading" :theme="theme" :color="form.color" class="mt-2 px-8 mx-1"
:class="submitButtonClass" @click.prevent="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?utm_source=form&utm_content=powered_by"
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 <span class="font-semibold">OpnForm</span></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.editable_submissions && submissionId" class="mt-5">
<a target="_parent" :href="form.share_url+'?submission_id='+submissionId" class="text-nt-blue hover:underline">
{{ form.editable_submissions_button_text }}
</a>
</p>
<p v-if="!form.no_branding" class="mt-5">
<a target="_parent" href="https://opnform.com/?utm_source=form&utm_content=create_form_free" 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.vue'
import OpenFormButton from './OpenFormButton.vue'
import { themes } from '~/config/form-themes.js'
import VButton from '~/components/global/VButton.vue'
import VTransition from '~/components/global/transitions/VTransition.vue'
import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js'
import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
export default {
components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings },
mixins: [FormPendingSubmissionKey],
props: {
form: { type: Object, required: true },
creating: { type: Boolean, default: false }, // If true, fake form submit
adminPreview: { type: Boolean, default: false }, // If used in FormEditorPreview
submitButtonClass: { type: String, default: '' }
},
data () {
return {
loading: false,
submitted: false,
themes: themes,
passwordForm: new Form({
password: null
}),
hidePasswordDisabledMsg: false,
submissionId: false
}
},
computed: {
isIframe () {
return window.location !== window.parent.location || window.frameElement
},
isEmbedPopup () {
return window.location.href.includes('popup=true')
},
theme () {
return this.themes[this.themes.hasOwnProperty(this.form.theme) ? this.form.theme : 'default']
},
isPublicFormPage () {
return this.$route.name === 'forms.show_public'
},
isHideTitle () {
return this.form.hide_title || window.location.href.includes('hide_title=true')
}
},
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 (this.isIframe) {
window.parent.postMessage({
type: 'form-submitted',
form: {
slug: this.form.slug,
id: this.form.id
},
submission_data: form.data()
}, '*')
}
window.postMessage({
type: 'form-submitted',
form: {
slug: this.form.slug,
id: this.form.id
},
submission_data: form.data()
}, '*')
try {
window.localStorage.removeItem(this.formPendingSubmissionKey)
} catch (e) {}
if (response.data.redirect && response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
if (response.data.submission_id) {
this.submissionId = response.data.submission_id
}
this.loading = false
this.submitted = true
this.$emit('submitted', true)
// If enabled display confetti
if (this.form.confetti_on_submission) {
this.playConfetti()
}
}).catch((error) => {
if (error.response && 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, .nf-text {
ol {
@apply list-decimal list-inside;
margin-left: 10px;
}
ul {
@apply list-disc list-inside;
margin-left: 10px;
}
}
}
</style>

View File

@ -0,0 +1,423 @@
<template>
<div v-if="isAutoSubmit">
<p class="text-center p-4">
<loader class="h-6 w-6 text-nt-blue mx-auto" />
</p>
</div>
<form v-else-if="dataForm" @submit.prevent="">
<transition name="fade" mode="out-in" appear>
<div :key="currentFieldGroupIndex" class="form-group flex flex-wrap w-full">
<draggable v-model="currentFields"
item-key="id"
class="flex flex-wrap transition-all"
:class="{'-m-6 p-2 bg-gray-50 rounded-md':dragging}"
ghost-class="ghost-item"
handle=".draggable" :animation="200"
@start="onDragStart" @end="onDragEnd"
>
<template #item="{element}">
<open-form-field
:field="element"
:show-hidden="showHidden"
:form="form"
:data-form="dataForm"
:data-form-value="dataFormValue"
:theme="theme"
:admin-preview="adminPreview"
/>
</template>
</draggable>
</div>
</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 axios from 'axios'
import Form from 'vform'
import { computed } from 'vue'
import { useRecordsStore } from '../../../stores/records'
import { useWorkingFormStore } from '../../../stores/working_form'
import OpenFormButton from './OpenFormButton.vue'
import clonedeep from 'clone-deep'
import FormLogicPropertyResolver from '../../../forms/FormLogicPropertyResolver.js'
import OpenFormField from './OpenFormField.vue'
import draggable from 'vuedraggable'
import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js'
draggable.compatConfig = { MODE: 3 }
const VueHcaptcha = () => import('@hcaptcha/vue3-hcaptcha')
export default {
name: 'OpenForm',
components: { draggable, OpenFormField, OpenFormButton, VueHcaptcha },
mixins: [FormPendingSubmissionKey],
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
},
adminPreview: { type: Boolean, default: false } // If used in FormEditorPreview
},
setup () {
const recordsStore = useRecordsStore()
const workingFormStore = useWorkingFormStore()
return {
recordsStore,
workingFormStore
}
},
data () {
return {
dataForm: null,
currentFieldGroupIndex: 0,
/**
* Used to force refresh components by changing their keys
*/
formVersionId: 1,
darkModeEnabled: document.body.classList.contains('dark'),
isAutoSubmit: false,
/**
* If currently dragging a field
*/
dragging: false
}
},
computed: {
hCaptchaSiteKey: () => this.$config.hCaptchaSiteKey,
/**
* Create field groups (or Page) using page breaks if any
*/
fieldGroups () {
if (!this.fields) return []
const groups = []
let currentGroup = []
this.fields.forEach((field) => {
if (field.type === 'nf-page-break' && this.isFieldHidden(field)) return
currentGroup.push(field)
if (field.type === 'nf-page-break') {
groups.push(currentGroup)
currentGroup = []
}
})
groups.push(currentGroup)
return groups
},
currentFields: {
get () {
return this.fieldGroups[this.currentFieldGroupIndex]
},
set (val) {
// On re-order from the form, set the new order
// Add the previous groups and next to val, and set the properties on working form
const newFields = []
this.fieldGroups.forEach((group, index) => {
if (index < this.currentFieldGroupIndex) {
newFields.push(...group)
} else if (index === this.currentFieldGroupIndex) {
newFields.push(...val)
} else {
newFields.push(...group)
}
})
// set the properties on working_form store
this.workingFormStore.setProperties(newFields)
}
},
/**
* 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}xs
*/
isLastPage () {
return this.currentFieldGroupIndex === (this.fieldGroups.length - 1)
},
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
}
},
watch: {
form: {
deep: true,
handler () {
this.initForm()
}
},
fields: {
deep: true,
handler () {
this.initForm()
}
},
theme: {
handler () {
this.formVersionId++
}
},
dataForm: {
deep: true,
handler () {
if (this.isPublicFormPage && this.form && this.form.auto_save && this.dataFormValue) {
try {
window.localStorage.setItem(this.formPendingSubmissionKey, JSON.stringify(this.dataFormValue))
} catch (e) {
}
}
}
}
},
mounted () {
this.initForm()
if (window.location.href.includes('auto_submit=true')) {
this.isAutoSubmit = true
this.submitForm()
}
},
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()
}
if (this.form.editable_submissions && this.form.submission_id) {
this.dataForm.submission_id = this.form.submission_id
}
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
},
/**
* If more than one page, show first page with error
*/
onSubmissionFailure () {
this.isAutoSubmit = false
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'
})
}
},
async getSubmissionData () {
if (!this.form || !this.form.editable_submissions || !this.form.submission_id) {
return null
}
await this.recordsStore.loadRecord(
axios.get('/api/forms/' + this.form.slug + '/submissions/' + this.form.submission_id).then((response) => {
return { submission_id: this.form.submission_id, ...response.data.data }
})
)
return this.recordsStore.getById(this.form.submission_id)
},
async initForm () {
if (this.isPublicFormPage && this.form.editable_submissions) {
const urlParam = new URLSearchParams(window.location.search)
if (urlParam && urlParam.get('submission_id')) {
this.form.submission_id = urlParam.get('submission_id')
const data = await this.getSubmissionData()
if (data !== null && data) {
this.dataForm = new Form(data)
return
}
}
}
if (this.isPublicFormPage && this.form.auto_save) {
let pendingData
try {
pendingData = window.localStorage.getItem(this.formPendingSubmissionKey)
} catch (e) {
pendingData = null
}
if (pendingData !== null && pendingData) {
pendingData = JSON.parse(pendingData)
this.fields.forEach((field) => {
if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today'
const dateObj = new Date()
let currentDate = dateObj.getFullYear() + '-' +
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
String(dateObj.getDate()).padStart(2, '0')
if (field.with_time === true) {
currentDate += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' +
String(dateObj.getMinutes()).padStart(2, '0')
}
pendingData[field.id] = currentDate
}
})
this.dataForm = new Form(pendingData)
return
}
}
const formData = clonedeep(this.dataForm ? this.dataForm.data() : {})
let urlPrefill = null
if (this.isPublicFormPage) {
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 if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today'
const dateObj = new Date()
let currentDate = dateObj.getFullYear() + '-' +
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
String(dateObj.getDate()).padStart(2, '0')
if (field.with_time === true) {
currentDate += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' +
String(dateObj.getMinutes()).padStart(2, '0')
}
formData[field.id] = currentDate
} else { // Default prefill if any
formData[field.id] = field.prefill
}
})
this.dataForm = new Form(formData)
},
previousPage () {
this.currentFieldGroupIndex -= 1
window.scrollTo({ top: 0, behavior: 'smooth' })
return false
},
nextPage () {
this.currentFieldGroupIndex += 1
window.scrollTo({ top: 0, behavior: 'smooth' })
return false
},
isFieldHidden (field) {
return (new FormLogicPropertyResolver(field, this.dataFormValue)).isHidden()
},
onDragStart () {
this.dragging = true
},
onDragEnd () {
this.dragging = false
}
}
}
</script>
<style lang='scss' scoped>
.ghost-item {
@apply bg-blue-100 dark:bg-blue-900 rounded-md;
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<button :type="nativeType" :disabled="loading?true:null" :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.js'
export default {
name: 'OpenFormButton',
props: {
color: {
type: String,
required: true
},
size: {
type: String,
default: 'medium'
},
nativeType: {
type: String,
default: 'submit'
},
loading: {
type: Boolean,
default: false
},
theme: { type: Object, default: () => themes.default }
},
computed: {
buttonStyle () {
return {
backgroundColor: this.color,
color: this.getTextColor(this.color),
'--tw-ring-color': this.color
}
},
sizes () {
if (this.size === 'small') {
return {
font: 'sm',
'p-y': '1',
'p-x': '2'
}
}
return {
font: 'base',
'p-y': '2',
'p-x': '4'
}
}
},
methods: {
getTextColor (bgColor, lightColor = '#FFFFFF', darkColor = '#000000') {
const color = (bgColor.charAt(0) === '#') ? bgColor.substring(1, 7) : bgColor
const r = parseInt(color.substring(0, 2), 16) // hexToR
const g = parseInt(color.substring(2, 4), 16) // hexToG
const b = parseInt(color.substring(4, 6), 16) // hexToB
const uicolors = [r / 255, g / 255, b / 255]
const c = uicolors.map((col) => {
if (col <= 0.03928) {
return col / 12.92
}
return Math.pow((col + 0.055) / 1.055, 2.4)
})
const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2])
return (L > 0.45) ? darkColor : lightColor
}
}
}
</script>

View File

@ -0,0 +1,342 @@
<template>
<div v-if="!isFieldHidden"
:id="'block-'+field.id" :class="getFieldWidthClasses(field)"
>
<div :class="getFieldClasses(field)">
<div v-if="adminPreview"
class="absolute -translate-x-full top-0 bottom-0 opacity-0 group-hover/nffield:opacity-100 transition-opacity mb-4"
>
<div class="flex flex-col bg-white rounded-md" :class="{'lg:flex-row':!fieldSideBarOpened, 'xl:flex-row':fieldSideBarOpened}">
<div class="p-2 -mr-3 -mb-2 text-gray-300 hover:text-blue-500 cursor-pointer hidden xl:block" role="button"
:class="{'lg:block':!fieldSideBarOpened, 'xl:block':fieldSideBarOpened}"
@click.prevent="openAddFieldSidebar"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3"
stroke="currentColor" class="w-5 h-5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</div>
<div class="p-2 text-gray-300 hover:text-blue-500 cursor-pointer" role="button"
:class="{'lg:-mr-2':!fieldSideBarOpened, 'xl:-mr-2':fieldSideBarOpened}"
@click.prevent="editFieldOptions"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="w-5 h-5"
>
<path fill-rule="evenodd"
d="M11.828 2.25c-.916 0-1.699.663-1.85 1.567l-.091.549a.798.798 0 01-.517.608 7.45 7.45 0 00-.478.198.798.798 0 01-.796-.064l-.453-.324a1.875 1.875 0 00-2.416.2l-.243.243a1.875 1.875 0 00-.2 2.416l.324.453a.798.798 0 01.064.796 7.448 7.448 0 00-.198.478.798.798 0 01-.608.517l-.55.092a1.875 1.875 0 00-1.566 1.849v.344c0 .916.663 1.699 1.567 1.85l.549.091c.281.047.508.25.608.517.06.162.127.321.198.478a.798.798 0 01-.064.796l-.324.453a1.875 1.875 0 00.2 2.416l.243.243c.648.648 1.67.733 2.416.2l.453-.324a.798.798 0 01.796-.064c.157.071.316.137.478.198.267.1.47.327.517.608l.092.55c.15.903.932 1.566 1.849 1.566h.344c.916 0 1.699-.663 1.85-1.567l.091-.549a.798.798 0 01.517-.608 7.52 7.52 0 00.478-.198.798.798 0 01.796.064l.453.324a1.875 1.875 0 002.416-.2l.243-.243c.648-.648.733-1.67.2-2.416l-.324-.453a.798.798 0 01-.064-.796c.071-.157.137-.316.198-.478.1-.267.327-.47.608-.517l.55-.091a1.875 1.875 0 001.566-1.85v-.344c0-.916-.663-1.699-1.567-1.85l-.549-.091a.798.798 0 01-.608-.517 7.507 7.507 0 00-.198-.478.798.798 0 01.064-.796l.324-.453a1.875 1.875 0 00-.2-2.416l-.243-.243a1.875 1.875 0 00-2.416-.2l-.453.324a.798.798 0 01-.796.064 7.462 7.462 0 00-.478-.198.798.798 0 01-.517-.608l-.091-.55a1.875 1.875 0 00-1.85-1.566h-.344zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z"
clip-rule="evenodd"
/>
</svg>
</div>
<div
class="px-2 xl:pl-0 lg:pr-1 lg:pt-2 pb-2 bg-white rounded-md text-gray-300 hover:text-gray-500 cursor-grab draggable"
:class="{'lg:pr-1 lg:pl-0':!fieldSideBarOpened, 'xl:-mr-2':fieldSideBarOpened}"
role="button"
>
<svg
xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</div>
</div>
</div>
<component :is="getFieldComponents" v-if="getFieldComponents"
v-bind="inputProperties(field)" :required="isFieldRequired"
:disabled="isFieldDisabled?true:null"
/>
<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" :class="[getFieldAlignClasses(field)]"
v-html="field.content"
/>
<div v-if="field.type === 'nf-code' && field.content" :id="field.id" :key="field.id"
class="nf-code 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" :class="[getFieldAlignClasses(field)]"
>
<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>
</div>
</div>
</template>
<script>
import { computed } from 'vue'
import { useWorkingFormStore } from '../../../stores/working_form'
import FormLogicPropertyResolver from '../../../forms/FormLogicPropertyResolver.js'
import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js'
export default {
name: 'OpenFormField',
components: {},
mixins: [FormPendingSubmissionKey],
props: {
form: {
type: Object,
required: true
},
dataForm: {
type: Object,
required: true
},
dataFormValue: {
type: Object,
required: true
},
theme: {
type: Object,
required: true
},
showHidden: {
type: Boolean,
default: false
},
field: {
type: Object,
required: true
},
adminPreview: { type: Boolean, default: false } // If used in FormEditorPreview
},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore,
selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar)
}
},
data () {
return {}
},
computed: {
fieldComponents () {
return {
text: 'TextInput',
number: 'TextInput',
select: 'SelectInput',
multi_select: 'SelectInput',
date: 'DateInput',
files: 'FileInput',
checkbox: 'CheckboxInput',
url: 'TextInput',
email: 'TextInput',
phone_number: 'TextInput'
}
},
/**
* Get the right input component for the field/options combination
*/
getFieldComponents () {
const field = this.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 (field.type === 'number' && field.is_scale && field.scale_max_value) {
return 'ScaleInput'
}
if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) {
return 'FlatSelectInput'
}
if (field.type === 'checkbox' && field.use_toggle_switch) {
return 'ToggleSwitchInput'
}
if (field.type === 'signature') {
return 'SignatureInput'
}
if (field.type === 'phone_number' && !field.use_simple_text_input) {
return 'PhoneInput'
}
return this.fieldComponents[field.type]
},
isPublicFormPage () {
return this.$route.name === 'forms.show_public'
},
isFieldHidden () {
return !this.showHidden && this.shouldBeHidden
},
shouldBeHidden () {
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isHidden()
},
isFieldRequired () {
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isRequired()
},
isFieldDisabled () {
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isDisabled()
},
beingEdited () {
return this.adminPreview && this.showEditFieldSidebar && this.form.properties.findIndex((item) => {
return item.id === this.field.id
}) === this.selectedFieldIndex
},
selectionFieldsOptions () {
// For auto update hidden options
let fieldsOptions = []
if (['select', 'multi_select', 'status'].includes(this.field.type)) {
fieldsOptions = [...this.field[this.field.type].options]
if (this.field.hidden_options && this.field.hidden_options.length > 0) {
fieldsOptions = fieldsOptions.filter((option) => {
return this.field.hidden_options.indexOf(option.id) < 0
})
}
}
return fieldsOptions
},
fieldSideBarOpened () {
return this.adminPreview && (this.form && this.selectedFieldIndex !== null) ? (this.form.properties[this.selectedFieldIndex] && this.showEditFieldSidebar) : false
}
},
watch: {},
mounted () {
},
methods: {
editFieldOptions () {
this.workingFormStore.openSettingsForField(this.field)
},
openAddFieldSidebar () {
this.workingFormStore.openAddFieldSidebar(this.field)
},
/**
* Get the right input component for the field/options combination
*/
getFieldClasses () {
let classes = ''
if (this.adminPreview) {
classes += '-mx-4 px-4 -my-1 py-1 group/nffield relative transition-colors'
if (this.beingEdited) {
classes += ' bg-blue-50 rounded-md'
}
}
return classes
},
getFieldWidthClasses (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'
}
},
getFieldAlignClasses (field) {
if (!field.align || field.align === 'left') return 'text-left'
else if (field.align === 'right') {
return 'text-right'
} else if (field.align === 'center') {
return 'text-center'
} else if (field.align === 'justify') {
return 'text-justify'
}
},
/**
* 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.shouldBeHidden) ? ' (Hidden Field)' : ''),
color: this.form.color,
placeholder: field.placeholder,
help: field.help,
helpPosition: (field.help_position) ? field.help_position : 'below_input',
uppercaseLabels: this.form.uppercase_labels == 1 || this.form.uppercase_labels == true,
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
}
if (field.disable_past_dates) {
inputProperties.disablePastDates = true
} else if (field.disable_future_dates) {
inputProperties.disableFutureDates = 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)
} else if (field.type === 'number' && field.is_scale) {
inputProperties.minScale = parseInt(field.scale_min_value) ?? 1
inputProperties.maxScale = parseInt(field.scale_max_value) ?? 5
inputProperties.stepScale = parseInt(field.scale_step_value) ?? 1
} else if (field.type === 'number' || (field.type === 'phone_number' && field.use_simple_text_input)) {
inputProperties.pattern = '/\d*'
} else if (field.type === 'phone_number' && !field.use_simple_text_input) {
inputProperties.unavailableCountries = field.unavailable_countries ?? []
}
return inputProperties
}
}
}
</script>
<style lang='scss' scoped>
.nf-text {
ol {
@apply list-decimal list-inside;
}
ul {
@apply list-disc list-inside;
}
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<collapse class="py-5 w-full" :model-value="false">
<template #title>
<div class="flex">
<h3 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> Show advanced options
</h3>
</div>
</template>
<toggle-switch-input :value="value.hide_title" name="hide_title" class="mt-4"
label="Hide Form Title"
:disabled="(form.hide_title===true)?true:null"
:help="hideTitleHelp"
@update:model-value="onChangeHideTitle"
/>
<toggle-switch-input :value="value.auto_submit" name="auto_submit" class="mt-4"
label="Auto Submit Form"
help="Form will auto submit immediate after open URL"
@update:model-value="onChangeAutoSubmit"
/>
</collapse>
</template>
<script>
import Collapse from '~/components/global/Collapse.vue'
export default {
name: 'AdvancedFormUrlSettings',
components: { Collapse },
props: {
form: {
type: Object,
required: true
},
value: {
type: Object,
required: true
}
},
data () {
return {
}
},
computed: {
hideTitleHelp () {
return this.form.hide_title ? 'This option is disabled because the form title is already hidden' : null
}
},
watch: {},
mounted () {},
methods: {
onChangeHideTitle (val) {
this.value.hide_title = val
},
onChangeAutoSubmit (val) {
this.value.auto_submit = val
}
}
}
</script>

View File

@ -0,0 +1,67 @@
<template>
<div class="flex flex-wrap sm:flex-nowrap my-4 w-full">
<div class="w-full sm:w-auto border border-gray-300 rounded-md p-2 flex-grow select-all bg-gray-100">
<p class="select-all text-gray-900">
{{ content }}
</p>
</div>
<div class="w-full sm:w-40 sm:ml-2 mt-2 sm:mt-0 shrink-0">
<v-button color="light-gray" class="w-full" @click="copyToClipboard(content)">
<slot name="icon">
<svg class="h-4 w-4 -mt-1 text-blue-600 inline mr-1" viewBox="0 0 20 20" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4.16667 12.4998C3.3901 12.4998 3.00182 12.4998 2.69553 12.373C2.28715 12.2038 1.9627 11.8794 1.79354 11.471C1.66667 11.1647 1.66667 10.7764 1.66667 9.99984V4.33317C1.66667 3.39975 1.66667 2.93304 1.84833 2.57652C2.00812 2.26292 2.26308 2.00795 2.57669 1.84816C2.93321 1.6665 3.39992 1.6665 4.33334 1.6665H10C10.7766 1.6665 11.1649 1.6665 11.4711 1.79337C11.8795 1.96253 12.204 2.28698 12.3731 2.69536C12.5 3.00165 12.5 3.38993 12.5 4.1665M10.1667 18.3332H15.6667C16.6001 18.3332 17.0668 18.3332 17.4233 18.1515C17.7369 17.9917 17.9919 17.7368 18.1517 17.4232C18.3333 17.0666 18.3333 16.5999 18.3333 15.6665V10.1665C18.3333 9.23308 18.3333 8.76637 18.1517 8.40985C17.9919 8.09625 17.7369 7.84128 17.4233 7.68149C17.0668 7.49984 16.6001 7.49984 15.6667 7.49984H10.1667C9.23325 7.49984 8.76654 7.49984 8.41002 7.68149C8.09642 7.84128 7.84145 8.09625 7.68166 8.40985C7.50001 8.76637 7.50001 9.23308 7.50001 10.1665V15.6665C7.50001 16.5999 7.50001 17.0666 7.68166 17.4232C7.84145 17.7368 8.09642 17.9917 8.41002 18.1515C8.76654 18.3332 9.23325 18.3332 10.1667 18.3332Z"
stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</slot>
<slot></slot>
</v-button>
</div>
</div>
</template>
<script>
export default {
name: 'CopyContent',
props: {
content: {
type: String,
required: true
},
isDraft: {
type: Boolean,
default: false
},
},
data() {
return {}
},
computed: {},
watch: {},
mounted() {
},
methods: {
copyToClipboard(str) {
if (process.server) return
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
if(this.isDraft){
this.alertWarning('Copied! But other people won\'t be able to see the form since it\'s currently in draft mode')
} else {
this.alertSuccess('Copied!')
}
}
}
}
</script>

View File

@ -0,0 +1,325 @@
<template>
<div v-if="form" id="form-editor" class="relative flex w-full flex-col grow max-h-screen">
<!-- Navbar -->
<div class="w-full border-b p-2 flex items-center justify-between bg-white">
<a v-if="backButton" href="#" class="ml-2 flex text-blue font-semibold text-sm"
@click.prevent="$router.back()"
>
<svg class="w-3 h-3 text-blue mt-1 mr-1" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 9L1 5L5 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Go back
</a>
<div class="hidden md:flex items-center ml-3">
<h3 class="font-semibold text-lg max-w-[14rem] truncate text-gray-500">
{{ form.title }}
</h3>
</div>
<div class="flex items-center" :class="{'mx-auto md:mx-0':!backButton}">
<div class="hidden md:block mr-10 relative">
<a href="#"
class="text-sm px-3 py-2 hover:bg-gray-50 cursor-pointer rounded-md text-gray-500 px-0 sm:px-3 hover:text-gray-800 cursor-pointer mt-1"
@click.prevent="openCrisp"
>
Help
</a>
</div>
<v-button v-track.save_form_click size="small" class="w-full px-8 md:px-4 py-2"
:loading="updateFormLoading" :class="saveButtonClass"
@click="saveForm"
>
<svg class="w-4 h-4 text-white inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 21V13H7V21M7 3V8H15M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H16L21 8V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
<template v-if="form.visibility === 'public'">
Publish Form
</template>
<template v-else>
Save Changes
</template>
</v-button>
</div>
</div>
<div class="w-full flex grow overflow-y-scroll relative bg-gray-50">
<div class="relative w-full bg-white shrink-0 overflow-y-scroll border-r md:w-1/2 md:max-w-sm lg:w-2/5">
<div class="border-b bg-blue-50 p-5 text-nt-blue-dark md:hidden">
Please create this form on a device with a larger screen. That will allow you to preview your form changes.
</div>
<form-information />
<form-structure />
<form-customization />
<form-notifications />
<form-about-submission />
<form-access />
<form-security-privacy />
<form-custom-seo />
<form-custom-code />
</div>
<form-editor-preview />
<form-editor-sidebar />
<!-- Form Error Modal -->
<form-error-modal
:show="showFormErrorModal"
:validation-error-response="validationErrorResponse"
@close="showFormErrorModal=false"
/>
</div>
</div>
<div v-else class="flex justify-center items-center">
<loader class="w-6 h-6" />
</div>
</template>
<script>
import { computed } from 'vue'
import { useAuthStore } from '../../../../stores/auth'
import { useFormsStore } from '../../../../stores/forms'
import { useWorkingFormStore } from '../../../../stores/working_form'
import { useWorkspacesStore } from '../../../../stores/workspaces'
import FormEditorSidebar from './form-components/FormEditorSidebar.vue'
import FormErrorModal from './form-components/FormErrorModal.vue'
import FormInformation from './form-components/FormInformation.vue'
import FormStructure from './form-components/FormStructure.vue'
import FormCustomization from './form-components/FormCustomization.vue'
import FormCustomCode from './form-components/FormCustomCode.vue'
import FormAboutSubmission from './form-components/FormAboutSubmission.vue'
import FormNotifications from './form-components/FormNotifications.vue'
import FormEditorPreview from './form-components/FormEditorPreview.vue'
import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue'
import FormCustomSeo from './form-components/FormCustomSeo.vue'
import FormAccess from './form-components/FormAccess.vue'
import saveUpdateAlert from '../../../../mixins/forms/saveUpdateAlert.js'
import fieldsLogic from '../../../../mixins/forms/fieldsLogic.js'
export default {
name: 'FormEditor',
components: {
FormEditorSidebar,
FormEditorPreview,
FormNotifications,
FormAboutSubmission,
FormCustomCode,
FormCustomization,
FormStructure,
FormInformation,
FormErrorModal,
FormSecurityPrivacy,
FormCustomSeo,
FormAccess
},
mixins: [saveUpdateAlert, fieldsLogic],
props: {
isEdit: {
required: false,
type: Boolean,
default: false
},
isGuest: {
required: false,
type: Boolean,
default: false
},
backButton: {
required: false,
type: Boolean,
default: true
},
saveButtonClass: {
required: false,
type: String,
default: ''
}
},
setup () {
const authStore = useAuthStore()
const formsStore = useFormsStore()
const workingFormStore = useWorkingFormStore()
const workspacesStore = useWorkspacesStore()
return {
formsStore,
workingFormStore,
workspacesStore,
user: computed(() => authStore.user)
}
},
data () {
return {
showFormErrorModal: false,
validationErrorResponse: null,
updateFormLoading: false,
createdFormId: null
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
createdForm () {
return this.formsStore.getById(this.createdFormId)
},
workspace () {
return this.workspacesStore.getCurrent()
},
steps () {
return [
{
target: '#v-step-0',
header: {
title: 'Welcome to the OpnForm 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: () => this.$config.links.help
},
watch: {},
mounted () {
this.$emit('mounted')
this.$root.hideNavbar()
},
beforeUnmount () {
this.$root.hideNavbar(false)
},
methods: {
openCrisp () {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
},
showValidationErrors () {
this.showFormErrorModal = true
},
saveForm () {
this.form.properties = this.validateFieldsLogic(this.form.properties)
if (this.isGuest) {
this.saveFormGuest()
} else if (this.isEdit) {
this.saveFormEdit()
} else {
this.saveFormCreate()
}
},
saveFormEdit () {
if (this.updateFormLoading) return
this.updateFormLoading = true
this.validationErrorResponse = null
this.form.put('/api/open/forms/{id}/'.replace('{id}', this.form.id)).then((response) => {
const data = response.data
this.formsStore.addOrUpdate(data.form)
this.$emit('on-save')
this.$router.push({ name: 'forms.show', params: { slug: this.form.slug } })
this.$logEvent('form_saved', { form_id: this.form.id, form_slug: this.form.slug })
this.displayFormModificationAlert(data)
}).catch((error) => {
if (error.response.status === 422) {
this.validationErrorResponse = error.response.data
this.showValidationErrors()
}
}).finally(() => {
this.updateFormLoading = false
})
},
saveFormCreate () {
if (this.updateFormLoading) return
this.form.workspace_id = this.workspace.id
this.validationErrorResponse = null
this.updateFormLoading = true
this.form.post('/api/open/forms').then((response) => {
this.formsStore.addOrUpdate(response.data.form)
this.$emit('on-save')
this.createdFormId = response.data.form.id
this.$logEvent('form_created', { form_id: response.data.form.id, form_slug: response.data.form.slug })
this.$crisp.push(['set', 'session:event', [[['form_created', {
form_id: response.data.form.id,
form_slug: response.data.form.slug
}, 'blue']]]])
this.displayFormModificationAlert(response.data)
this.$router.push({
name: 'forms.show',
params: {
slug: this.createdForm.slug,
new_form: response.data.users_first_form
}
})
}).catch((error) => {
if (error.response && error.response.status === 422) {
this.validationErrorResponse = error.response.data
this.showValidationErrors()
}
}).finally(() => {
this.updateFormLoading = false
})
},
saveFormGuest () {
this.$emit('openRegister')
}
}
}
</script>
<style lang="scss">
.v-step {
color: white;
.v-step__header, .v-step__content {
color: white;
div {
color: white;
}
}
}
</style>

View File

@ -0,0 +1,341 @@
<template>
<div>
<v-button v-if="formFields && formFields.length > 8"
class="w-full mb-3" color="light-gray"
@click="openAddFieldSidebar"
>
<svg class="w-4 h-4 text-nt-blue inline mr-1 -mt-1" viewBox="0 0 14 14" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.00001 1.1665V12.8332M1.16667 6.99984H12.8333" stroke="currentColor" stroke-width="1.67"
stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Add block
</v-button>
<draggable v-model="formFields"
item-key="id"
class="bg-white overflow-hidden dark:bg-notion-dark-light rounded-md w-full mx-auto border transition-colors"
ghost-class="bg-gray-50"
handle=".draggable"
:animation="200"
>
<template #item="{element, index}">
<div class="w-full mx-auto transition-colors"
:class="{'bg-gray-100 dark:bg-gray-800':element.hidden,'bg-white dark:bg-notion-dark-light':!element.hidden && !element.type==='nf-page-break', 'border-b': (index!== formFields.length -1), 'bg-blue-50 dark:bg-blue-900':element && element.type==='nf-page-break'}"
>
<div v-if="element" class="flex items-center space-x-1 group py-2 pr-4 relative">
<!-- Drag handler -->
<div class="cursor-move draggable p-2 -mr-2">
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 18 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 1.0835H16.5M1.5 6.91683H16.5" stroke="currentColor" stroke-width="1.67"
stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</div>
<!-- Field name and type -->
<div class="flex flex-col flex-grow truncate">
<editable-div class="max-w-full flex items-center" :model-value="element.name"
@update:model-value="onChangeName(element, $event)"
>
<div class="cursor-pointer max-w-full truncate">
{{ element.name }}
</div>
</editable-div>
<p class="text-xs text-gray-400 w-full truncate pl-2">
<span class="capitalize">{{ formatType(element) }}</span>
</p>
</div>
<template v-if="removing == element.id">
<div class="flex text-sm items-center">
Remove block?
<v-button class="inline ml-1" color="red" size="small" @click="removeBlock(index)">
Yes
</v-button>
<v-button class="inline ml-1" color="light-gray" size="small" @click="removing=false">
No
</v-button>
</div>
</template>
<template v-else>
<button
class="hover:bg-red-50 text-gray-500 hover:text-red-600 rounded transition-colors cursor-pointer p-2 hidden md:group-hover:block"
@click="removing=element.id"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 6H5M5 6H21M5 6V20C5 20.5304 5.21071 21.0391 5.58579 21.4142C5.96086 21.7893 6.46957 22 7 22H17C17.5304 22 18.0391 21.7893 18.4142 21.4142C18.7893 21.0391 19 20.5304 19 20V6H5ZM8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M10 11V17M14 11V17"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</button>
<button class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-2 hidden"
:class="{'text-blue-500': !element.hidden, 'text-gray-500': element.hidden, 'group-hover:md:block': !element.hidden, 'md:block':element.hidden}"
@click="toggleHidden(element)"
>
<template v-if="!element.hidden">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
<path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</template>
<template v-else>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1027_7292)">
<path
d="M9.9 4.24C10.5883 4.07888 11.2931 3.99834 12 4C19 4 23 12 23 12C22.393 13.1356 21.6691 14.2047 20.84 15.19M14.12 14.12C13.8454 14.4147 13.5141 14.6512 13.1462 14.8151C12.7782 14.9791 12.3809 15.0673 11.9781 15.0744C11.5753 15.0815 11.1752 15.0074 10.8016 14.8565C10.4281 14.7056 10.0887 14.481 9.80385 14.1962C9.51897 13.9113 9.29439 13.5719 9.14351 13.1984C8.99262 12.8248 8.91853 12.4247 8.92563 12.0219C8.93274 11.6191 9.02091 11.2218 9.18488 10.8538C9.34884 10.4859 9.58525 10.1546 9.88 9.88M1 1L23 23M17.94 17.94C16.2306 19.243 14.1491 19.9649 12 20C5 20 1 12 1 12C2.24389 9.6819 3.96914 7.65661 6.06 6.06L17.94 17.94Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_1027_7292">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
</template>
</button>
<button v-if="!element.type.startsWith('nf-')"
class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-2 hidden"
:class="{'group-hover:md:block': !element.required, 'md:block':element.required}"
@click="toggleRequired(element)"
>
<div class="w-4 h-4 text-center font-bold text-3xl"
:class="{'text-red-500': element.required, 'text-gray-500': !element.required}"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2V12M12 12V22M12 12L4.93 4.93M12 12L19.07 19.07M12 12H2M12 12H22M12 12L4.93 19.07M12 12L19.07 4.93"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</div>
</button>
<button class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-2"
@click="editOptions(index)"
>
<svg class="h-4 w-4 text-blue-600" width="24" height="24" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1027_7210)">
<path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
<path
d="M19.4 15C19.2669 15.3016 19.2272 15.6362 19.286 15.9606C19.3448 16.285 19.4995 16.5843 19.73 16.82L19.79 16.88C19.976 17.0657 20.1235 17.2863 20.2241 17.5291C20.3248 17.7719 20.3766 18.0322 20.3766 18.295C20.3766 18.5578 20.3248 18.8181 20.2241 19.0609C20.1235 19.3037 19.976 19.5243 19.79 19.71C19.6043 19.896 19.3837 20.0435 19.1409 20.1441C18.8981 20.2448 18.6378 20.2966 18.375 20.2966C18.1122 20.2966 17.8519 20.2448 17.6091 20.1441C17.3663 20.0435 17.1457 19.896 16.96 19.71L16.9 19.65C16.6643 19.4195 16.365 19.2648 16.0406 19.206C15.7162 19.1472 15.3816 19.1869 15.08 19.32C14.7842 19.4468 14.532 19.6572 14.3543 19.9255C14.1766 20.1938 14.0813 20.5082 14.08 20.83V21C14.08 21.5304 13.8693 22.0391 13.4942 22.4142C13.1191 22.7893 12.6104 23 12.08 23C11.5496 23 11.0409 22.7893 10.6658 22.4142C10.2907 22.0391 10.08 21.5304 10.08 21V20.91C10.0723 20.579 9.96512 20.258 9.77251 19.9887C9.5799 19.7194 9.31074 19.5143 9 19.4C8.69838 19.2669 8.36381 19.2272 8.03941 19.286C7.71502 19.3448 7.41568 19.4995 7.18 19.73L7.12 19.79C6.93425 19.976 6.71368 20.1235 6.47088 20.2241C6.22808 20.3248 5.96783 20.3766 5.705 20.3766C5.44217 20.3766 5.18192 20.3248 4.93912 20.2241C4.69632 20.1235 4.47575 19.976 4.29 19.79C4.10405 19.6043 3.95653 19.3837 3.85588 19.1409C3.75523 18.8981 3.70343 18.6378 3.70343 18.375C3.70343 18.1122 3.75523 17.8519 3.85588 17.6091C3.95653 17.3663 4.10405 17.1457 4.29 16.96L4.35 16.9C4.58054 16.6643 4.73519 16.365 4.794 16.0406C4.85282 15.7162 4.81312 15.3816 4.68 15.08C4.55324 14.7842 4.34276 14.532 4.07447 14.3543C3.80618 14.1766 3.49179 14.0813 3.17 14.08H3C2.46957 14.08 1.96086 13.8693 1.58579 13.4942C1.21071 13.1191 1 12.6104 1 12.08C1 11.5496 1.21071 11.0409 1.58579 10.6658C1.96086 10.2907 2.46957 10.08 3 10.08H3.09C3.42099 10.0723 3.742 9.96512 4.0113 9.77251C4.28059 9.5799 4.48572 9.31074 4.6 9C4.73312 8.69838 4.77282 8.36381 4.714 8.03941C4.65519 7.71502 4.50054 7.41568 4.27 7.18L4.21 7.12C4.02405 6.93425 3.87653 6.71368 3.77588 6.47088C3.67523 6.22808 3.62343 5.96783 3.62343 5.705C3.62343 5.44217 3.67523 5.18192 3.77588 4.93912C3.87653 4.69632 4.02405 4.47575 4.21 4.29C4.39575 4.10405 4.61632 3.95653 4.85912 3.85588C5.10192 3.75523 5.36217 3.70343 5.625 3.70343C5.88783 3.70343 6.14808 3.75523 6.39088 3.85588C6.63368 3.95653 6.85425 4.10405 7.04 4.29L7.1 4.35C7.33568 4.58054 7.63502 4.73519 7.95941 4.794C8.28381 4.85282 8.61838 4.81312 8.92 4.68H9C9.29577 4.55324 9.54802 4.34276 9.72569 4.07447C9.90337 3.80618 9.99872 3.49179 10 3.17V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.09C14.0013 3.41179 14.0966 3.72618 14.2743 3.99447C14.452 4.26276 14.7042 4.47324 15 4.6C15.3016 4.73312 15.6362 4.77282 15.9606 4.714C16.285 4.65519 16.5843 4.50054 16.82 4.27L16.88 4.21C17.0657 4.02405 17.2863 3.87653 17.5291 3.77588C17.7719 3.67523 18.0322 3.62343 18.295 3.62343C18.5578 3.62343 18.8181 3.67523 19.0609 3.77588C19.3037 3.87653 19.5243 4.02405 19.71 4.21C19.896 4.39575 20.0435 4.61632 20.1441 4.85912C20.2448 5.10192 20.2966 5.36217 20.2966 5.625C20.2966 5.88783 20.2448 6.14808 20.1441 6.39088C20.0435 6.63368 19.896 6.85425 19.71 7.04L19.65 7.1C19.4195 7.33568 19.2648 7.63502 19.206 7.95941C19.1472 8.28381 19.1869 8.61838 19.32 8.92V9C19.4468 9.29577 19.6572 9.54802 19.9255 9.72569C20.1938 9.90337 20.5082 9.99872 20.83 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.91C20.5882 14.0013 20.2738 14.0966 20.0055 14.2743C19.7372 14.452 19.5268 14.7042 19.4 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_1027_7210">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
</button>
</template>
</div>
</div>
</template>
</draggable>
<v-button
class="w-full mt-3" color="light-gray"
@click="openAddFieldSidebar"
>
<svg class="w-4 h-4 text-nt-blue inline mr-1 -mt-1" viewBox="0 0 14 14" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.00001 1.1665V12.8332M1.16667 6.99984H12.8333" stroke="currentColor" stroke-width="1.67"
stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Add block
</v-button>
</div>
</template>
<script>
import { computed } from 'vue'
import { useWorkingFormStore } from '../../../../stores/working_form'
import draggable from 'vuedraggable'
import ProTag from '~/components/global/ProTag.vue'
import clonedeep from 'clone-deep'
import EditableDiv from '~/components/global/EditableDiv.vue'
import VButton from '~/components/global/VButton.vue'
draggable.compatConfig = { MODE: 3 }
export default {
name: 'FormFieldsEditor',
components: {
VButton,
ProTag,
draggable,
EditableDiv
},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
formFields: [],
removing: null
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
watch: {
formFields: {
deep: true,
handler () {
this.form.properties = this.formFields
}
},
'form.properties': {
deep: true,
handler () {
// If different, then update
if (this.form.properties.length !== this.formFields.length ||
JSON.stringify(this.form.properties) !== JSON.stringify(this.formFields)) {
this.formFields = clonedeep(this.form.properties)
}
}
}
},
mounted () {
this.init()
},
methods: {
onChangeName (field, newName) {
field.name = newName
},
toggleHidden (field) {
field.hidden = !field.hidden
if (field.hidden) {
field.required = false
} else {
field.generates_uuid = false
field.generates_auto_increment_id = false
}
},
toggleRequired (field) {
field.required = !field.required
if (field.required) {
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' || this.$route.name === 'forms.create.guest') { // Set Default fields
this.formFields = (this.form.properties.length > 0) ? clonedeep(this.form.properties) : 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
field.help_position = field.help_position || 'below_input'
return field
})
}
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
},
editOptions (index) {
this.workingFormStore.openSettingsForField(index)
},
removeBlock (blockIndex) {
const newFields = clonedeep(this.formFields)
newFields.splice(blockIndex, 1)
this.formFields = newFields
this.closeSidebar()
},
closeSidebar () {
this.workingFormStore.closeEditFieldSidebar()
},
openAddFieldSidebar () {
this.workingFormStore.openAddFieldSidebar(null)
}
}
}
</script>

View File

@ -0,0 +1,120 @@
<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="false" 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">
<v-button :to="{name:'pricing'}">
Subscribe
</v-button>
</p>
</div>
</div>
<img src="/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
:options="chartOptions"
:data="chartData"
/>
</div>
</template>
<script>
import axios from 'axios'
import { Line as LineChart } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
CategoryScale,
PointElement
} from 'chart.js'
import ProTag from '~/components/global/ProTag.vue'
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
CategoryScale,
PointElement
)
export default {
name: 'FormStats',
components: {
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: true
}
}
},
mounted () {
this.getChartData()
},
methods: {
getChartData () {
if (!this.form) { return null }
this.isLoading = true
axios.get('/api/open/workspaces/' + this.form.workspace_id + '/form-stats/' + this.form.id).then((response) => {
const statsData = response.data
if (statsData && statsData.views !== undefined) {
this.chartData.labels = Object.keys(statsData.views)
this.chartData.datasets[0].data = statsData.views
this.chartData.datasets[1].data = statsData.submissions
this.isLoading = false
}
})
}
}
}
</script>

View File

@ -0,0 +1,250 @@
<template>
<div
class="my-4 w-full mx-auto"
>
<h3 class="font-semibold mb-4 text-xl">
Form Submissions
</h3>
<!-- Table columns modal -->
<modal :show="showColumnsModal" @close="showColumnsModal=false">
<template #icon>
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 5H8C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 15C9.65685 15 11 13.6569 11 12C11 10.3431 9.65685 9 8 9C6.34315 9 5 10.3431 5 12C5 13.6569 6.34315 15 8 15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<template #title>
Display columns
</template>
<div class="px-4">
<template v-if="properties.length > 0">
<h4 class="font-bold mb-2">
Form Fields
</h4>
<div v-for="field in properties" :key="field.id" class="p-2 border">
{{ field.name }}
<v-switch v-model="displayColumns[field.id]" class="float-right" @update:model-value="onChangeDisplayColumns" />
</div>
</template>
<template v-if="removed_properties.length > 0">
<h4 class="font-bold mb-2 mt-4">
Removed Fields
</h4>
<div v-for="field in removed_properties" :key="field.id" class="p-2 border">
{{ field.name }}
<v-switch v-model="displayColumns[field.id]" class="float-right" @update:model-value="onChangeDisplayColumns" />
</div>
</template>
</div>
</modal>
<loader v-if="!form || !formInitDone" class="h-6 w-6 text-nt-blue mx-auto" />
<div v-else>
<div v-if="form && tableData.length > 0" class="flex flex-wrap items-end">
<div class="flex-grow">
<text-input class="w-64" :form="searchForm" name="search" placeholder="Search..." />
</div>
<div class="font-semibold flex gap-4">
<p class="float-right text-xs uppercase mb-2">
<a
href="javascript:void(0);" class="text-gray-500" @click="showColumnsModal=true"
>Display columns</a>
</p>
<p class="text-right text-xs uppercase">
<a
:href="exportUrl" target="_blank"
>Export as CSV</a>
</p>
</div>
</div>
<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="filteredData"
:loading="isLoading"
@resize="dataChanged()"
@deleted="onDeleteRecord()"
/>
</scroll-shadow>
</div>
</div>
</template>
<script>
import axios from 'axios'
import Fuse from 'fuse.js'
import Form from 'vform'
import { useWorkingFormStore } from '../../../../stores/working_form'
import ScrollShadow from '~/components/global/ScrollShadow.vue'
import OpenTable from '../../tables/OpenTable.vue'
import clonedeep from 'clone-deep'
import VSwitch from '../../../forms/components/VSwitch.vue'
export default {
name: 'FormSubmissions',
components: { ScrollShadow, OpenTable, VSwitch },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
formInitDone: false,
isLoading: false,
tableData: [],
currentPage: 1,
fullyLoaded: false,
showColumnsModal: false,
properties: [],
removed_properties: [],
displayColumns: {},
searchForm: new Form({
search: ''
})
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
set (value) {
this.workingFormStore.set(value)
}
},
exportUrl () {
if (!this.form) {
return ''
}
return '/api/open/forms/' + this.form.id + '/submissions/export'
},
filteredData () {
if (!this.tableData) return []
const filteredData = clonedeep(this.tableData)
if (this.searchForm.search === '' || this.searchForm.search === null) {
return filteredData
}
// Fuze search
const fuzeOptions = {
keys: this.form.properties.map((field) => field.id)
}
const fuse = new Fuse(filteredData, fuzeOptions)
return fuse.search(this.searchForm.search).map((res) => {
return res.item
})
}
},
watch: {
'form.id' () {
if (this.form === null) {
return
}
this.initFormStructure()
this.getSubmissionsData()
}
},
mounted () {
this.initFormStructure()
this.getSubmissionsData()
},
methods: {
initFormStructure () {
if (!this.form || !this.form.properties || this.formInitDone) {
return
}
// check if form properties already has a created_at column
let hasCreatedAt = false
this.form.properties.forEach((property) => {
if (property.id === 'created_at') {
hasCreatedAt = true
}
})
if (!hasCreatedAt) {
// Add a "created at" column
const columns = clonedeep(this.form.properties)
columns.push({
name: 'Created at',
id: 'created_at',
type: 'date',
width: 140
})
this.form.properties = columns
}
this.formInitDone = true
this.properties = clonedeep(this.form.properties)
this.removed_properties = (this.form.removed_properties) ? clonedeep(this.form.removed_properties) : []
// Get display columns from local storage
const tmpColumns = window.localStorage.getItem('display-columns-formid-' + this.form.id)
if (tmpColumns !== null && tmpColumns) {
this.displayColumns = JSON.parse(tmpColumns)
this.onChangeDisplayColumns()
} else {
this.form.properties.forEach((field) => {
this.displayColumns[field.id] = 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))
this.dataChanged()
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 () {
if (this.$refs.shadows) {
this.$refs.shadows.toggleShadow()
this.$refs.shadows.calcDimensions()
}
},
onChangeDisplayColumns () {
window.localStorage.setItem('display-columns-formid-' + this.form.id, JSON.stringify(this.displayColumns))
this.form.properties = this.properties.concat(this.removed_properties).filter((field) => {
return this.displayColumns[field.id] === true
})
},
onDeleteRecord () {
this.fullyLoaded = false
this.tableData = []
this.getSubmissionsData()
}
}
}
</script>

View File

@ -0,0 +1,88 @@
<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
},
extraQueryParam: {
type: String,
default: ''
}
},
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])
}
})
if(uriComponents.toString() !== ""){
return (this.extraQueryParam) ? url + '?' + uriComponents + '&' + this.extraQueryParam : url + '?' + uriComponents
}else{
return (this.extraQueryParam) ? url + '?' + this.extraQueryParam : url
}
}
},
watch: {},
mounted () {
},
methods: {
getPropertyUriComponent (property) {
const prefillValue = encodeURIComponent(this.formData[property.id])
return encodeURIComponent(property.id) + '=' + prefillValue
},
copyToClipboard () {
if (process.server) return
const str = this.preFillUrl
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
}
}
</script>

View File

@ -0,0 +1,275 @@
<template>
<div>
<div class="p-4 border-b sticky top-0 z-10 bg-white">
<div class="flex">
<button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click.prevent="closeSidebar">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<div class="font-semibold inline ml-2 truncate flex-grow truncate">
Add Block
</div>
</div>
</div>
<div class="py-2 px-4">
<div>
<p class="text-gray-500 uppercase text-xs font-semibold mb-2">
Input Blocks
</p>
<div class="grid grid-cols-2 gap-2">
<div v-for="(block, i) in inputBlocks" :key="block.name"
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
role="button" @click.prevent="addBlock(block.name)"
>
<div class="mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2" v-html="block.icon"
></svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1">
{{ block.title }}
</p>
</div>
</div>
</div>
<div class="border-t mt-6">
<p class="text-gray-500 uppercase text-xs font-semibold mb-2 mt-6">
Layout Blocks
</p>
<div class="grid grid-cols-2 gap-2">
<div v-for="(block, i) in layoutBlocks" :key="block.name"
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
role="button" @click.prevent="addBlock(block.name)"
>
<div class="mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2" v-html="block.icon"
></svg>
</div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1">
{{ block.title }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Form from 'vform'
import clonedeep from 'clone-deep'
import { computed } from 'vue'
import { useWorkingFormStore } from '../../../../../stores/working_form'
export default {
name: 'AddFormBlock',
components: {},
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore,
selectedFieldIndex : computed(() => workingFormStore.selectedFieldIndex)
}
},
data () {
return {
blockForm: null,
inputBlocks: [
{
name: 'text',
title: 'Text Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>'
},
{
name: 'date',
title: 'Date Input',
icon: '<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"/>'
},
{
name: 'url',
title: 'URL Input',
icon: '<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"/>'
},
{
name: 'phone_number',
title: 'Phone Input',
icon: '<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"/>'
},
{
name: 'email',
title: 'Email Input',
icon: '<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"/>'
},
{
name: 'checkbox',
title: 'Checkbox Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>'
},
{
name: 'select',
title: 'Select Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>'
},
{
name: 'multi_select',
title: 'Multi-select Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>'
},
{
name: 'number',
title: 'Number Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>'
},
{
name: 'files',
title: 'File Input',
icon: '<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" />'
},
{
name: 'signature',
title: 'Signature Input',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />'
}
],
layoutBlocks: [
{
name: 'nf-text',
title: 'Text Block',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h8m-8 6h16" />'
},
{
name: 'nf-page-break',
title: 'Page-break Block',
icon: '<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" />'
},
{
name: 'nf-divider',
title: 'Divider Block',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />'
},
{
name: 'nf-image',
title: 'Image Block',
icon: '<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" />'
},
{
name: 'nf-code',
title: 'Code Block',
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />'
}
]
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
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',
signature: 'Signature',
'nf-text': 'Text Block',
'nf-page-break': 'Page Break',
'nf-divider': 'Divider',
'nf-image': 'Image',
'nf-code': 'Code Block'
}
}
},
watch: {},
mounted () {
this.reset()
},
methods: {
closeSidebar () {
this.workingFormStore.closeAddFieldSidebar()
},
reset () {
this.blockForm = new Form({
type: null,
name: null
})
},
addBlock (type) {
this.blockForm.type = type
this.blockForm.name = this.defaultBlockNames[type]
const newBlock = this.prefillDefault(this.blockForm.data())
newBlock.id = this.generateUUID()
newBlock.hidden = false
if (['select', 'multi_select'].includes(this.blockForm.type)) {
newBlock[this.blockForm.type] = { options: [] }
}
newBlock.help_position = 'below_input'
if (this.selectedFieldIndex === null || this.selectedFieldIndex === undefined) {
const newFields = clonedeep(this.form.properties)
newFields.push(newBlock)
this.form.properties = newFields
this.workingFormStore.openSettingsForField(this.form.properties.length - 1)
} else {
const newFields = clonedeep(this.form.properties)
newFields.splice(this.selectedFieldIndex + 1, 0, newBlock)
this.form.properties = newFields
this.workingFormStore.openSettingsForField(this.selectedFieldIndex + 1)
}
this.reset()
},
generateUUID () {
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'
} else if (data.type === 'nf-code') {
data.content = '<div class="text-blue-500 italic">This is a code block.</div>'
} else if (data.type === 'signature') {
data.help = 'Draw your signature above'
}
return data
}
}
}
</script>

View File

@ -0,0 +1,205 @@
<template>
<editor-options-panel name="About Submissions" :already-opened="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.83333 6.08333H9M4.83333 9H11.5M4.83333 14V15.9463C4.83333 16.3903 4.83333 16.6123 4.92436 16.7263C5.00352 16.8255 5.12356 16.8832 5.25045 16.8831C5.39636 16.8829 5.56973 16.7442 5.91646 16.4668L7.90434 14.8765C8.31043 14.5517 8.51347 14.3892 8.73957 14.2737C8.94017 14.1712 9.15369 14.0963 9.37435 14.051C9.62306 14 9.88308 14 10.4031 14H12.5C13.9001 14 14.6002 14 15.135 13.7275C15.6054 13.4878 15.9878 13.1054 16.2275 12.635C16.5 12.1002 16.5 11.4001 16.5 10V5.5C16.5 4.09987 16.5 3.3998 16.2275 2.86502C15.9878 2.39462 15.6054 2.01217 15.135 1.77248C14.6002 1.5 13.9001 1.5 12.5 1.5H5.5C4.09987 1.5 3.3998 1.5 2.86502 1.77248C2.39462 2.01217 2.01217 2.39462 1.77248 2.86502C1.5 3.3998 1.5 4.09987 1.5 5.5V10.6667C1.5 11.4416 1.5 11.8291 1.58519 12.147C1.81635 13.0098 2.49022 13.6836 3.35295 13.9148C3.67087 14 4.05836 14 4.83333 14Z"
stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>
<text-input name="submit_button_text" class="mt-4"
:form="form"
label="Text of Submit Button"
:required="true"
/>
<toggle-switch-input name="editable_submissions" :form="form" class="mt-4"
help="Gives user a unique url to update their submission"
>
<template #label>
Editable submissions
<pro-tag class="ml-1" />
</template>
</toggle-switch-input>
<text-input v-if="form.editable_submissions" name="editable_submissions_button_text"
:form="form"
label="Text of editable submissions button"
:required="true"
/>
<flat-select-input :form="submissionOptions" name="databaseAction" label="Database Submission Action"
:options="[
{name:'Create new record (default)', value:'create'},
{name:'Update Record (or create if no match)', 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>
</flat-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="$crisp.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>
<toggle-switch-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"
/>
</template>
</editor-options-panel>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import ProTag from '~/components/global/ProTag.vue'
import VTransition from '~/components/global/transitions/VTransition.vue'
export default {
components: {EditorOptionsPanel, ProTag, VTransition},
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
submissionOptions: {}
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.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 && !['files', 'signature', 'multi_select'].includes(field.type)
}).map((field) => {
return {
name: field.name,
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.form.redirect_url = null
}
if (val.databaseAction === 'create') {
this.form.database_fields_update = null
}
}
}
}
}
</script>

View File

@ -0,0 +1,73 @@
<template>
<editor-options-panel name="Form Access" :already-opened="false">
<template #icon>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</template>
<text-input name="password" :form="form" class="mt-4"
label="Form Password" help="Leave empty to disable password"
/>
<date-input :with-time="true" name="closes_at" class="mt-4"
:form="form"
label="Close form on a scheduled date"
help="Leave empty to keep the form open"
:required="false"
/>
<rich-text-area-input v-if="form.closes_at || form.visibility=='closed'" name="closed_text"
:form="form" class="mt-4"
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="Limit number of submissions" placeholder="Max submissions" class="mt-4"
help="Leave empty for unlimited submissions"
:required="false"
/>
<rich-text-area-input v-if="form.max_submissions_count && form.max_submissions_count > 0"
name="max_submissions_reached_text" class="mt-4"
: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"
/>
</editor-options-panel>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default {
components: { EditorOptionsPanel },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
watch: {},
mounted () {
},
methods: {}
}
</script>

View File

@ -0,0 +1,50 @@
<template>
<editor-options-panel name="Custom Code" :already-opened="false" :has-pro-tag="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2.26953V6.40007C14 6.96012 14 7.24015 14.109 7.45406C14.2049 7.64222 14.3578 7.7952 14.546 7.89108C14.7599 8.00007 15.0399 8.00007 15.6 8.00007H19.7305M14 17.5L16.5 15L14 12.5M10 12.5L7.5 15L10 17.5M20 9.98822V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V6.8C4 5.11984 4 4.27976 4.32698 3.63803C4.6146 3.07354 5.07354 2.6146 5.63803 2.32698C6.27976 2 7.11984 2 8.8 2H12.0118C12.7455 2 13.1124 2 13.4577 2.08289C13.7638 2.15638 14.0564 2.27759 14.3249 2.44208C14.6276 2.6276 14.887 2.88703 15.4059 3.40589L18.5941 6.59411C19.113 7.11297 19.3724 7.3724 19.5579 7.67515C19.7224 7.94356 19.8436 8.2362 19.9171 8.5423C20 8.88757 20 9.25445 20 9.98822Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>
<p class="mt-4">
The code will be injected in the <span class="font-semibold">head</span> section of your form page.
</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"
/>
</editor-options-panel>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import CodeInput from '../../../../forms/CodeInput.vue'
export default {
components: { EditorOptionsPanel, CodeInput },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
}
</script>

View File

@ -0,0 +1,80 @@
<template>
<editor-options-panel name="Link Settings - SEO" :already-opened="false" :has-pro-tag="true">
<template #icon>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
</template>
<p class="mt-4 text-gray-500 text-sm">
Customize the link, images and text that appear when you share your form on other sites (Open Graph).
</p>
<select-input v-if="customDomainAllowed" v-model="form.custom_domain" :disabled="customDomainOptions.length <= 0" :options="customDomainOptions" name="type"
class="mt-4" label="Form Domain" placeholder="yourdomain.com"
/>
<text-input v-model="form.seo_meta.page_title" name="page_title" class="mt-4"
label="Page Title" help="Under or approximately 60 characters"
/>
<text-area-input v-model="form.seo_meta.page_description" name="page_description" class="mt-4"
label="Page Description" help="Between 150 and 160 characters"
/>
<image-input v-model="form.seo_meta.page_thumbnail" name="page_thumbnail" class="mt-4"
label="Page Thumbnail Image" help="Also know as og:image - 1200 X 630"
/>
</editor-options-panel>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default {
components: { EditorOptionsPanel },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
workspace () {
return this.$store.getters['open/workspaces/getCurrent']()
},
customDomainOptions () {
return this.workspace.custom_domains.map((domain) => {
return {
name: domain,
value: domain
}
})
},
customDomainAllowed () {
return this.$config.custom_domains_enabled
}
},
watch: {},
mounted () {
['page_title', 'page_description', 'page_thumbnail'].forEach((keyname) => {
if (this.form.seo_meta[keyname] === undefined) {
this.form.seo_meta[keyname] = null
}
})
},
methods: {}
}
</script>

View File

@ -0,0 +1,135 @@
<template>
<editor-options-panel name="Customization" :already-opened="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66667 9.99984C1.66667 14.6022 5.39763 18.3332 10 18.3332C11.3807 18.3332 12.5 17.2139 12.5 15.8332V15.4165C12.5 15.0295 12.5 14.836 12.5214 14.6735C12.6691 13.5517 13.5519 12.6689 14.6737 12.5212C14.8361 12.4998 15.0297 12.4998 15.4167 12.4998H15.8333C17.214 12.4998 18.3333 11.3805 18.3333 9.99984C18.3333 5.39746 14.6024 1.6665 10 1.6665C5.39763 1.6665 1.66667 5.39746 1.66667 9.99984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.83333 10.8332C6.29357 10.8332 6.66667 10.4601 6.66667 9.99984C6.66667 9.5396 6.29357 9.1665 5.83333 9.1665C5.3731 9.1665 5 9.5396 5 9.99984C5 10.4601 5.3731 10.8332 5.83333 10.8332Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.3333 7.49984C13.7936 7.49984 14.1667 7.12674 14.1667 6.6665C14.1667 6.20627 13.7936 5.83317 13.3333 5.83317C12.8731 5.83317 12.5 6.20627 12.5 6.6665C12.5 7.12674 12.8731 7.49984 13.3333 7.49984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.33333 6.6665C8.79357 6.6665 9.16667 6.29341 9.16667 5.83317C9.16667 5.37293 8.79357 4.99984 8.33333 4.99984C7.8731 4.99984 7.5 5.37293 7.5 5.83317C7.5 6.29341 7.8731 6.6665 8.33333 6.6665Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</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)"
/>
<toggle-switch-input name="hide_title" :form="form" class="mt-4"
label="Hide Title"
/>
<toggle-switch-input name="no_branding" :form="form" class="mt-4">
<template #label>
Remove OpnForm Branding
<pro-tag class="ml-1" />
</template>
</toggle-switch-input>
<toggle-switch-input name="uppercase_labels" :form="form" class="mt-4"
label="Uppercase Input Labels"
/>
<toggle-switch-input name="transparent_background" :form="form" class="mt-4"
label="Transparent Background" help="Only applies when form is embedded"
/>
<toggle-switch-input name="confetti_on_submission" :form="form" class="mt-4"
label="Confetti on successful submisison"
@update:model-value="onChangeConfettiOnSubmission"
/>
<toggle-switch-input name="auto_save" :form="form"
label="Auto save form response"
help="Will save data in browser, if user not submit the form then next time will auto prefill last entered data"
/>
</editor-options-panel>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import ProTag from '~/components/global/ProTag.vue'
export default {
components: { EditorOptionsPanel, ProTag },
props: {
},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
isMounted: false
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
watch: {},
mounted () {
this.isMounted = true
},
methods: {
onChangeConfettiOnSubmission (val) {
this.form.confetti_on_submission = val
if (this.isMounted && val) {
this.playConfetti()
}
},
openChat () {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
}
}
}
</script>

View File

@ -0,0 +1,114 @@
<template>
<!-- Form Preview (desktop only) -->
<div
class="bg-gray-50 dark:bg-notion-dark-light hidden md:flex flex-grow p-5 flex-col items-center overflow-y-scroll"
>
<div class="border rounded-lg bg-white dark:bg-notion-dark w-full block transition-all max-w-5xl">
<transition enter-active-class="linear duration-100 overflow-hidden"
enter-from-class="max-h-0"
enter-to-class="max-h-56"
leave-active-class="linear duration-100 overflow-hidden"
leave-from-class="max-h-56"
leave-to-class="max-h-0"
>
<div v-if="(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 object-contain 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"
:admin-preview="true"
@restarted="previewFormSubmitted=false"
@submitted="previewFormSubmitted=true"
/>
</div>
<p class="text-center text-xs text-gray-400 dark:text-gray-600 mt-1">
Form Preview <span v-if="creating"
class="font-normal text-gray-400 dark:text-gray-600 text-xs"
>- Answers won't be saved</span>
<br>
<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-4 w-4 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>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import VSwitch from '../../../../forms/components/VSwitch.vue'
import OpenCompleteForm from '../../OpenCompleteForm.vue'
export default {
components: { OpenCompleteForm, VSwitch },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
previewFormSubmitted: false
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
creating () { // returns true if we are creating a form
return !this.form.hasOwnProperty('id')
}
},
watch: {},
mounted () {
},
methods: {
coverPictureSrc (val) {
try {
// Is valid url
new URL(val)
} catch (_) {
// Is file
return URL.createObjectURL(val)
}
return val
}
}
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<editor-right-sidebar :show="form && (showEditFieldSidebar || showAddFieldSidebar)">
<transition mode="out-in">
<form-field-edit v-if="showEditFieldSidebar" :key="editFieldIndex" v-motion-fade="'fade'" />
<add-form-block v-else-if="showAddFieldSidebar" v-motion-fade="'fade'" />
</transition>
</editor-right-sidebar>
</template>
<script>
import { computed } from 'vue'
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorRightSidebar from '../../../editors/EditorRightSidebar.vue'
import FormFieldEdit from '../../fields/FormFieldEdit.vue'
import AddFormBlock from './AddFormBlock.vue'
export default {
name: 'FormEditorSidebar',
components: { EditorRightSidebar, AddFormBlock, FormFieldEdit },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore,
editFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar),
showAddFieldSidebar: computed(() => workingFormStore.showAddFieldSidebar)
}
},
data () {
return {}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
watch: {},
mounted () {
},
methods: {}
}
</script>

View File

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

View File

@ -0,0 +1,169 @@
<template>
<editor-options-panel name="Information" :already-opened="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13.3332V9.99984M10 6.6665H10.0083M18.3333 9.99984C18.3333 14.6022 14.6024 18.3332 10 18.3332C5.39763 18.3332 1.66667 14.6022 1.66667 9.99984C1.66667 5.39746 5.39763 1.6665 10 1.6665C14.6024 1.6665 18.3333 5.39746 18.3333 9.99984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>
<text-input name="title" class="mt-4"
:form="form"
label="Form Title"
:required="true"
/>
<rich-text-area-input name="description"
:form="form"
label="Description"
:required="false"
/>
<select-input name="tags" label="Tags" :form="form" class="mt-4"
help="To organize your forms (hidden to respondents)"
placeholder="Select Tag(s)" :multiple="true" :allow-creation="true"
:options="allTagsOptions"
/>
<select-input name="visibility" label="Visibility" :form="form" class="mt-4"
help="Only public form will be accessible"
placeholder="Select Visibility" :required="true"
:options="visibilityOptions"
/>
<v-button v-if="copyFormOptions.length > 0" color="light-gray" class="w-full mt-4" @click="showCopyFormSettingsModal=true">
<svg class="h-5 w-5 -mt-1 text-nt-blue inline mr-2" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.16667 12.4998C3.3901 12.4998 3.00182 12.4998 2.69553 12.373C2.28715 12.2038 1.9627 11.8794 1.79354 11.471C1.66667 11.1647 1.66667 10.7764 1.66667 9.99984V4.33317C1.66667 3.39975 1.66667 2.93304 1.84833 2.57652C2.00812 2.26292 2.26308 2.00795 2.57669 1.84816C2.93321 1.6665 3.39992 1.6665 4.33334 1.6665H10C10.7766 1.6665 11.1649 1.6665 11.4711 1.79337C11.8795 1.96253 12.204 2.28698 12.3731 2.69536C12.5 3.00165 12.5 3.38993 12.5 4.1665M10.1667 18.3332H15.6667C16.6001 18.3332 17.0668 18.3332 17.4233 18.1515C17.7369 17.9917 17.9919 17.7368 18.1517 17.4232C18.3333 17.0666 18.3333 16.5999 18.3333 15.6665V10.1665C18.3333 9.23308 18.3333 8.76637 18.1517 8.40985C17.9919 8.09625 17.7369 7.84128 17.4233 7.68149C17.0668 7.49984 16.6001 7.49984 15.6667 7.49984H10.1667C9.23325 7.49984 8.76654 7.49984 8.41002 7.68149C8.09642 7.84128 7.84145 8.09625 7.68166 8.40985C7.50001 8.76637 7.50001 9.23308 7.50001 10.1665V15.6665C7.50001 16.5999 7.50001 17.0666 7.68166 17.4232C7.84145 17.7368 8.09642 17.9917 8.41002 18.1515C8.76654 18.3332 9.23325 18.3332 10.1667 18.3332Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Copy another form's settings
</v-button>
<modal :show="showCopyFormSettingsModal" max-width="md" @close="showCopyFormSettingsModal=false">
<template #icon>
<svg class="w-10 h-10 text-blue" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 27C16.0681 27 15.6022 27 15.2346 26.8478C14.7446 26.6448 14.3552 26.2554 14.1522 25.7654C14 25.3978 14 24.9319 14 24V17.2C14 16.0799 14 15.5198 14.218 15.092C14.4097 14.7157 14.7157 14.4097 15.092 14.218C15.5198 14 16.0799 14 17.2 14H24C24.9319 14 25.3978 14 25.7654 14.1522C26.2554 14.3552 26.6448 14.7446 26.8478 15.2346C27 15.6022 27 16.0681 27 17M24.2 34H30.8C31.9201 34 32.4802 34 32.908 33.782C33.2843 33.5903 33.5903 33.2843 33.782 32.908C34 32.4802 34 31.9201 34 30.8V24.2C34 23.0799 34 22.5198 33.782 22.092C33.5903 21.7157 33.2843 21.4097 32.908 21.218C32.4802 21 31.9201 21 30.8 21H24.2C23.0799 21 22.5198 21 22.092 21.218C21.7157 21.4097 21.4097 21.7157 21.218 22.092C21 22.5198 21 23.0799 21 24.2V30.8C21 31.9201 21 32.4802 21.218 32.908C21.4097 33.2843 21.7157 33.5903 22.092 33.782C22.5198 34 23.0799 34 24.2 34Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<template #title>
Copy Settings from another form
</template>
<div class="p-4 min-h-[450px]">
<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">
<v-button color="white" class="w-full mr-2" @click="showCopyFormSettingsModal=false">
Cancel
</v-button>
<v-button color="blue" class="w-full" @click="copySettings">
Confirm & Copy
</v-button>
</div>
</div>
</modal>
</editor-options-panel>
</template>
<script>
import { computed } from 'vue'
import clonedeep from 'clone-deep'
import { useFormsStore } from '../../../../../stores/forms'
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import SelectInput from '../../../../forms/SelectInput.vue'
export default {
components: { SelectInput, EditorOptionsPanel },
props: {},
setup () {
const formsStore = useFormsStore()
const workingFormStore = useWorkingFormStore()
return {
formsStore,
workingFormStore,
forms : computed(() => formsStore.content)
}
},
data () {
return {
showCopyFormSettingsModal: false,
copyFormId: null,
visibilityOptions: [
{
name: "Published",
value: "public"
},
{
name: "Draft - not publicly accessible",
value: "draft"
},
{
name: "Closed - won\'t accept new submissions",
value: "closed"
}
]
}
},
computed: {
copyFormOptions () {
return this.forms.filter((form) => {
return this.form.id !== form.id
}).map((form) => {
return {
name: form.title,
value: form.id
}
})
},
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
allTagsOptions () {
return this.formsStore.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', 'last_edited_human'].forEach((property) => {
if (copyForm.hasOwnProperty(property)) {
delete copyForm[property]
}
})
// Apply changes
Object.keys(copyForm).forEach((property) => {
this.form[property] = copyForm[property]
})
this.showCopyFormSettingsModal = false
}
}
}
</script>

View File

@ -0,0 +1,75 @@
<template>
<editor-options-panel name="Notifications & Integrations" :already-opened="true" :has-pro-tag="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6M22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6M22 6L12 13L2 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>
<form-notifications-option class="mt-2" />
<form-notifications-submission-confirmation />
<form-notifications-slack />
<form-notifications-discord />
<form-notifications-webhook />
<v-button color="white"
class="flex items-center mt-3 cursor-pointer relative w-full rounded-lg 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"
:href="zapierUrl" target="_blank"
>
<div class="flex-grow flex items-center">
<svg class="w-5 h-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>
<p class="flex-grow text-center font-normal">
Zapier Integration
</p>
</div>
</v-button>
</editor-options-panel>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import FormNotificationsOption from './components/FormNotificationsOption.vue'
import FormNotificationsSlack from './components/FormNotificationsSlack.vue'
import FormNotificationsDiscord from './components/FormNotificationsDiscord.vue'
import FormNotificationsSubmissionConfirmation from './components/FormNotificationsSubmissionConfirmation.vue'
import FormNotificationsWebhook from './components/FormNotificationsWebhook.vue'
export default {
components: { FormNotificationsSubmissionConfirmation, FormNotificationsSlack, FormNotificationsDiscord, FormNotificationsOption, EditorOptionsPanel, FormNotificationsWebhook },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
zapierUrl: () => this.$config.links.zapier_integration
},
watch: {
},
mounted () {
},
}
</script>

View File

@ -0,0 +1,48 @@
<template>
<editor-options-panel name="Security & Privacy" :already-opened="false">
<template #icon>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" 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>
</template>
<toggle-switch-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"
/>
<toggle-switch-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"
/>
</editor-options-panel>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default {
components: { EditorOptionsPanel },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
}
</script>

View File

@ -0,0 +1,44 @@
<template>
<editor-options-panel name="Form Structure" :already-opened="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8333 7.33333C14.7668 7.33333 15.2335 7.33333 15.59 7.15168C15.9036 6.99189 16.1586 6.73692 16.3183 6.42332C16.5 6.0668 16.5 5.60009 16.5 4.66667V4.16667C16.5 3.23325 16.5 2.76654 16.3183 2.41002C16.1586 2.09641 15.9036 1.84145 15.59 1.68166C15.2335 1.5 14.7668 1.5 13.8333 1.5L4.16667 1.5C3.23325 1.5 2.76654 1.5 2.41002 1.68166C2.09641 1.84144 1.84144 2.09641 1.68166 2.41002C1.5 2.76654 1.5 3.23325 1.5 4.16667L1.5 4.66667C1.5 5.60009 1.5 6.0668 1.68166 6.42332C1.84144 6.73692 2.09641 6.99189 2.41002 7.15168C2.76654 7.33333 3.23325 7.33333 4.16667 7.33333L13.8333 7.33333Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.8333 16.5C14.7668 16.5 15.2335 16.5 15.59 16.3183C15.9036 16.1586 16.1586 15.9036 16.3183 15.59C16.5 15.2335 16.5 14.7668 16.5 13.8333V13.3333C16.5 12.3999 16.5 11.9332 16.3183 11.5767C16.1586 11.2631 15.9036 11.0081 15.59 10.8483C15.2335 10.6667 14.7668 10.6667 13.8333 10.6667L4.16667 10.6667C3.23325 10.6667 2.76654 10.6667 2.41002 10.8483C2.09641 11.0081 1.84144 11.2631 1.68166 11.5767C1.5 11.9332 1.5 12.3999 1.5 13.3333L1.5 13.8333C1.5 14.7668 1.5 15.2335 1.68166 15.59C1.84144 15.9036 2.09641 16.1586 2.41002 16.3183C2.76654 16.5 3.23325 16.5 4.16667 16.5H13.8333Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>
<form-fields-editor class="mt-5" />
</editor-options-panel>
</template>
<script>
import { useWorkingFormStore } from '../../../../../stores/working_form'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import FormFieldsEditor from '../FormFieldsEditor.vue'
export default {
components: { EditorOptionsPanel, FormFieldsEditor },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
}
}
</script>

View File

@ -0,0 +1,90 @@
<template>
<div>
<button
class="flex items-center mt-3 cursor-pointer relative w-full rounded-lg 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="showModal=true"
>
<div class="flex-grow flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 inline" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path><path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path><path d="M7.5 7.5c3.5 -1 5.5 -1 9 0"></path><path d="M7 16.5c3.5 1 6.5 1 10 0"></path><path d="M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-1 2.5"></path><path d="M8.5 17c0 1 -1.356 3 -1.832 3c-1.429 0 -2.698 -1.667 -3.333 -3c-.635 -1.667 -.476 -5.833 1.428 -11.5c1.388 -1.015 2.782 -1.34 4.237 -1.5l1 2.5"></path></svg>
<p class="flex-grow text-center">
Discord Notifications
</p>
</div>
<div v-if="form.notifies_discord">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 text-nt-blue"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</button>
<modal :show="showModal" @close="showModal=false">
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue">
Discord Notifications
<pro-tag />
</h2>
<toggle-switch-input name="notifies_discord" :form="form" class="mt-4"
label="Receive a Discord notification on submission"
/>
<template v-if="form.notifies_discord">
<text-input name="discord_webhook_url" :form="form" class="mt-4"
label="Discord webhook url" help="help"
>
<template #help>
Receive a discord message on each form submission.
<a href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" target="_blank">Click
here</a> to learn how to get a discord webhook url.
</template>
</text-input>
<h4 class="font-bold mt-4">Discord message actions</h4>
<form-notifications-message-actions v-model="form.notification_settings.discord" />
</template>
</modal>
</div>
</template>
<script>
import { useWorkingFormStore } from '../../../../../../stores/working_form'
import ProTag from '~/components/global/ProTag.vue'
import FormNotificationsMessageActions from './FormNotificationsMessageActions.vue'
export default {
components: { ProTag, FormNotificationsMessageActions },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
showModal: false
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
watch: {},
mounted () {
if(!this.form.notification_settings.discord || Array.isArray(this.form.notification_settings.discord)){
this.form.notification_settings.discord = {}
}
},
methods: {}
}
</script>

View File

@ -0,0 +1,64 @@
<template>
<div>
<toggle-switch-input name="include_submission_data" v-model="compVal.include_submission_data" class="mt-4"
label="Include submission data"
help="With form submission answers"
/>
<toggle-switch-input name="link_open_form" v-model="compVal.link_open_form" class="mt-4"
label="Open Form"
help="Link to the form public page"
/>
<toggle-switch-input name="link_edit_form" v-model="compVal.link_edit_form" class="mt-4"
label="Edit Form"
help="Link to the form admin page"
/>
<toggle-switch-input name="views_submissions_count" v-model="compVal.views_submissions_count" class="mt-4"
label="Analytics (views & submissions)"
/>
<toggle-switch-input name="link_edit_submission" v-model="compVal.link_edit_submission" class="mt-4"
label="Link to the Edit Submission Record"
/>
</div>
</template>
<script>
export default {
name: 'FormNotificationsMessageActions',
components: { },
props: {
value: { required: false }
},
data () {
return {
content: this.value ?? {}
}
},
computed: {
compVal: {
set (val) {
this.content = val
this.$emit('input', this.compVal)
},
get () {
return this.content
}
}
},
watch: {},
created () {
if(this.compVal === undefined || this.compVal === null){
this.compVal = {}
}
['include_submission_data', 'link_open_form', 'link_edit_form', 'views_submissions_count', 'link_edit_submission'].forEach((keyname) => {
if (this.compVal[keyname] === undefined) {
this.compVal[keyname] = true
}
})
},
methods: { }
}
</script>

View File

@ -0,0 +1,103 @@
<template>
<div>
<button
class="flex items-center mt-3 cursor-pointer relative w-full rounded-lg 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="showModal=true"
>
<div class="flex-grow flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 inline"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<p class="flex-grow text-center">
Email Notifications
</p>
</div>
<div v-if="form.notifies">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 text-nt-blue"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</button>
<modal :show="showModal" @close="showModal=false">
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue">
Form Notifications
<pro-tag />
</h2>
<toggle-switch-input name="notifies" :form="form" class="mt-4"
label="Receive email notifications on submission"
/>
<template v-if="form.notifies">
<text-input name="notification_reply_to"
v-model="form.notification_settings.notification_reply_to" class="mt-4"
label="Notification Reply To"
:help="notifiesHelp"
/>
<text-area-input name="notification_emails" :form="form" class="mt-4"
label="Notification Emails" help="Add one email per line"
/>
</template>
</modal>
</div>
</template>
<script>
import { useWorkingFormStore } from '../../../../../../stores/working_form'
import ProTag from '~/components/global/ProTag.vue'
export default {
components: { ProTag },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
showModal: false
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
replayToEmailField () {
const emailFields = this.form.properties.filter((field) => {
return field.type === 'email' && !field.hidden
})
if (emailFields.length === 1) return emailFields[0]
return null
},
notifiesHelp () {
if (this.replayToEmailField) {
return 'If empty, Reply-to for this notification will be the email filled in the field "' + this.replayToEmailField.name + '".'
}
return 'If empty, Reply-to for this notification will be your own email. Add a single email field to your form, and it will automatically become the reply to value.'
}
},
watch: {},
mounted () {
},
methods: {}
}
</script>

View File

@ -0,0 +1,91 @@
<template>
<div>
<button
class="flex items-center mt-3 cursor-pointer relative w-full rounded-lg 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="showModal=true"
>
<div class="flex-grow flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 inline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 10c-.83 0-1.5-.67-1.5-1.5v-5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5z" /><path d="M20.5 10H19V8.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z" /><path d="M9.5 14c.83 0 1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5S8 21.33 8 20.5v-5c0-.83.67-1.5 1.5-1.5z" /><path d="M3.5 14H5v1.5c0 .83-.67 1.5-1.5 1.5S2 16.33 2 15.5 2.67 14 3.5 14z" /><path d="M14 14.5c0-.83.67-1.5 1.5-1.5h5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-5c-.83 0-1.5-.67-1.5-1.5z" /><path d="M15.5 19H14v1.5c0 .83.67 1.5 1.5 1.5s1.5-.67 1.5-1.5-.67-1.5-1.5-1.5z" /><path d="M10 9.5C10 8.67 9.33 8 8.5 8h-5C2.67 8 2 8.67 2 9.5S2.67 11 3.5 11h5c.83 0 1.5-.67 1.5-1.5z" /><path d="M8.5 5H10V3.5C10 2.67 9.33 2 8.5 2S7 2.67 7 3.5 7.67 5 8.5 5z" /></svg>
<p class="flex-grow text-center">
Slack Notifications
</p>
</div>
<div v-if="form.notifies_slack">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 text-nt-blue"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</button>
<modal :show="showModal" @close="showModal=false">
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue">
Slack Notifications
<pro-tag />
</h2>
<toggle-switch-input name="notifies_slack" :form="form" class="mt-4"
label="Receive a Slack notification on submission"
/>
<template v-if="form.notifies_slack">
<text-input name="slack_webhook_url" :form="form" class="mt-4"
label="Slack webhook url" help="help"
>
<template #help>
Receive slack message on each form submission. <a href="https://api.slack.com/messaging/webhooks"
target="_blank"
>Click here</a> to learn how to get a slack
webhook url
</template>
</text-input>
<h4 class="font-bold mt-4">Slack message actions</h4>
<form-notifications-message-actions v-model="form.notification_settings.slack" />
</template>
</modal>
</div>
</template>
<script>
import { useWorkingFormStore } from '../../../../../../stores/working_form'
import ProTag from '~/components/global/ProTag.vue'
import FormNotificationsMessageActions from './FormNotificationsMessageActions.vue'
export default {
components: { ProTag, FormNotificationsMessageActions },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
showModal: false
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
watch: {},
mounted () {
if(!this.form.notification_settings.slack || Array.isArray(this.form.notification_settings.slack)){
this.form.notification_settings.slack = {}
}
},
methods: {}
}
</script>

View File

@ -0,0 +1,122 @@
<template>
<div>
<button
class="flex items-center mt-3 cursor-pointer relative w-full rounded-lg 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="showModal=true"
>
<div class="flex-grow flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
<p class="flex-grow text-center">
Send submission confirmation
</p>
</div>
<div v-if="form.send_submission_confirmation">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 text-nt-blue"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</button>
<modal :show="showModal" @close="showModal=false">
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue">
Submission confirmation
<pro-tag />
</h2>
<toggle-switch-input :disabled="(emailSubmissionConfirmationField===null)?true:null" name="send_submission_confirmation"
:form="form" class="mt-4"
label="Send submission confirmation" :help="emailSubmissionConfirmationHelp"
/>
<template v-if="form.send_submission_confirmation">
<text-input v-model="form.notification_settings.confirmation_reply_to"
name="confirmation_reply_to" class="mt-4"
label="Confirmation Reply To" help="help"
>
<template #help>
If empty, Reply-to will be your own email.
</template>
</text-input>
<text-input 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 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 name="notification_body"
:form="form" class="mt-4"
label="Confirmation email content" help="Content of the confirmation email that will be sent"
/>
<toggle-switch-input name="notifications_include_submission"
:form="form" class="mt-4"
label="Include submission data" help="If enabled the confirmation email will contain form submission answers"
/>
</template>
</modal>
</div>
</template>
<script>
import { useWorkingFormStore } from '../../../../../../stores/working_form'
import ProTag from '~/components/global/ProTag.vue'
export default {
components: { ProTag },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
showModal: false
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
emailSubmissionConfirmationField () {
if (!this.form.properties || !Array.isArray(this.form.properties)) return null
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.form.send_submission_confirmation = false
}
}
},
mounted () {
},
methods: {}
}
</script>

View File

@ -0,0 +1,89 @@
<template>
<div>
<button
class="flex items-center mt-3 cursor-pointer relative w-full rounded-lg 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="showModal=true"
>
<div class="flex-grow flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 inline"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z"
/>
</svg>
<p class="flex-grow text-center">
Webhook Notifications
</p>
</div>
<div v-if="form.notifies_webhook">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 text-nt-blue"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</button>
<modal :show="showModal" @close="showModal=false">
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue">
Webhook Notifications
<pro-tag />
</h2>
<toggle-switch-input name="notifies_webhook" :form="form" class="mt-4"
label="Trigger a webhook notification on form submission"
@change="onToggleChange"
/>
<text-input v-if="form.notifies_webhook" name="webhook_url" :form="form" class="mt-4"
label="Webhook url" help="We will post form submissions to this endpoint"
/>
</modal>
</div>
</template>
<script>
import { useWorkingFormStore } from '../../../../../../stores/working_form'
import ProTag from '~/components/global/ProTag.vue'
export default {
components: { ProTag },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
showModal: false
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
}
},
watch: {},
mounted () {
},
methods: {
onToggleChange () {
if (!this.form.notifies_webhook) {
this.form.webhook_url = ''
}
}
}
}
</script>

View File

@ -0,0 +1,174 @@
<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"
@update:modelValue="operatorChanged()"
/>
<template v-if="hasInput">
<component v-bind="inputComponentData" :is="inputComponentData.component" v-model="content.value" class="w-full"
:name="'value_'+property.id" placeholder="Filter Value"
@update:modelValue="emitInput()"
/>
</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 (this.property.type === 'phone_number' && !this.property.use_simple_text_input) {
componentData.component = 'PhoneInput'
}
if (['select', 'multi_select'].includes(this.property.type)) {
componentData.multiple = false
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.emitInput()
},
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(' ')
},
emitInput () {
this.$emit('update:modelValue', this.castContent(this.content))
}
}
}
</script>

View File

@ -0,0 +1,119 @@
<template>
<query-builder v-model="query" :rules="rules" :config="config" @update:modelValue="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"
:model-value="props.currentOperator"
:options="props.operators"
emit-key="identifier"
option-key="identifier"
name="operator-input"
margin-bottom=""
@update:modelValue="props.updateCurrentOperator($event)"
/>
</div>
</template>
<template #groupControl="props">
<group-control-slot :group-ctrl="props" />
</template>
<template #rule="ruleCtrl">
<component
:is="ruleCtrl.ruleComponent"
:model-value="ruleCtrl.ruleData"
@update:modelValue="ruleCtrl.updateRuleData"
/>
</template>
</query-builder>
</template>
<script>
import { defineComponent } from 'vue'
import QueryBuilder from 'query-builder-vue-3'
import ColumnCondition from './ColumnCondition.vue'
import GroupControlSlot from './GroupControlSlot.vue'
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 defineComponent({
extends: ColumnCondition,
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('update:modelValue', this.query)
}
}
}
</script>

View File

@ -0,0 +1,213 @@
<template>
<div v-if="logic" :key="resetKey">
<h3 class="font-semibold block text-lg">
Logic
</h3>
<p class="text-gray-400 text-xs mb-3">
Add some logic to this block. Start by adding some conditions, and then add some actions.
</p>
<div class="relative flex">
<div>
<v-button color="light-gray" size="small" @click="showCopyFormModal=true">
<svg class="h-4 w-4 text-blue-600 inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5M11 9H20C21.1046 9 22 9.89543 22 11V20C22 21.1046 21.1046 22 20 22H11C9.89543 22 9 21.1046 9 20V11C9 9.89543 9.89543 9 11 9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Copy from
</v-button>
</div>
<div>
<v-button color="light-gray" shade="light" size="small" class="ml-1" @click="clearAll">
<svg class="text-red-600 h-4 w-4 inline -mt-1 mr-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 9L12 15M12 9L18 15M21 4H8L1 12L8 20H21C21.5304 20 22.0391 19.7893 22.4142 19.4142C22.7893 19.0391 23 18.5304 23 18V6C23 5.46957 22.7893 4.96086 22.4142 4.58579C22.0391 4.21071 21.5304 4 21 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Clear All
</v-button>
</div>
</div>
<h5 class="font-semibold mt-3">
1. Conditions
</h5>
<condition-editor ref="filter-editor" v-model="logic.conditions" class="mt-1 border-t border rounded-md" :form="form" />
<h5 class="font-semibold mt-3">
2. Actions
</h5>
<select-input :key="resetKey" v-model="logic.actions" name="actions"
:multiple="true" class="mt-1" placeholder="Actions..."
help="Action(s) triggerred when above conditions are true"
:options="actionOptions"
@update:model-value="onActionInput"
/>
<modal :show="showCopyFormModal" @close="showCopyFormModal = false">
<div class="min-h-[450px]">
<h3 class="font-semibold block text-lg">
Copy logic from another field
</h3>
<p class="text-gray-400 text-xs 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>
</div>
</modal>
</div>
</template>
<script>
import ConditionEditor from './ConditionEditor.vue'
import Modal from '../../../../global/Modal.vue'
import SelectInput from '../../../../forms/SelectInput.vue'
import clonedeep from 'clone-deep'
export default {
name: 'FormBlockLogicEditor',
components: { SelectInput, Modal, 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 && field.hasOwnProperty('logic') && field.logic !== null && field.logic !== {}
}).map((field) => {
return { name: field.name, value: field.id }
})
},
actionOptions () {
if (['nf-text', 'nf-code', 'nf-page-break', 'nf-divider', 'nf-image'].includes(this.field.type)) {
if (this.field.hidden) {
return [{ name: 'Show Block', value: 'show-block' }]
} else {
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 if (this.field.disabled) {
return [
{ name: 'Enable Block', value: 'enable-block' },
(this.field.required
? { name: 'Make it optional', value: 'make-it-optional' }
: {
name: 'Require answer',
value: 'require-answer'
})
]
} else {
return [
{ name: 'Hide Block', value: 'hide-block' },
{ name: 'Disable Block', value: 'disable-block' },
(this.field.required
? { name: 'Make it optional', value: 'make-it-optional' }
: {
name: 'Require answer',
value: 'require-answer'
})
]
}
}
},
watch: {
logic: {
handler () {
this.field.logic = this.logic
},
deep: true
},
'field.id': {
handler (field, oldField) {
// On field change, reset logic
this.logic = this.field.logic || {
conditions: null,
actions: []
}
}
},
'field.required': 'cleanConditions',
'field.disabled': 'cleanConditions',
'field.hidden': 'cleanConditions'
},
mounted () {
if (!this.field.hasOwnProperty('logic')) {
this.field.logic = this.logic
}
},
methods: {
clearAll () {
this.logic.conditions = null
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.logic.actions = ['require-answer']
} else if (this.logic.actions[1] === 'hide-block' && this.logic.actions[0] === 'require-answer') {
this.logic.actions = ['hide-block']
}
this.refreshActions()
}
},
cleanConditions () {
const availableActions = this.actionOptions.map(function (op) { return op.value })
this.logic.actions = availableActions.filter(value => this.logic.actions.includes(value))
this.refreshActions()
},
refreshActions () {
this.resetKey++
},
copyLogic () {
if (this.copyFrom) {
const property = this.form.properties.find((property) => {
return property.id === this.copyFrom
})
if (property && property.logic) {
this.logic = clonedeep(property.logic)
this.cleanConditions()
}
}
this.showCopyFormModal = false
}
}
}
</script>

View File

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

View File

@ -0,0 +1,191 @@
<template>
<modal :show="show" @close="$emit('close')">
<template #icon>
<svg class="w-10 h-10 text-blue" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M17 27C16.0681 27 15.6022 27 15.2346 26.8478C14.7446 26.6448 14.3552 26.2554 14.1522 25.7654C14 25.3978 14 24.9319 14 24V17.2C14 16.0799 14 15.5198 14.218 15.092C14.4097 14.7157 14.7157 14.4097 15.092 14.218C15.5198 14 16.0799 14 17.2 14H24C24.9319 14 25.3978 14 25.7654 14.1522C26.2554 14.3552 26.6448 14.7446 26.8478 15.2346C27 15.6022 27 16.0681 27 17M24.2 34H30.8C31.9201 34 32.4802 34 32.908 33.782C33.2843 33.5903 33.5903 33.2843 33.782 32.908C34 32.4802 34 31.9201 34 30.8V24.2C34 23.0799 34 22.5198 33.782 22.092C33.5903 21.7157 33.2843 21.4097 32.908 21.218C32.4802 21 31.9201 21 30.8 21H24.2C23.0799 21 22.5198 21 22.092 21.218C21.7157 21.4097 21.4097 21.7157 21.218 22.092C21 22.5198 21 23.0799 21 24.2V30.8C21 31.9201 21 32.4802 21.218 32.908C21.4097 33.2843 21.7157 33.5903 22.092 33.782C22.5198 34 23.0799 34 24.2 34Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</template>
<template #title>
<template v-if="template">
Edit Template
</template>
<template v-else>
Create Template
</template>
</template>
<div class="p-4">
<p v-if="!template">
New template will be create from your form: <span class="font-semibold">{{ form.title }}</span>.
</p>
<form v-if="templateForm" class="mt-6" @submit.prevent="onSubmit" @keydown="templateForm.onKeydown($event)">
<div class="-m-6">
<div class="border-t py-4 px-6">
<toggle-switch-input v-if="user && (user.admin || user.template_editor)" name="publicly_listed" :form="templateForm" class="mt-4" label="Publicly Listed?" />
<text-input name="name" :form="templateForm" class="mt-4" label="Title" :required="true" />
<text-input name="slug" :form="templateForm" class="mt-4" label="Slug" :required="true" />
<text-area-input name="short_description" :form="templateForm" class="mt-4" label="Short Description"
:required="true"
/>
<rich-text-area-input name="description" :form="templateForm" class="mt-4" label="Description"
:required="true"
/>
<text-input name="image_url" :form="templateForm" class="mt-4" label="Image" :required="true" />
<select-input name="types" :form="templateForm" class="mt-4" label="Types" :options="typesOptions"
:multiple="true" :searchable="true"
/>
<select-input name="industries" :form="templateForm" class="mt-4" label="Industries"
:options="industriesOptions" :multiple="true" :searchable="true"
/>
<select-input name="related_templates" :form="templateForm" class="mt-4" label="Related Templates"
:options="templatesOptions" :multiple="true" :searchable="true"
/>
<questions-editor name="questions" :questions="templateForm.questions" class="mt-4"
label="Frequently asked questions"
/>
</div>
<div class="flex justify-end mt-4 pb-5 px-6">
<v-button class="mr-2" :loading="templateForm.busy">
<template v-if="template">
Update
</template>
<template v-else>
Create
</template>
</v-button>
<v-button v-if="template" color="red" class="mr-2"
@click.prevent="alertConfirm('Do you really want to delete this template?', deleteFormTemplate)"
>
Delete
</v-button>
<v-button color="white" @click.prevent="$emit('close')">
Close
</v-button>
</div>
</div>
</form>
</div>
</modal>
</template>
<script>
import axios from 'axios'
import Form from 'vform'
import { computed } from 'vue'
import { useAuthStore } from '../../../../../stores/auth'
import { useTemplatesStore } from '../../../../../stores/templates'
import QuestionsEditor from './QuestionsEditor.vue'
export default {
name: 'FormTemplateModal',
components: { QuestionsEditor },
props: {
show: { type: Boolean, required: true },
form: { type: Object, required: true },
template: { type: Object, required: false, default: () => {} }
},
setup () {
const authStore = useAuthStore()
const templatesStore = useTemplatesStore()
return {
templatesStore,
user : computed(() => authStore.user),
templates : computed(() => templatesStore.content),
industries : computed(() => templatesStore.industries),
types : computed(() => templatesStore.types)
}
},
data: () => ({
templateForm: null
}),
mounted () {
this.templateForm = new Form(this.template ?? {
publicly_listed: false,
name: '',
slug: '',
short_description: '',
description: '',
image_url: '',
types: null,
industries: null,
related_templates: null,
questions: []
})
this.templatesStore.loadIfEmpty()
},
computed: {
typesOptions () {
return Object.values(this.types).map((type) => {
return {
name: type.name,
value: type.slug
}
})
},
industriesOptions () {
return Object.values(this.industries).map((industry) => {
return {
name: industry.name,
value: industry.slug
}
})
},
templatesOptions () {
return this.templates.map((template) => {
return {
name: template.name,
value: template.slug
}
})
}
},
methods: {
onSubmit () {
if (this.template) {
this.updateFormTemplate()
} else {
this.createFormTemplate()
}
},
async createFormTemplate () {
this.templateForm.form = this.form
await this.templateForm.post('/api/templates').then((response) => {
if (response.data.message) {
this.alertSuccess(response.data.message)
}
this.templatesStore.addOrUpdate(response.data.data)
this.$emit('close')
})
},
async updateFormTemplate () {
this.templateForm.form = this.form
await this.templateForm.put('/api/templates/' + this.template.id).then((response) => {
if (response.data.message) {
this.alertSuccess(response.data.message)
}
this.templatesStore.addOrUpdate(response.data.data)
this.$emit('close')
})
},
async deleteFormTemplate () {
if (!this.template) return
axios.delete('/api/templates/' + this.template.id).then((response) => {
if (response.data.message) {
this.alertSuccess(response.data.message)
}
this.$router.push({ name: 'templates' })
this.templatesStore.remove(this.template)
this.$emit('close')
})
}
}
}
</script>

View File

@ -0,0 +1,77 @@
<template>
<div :class="wrapperClass">
<label v-if="label" :for="id?id:name"
:class="[theme.default.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]"
>
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
<loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
<div v-else class="my-3">
<div v-for="(questionForm, quesKey) in allQuestions" :key="quesKey" class="bg-gray-100 p-2 mb-4">
<v-button color="red" size="small" class="mb-2" @click.prevent="onRemove(quesKey)">
<svg class="h-4 w-4 text-white inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6H5M5 6H21M5 6V20C5 20.5304 5.21071 21.0391 5.58579 21.4142C5.96086 21.7893 6.46957 22 7 22H17C17.5304 22 18.0391 21.7893 18.4142 21.4142C18.7893 21.0391 19 20.5304 19 20V6H5ZM8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Remove
</v-button>
<text-input name="question" :form="questionForm" placeholder="Question title" />
<rich-text-area-input name="answer" :form="questionForm" class="mt-4" placeholder="Question response" />
</div>
<v-button v-if="addNew" color="green" size="small" nativeType="button" class="mt-2 flex" @click.prevent="onAdd">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-1 inline" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Add New
</v-button>
</div>
<small v-if="help" :class="theme.SelectInput.help">
<slot name="help">{{ help }}</slot>
</small>
<has-error v-if="hasValidation" :form="form" :field="name" />
</div>
</template>
<script>
import inputMixin from '~/mixins/forms/input.js'
export default {
name: 'QuestionsEditor',
mixins: [inputMixin],
props: {
loading: { type: Boolean, default: false },
addNew: { type: Boolean, default: true },
questions: { type: Array, default: [] },
},
data () {
return {
allQuestions: null,
newQuestion: {
question: '',
answer: '',
}
}
},
mounted () {
this.allQuestions = (this.questions.length > 0) ? this.questions : [this.newQuestion]
},
watch: { },
computed: { },
methods: {
onAdd() {
this.allQuestions.push(this.newQuestion)
},
onRemove(key){
this.allQuestions.splice(key, 1)
}
}
}
</script>

View File

@ -0,0 +1,166 @@
<template>
<div>
<div class="p-4 border-b sticky top-0 z-10 bg-white">
<button v-if="!field" class="text-gray-500 hover:text-gray-900 cursor-pointer" @click.prevent="closeSidebar">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<template v-else>
<div class="flex">
<button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click.prevent="closeSidebar">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<div class="font-semibold inline ml-2 truncate flex-grow truncate">
Configure "<span class="truncate">{{ field.name }}</span>"
</div>
</div>
<div class="flex mt-2">
<v-button color="light-gray" class="border-r-0 rounded-r-none text-xs hover:bg-red-50" size="small"
@click="removeBlock"
>
<svg class="h-4 w-4 text-red-600 inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6H5M5 6H21M5 6V20C5 20.5304 5.21071 21.0391 5.58579 21.4142C5.96086 21.7893 6.46957 22 7 22H17C17.5304 22 18.0391 21.7893 18.4142 21.4142C18.7893 21.0391 19 20.5304 19 20V6H5ZM8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M10 11V17M14 11V17"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Remove
</v-button>
<v-button size="small" class="text-xs" :class="{
'rounded-none border-r-0':!isBlockField && typeCanBeChanged,
'rounded-l-none':isBlockField || !typeCanBeChanged
}" color="light-gray" @click="duplicateBlock"
>
<svg class="h-4 w-4 text-blue-600 inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5M11 9H20C21.1046 9 22 9.89543 22 11V20C22 21.1046 21.1046 22 20 22H11C9.89543 22 9 21.1046 9 20V11C9 9.89543 9.89543 9 11 9Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Duplicate
</v-button>
<change-field-type v-if="!isBlockField" btn-classes="rounded-l-none text-xs" :field="field"
@changeType="onChangeType"
/>
</div>
</template>
</div>
<template v-if="field">
<field-options v-if="!isBlockField" :form="form" :field="field" />
<block-options v-if="isBlockField" :form="form" :field="field" />
</template>
<div v-else class="text-center p-10">
Click on field's setting icon in your form to modify it
</div>
</div>
</template>
<script>
import { computed } from 'vue'
import clonedeep from 'clone-deep'
import { useWorkingFormStore } from '../../../../stores/working_form'
import ChangeFieldType from './components/ChangeFieldType.vue'
import FieldOptions from './components/FieldOptions.vue'
import BlockOptions from './components/BlockOptions.vue'
export default {
name: 'FormFieldEdit',
components: { ChangeFieldType, FieldOptions, BlockOptions },
props: {},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore,
selectedFieldIndex : computed(() => workingFormStore.selectedFieldIndex)
}
},
data () {
return {
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
field () {
return (this.form && this.selectedFieldIndex !== null) ? this.form.properties[this.selectedFieldIndex] : null
},
isBlockField () {
return this.field && this.field.type.startsWith('nf')
},
typeCanBeChanged () {
return ['text', 'email', 'phone', 'number', 'select', 'multi_select'].includes(this.field.type)
}
},
watch: {},
created () {
},
mounted () {
},
methods: {
onChangeType (newType) {
if (['select', 'multi_select'].includes(this.field.type)) {
this.field[newType] = this.field[this.field.type] // Set new options with new type
delete this.field[this.field.type] // remove old type options
}
this.field.type = newType
},
removeBlock () {
const newFields = clonedeep(this.form.properties)
newFields.splice(this.selectedFieldIndex, 1)
this.form.properties = newFields
this.closeSidebar()
},
duplicateBlock () {
const newFields = clonedeep(this.form.properties)
const newField = clonedeep(this.form.properties[this.selectedFieldIndex])
newField.id = this.generateUUID()
newFields.push(newField)
this.form.properties = newFields
this.closeSidebar()
},
closeSidebar () {
this.workingFormStore.closeEditFieldSidebar()
},
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)
})
}
}
}
</script>

View File

@ -0,0 +1,160 @@
<template>
<div v-if="field">
<!-- General -->
<div class="border-b py-2 px-4">
<h3 class="font-semibold block text-lg">
General
</h3>
<p class="text-gray-400 mb-3 text-xs">
Exclude this field or make it required.
</p>
<v-checkbox v-model="field.hidden" class="mb-3"
:name="field.id+'_hidden'"
@update:model-value="onFieldHiddenChange"
>
Hidden
</v-checkbox>
<select-input name="width" class="mt-3"
: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"
/>
<select-input v-if="['nf-text','nf-image'].includes(field.type)" name="align" class="mt-3"
:options="[
{name:'Left',value:'left'},
{name:'Center',value:'center'},
{name:'Right',value:'right'},
{name:'Justify',value:'justify'}
]"
:form="field" label="Field Alignment"
/>
</div>
<div v-if="field.type == 'nf-text'" class="border-b py-2 px-4">
<rich-text-area-input name="content"
:form="field"
label="Content"
:required="false"
/>
</div>
<div v-else-if="field.type == 'nf-page-break'" class="border-b py-2 px-4">
<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-divider'" class="border-b py-2 px-4">
<text-input name="name"
:form="field" :required="true"
label="Field Name"
/>
</div>
<div v-else-if="field.type == 'nf-image'" class="border-b py-2 px-4">
<text-input name="name"
:form="field" :required="true"
label="Field Name"
/>
<image-input name="image_block" class="mt-3"
:form="field" label="Upload Image" :required="false"
/>
</div>
<div v-else-if="field.type == 'nf-code'" class="border-b py-2 px-4">
<code-input name="content" :form="field" label="Content"
help="You can add any html code, including iframes"
/>
</div>
<div v-else class="border-b py-2 px-4">
<p>No settings found.</p>
</div>
<!-- Logic Block -->
<!-- <form-block-logic-editor class="py-2 px-4 border-b" :form="form" :field="field" />-->
</div>
</template>
<script>
import CodeInput from '../../../../forms/CodeInput.vue'
export default {
name: 'BlockOptions',
components: { CodeInput },
props: {
field: {
type: Object,
required: false
},
form: {
type: Object,
required: false
}
},
data () {
return {
editorToolbarCustom: [
['bold', 'italic', 'underline', 'link']
]
}
},
computed: {},
watch: {
'field.width': {
handler (val) {
if (val === undefined || val === null) {
this.field.width = 'full'
}
},
immediate: true
},
'field.align': {
handler (val) {
if (val === undefined || val === null) {
this.field.align = 'left'
}
},
immediate: true
}
},
created () {
if (this.field?.width === undefined || this.field?.width === null) {
this.field.width = 'full'
}
},
mounted () {},
methods: {
onFieldHiddenChange (val) {
this.field.hidden = val
if (this.field.hidden) {
this.field.required = false
}
},
onFieldHelpPositionChange (val) {
if (!val) {
this.field.help_position = 'below_input'
}
}
}
}
</script>

View File

@ -0,0 +1,82 @@
<template>
<dropdown v-if="changeTypeOptions.length > 0" dusk="nav-dropdown">
<template #trigger="{toggle}">
<v-button class="relative" :class="btnClasses" size="small" color="light-gray" @click.stop="toggle">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-4 w-4 text-blue-600 inline mr-1 -mt-1">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span class="whitespace-nowrap">Change Type</span>
</v-button>
</template>
<a v-for="(op, index) in changeTypeOptions" :key="index" href="#"
class="block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@click.prevent="changeType(op.value)"
>
{{ op.name }}
</a>
</dropdown>
</template>
<script>
import Dropdown from '~/components/global/Dropdown.vue'
export default {
name: 'ChangeFieldType',
components: { Dropdown },
props: {
field: {
type: Object,
required: true
},
btnClasses: {
type: String,
required: true
}
},
data () {
return {}
},
computed: {
changeTypeOptions () {
let newTypes = []
if (['text', 'email', 'phone', 'number'].includes(this.field.type)) {
newTypes = [
{ name: 'Text Input', value: 'text' },
{ name: 'Email Input', value: 'email' },
{ name: 'Phone Input', value: 'phone' },
{ name: 'Number Input', value: 'number' }
]
}
if (['select', 'multi_select'].includes(this.field.type)) {
newTypes = [
{ name: 'Select Input', value: 'select' },
{ name: 'Multi-Select Input', value: 'multi_select' }
]
}
return newTypes.filter((item) => {
return item.value !== this.field.type
}).map((item) => {
return {
name: item.name,
value: item.value
}
})
}
},
watch: {},
mounted () {
},
methods: {
changeType (newType) {
if (newType) {
this.$emit('changeType', newType)
}
}
}
}
</script>

View File

@ -0,0 +1,615 @@
<template>
<div v-if="field" class="py-2">
<!-- General -->
<div class="border-b px-4">
<h3 class="font-semibold block text-lg">
General
</h3>
<p class="text-gray-400 mb-2 text-xs">
Exclude this field or make it required.
</p>
<v-checkbox v-model="field.hidden" class="mb-3"
:name="field.id+'_hidden'"
@update:model-value="onFieldHiddenChange"
>
Hidden
</v-checkbox>
<v-checkbox v-model="field.required" class="mb-3"
:name="field.id+'_required'"
@update:model-value="onFieldRequiredChange"
>
Required
</v-checkbox>
<v-checkbox v-model="field.disabled" class="mb-3"
:name="field.id+'_disabled'"
@update:model-value="onFieldDisabledChange"
>
Disabled
</v-checkbox>
</div>
<!-- Checkbox -->
<div v-if="field.type === 'checkbox'" class="border-b py-2 px-4">
<h3 class="font-semibold block text-lg">
Checkbox
</h3>
<p class="text-gray-400 mb-3 text-xs">
Advanced options for checkbox.
</p>
<v-checkbox v-model="field.use_toggle_switch" class="mt-3"
name="use_toggle_switch" help=""
>
Use toggle switch
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
If enabled, checkbox will be replaced with a toggle switch
</p>
</div>
<!-- File Uploads -->
<div v-if="field.type === 'files'" class="border-b py-2 px-4">
<h3 class="font-semibold block text-lg">
File uploads
</h3>
<v-checkbox v-model="field.multiple" class="mt-3"
:name="field.id+'_multiple'"
>
Allow multiple files
</v-checkbox>
<text-input name="allowed_file_types" class="mt-3" :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="border-b py-2 px-4">
<h3 class="font-semibold block text-lg">
Number Options
</h3>
<v-checkbox v-model="field.is_rating" class="mt-3"
:name="field.id+'_is_rating'" @update:model-value="initRating"
>
Rating
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
Transform this field into a star rating input.
</p>
<text-input v-if="field.is_rating" name="rating_max_value" native-type="number" :min="1" class="mt-3"
:form="field" required
label="Max rating value"
/>
<v-checkbox v-model="field.is_scale" class="mt-4"
:name="field.id+'_is_scale'" @input="initScale"
>
Scale
</v-checkbox>
<p class="text-gray-400 text-xs mb-5">
Transform this field into a scale/score input.
</p>
<template v-if="field.is_scale">
<text-input name="scale_min_value" native-type="number" class="mt-4"
:form="field" required
label="Min scale value"
/>
<text-input name="scale_max_value" native-type="number" :min="1" class="mt-4"
:form="field" required
label="Max scale value"
/>
<text-input name="scale_step_value" native-type="number" :min="1" class="mt-4"
:form="field" required
label="Scale step svalue"
/>
</template>
</div>
<!-- Text Options -->
<div v-if="field.type === 'text' && displayBasedOnAdvanced" class="border-b py-2 px-4">
<h3 class="font-semibold block text-lg">
Text Options
</h3>
<p class="text-gray-400 mb-3 text-xs">
Keep it simple or make it a multi-lines input.
</p>
<v-checkbox v-model="field.multi_lines" class="mb-2"
:name="field.id+'_multi_lines'"
@update:model-value="field.multi_lines = $event"
>
Multi-lines input
</v-checkbox>
</div>
<!-- Date Options -->
<div v-if="field.type === 'date'" class="border-b py-2 px-4">
<h3 class="font-semibold block text-lg">
Date Options
</h3>
<v-checkbox v-model="field.date_range" class="mt-3"
:name="field.id+'_date_range'"
@update:model-value="onFieldDateRangeChange"
>
Date Range
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
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'"
@update:model-value="onFieldWithTimeChange"
>
Date with time
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
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-3"
:form="field" :options="timezonesOptions"
label="Timezone" :searchable="true"
help="Make sure to select correct timezone. Leave blank otherwise."
/>
<v-checkbox v-model="field.prefill_today"
name="prefill_today"
@update:model-value="onFieldPrefillTodayChange"
>
Prefill with 'today'
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
if enabled we will pre-fill this field with the current date
</p>
<v-checkbox v-model="field.disable_past_dates"
name="disable_past_dates" class="mb-3"
@update:model-value="onFieldDisablePastDatesChange"
>
Disable past dates
</v-checkbox>
<v-checkbox v-model="field.disable_future_dates"
name="disable_future_dates" class="mb-3"
@update:model-value="onFieldDisableFutureDatesChange"
>
Disable future dates
</v-checkbox>
</div>
<!-- select/multiselect Options -->
<div v-if="['select','multi_select'].includes(field.type)" class="border-b py-2 px-4">
<h3 class="font-semibold block text-lg">
Select Options
</h3>
<p class="text-gray-400 mb-3 text-xs">
Advanced options for your select/multiselect fields.
</p>
<text-area-input v-model="optionsText" :name="field.id+'_options_text'" class="mt-3"
label="Set selection options"
help="Add one option per line"
@update:model-value="onFieldOptionsChange"
/>
<v-checkbox v-model="field.allow_creation"
name="allow_creation" help="" @update:model-value="onFieldAllowCreationChange"
>
Allow respondent to create new options
</v-checkbox>
<v-checkbox v-model="field.without_dropdown" class="mt-3"
name="without_dropdown" help="" @update:model-value="onFieldWithoutDropdownChange"
>
Always show all select options
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
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="border-b py-2 px-4">
<h3 class="font-semibold block text-lg">
Customization
</h3>
<p class="text-gray-400 mb-3 text-xs">
Change your form field name, pre-fill a value, add hints, etc.
</p>
<text-input name="name" class="mt-3"
:form="field" :required="true"
label="Field Name"
/>
<v-checkbox v-model="field.hide_field_name" class="mt-3"
:name="field.id+'_hide_field_name'"
>
Hide field name
</v-checkbox>
<v-checkbox v-if="field.type === 'phone_number'" v-model="field.use_simple_text_input" class="mt-3"
:name="field.id+'_use_simple_text_input'"
>
Use simple text input
</v-checkbox>
<template v-if="field.type === 'phone_number' && !field.use_simple_text_input">
<select-input v-model="field.unavailable_countries" class="mt-4" wrapper-class="relative"
:options="allCountries" :multiple="true"
:searchable="true" :search-keys="['name']" :option-key="'code'" :emit-key="'code'"
label="Disabled countries" :placeholder="'Select a country'"
help="Remove countries from the phone input"
>
<template #selected="{option, selected}">
<div class="flex items-center space-x-2 justify-center overflow-hidden">
{{ option.length }} selected
</div>
</template>
<template #option="{option, selected}">
<div class="flex items-center space-x-2 hover:text-white">
<country-flag size="normal" class="!-mt-[9px]" :country="option.code" />
<span class="grow">{{ option.name }}</span>
<span>{{ option.dial_code }}</span>
</div>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-2 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>
</template>
</select-input>
<small class="flex -mt-2">
<a href="#" class="grow" @click.prevent="selectAllCountries">Select All</a>
<a href="#" @click.prevent="field.unavailable_countries=null">Un-select All</a>
</small>
</template>
<!-- Pre-fill depends on type -->
<v-checkbox v-if="field.type=='checkbox'" v-model="field.prefill" class="mt-3"
:name="field.id+'_prefill'"
@update:model-value="field.prefill = $event"
>
Pre-filled value
</v-checkbox>
<select-input v-else-if="['select','multi_select'].includes(field.type)" name="prefill" class="mt-3"
:form="field" :options="prefillSelectsOptions"
label="Pre-filled value"
:multiple="field.type==='multi_select'"
/>
<date-input v-else-if="field.type==='date' && field.prefill_today!==true" name="prefill" class="mt-3"
:form="field" :with-time="field.with_time===true"
:date-range="field.date_range===true"
label="Pre-filled value"
/>
<phone-input v-else-if="field.type === 'phone_number' && !field.use_simple_text_input"
name="prefill" class="mt-3"
:form="field" :can-only-country="true" :unavailable-countries="field.unavailable_countries ?? []"
label="Pre-filled value"
/>
<text-area-input v-else-if="field.type === 'text' && field.multi_lines"
name="prefill" class="mt-3"
:form="field"
label="Pre-filled value"
/>
<file-input v-else-if="field.type==='files'" name="prefill" class="mt-4"
:form="field"
label="Pre-filled file"
:multiple="field.multiple===true" :move-to-form-assets="true"
/>
<text-input v-else-if="!['files', 'signature'].includes(field.type)" name="prefill" class="mt-3"
: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-3"
:form="field"
label="Empty Input Text (Placeholder)"
/>
<select-input name="width" class="mt-3"
: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"
/>
<!-- Help -->
<rich-text-area-input name="help" class="mt-3"
:form="field"
:editor-toolbar="editorToolbarCustom"
label="Field Help"
help="Your field help will be shown below/above the field, just like this message."
:help-position="field.help_position"
/>
<select-input name="help_position" class="mt-3"
:options="[
{name:'Below input',value:'below_input'},
{name:'Above input',value:'above_input'},
]"
:form="field" label="Field Help Position"
@input="onFieldHelpPositionChange"
/>
<template v-if="['text','number','url','email'].includes(field.type)">
<text-input 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-3"
label="Always show character limit"
/>
</template>
</div>
<!-- Advanced Options -->
<div v-if="field.type === 'text'" class="border-b py-2 px-4">
<h3 class="font-semibold block text-lg mb-3">
Advanced Options
</h3>
<v-checkbox v-model="field.generates_uuid"
:name="field.id+'_generates_uuid'"
@update:model-value="onFieldGenUIdChange"
>
Generates a unique id
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
If you enable this, we will hide this field and fill it with 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'"
@update:model-value="onFieldGenAutoIdChange"
>
Generates an auto-incremented id
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
If you enable this, we will hide this field and fill it a unique incrementing number on each new form submission
</p>
</div>
<!-- Logic Block -->
<!-- <form-block-logic-editor class="py-2 px-4 border-b" :form="form" :field="field" />-->
</div>
</template>
<script>
import timezones from '../../../../../../data/timezones.json'
import countryCodes from '../../../../../../data/country_codes.json'
import CountryFlag from 'vue-country-flag-next'
export default {
name: 'FieldOptions',
components: { CountryFlag },
props: {
field: {
type: Object,
required: false
},
form: {
type: Object,
required: false
}
},
data () {
return {
typesWithoutPlaceholder: ['date', 'checkbox', 'files'],
editorToolbarCustom: [
['bold', 'italic', 'underline', 'link']
],
allCountries: countryCodes
}
},
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: {
'field.width': {
handler (val) {
if (val === undefined || val === null) {
this.field.width = 'full'
}
},
immediate: true
},
'field.align': {
handler (val) {
if (val === undefined || val === null) {
this.field.align = 'left'
}
},
immediate: true
}
},
created () {
if (this.field?.width === undefined || this.field?.width === null) {
this.field.width = 'full'
}
},
mounted () {
if (['text', 'number', 'url', 'email'].includes(this.field?.type) && !this.field?.max_char_limit) {
this.field.max_char_limit = 2000
}
},
methods: {
onFieldDisabledChange (val) {
this.field.disabled = val
if (this.field.disabled) {
this.field.hidden = false
}
},
onFieldRequiredChange (val) {
this.field.required = val
if (this.field.required) {
this.field.hidden = false
}
},
onFieldHiddenChange (val) {
this.field.hidden = val
if (this.field.hidden) {
this.field.required = false
this.field.disabled = false
} else {
this.field.generates_uuid = false
this.field.generates_auto_increment_id = false
}
},
onFieldDateRangeChange (val) {
this.field.date_range = val
if (this.field.date_range) {
this.field.with_time = false
this.field.prefill_today = false
}
},
onFieldWithTimeChange (val) {
this.field.with_time = val
if (this.field.with_time) {
this.field.date_range = false
}
},
onFieldGenUIdChange (val) {
this.field.generates_uuid = val
if (this.field.generates_uuid) {
this.field.generates_auto_increment_id = false
this.field.hidden = true
}
},
onFieldGenAutoIdChange (val) {
this.field.generates_auto_increment_id = val
if (this.field.generates_auto_increment_id) {
this.field.generates_uuid = false
this.field.hidden = true
}
},
initRating () {
if (this.field.is_rating) {
this.$set(this.field, 'is_scale', false)
if (!this.field.rating_max_value) {
this.$set(this.field, 'rating_max_value', 5)
}
}
},
initScale () {
if (this.field.is_scale) {
this.$set(this.field, 'is_rating', false)
if (!this.field.scale_min_value) {
this.$set(this.field, 'scale_min_value', 1)
}
if (!this.field.scale_max_value) {
this.$set(this.field, 'scale_max_value', 5)
}
if (!this.field.scale_step_value) {
this.$set(this.field, 'scale_step_value', 1)
}
}
},
onFieldOptionsChange (val) {
const vals = (val) ? val.trim().split('\n') : []
const tmpOpts = vals.map(name => {
return {
name: name,
id: name
}
})
this.field[this.field.type] = { options: tmpOpts }
},
onFieldPrefillTodayChange (val) {
this.field.prefill_today = val
if (this.field.prefill_today) {
this.field.prefill = 'Pre-filled with current date'
this.field.date_range = false
this.field.disable_future_dates = false
this.field.disable_past_dates = false
} else {
this.field.prefill = null
}
},
onFieldAllowCreationChange (val) {
this.field.allow_creation = val
if (this.field.allow_creation) {
this.field.without_dropdown = false
}
},
onFieldWithoutDropdownChange (val) {
this.field.without_dropdown = val
if (this.field.without_dropdown) {
this.field.allow_creation = false
}
},
onFieldDisablePastDatesChange (val) {
this.field.disable_past_dates = val
if (this.field.disable_past_dates) {
this.field.disable_future_dates = false
this.field.prefill_today = false
}
},
onFieldDisableFutureDatesChange (val) {
this.field.disable_future_dates = val
if (this.field.disable_future_dates) {
this.field.disable_past_dates = false
this.field.prefill_today = false
}
},
onFieldHelpPositionChange (val) {
if (!val) {
this.field.help_position = 'below_input'
}
},
selectAllCountries () {
this.field.unavailable_countries = this.allCountries.map(item => {
return item.code
})
}
}
}
</script>

View File

@ -0,0 +1,391 @@
<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.cell_width ? col.cell_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: 100px">
<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="index" class="n-table-row" :class="{'first':index===0}">
<td v-for="col, colIndex in form.properties"
:key="col.id"
:style="{width: col.cell_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>
<td v-if="hasActions" class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b"
style="width: 100px"
>
<record-operations :form="form" :structure="form.properties" :rowid="row.id" @deleted="$emit('deleted')" />
</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 { useWorkingFormStore } from '../../../stores/working_form'
import OpenText from './components/OpenText.vue'
import OpenUrl from './components/OpenUrl.vue'
import OpenSelect from './components/OpenSelect.vue'
import OpenDate from './components/OpenDate.vue'
import OpenFile from './components/OpenFile.vue'
import OpenCheckbox from './components/OpenCheckbox.vue'
import ResizableTh from './components/ResizableTh.vue'
import RecordOperations from '../components/RecordOperations.vue'
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, RecordOperations },
props: {
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
allowResize: {
required: false,
default: true,
type: Boolean
}
},
setup () {
const workingFormStore = useWorkingFormStore()
return {
workingFormStore
}
},
data () {
return {
tableHash: null,
skip: false
}
},
computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
hasActions () {
// In future if want to hide based on condition
return true
},
fieldComponents () {
return {
text: OpenText,
number: OpenText,
select: OpenSelect,
multi_select: OpenSelect,
date: OpenDate,
files: OpenFile,
checkbox: OpenCheckbox,
url: OpenUrl,
email: OpenText,
phone_number: OpenText,
signature: OpenFile
}
}
},
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))
if (parent) {
parent.addEventListener('scroll', this.handleScroll, { passive: true })
}
window.addEventListener('resize', this.handleScroll)
this.onStructureChange()
this.handleScroll()
},
beforeUnmount () {
const parent = document.getElementById('table-page')
if (parent) {
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 && this.form.properties) {
this.$nextTick(() => {
this.form.properties.forEach(col => {
if (!col.hasOwnProperty('cell_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].cell_width = width
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>

View File

@ -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>

View 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>

View 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>

View File

@ -0,0 +1,39 @@
<template>
<span v-if="value" class="-mb-2">
<template v-if="valueIsObject">
<open-tag v-for="(val,index) in value" :key="index+val" :opt="val" />
</template>
<open-tag v-else :opt="value" />
</span>
</template>
<script>
import OpenTag from './OpenTag.vue'
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>

View 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>

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show More