Initial commit

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

View File

@@ -0,0 +1,402 @@
{
"email": {
"comparators": {
"equals": {
"expected_type": "string"
},
"does_not_equal": {
"expected_type": "string"
},
"contains": {
"expected_type": "string"
},
"does_not_contain": {
"expected_type": "string"
},
"starts_with": {
"expected_type": "string"
},
"ends_with": {
"expected_type": "string"
},
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
},
"url": {
"comparators": {
"equals": {
"expected_type": "string"
},
"does_not_equal": {
"expected_type": "string"
},
"contains": {
"expected_type": "string"
},
"does_not_contain": {
"expected_type": "string"
},
"starts_with": {
"expected_type": "string"
},
"ends_with": {
"expected_type": "string"
},
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
},
"phone_number": {
"comparators": {
"equals": {
"expected_type": "string"
},
"does_not_equal": {
"expected_type": "string"
},
"contains": {
"expected_type": "string"
},
"does_not_contain": {
"expected_type": "string"
},
"starts_with": {
"expected_type": "string"
},
"ends_with": {
"expected_type": "string"
},
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
},
"text": {
"comparators": {
"equals": {
"expected_type": "string"
},
"does_not_equal": {
"expected_type": "string"
},
"contains": {
"expected_type": "string"
},
"does_not_contain": {
"expected_type": "string"
},
"starts_with": {
"expected_type": "string"
},
"ends_with": {
"expected_type": "string"
},
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
},
"number": {
"comparators": {
"equals": {
"expected_type": "number"
},
"does_not_equal": {
"expected_type": "number"
},
"greater_than": {
"expected_type": "number"
},
"less_than": {
"expected_type": "number"
},
"greater_than_or_equal_to": {
"expected_type": "number"
},
"less_than_or_equal_to": {
"expected_type": "number"
},
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
},
"checkbox": {
"comparators": {
"equals": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"does_not_equal": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
},
"select": {
"comparators": {
"equals": {
"expected_type": "string"
},
"does_not_equal": {
"expected_type": "string"
},
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
},
"multi_select": {
"comparators": {
"contains": {
"expected_type": "object",
"format": {
"type": "uuid"
}
},
"does_not_contain": {
"expected_type": "object",
"format": {
"type": "uuid"
}
},
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
},
"date": {
"comparators": {
"equals": {
"expected_type": "string",
"format": {
"type": "date"
}
},
"before": {
"expected_type": "string",
"format": {
"type": "date"
}
},
"after": {
"expected_type": "string",
"format": {
"type": "date"
}
},
"on_or_before": {
"expected_type": "string",
"format": {
"type": "date"
}
},
"on_or_after": {
"expected_type": "string",
"format": {
"type": "date"
}
},
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"past_week": {
"expected_type": "object",
"format": {
"type": "empty",
"values": "{}"
}
},
"past_month": {
"expected_type": "object",
"format": {
"type": "empty",
"values": "{}"
}
},
"past_year": {
"expected_type": "object",
"format": {
"type": "empty",
"values": "{}"
}
},
"next_week": {
"expected_type": "object",
"format": {
"type": "empty",
"values": "{}"
}
},
"next_month": {
"expected_type": "object",
"format": {
"type": "empty",
"values": "{}"
}
},
"next_year": {
"expected_type": "object",
"format": {
"type": "empty",
"values": "{}"
}
}
}
},
"files": {
"comparators": {
"is_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
},
"is_not_empty": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [
true
]
}
}
}
}
}

File diff suppressed because it is too large Load Diff

29
resources/js/app.js vendored Normal file
View File

@@ -0,0 +1,29 @@
import Vue from 'vue'
import store from '~/store'
import router from '~/router'
import i18n from '~/plugins/i18n'
import App from '~/components/App'
import LoadScript from 'vue-plugin-load-script'
import Base from './base'
import VueTour from 'vue-tour'
import '~/plugins'
import '~/components'
Vue.config.productionTip = false
Vue.mixin(Base)
Vue.use(LoadScript)
/* Vue Tour */
require('vue-tour/dist/vue-tour.css')
Vue.use(VueTour)
/* eslint-disable no-new */
new Vue({
i18n,
store,
router,
...App
})

98
resources/js/base.js vendored Normal file
View File

@@ -0,0 +1,98 @@
/**
* Base mixin for all Vue components
*/
import debounce from 'debounce'
export default {
computed: {},
metaInfo () {
const info = {
meta: this.metaTags ?? []
}
if (this.metaTitle) {
info.title = this.metaTitle
info.meta = [
...info.meta,
{ vmid: 'og:title', property: 'og:title', content: this.metaTitle },
{ vmid: 'twitter:title', property: 'twitter:title', content: this.metaTitle }
]
}
if (this.metaDescription) {
info.meta = [
...info.meta,
{ vmid: 'description', name: 'description', content: this.metaDescription },
{ vmid: 'og:description', property: 'og:description', content: this.metaDescription },
{ vmid: 'twitter:description', property: 'twitter:description', content: this.metaDescription }
]
}
return info
},
methods: {
/**
* Creates a debounced function that delays invoking a callback.
*/
debouncer: debounce((callback) => callback(), 500),
/**
* Show an error message.
*/
alertError (message) {
this.$root.alert.type = 'error'
this.$root.alert.autoClose = false
this.$root.alert.message = message
window.scrollTo({ top: 0, behavior: 'smooth' })
},
/**
* Show a success message.
*/
alertSuccess (message, autoClose = 6000) {
this.$root.alert.type = 'success'
this.$root.alert.autoClose = autoClose
this.$root.alert.message = message
window.scrollTo({ top: 0, behavior: 'smooth' })
},
/**
* Show a warning message.
*/
alertWarning (message, autoClose) {
this.$root.alert.type = 'warning'
this.$root.alert.autoClose = autoClose
this.$root.alert.message = message
window.scrollTo({ top: 0, behavior: 'smooth' })
},
/**
* Show confirmation message.
*/
alertConfirm (message, success, failure) {
this.$root.alert.type = 'confirmation'
this.$root.alert.autoClose = false
this.$root.alert.message = message
this.$root.alert.confirmationProceed = success
this.$root.alert.confirmationCancel = failure
window.scrollTo({ top: 0, behavior: 'smooth' })
},
/**
* Show confirmation message.
*/
closeAlert () {
this.$root.alert = {
type: null,
autoClose: 0,
message: '',
confirmationProceed: null,
confirmationCancel: null
}
}
}
}

View File

@@ -0,0 +1,142 @@
<template>
<div id="app" class="bg-white dark:bg-notion-dark">
<loading v-show="!isIframe" ref="loading" />
<!-- <hotjar />-->
<amplitude />
<crisp />
<!-- <llamafi />-->
<transition enter-active-class="linear duration-200 overflow-hidden"
enter-class="max-h-0"
enter-to-class="max-h-screen"
leave-active-class="linear duration-200 overflow-hidden"
leave-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">
<component :is="layout" v-if="layout" />
</transition>
<portal-target name="modals" multiple />
<stop-impersonation />
</div>
</template>
<script>
import Loading from './Loading'
import { mapState } from 'vuex'
import Hotjar from './service/Hotjar'
import Amplitude from './service/Amplitude'
import Crisp from './service/Crisp'
import StopImpersonation from './pages/StopImpersonation'
// Load layout components dynamically.
const requireContext = require.context('~/layouts', false, /.*\.vue$/)
const layouts = requireContext.keys()
.map(file =>
[file.replace(/(^.\/)|(\.vue$)/g, ''), requireContext(file)]
)
.reduce((components, [name, component]) => {
components[name] = component.default || component
return components
}, {})
export default {
el: '#app',
components: {
StopImpersonation,
Crisp,
Amplitude,
Hotjar,
Loading
},
data: () => ({
layout: null,
defaultLayout: 'default',
announcement: false,
alert: {
type: null,
autoClose: 0,
message: '',
confirmationProceed: null,
confirmationCancel: null
}
}),
metaInfo () {
const { appName } = window.config
const description = "Create beautiful forms for free. Unlimited fields, unlimited submissions. It's free and it takes less than 1 minute to create your first form."
return {
title: appName,
titleTemplate: `%s · ${appName}`,
meta: [
{ vmid: 'description', name: 'description', content: description },
{ vmid: 'og:title', property: 'og:title', content: appName },
{ vmid: 'og:description', property: 'og:description', content: description },
{ vmid: 'og:image', property: 'og:image', content: '/img/social-preview.png' },
{ vmid: 'twitter:title', property: 'twitter:title', content: appName },
{ vmid: 'twitter:description', property: 'twitter:description', content: description },
{ vmid: 'twitter:image', property: 'twitter:image', content: '/img/social-preview.png' },
{ vmid: 'twitter:card', property: 'twitter:card', content: 'summary_large_image' }
]
}
},
mounted () {
this.$loading = this.$refs.loading
// Dark mode
if (window.localStorage.getItem('opnform-dark-mode-enabled') === '1' || window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark')
}
},
methods: {
/**
* Set the application layout.
*
* @param {String} layout
*/
setLayout (layout) {
if (!layout || !layouts[layout]) {
layout = this.defaultLayout
}
this.layout = layouts[layout]
},
workspaceAdded () {
this.$router.push({ name: 'home' })
}
},
computed: {
isIframe () {
return window.location !== window.parent.location || window.frameElement
},
isOnboardingPage () {
return this.$route.name === 'onboarding'
}
},
watch: {
}
}
</script>

View File

@@ -0,0 +1,13 @@
<template>
<transition name="page" mode="out-in">
<slot>
<router-view />
</slot>
</transition>
</template>
<script>
export default {
name: 'Child'
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div :style="{
width: `${percent}%`,
height: height,
opacity: show ? 1 : 0,
'background-color': canSuccess ? color : failedColor
}" class="progress"
/>
</template>
<script>
// https://github.com/nuxt/nuxt.js/blob/master/lib/app/components/nuxt-loading.vue
export default {
data: () => ({
percent: 0,
show: false,
canSuccess: true,
duration: 3000,
height: '2px',
color: '#77b6ff',
failedColor: 'red'
}),
methods: {
start () {
this.show = true
this.canSuccess = true
if (this._timer) {
clearInterval(this._timer)
this.percent = 0
}
this._cut = 10000 / Math.floor(this.duration)
this._timer = setInterval(() => {
this.increase(this._cut * Math.random())
if (this.percent > 95) {
this.finish()
}
}, 100)
return this
},
set (num) {
this.show = true
this.canSuccess = true
this.percent = Math.floor(num)
return this
},
get () {
return Math.floor(this.percent)
},
increase (num) {
this.percent = this.percent + Math.floor(num)
return this
},
decrease (num) {
this.percent = this.percent - Math.floor(num)
return this
},
finish () {
this.percent = 100
this.hide()
return this
},
pause () {
clearInterval(this._timer)
return this
},
hide () {
clearInterval(this._timer)
this._timer = null
setTimeout(() => {
this.show = false
this.$nextTick(() => {
setTimeout(() => {
this.percent = 0
}, 200)
})
}, 500)
return this
},
fail () {
this.canSuccess = false
return this
}
}
}
</script>
<style scoped>
.progress {
position: fixed;
top: 0px;
left: 0px;
right: 0px;
height: 2px;
width: 0%;
transition: width 0.2s, opacity 0.4s;
opacity: 1;
background-color: #efc14e;
z-index: 999999;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<Dropdown v-if="Object.keys(locales).length > 1"
dropdown-class="origin-top-right absolute right-0 mt-2 w-20 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5">
<template #trigger="{toggle}">
<a class="text-gray-300 hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium" href="#" role="button" @click.prevent="toggle"
>
{{ locales[locale] }}
</a>
</template>
<a v-for="(value, key) in locales" :key="key" class="block block text-center 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 z-10" href="#"
@click.prevent="setLocale(key)"
>
{{ value }}
</a>
</Dropdown>
</template>
<script>
import { mapGetters } from 'vuex'
import { loadMessages } from '~/plugins/i18n'
import Dropdown from './common/Dropdown'
export default {
components: { Dropdown },
computed: mapGetters({
locale: 'lang/locale',
locales: 'lang/locales'
}),
methods: {
setLocale (locale) {
if (this.$i18n.locale !== locale) {
loadMessages(locale)
this.$store.dispatch('lang/setLocale', { locale })
}
}
}
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<v-button v-if="githubAuth" color="gray" type="button" @click="login">
<div class="flex justify-center">
{{ $t('login_with') }}
<svg class="w-6 h-6 text-white ml-2" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</div>
</v-button>
</template>
<script>
export default {
name: 'LoginWithGithub',
computed: {
githubAuth: () => window.config.githubAuth,
url: () => '/api/oauth/github'
},
mounted () {
window.addEventListener('message', this.onMessage, false)
},
beforeDestroy () {
window.removeEventListener('message', this.onMessage)
},
methods: {
async login () {
const newWindow = openWindow('', this.$t('login'))
const url = await this.$store.dispatch('auth/fetchOauthUrl', {
provider: 'github'
})
newWindow.location.href = url
},
/**
* @param {MessageEvent} e
*/
onMessage (e) {
if (e.origin !== window.origin || !e.data.token) {
return
}
this.$store.dispatch('auth/saveToken', {
token: e.data.token
})
this.$router.push({ name: 'home' })
}
}
}
/**
* @param {Object} options
* @return {Window}
*/
function openWindow (url, title, options = {}) {
if (typeof url === 'object') {
options = url
url = ''
}
options = { url, title, width: 600, height: 720, ...options }
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : window.screen.left
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : window.screen.top
const width = window.innerWidth || document.documentElement.clientWidth || window.screen.width
const height = window.innerHeight || document.documentElement.clientHeight || window.screen.height
options.left = ((width / 2) - (options.width / 2)) + dualScreenLeft
options.top = ((height / 2) - (options.height / 2)) + dualScreenTop
const optionsStr = Object.keys(options).reduce((acc, key) => {
acc.push(`${key}=${options[key]}`)
return acc
}, []).join(',')
const newWindow = window.open(url, title, optionsStr)
if (window.focus) {
newWindow.focus()
}
return newWindow
}
</script>

View File

@@ -0,0 +1,133 @@
<template>
<portal to="modals" :order="portalOrder">
<transition leave-active-class="duration-200" name="fade" appear>
<div v-if="show" class="fixed z-30 top-0 inset-x-0 px-4 pt-6 sm:px-0 sm:flex sm:items-top sm:justify-center">
<transition enter-active-class="transition-all delay-75 linear duration-300"
enter-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-all linear duration-100"
leave-class="opacity-100"
leave-to-class="opacity-0"
appear @after-leave="leaveCallback"
>
<div v-if="show" class="fixed inset-0 transform" @click="close">
<div class="absolute inset-0 bg-gray-500 opacity-75" />
</div>
</transition>
<transition enter-active-class="delay-75 linear duration-300"
enter-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="linear duration-200" appear
leave-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div v-if="show" class="modal-content bg-white dark:bg-notion-dark rounded-lg overflow-y-scroll shadow-xl transform transition-all sm:w-full"
:class="maxWidthClass"
>
<div class="bg-white dark:bg-notion-dark px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 v-if="$scopedSlots.hasOwnProperty('title')" class="text-lg">
<slot name="title" />
</h3>
</div>
</div>
<div class="mt-2 w-full">
<slot />
</div>
</div>
<div v-if="$scopedSlots.hasOwnProperty('footer')" class="px-6 py-4 bg-gray-100 text-right">
<slot name="footer" />
</div>
</div>
</transition>
</div>
</transition>
</portal>
</template>
<script>
export default {
name: 'Modal',
props: {
show: {
default: false
},
maxWidth: {
default: '2xl'
},
closeable: {
default: true
},
portalOrder: {
default: 1
},
afterLeave: {
type: Function,
required: false
}
},
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]
}
},
watch: {
show: {
immediate: true,
handler: (show) => {
if (show) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = null
}
}
}
},
created () {
const closeOnEscape = (e) => {
if (e.key === 'Escape' && this.show) {
this.close()
}
}
document.addEventListener('keydown', closeOnEscape)
this.$once('hook:destroyed', () => {
document.removeEventListener('keydown', closeOnEscape)
})
},
methods: {
close () {
if (this.closeable) {
this.$emit('close')
}
},
leaveCallback () {
if (this.afterLeave) {
this.afterLeave()
}
}
}
}
</script>
<style lang="scss" scoped>
.modal-content {
max-height: calc(100vh - 40px);
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<nav v-if="hasNavbar" class="bg-white dark:bg-notion-dark">
<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' : 'welcome' }" class="flex-shrink-0 font-bold flex items-center">
<img :src="asset('img/logo.svg')" alt="notion tools logo" class="w-8 h-8">
<span
class="ml-3 text-xl hidden sm:inline text-black dark:text-white"
>
{{ appName }}</span><span
class="ml-3 text-sm uppercase hidden sm:inline text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-blue-600"
>Beta</span>
</router-link>
<workspace-dropdown class="ml-6" />
</div>
<div v-if="user" class="hidden md:block ml-auto relative">
<a href="#" class="text-sm text-gray-400 hover:text-gray-800 cursor-pointer mt-1"
@click.prevent="$getCrisp().push(['do', 'helpdesk:search'])"
>
Help
</a>
</div>
<div v-if="showAuth" class="block">
<div class="ml-4 flex items-center md:ml-6">
<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 font-medium 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.prevent="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 :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>
{{ $t('settings') }}
</router-link>
<a href="#"
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"
@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>
{{ $t('logout') }}
</a>
</dropdown>
<div v-else>
<router-link v-if="$route.name !== 'login'" :to="{ name: 'login' }"
class="text-gray-400 hover:text-gray-800 dark:hover:text-white px-0 sm:px-3 py-2 rounded-md text-sm font-medium"
active-class="text-gray-800 dark:text-white"
>
{{ $t('login') }}
</router-link>
<router-link :to="{ name: 'register' }"
class="text-gray-300 hover:text-gray-800 dark:hover:text-white pl-3 py-2 rounded-md text-sm font-medium"
active-class="text-gray-800 dark:text-white"
>
<v-button v-track.nav_create_form_click>
Create Form
</v-button>
</router-link>
</div>
</div>
</div>
<div>
<transition name="fade" mode="out-in">
<button v-if="darkModeEnabled" key="sun"
class="p-1 text-sm text-gray-400 hover:text-gray-800 dark:hover:text-white cursor-pointer mt-1 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800"
@click="toggleDarkMode"
>
<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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</button>
<button v-else key="moon"
class="p-1 text-sm text-gray-400 hover:text-gray-800 dark:hover:text-white cursor-pointer mt-1 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800"
@click="toggleDarkMode"
>
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
</button>
</transition>
</div>
</div>
</div>
</div>
</div>
</nav>
</template>
<script>
import { mapGetters } from 'vuex'
import Dropdown from './common/Dropdown'
import axios from 'axios'
import WorkspaceDropdown from './WorkspaceDropdown'
export default {
components: {
WorkspaceDropdown,
Dropdown
},
data: () => ({
appName: window.config.appName,
darkModeEnabled: false
}),
computed: {
form () {
if (this.$route.name && this.$route.name.startsWith('forms.show_public')) {
return this.$store.getters['open/forms/getBySlug'](this.$route.params.slug)
}
return null
},
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 true
},
isIframe () {
return window.location !== window.parent.location || window.frameElement
},
...mapGetters({
user: 'auth/user'
}),
userOnboarded () {
return this.user && this.user.workspaces_count > 0
}
},
watch: {
darkModeEnabled: {
handler (val) {
window.localStorage.setItem('opnform-dark-mode-enabled', val ? 1 : 0)
},
deep: true
}
},
mounted () {
this.darkModeEnabled = document.body.classList.contains('dark')
},
methods: {
async logout () {
// Log out the user.
await this.$store.dispatch('auth/logout')
// Reset store
this.$store.dispatch('open/workspaces/resetState')
this.$store.dispatch('open/forms/resetState')
// Redirect to login.
this.$router.push({ name: 'login' })
},
toggleDarkMode () {
document.body.classList.toggle('dark')
this.darkModeEnabled = document.body.classList.contains('dark')
}
}
}
</script>

View File

@@ -0,0 +1,99 @@
<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.prevent="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">
<a :key="worksp.id" 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 Dropdown from './common/Dropdown'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'WorkspaceDropdown',
components: {
Dropdown
},
data: () => ({
appName: window.config.appName
}),
computed: {
...mapState({
workspaces: state => state['open/workspaces'].content,
loading: state => state['open/workspaces'].loading
}),
...mapGetters({
user: 'auth/user'
}),
workspace () {
return this.$store.getters['open/workspaces/getCurrent']()
}
},
watch: {
},
mounted () {
},
methods: {
switchWorkspace (workspace) {
this.$store.commit('open/workspaces/setCurrentId', workspace.id)
this.$refs.dropdown.close()
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' })
}
this.$store.dispatch('open/forms/load', workspace.id)
},
isUrl (str) {
try {
new URL(str)
} catch (_) {
return false
}
return true
}
}
}
</script>
<style>
</style>

View File

@@ -0,0 +1,95 @@
<template>
<transition enter-active-class="linear duration-500 overflow-hidden"
enter-class="max-h-0 opacity-0"
enter-to-class="max-h-screen opacity-100"
leave-active-class="linear duration-500 overflow-hidden"
leave-class="max-h-screen opacity-100"
leave-to-class="max-h-0 opacity-0"
>
<div :class="alertClasses" class="border shadow-sm p-2 flex items-center rounded-md">
<div class="flex-grow">
<p class="mb-0 py-2 px-4" :class="textClasses" v-html="message"/>
</div>
<div class="justify-end">
<v-button v-if="type == 'error'" color="red" shade="light" @click="close">
Close
</v-button>
<v-button v-if="type == 'success'" color="green" shade="light" @click="close">
OK
</v-button>
<v-button v-if="type == 'warning'" color="yellow" shade="light" @click="close">
OK
</v-button>
<v-button v-if="type == 'confirmation'" class="mr-1 mb-1" @click="confirm">
Yes
</v-button>
<v-button v-if="type == 'confirmation'" color="gray" shade="light" @click="cancel">
No, cancel
</v-button>
</div>
</div>
</transition>
</template>
<script>
export default {
name: 'Alert',
props: ['type', 'message', 'autoClose', 'confirmationProceed', 'confirmationCancel'],
data () {
return {
timeout: null
}
},
computed: {
alertClasses () {
if (this.type === 'error') return 'bg-red-100 border-red-500'
if (this.type === 'success') return 'bg-green-100 border-green-500'
if (this.type === 'warning') return 'bg-yellow-100 border-yellow-500'
return 'bg-blue-50 border-nt-blue-light'
},
textClasses () {
if (this.type === 'error') return 'text-red-600'
if (this.type === 'success') return 'text-green-600'
if (this.type === 'warning') return 'text-yellow-600'
return 'text-nt-blue'
}
},
mounted () {
if (this.autoClose) {
this.timeout = setTimeout(() => {
this.close()
}, this.autoClose)
}
},
methods: {
/**
* Close the modal.
*/
close () {
clearTimeout(this.timeout)
this.$emit('close')
},
/**
* Confirm and close the modal.
*/
confirm () {
this.confirmationProceed()
this.close()
},
/**
* Cancel and close the modal.
*/
cancel () {
if (this.confirmationCancel) {
this.confirmationCancel()
}
this.close()
}
}
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="breadcrumbs flex">
<div v-for="(item,index) in path" :key="item.label" class="flex items-center">
<router-link v-if="item.route" class="p-1 hover:bg-blue-50 rounded-md" :to="item.route">
{{ item.label }}
</router-link>
<div v-else class="p-1" :class="{'font-semibold': index===path.length-1}">
{{ item.label }}
</div>
<div v-if="index!==path.length-1">
<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="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Breadcrumb',
props: {
/**
* route: Route object
* label: Label
*/
path: { type: Array }
},
data () {
return {}
},
computed: {},
mounted () {
},
methods: {}
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<button :type="nativeType" :disabled="loading" :class="`py-${sizes['p-y']} px-${sizes['p-x']}
bg-${color}-${colorShades['main']} hover:bg-${color}-${colorShades['hover']} focus:ring-${color}-${colorShades['ring']}
focus:ring-offset-${color}-${colorShades['ring-offset']} text-${colorShades['text']}
transition ease-in duration-200 text-center text-${sizes['font']} font-semibold shadow-md focus:outline-none focus:ring-2
focus:ring-offset-2 rounded-lg`"
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>
export default {
name: 'VButton',
props: {
color: {
type: String,
default: 'nt-blue'
},
shade: {
type: String,
default: 'normal'
},
size: {
type: String,
default: 'medium'
},
nativeType: {
type: String,
default: 'submit'
},
loading: {
type: Boolean,
default: false
}
},
computed: {
colorShades () {
if (this.color === 'nt-blue') {
return {
main: 'default',
hover: 'light',
ring: 'light',
'ring-offset': 'lighter',
text: 'white'
}
}
if (this.shade === 'lighter') {
return {
main: '200',
hover: '300',
ring: '100',
'ring-offset': '50',
text: 'gray-900'
}
}
if (this.shade === 'light') {
return {
main: '400',
hover: '500',
ring: '300',
'ring-offset': '150',
text: 'white'
}
}
return {
main: '600',
hover: '700',
ring: '500',
'ring-offset': '200',
text: 'white'
}
},
sizes () {
if (this.size === 'small') {
return {
font: 'sm',
'p-y': '1',
'p-x': '2'
}
}
return {
font: 'base',
'p-y': '2',
'p-x': '4'
}
}
}
}
</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,40 @@
<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>
<v-transition>
<div v-if="showContent" class="w-full">
<slot />
</div>
</v-transition>
</div>
</template>
<script>
import VTransition from './transitions/VTransition'
export default {
name: 'Collapse',
components: { VTransition },
props: {
defaultValue: { type: Boolean, default: false }
},
data () {
return {
showContent: this.defaultValue
}
},
methods: {
trigger () {
this.showContent = !this.showContent
}
}
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="relative">
<div>
<slot name="trigger"
:toggle="toggle"
:open="open"
:close="close"
/>
</div>
<transition name="fade">
<div
v-if="isOpen"
v-on-clickaway="close"
:class="dropdownClass"
>
<div class="py-1 " role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
<slot />
</div>
</div>
</transition>
</div>
</template>
<script>
import { directive as onClickaway } from 'vue-clickaway'
export default {
name: 'Dropdown',
directives: {
onClickaway: onClickaway
},
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-50' }
},
data () {
return {
isOpen: false
}
},
methods: {
open () {
this.isOpen = true
},
close () {
this.isOpen = false
},
toggle () {
this.isOpen = !this.isOpen
}
}
}
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div ref="parent"
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="editinput" 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>
export default {
props: {
value: {required: true},
textAlign: {type: String, default: 'left'},
contentClass: {type: String | Object, default: ''}
},
data() {
return {
content: this.value,
editing: false,
divHeight: 0
}
},
methods: {
startEditing() {
this.divHeight = this.$refs.parent.offsetHeight
this.editing = true
this.$nextTick(() => {
this.$refs.editinput.focus()
})
},
handleInput(e) {
this.$emit('input', this.content)
}
}
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<router-link :class="`py-${sizes['p-y']} px-${sizes['p-x']}
bg-${color}-${colorShades['main']} hover:bg-${color}-${colorShades['hover']} focus:ring-${color}-${colorShades['ring']}
focus:ring-offset-${color}-${colorShades['ring-offset']} text-${colorShades['text']}
transition ease-in duration-200 text-center text-${sizes['font']} font-semibold shadow-md focus:outline-none focus:ring-2
focus:ring-offset-2 rounded-lg hover:no-underline inline-block`" :to="to" :target="target"
>
<template v-if="!loading">
<slot />
</template>
<loader v-else class="h-6 w-6 text-white mx-auto" />
</router-link>
</template>
<script>
export default {
name: 'FancyLink',
props: {
to: {
type: Object
},
color: {
type: String,
default: 'nt-blue'
},
target: {
type: String,
default: '_self'
},
shade: {
type: String,
default: 'normal'
},
size: {
type: String,
default: 'medium'
},
loading: {
type: Boolean,
default: false
}
},
computed: {
colorShades () {
if (this.color === 'nt-blue') {
return {
main: 'default',
hover: 'light',
ring: 'light',
'ring-offset': 'lighter',
text: 'white'
}
}
if (this.shade === 'lighter') {
return {
main: '200',
hover: '300',
ring: '100',
'ring-offset': '50',
text: 'gray-900'
}
}
if (this.shade === 'light') {
return {
main: '400',
hover: '500',
ring: '300',
'ring-offset': '150',
text: 'white'
}
}
return {
main: '600',
hover: '700',
ring: '500',
'ring-offset': '200',
text: 'white'
}
},
sizes () {
if (this.size === 'small') {
return {
font: 'sm',
'p-y': '1',
'p-x': '2'
}
}
return {
font: 'base',
'p-y': '2',
'p-x': '4'
}
}
}
}
</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,79 @@
<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">
OpenForm PRO
</h2>
<h4 v-if="user.is_subscribed && !user.has_enterprise_subscription" class="text-center mt-5">
We're happy to have you as a Pro customer. If you're having any issue with OpenForm, or if you have a
feature request, please <a href="mailto:contact@opnform.com">contact us</a>.
<br><br>
If you need to collaborate, or to work with multiple workspaces, or just larger file uploads, you can
also upgrade our subscription to get an Enterprise subscription.
</h4>
<h4 v-if="user.is_subscribed && user.has_enterprise_subscription" class="text-center mt-5">
We're happy to have you as an Enterprise customer. If you're having any issue with OpenForm, or if you have a
feature request, please <a href="mailto:contact@opnform.com">contact us</a>.
</h4>
<p v-if="!user.is_subscribed" class="mt-4">
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 OpenForm. <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>
<p class="my-4 text-center">
Feel free to <a href="mailto:contact@opnform.com">contact us</a> if you have any feature request.
</p>
<div class="mb-4 text-center">
<v-button color="gray" shade="light" @click="showPremiumModal=false">
Close
</v-button>
</div>
</modal>
</div>
</template>
<script>
import Modal from '../Modal'
import axios from 'axios'
import { mapGetters } from 'vuex'
export default {
name: 'ProTag',
components: { Modal },
props: {},
data () {
return {
showPremiumModal: false,
checkoutLoading: false
}
},
computed: {
...mapGetters({
user: 'auth/user',
currentWorkSpace: 'open/workspaces/getCurrent',
}),
shouldDisplayProTag() {
return false; //!this.user.is_subscribed && !(this.currentWorkSpace.is_pro || this.currentWorkSpace.is_enterprise);
},
},
mounted () {
},
methods: {}
}
</script>

View File

@@ -0,0 +1,184 @@
<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
}
},
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)
// Cleanup when the component is destroyed.
this.$once('hook:destroyed', () => scrollContainerObserver.disconnect())
}
// Recalculate the container dimensions when the wrapper is resized.
const wrapObserver = newResizeObserver(this.calcDimensions)
if (wrapObserver) {
wrapObserver.observe(this.$el)
// Cleanup when the component is destroyed.
this.$once('hook:destroyed', () => wrapObserver.disconnect())
}
},
destroyed () {
window.removeEventListener('resize', this.calcDimensions)
},
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>

15
resources/js/components/common/index.js vendored Normal file
View File

@@ -0,0 +1,15 @@
import Vue from 'vue'
import Dropdown from './Dropdown'
import Card from './Card'
import Button from './Button'
import FancyLink from './FancyLink';
// Components that are registered globaly.
[
FancyLink,
Card,
Button,
Dropdown
].forEach(Component => {
Vue.component(Component.name, Component)
})

View File

@@ -0,0 +1,19 @@
<template>
<transition v-if="name=='slideInUp'"
enter-active-class="linear duration-300 overflow-hidden"
enter-class="max-h-0"
enter-to-class="max-h-screen"
leave-active-class="linear duration-300 overflow-hidden"
leave-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,29 @@
<template>
<div :class="wrapperClass">
<v-checkbox :id="id?id:name" v-model="compVal" :disabled="disabled" :name="name" @input="$emit('input',$event)">
{{ label }}
</v-checkbox>
<small v-if="help" :class="theme.default.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'
import VCheckbox from './components/VCheckbox'
export default {
name: 'CheckboxInput',
components: { VCheckbox },
mixins: [inputMixin],
props: {},
mounted () {
this.compVal = !!this.compVal
this.$emit('input', !!this.compVal)
}
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div :class="wrapperClass">
<label v-if="label" :for="id?id:name"
:class="[theme.CodeInput.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]"
>
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
<prism-editor :id="id?id:name" v-model="compVal" :disabled="disabled"
class="code-editor"
:class="[theme.CodeInput.input,{ 'ring-red-500 ring-2': hasValidation && form.errors.has(name), 'cursor-not-allowed bg-gray-200':disabled }]"
:style="inputStyle" :name="name"
:placeholder="placeholder"
:highlight="highlighter" @change="onChange"
/>
<small v-if="help" :class="theme.CodeInput.help">
<slot name="help">{{ help }}</slot>
</small>
<has-error v-if="hasValidation" :form="form" :field="name" />
</div>
</template>
<script>
// import Prism Editor
import { PrismEditor } from 'vue-prism-editor'
import 'vue-prism-editor/dist/prismeditor.min.css' // import the styles somewhere
// import highlighting library (you can use any library you want just return html string)
import { highlight, languages } from 'prismjs/components/prism-core'
import 'prismjs/components/prism-clike'
import 'prismjs/components/prism-markup'
import 'prismjs/themes/prism-tomorrow.css' // import syntax highlighting styles
import inputMixin from '~/mixins/forms/input'
export default {
name: 'CodeInput',
components: { PrismEditor },
mixins: [inputMixin],
methods: {
onChange (event) {
const file = event.target.files[0]
this.$set(this.form, this.name, file)
},
highlighter (code) {
return highlight(code, languages.markup) // languages.<insert language> to return html with markup
}
}
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div :class="wrapperClass">
<input :id="id?id:name" v-model="compVal" :disabled="disabled"
type="color"
:name="name"
>
<label v-if="label" :for="id?id:name" class="text-gray-700 dark:text-gray-300">
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
<small v-if="help" :class="theme.default.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'
export default {
name: 'ColorInput',
mixins: [inputMixin]
}
</script>

View File

@@ -0,0 +1,78 @@
<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>
<t-datepicker :id="id?id:name" ref="datepicker" v-model="compVal" class="datepicker" :disabled="disabled"
:class="{ 'ring-red-500 ring-2': hasValidation && form.errors.has(name), 'cursor-not-allowed bg-gray-200':disabled }"
:style="inputStyle" :name="name" :fixed-classes="fixedClasses" :range="dateRange"
:placeholder="placeholder" :timepicker="useTime"
:date-format="useTime?'Z':'Y-m-d'"
:user-format="useTime?'F j, Y - H:i':'F j, Y'"
/>
<small v-if="help" :class="theme.default.help">
<slot name="help">{{ help }}</slot>
</small>
<has-error v-if="hasValidation" :form="form" :field="name" />
</div>
</template>
<script>
import { fixedClasses } from '../../plugins/config/vue-tailwind/datePicker'
import inputMixin from '~/mixins/forms/input'
export default {
name: 'DateInput',
mixins: [inputMixin],
props: {
withTime: { type: Boolean, default: false },
dateRange: { type: Boolean, default: false }
},
data: () => ({
fixedClasses: fixedClasses
}),
computed: {
useTime () {
return this.withTime && !this.dateRange
}
},
watch: {
color: {
handler () {
this.setInputColor()
},
immediate: true
}
},
mounted () {
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)
}
}
}
}
</script>

View File

@@ -0,0 +1,244 @@
<template>
<div :class="wrapperClass">
<label v-if="label"
:class="[theme.default.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]"
>
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
<span class="inline-block w-full rounded-md shadow-sm">
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" role="button"
class="flex cursor-pointer relative w-full" :class="[theme.default.input,{'ring-red-500 ring-2': hasValidation && form.errors.has(name)}]"
:style="inputStyle" @click.self="showUploadModal=true"
>
<div v-if="currentUrl==null" class="h-6 text-gray-600 dark:text-gray-400 flex-grow" @click.prevent="showUploadModal=true">
Upload {{ multiple?'file(s)':'a file' }} <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>
<template v-else>
<div class="flex-grow h-6 text-gray-600 dark:text-gray-400" @click.prevent="showUploadModal=true">
<div class="truncate">
<p v-if="files.length==1"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline mr-2 -mt-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>{{ files[0].file.name }}</p>
<p v-else><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline mr-2 -mt-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>{{ files.length }} files</p>
</div>
</div>
<a href="#" v-if="files.length>0" class="hover:text-nt-blue" @click.prevent="clearAll" role="button">
<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>
</template>
</button>
</span>
<small v-if="help" :class="theme.default.help">
<slot name="help">{{ help }}</slot>
</small>
<has-error v-if="hasValidation" :form="form" :field="name" />
<!-- Modal -->
<modal :portal-order="2" :show="showUploadModal" @close="showUploadModal=false">
<h2 class="text-lg font-semibold">
Upload {{ multiple?'file(s)':'a file' }}
</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" :multiple="multiple" type="file" :name="name"
@change="manualFileUpload"
:accept="acceptExtensions"
>
<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 {{ multiple?'file(s)':'a file' }}
</button>
or drag and drop
</p>
<p class="mt-1 text-xs text-gray-500">
Up to {{ mbLimit }}mb
</p>
</div>
</template>
</div>
<div v-if="files.length" class="mt-4">
<div class="border rounded-md">
<div v-for="file,index in files" class="flex p-2" :class="{'border-t':index!==0}">
<p class="flex-grow truncate text-gray-500">
{{ file.file.name }}
</p>
<div>
<a href="#" class="text-gray-400 dark:text-gray-600 hover:text-nt-blue flex" @click.prevent="clearFile(index)" role="button">
<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>
</div>
</div>
</div>
</div>
</div>
</div>
</modal>
</div>
</template>
<script>
import Modal from '../Modal'
import inputMixin from '~/mixins/forms/input'
export default {
name: 'FileInput',
components: { Modal },
mixins: [inputMixin],
props: {
multiple: { type: Boolean, default: true },
mbLimit: { type: Number, default: 5 },
accept: { type: String, default: "" }
},
data: () => ({
showUploadModal: false,
files: [],
uploadDragoverTracking: false,
uploadDragoverEvent: false,
loading: false
}),
computed: {
currentUrl () {
return this.form[this.name]
},
acceptExtensions(){
if(this.accept){
return this.accept.split(",").map((i) => {
return "."+i.trim()
}).join(",")
}
return ""
}
},
watch: {
files: {
deep: true,
handler (files) {
this.compVal = files.map(file => file.url)
}
}
},
created () {
},
methods: {
clearAll () {
this.files = []
},
clearFile (index) {
this.files.splice(index, 1)
},
onUploadDragoverEvent (e) {
this.uploadDragoverEvent = true
this.uploadDragoverTracking = true
},
onUploadDropEvent (e) {
this.uploadDragoverEvent = false
this.uploadDragoverTracking = false
this.droppedFiles(e)
},
droppedFiles (e) {
const droppedFiles = e.dataTransfer.files
if (!droppedFiles) return
droppedFiles.forEach(file => {
this.uploadFileToServer(file)
})
},
openFileUpload () {
this.$refs['actual-input'].click()
},
manualFileUpload (e) {
e.target.files.forEach(file => {
this.uploadFileToServer(file)
})
},
uploadFileToServer (file) {
this.loading = true
this.storeFile(file).then(response => {
if (!this.multiple) {
this.files = []
}
this.files.push({
file: file,
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
})
this.showUploadModal = false
this.loading = false
}).catch((error) => {
this.clearAll()
this.showUploadModal = false
this.loading = false
})
}
}
}
</script>

View File

@@ -0,0 +1,86 @@
<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 v-for="option in options" :key="option[optionKey]" class="flex border mb-4 p-3 cursor-pointer rounded-2xl" @click="onSelect(option[optionKey])">
<p class="flex-grow">
{{ option[displayKey] }}
</p>
<span v-if="isSelected(option[optionKey])" class="float-right">
<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>
</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'
/**
* Options: {name,value} objects
*/
export default {
name: 'FlatSelectInput',
mixins: [inputMixin],
props: {
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 },
},
data () {
return {
}
},
computed: {
},
methods: {
onSelect (value) {
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,188 @@
<template>
<div :class="wrapperClass">
<label v-if="label"
:class="[theme.default.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]"
>
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
<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>
<small v-if="help" :class="theme.default.help">
<slot name="help">{{ help }}</slot>
</small>
<has-error v-if="hasValidation" :form="form" :field="name" />
<!-- 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" @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>
or drag and drop
</p>
<p class="mt-1 text-xs text-gray-500">
.jpg, .jpeg, .png, .bmp, .gif up to 5mb
</p>
</div>
</template>
</div>
</div>
</div>
</div>
</modal>
</div>
</template>
<script>
import Modal from '../Modal'
import axios from 'axios'
import inputMixin from '~/mixins/forms/input'
export default {
name: 'ImageInput',
components: { Modal },
mixins: [inputMixin],
props: {},
data: () => ({
showUploadModal: false,
file: [],
uploadDragoverTracking: false,
uploadDragoverEvent: false,
loading: false
}),
computed: {
currentUrl () {
return this.compVal
}
},
methods: {
clearUrl () {
this.$set(this.form, this.name, null)
},
onUploadDragoverEvent (e) {
this.uploadDragoverEvent = true
this.uploadDragoverTracking = true
},
onUploadDropEvent (e) {
this.uploadDragoverEvent = false
this.uploadDragoverTracking = false
this.droppedFiles(e)
},
droppedFiles (e) {
const droppedFiles = e.dataTransfer.files
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,67 @@
<template>
<div :class="wrapperClass" :style="inputStyle">
<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>
<div class="stars-outer">
<div v-for="i in numberOfStars" :key="i"
class="cursor-pointer inline-block"
:class="{'text-yellow-400':i<=compVal, 'text-yellow-100 dark:text-yellow-900':i>compVal && i<=hoverRating ,'text-gray-200 dark:text-gray-800':i>compVal && i>hoverRating}"
role="button" @click="setRating(i)"
@mouseover="hoverRating = i"
@mouseleave="hoverRating = null"
>
<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>
<small v-if="help" :class="theme.default.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'
export default {
name: 'RatingInput',
mixins: [inputMixin],
props: {
numberOfStars: { type: Number, default: 5 }
},
data () {
return {
hoverRating: null
}
},
updated () {
if (this.compVal === null) {
this.compVal = 0
}
},
methods: {
setRating (val) {
if (this.compVal === val) {
this.compVal = 0
} else {
this.compVal = val
}
}
}
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div :class="wrapperClass">
<label v-if="label" :for="id?id:name"
:class="[theme.RichTextAreaInput.label, {'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]"
>
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
<vue-editor :id="id?id:name" ref="editor" v-model="compVal" :disabled="disabled"
:placeholder="placeholder" :class="[{ 'ring-red-500 ring-2': hasValidation && form.errors.has(name) }, theme.RichTextAreaInput.input]"
:editor-toolbar="editorToolbar" class="rich-editor resize-y"
:style="inputStyle"
/>
<small v-if="help" :class="theme.RichTextAreaInput.help">
<slot name="help">{{ help }}</slot>
</small>
<has-error v-if="hasValidation" :form="form" :field="name" />
</div>
</template>
<script>
import { VueEditor, Quill } from 'vue2-editor'
import inputMixin from '~/mixins/forms/input'
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion')
export default {
name: 'RichTextAreaInput',
components: { VueEditor },
mixins: [inputMixin],
props: {
editorToolbar: {
type: Array,
default: () => {
return [
[{ header: 1 }, { header: 2 }],
['bold', 'italic', 'underline', 'link'],
[{ list: 'ordered' }, { list: 'bullet' }]
]
}
}
}
}
</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,109 @@
<template>
<div :class="wrapperClass">
<v-select v-model="compVal"
:dusk="name"
: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)"
:allowCreation="allowCreation"
@update-options="updateOptions"
>
<template #selected="{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>
<slot name="selected" :option="option" :optionName="getOptionName(option)">
<div class="flex items-center truncate mr-6">
<div>{{ getOptionName(option) }}</div>
</div>
</slot>
</template>
</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>
<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'
/**
* Options: {name,value} objects
*/
export default {
name: 'SelectInput',
mixins: [inputMixin],
props: {
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 }
},
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,29 @@
<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>
<textarea :id="id?id:name" v-model="compVal" :disabled="disabled"
:class="[theme.default.input,{ 'ring-red-500 ring-2': hasValidation && form.errors.has(name) }]"
class="resize-y"
:name="name" :style="inputStyle"
:placeholder="placeholder"
/>
<small v-if="help" :class="theme.default.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'
export default {
name: 'TextAreaInput',
mixins: [inputMixin]
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div :class="wrapperClass" :style="inputStyle">
<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>
<input :id="id?id:name" v-model="compVal" :disabled="disabled"
:type="nativeType"
:style="inputStyle"
:class="[theme.default.input,{ 'ring-red-500 ring-2': hasValidation && form.errors.has(name), '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"
>
<div v-if="help || showCharLimit" class="flex">
<small v-if="help" :class="theme.default.help" class="flex-grow">
<slot name="help">{{ help }}</slot>
</small>
<small v-else class="flex-grow"></small>
<small v-if="showCharLimit && maxCharLimit" :class="theme.default.help">
{{ charCount }}/{{ maxCharLimit }}
</small>
</div>
<has-error v-if="hasValidation" :form="form" :field="name" />
</div>
</template>
<script>
import inputMixin from '~/mixins/forms/input'
export default {
name: 'TextInput',
mixins: [inputMixin],
props: {
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 },
},
data: () => ({}),
computed: {
compVal: {
set (val) {
if (this.form) {
this.$set(this.form, this.nativeType !== 'file' ? this.name : 'file-' + this.name, val)
} else {
this.content = val
}
if (this.hasValidation) {
this.form.errors.clear(this.name)
}
this.$emit('input', val)
},
get () {
if (this.form) {
return this.form[this.nativeType !== 'file' ? this.name : 'file-' + this.name]
}
return this.content
}
},
charCount() {
return (this.compVal) ? this.compVal.length : 0
}
},
watch: {},
created () {},
methods: {
onChange (event) {
if (this.nativeType !== 'file') return
const file = event.target.files[0]
this.$set(this.form, this.name, file)
},
/**
* Pressing enter won't submit form
* @param event
* @returns {boolean}
*/
onEnterPress (event) {
event.preventDefault()
return false
}
}
}
</script>

View File

@@ -0,0 +1,88 @@
<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"
@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>
export default {
name: 'VCheckbox',
props: {
id: { type: String, default: null },
name: { type: String, default: 'checkbox' },
value: { type: [Boolean, String], default: false },
checked: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
size: { type: String, default: 'normal' }
},
data: () => ({
internalValue: false
}),
computed: {
sizeClasses () {
if (this.size === 'small') {
return 'w-3 h-3'
}
return 'w-5 h-5'
}
},
watch: {
value (val) {
this.internalValue = val
},
checked (val) {
this.internalValue = val
},
internalValue (val, oldVal) {
// Support form data string checkbox (string 1 or 0)
if (val === 0 || val === '0') val = false
if (val === 1 || val === '1') val = true
if (val !== oldVal) {
this.$emit('input', val)
}
}
},
created () {
this.internalValue = this.value
if ('checked' in this.$options.propsData) {
this.internalValue = this.checked
}
},
mounted () {
this.$emit('input', this.internalValue)
},
methods: {
handleClick (e) {
this.$emit('click', e)
if (!e.isPropagationStopped) {
this.internalValue = e.target.checked
this.$emit('input', this.internalValue)
}
}
}
}
</script>

View File

@@ -0,0 +1,223 @@
<template>
<div class="v-select">
<label v-if="label"
:class="[theme.SelectInput.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]"
>
{{ label }}
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
<div v-on-clickaway="closeDropdown"
class="relative"
>
<span class="inline-block w-full rounded-md">
<button type="button" :dusk="dusk" 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}]"
@click="openDropdown"
>
<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="value" key="value" class="flex" :class="{'min-h-8':multiple}">
<slot name="selected" :option="value" />
</div>
<div v-else key="placeholder">
<slot name="placeholder">
<div class="text-gray-400 dark:text-gray-500 w-full text-left" :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>
<!-- Select popover, show/hide based on select state. -->
<div v-show="isOpen" :dusk="dusk+'_dropdown'"
class="absolute mt-1 w-full rounded-md bg-white dark:bg-notion-dark-light shadow-lg z-10"
>
<ul tabindex="-1" role="listbox" aria-labelled by="listbox-label" aria-activedescendant="listbox-item-3"
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 name="search" :color="color" v-model="searchTerm" :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"
class="text-gray-900 cursor-default select-none relative py-2 pl-3 pr-9 cursor-pointer group hover:text-white hover:bg-nt-blue focus:outline-none focus:text-white focus:bg-nt-blue"
:dusk="dusk+'_option'" @click="select(item)"
>
<slot name="option" :option="item" :selected="isSelected(item)" />
</li>
</template>
<p v-else-if="!loading" class="w-full text-gray-500 text-center py-2">
No option available.
</p>
<li v-if="allowCreation && searchTerm" role="option"
class="text-gray-900 cursor-default select-none relative py-2 pl-3 pr-9 cursor-pointer group hover:text-white hover:bg-nt-blue focus:outline-none focus:text-white focus:bg-nt-blue"
@click="createOption(searchTerm)"
>
Create <b class="px-1 bg-gray-300 rounded group-hover:text-black">{{searchTerm}}</b>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import { directive as onClickaway } from 'vue-clickaway'
import TextInput from '../TextInput'
import Fuse from 'fuse.js'
import { themes } from '~/config/form-themes'
import debounce from 'debounce'
export default {
name: 'VSelect',
components: { TextInput },
directives: {
onClickaway: onClickaway
},
props: {
data: Array,
value: { default: null },
label: { type: String, default: null },
dusk: { type: String, default: null },
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 }, // Key used for emitted value, emit object if 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 },
},
data () {
return {
isOpen: false,
searchTerm: ''
}
},
computed: {
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
}
// Fuze 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': function (val) {
if (!this.debouncedRemote) return
if ((this.remote && val) || (val === '' && !this.value) || (val === '' && this.isOpen)) {
return this.debouncedRemote(val)
}
}
},
methods: {
isSelected (value) {
if (!this.value) return false
if (this.emitKey && value[this.emitKey]) {
value = value[this.emitKey]
}
if (this.multiple) {
return this.value.includes(value)
}
return this.value === value
},
closeDropdown () {
this.isOpen = false
this.searchTerm = ''
},
openDropdown () {
this.isOpen = !this.isOpen
},
select (value) {
if (!this.multiple) {
this.closeDropdown()
}
if (this.emitKey) {
value = value[this.emitKey]
}
if (this.multiple) {
const emitValue = Array.isArray(this.value) ? [...this.value] : []
// Already in value, remove it
if (this.isSelected(value)) {
this.$emit('input', emitValue.filter((item) => {
if (this.emitKey) {
return item !== value
}
return item[this.optionKey] !== value && item[this.optionKey] !== value[this.optionKey]
}))
return
}
// Otherwise add value
emitValue.push(value)
this.$emit('input', emitValue)
} else {
if (this.value === value) {
this.$emit('input', null)
} else {
this.$emit('input', value)
}
}
},
createOption(newOption) {
if(newOption){
let newItem = {
'name': newOption,
'value': newOption,
}
this.$emit("update-options", newItem)
this.select(newItem)
}
}
}
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div>
<Motion
v-model="value"
:options="{
duration: 150,
}"
:trigger="[
'bg-gray-200 border-gray-300 duration-100 dark:bg-gray-700 dark:border-gray-600',
'bg-gray-200 dark:bg-gray-700',
'bg-nt-blue border-nt-blue',
'bg-nt-blue duration-100',
]"
class="inline-flex items-center h-6 w-12 p-1 border rounded-full cursor-pointer focus:outline-none"
@click="$emit('input',!internalValue)"
>
<Motion
v-model="internalValue"
tag="span"
:options="{
duration: 150,
}"
:trigger="[
'translate-x-0 duration-150',
'rounded-2xl scale-75 duration-100',
'translate-x-6 duration-100',
'scale-100 duration-150',
]"
class="inline-block h-4 w-4 rounded-full bg-white dark:bg-gray-500 shadow"
/>
</Motion>
</div>
</template>
<script>
import Motion from 'tinymotion'
export default {
name: 'VSwitch',
components: { Motion },
props: {
value: { type: Boolean, default: false }
},
data () {
return {
internalValue: this.value
}
},
computed: {
sizeClasses () {
if (this.size === 'small') {
return 'w-3 h-3'
}
return 'w-5 h-5'
}
},
watch: {
value (val) {
this.internalValue = val
}
},
mounted () {
this.internalValue = this.value
},
methods: {
}
}
</script>
<style scoped>
.translate-x-6 {
--tw-translate-x: 1.4rem !important;
}
</style>

40
resources/js/components/forms/index.js vendored Normal file
View File

@@ -0,0 +1,40 @@
import Vue from 'vue'
import HasError from './validation/HasError.vue'
import AlertError from './validation/AlertError'
import AlertSuccess from './validation/AlertSuccess'
import VCheckbox from './components/VCheckbox'
import TextInput from './TextInput'
import TextAreaInput from './TextAreaInput'
import VSelect from './components/VSelect'
import CheckboxInput from './CheckboxInput'
import SelectInput from './SelectInput'
import ColorInput from './ColorInput'
import RichTextAreaInput from './RichTextAreaInput'
import FileInput from './FileInput'
import ImageInput from './ImageInput'
import DateInput from './DateInput';
import RatingInput from './RatingInput';
import FlatSelectInput from './FlatSelectInput';
// Components that are registered globaly.
[
HasError,
AlertError,
AlertSuccess,
VCheckbox,
VSelect,
CheckboxInput,
ColorInput,
TextInput,
SelectInput,
TextAreaInput,
FileInput,
ImageInput,
RichTextAreaInput,
DateInput,
RatingInput,
FlatSelectInput
].forEach(Component => {
Vue.component(Component.name, Component)
})

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'
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'
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.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>

17
resources/js/components/index.js vendored Normal file
View File

@@ -0,0 +1,17 @@
import './common'
import './forms'
import Vue from 'vue'
import Child from './Child'
import Modal from './Modal'
import Loader from './common/Loader'
// Components that are registered globaly.
[
Child,
Modal,
Loader
].forEach(Component => {
Vue.component(Component.name, Component)
})

View File

@@ -0,0 +1,52 @@
<template>
<notion-renderer v-if="!loading" :block-map="blockMap" />
<div v-else class="my-10 py-20 flex items-center justify-center">
<loader class="h-6 w-6 text-nt-blue mx-auto" />
</div>
</template>
<script>
import { NotionRenderer, getPageBlocks } from 'vue-notion'
export default {
name: 'NotionPage',
components: { NotionRenderer },
props: {
pageId: {
type: String,
required: true
}
},
data () {
return {
loading: false,
blockMap: null
}
},
computed: {
apiUrl: () => window.config.notion.worker
},
watch: {},
mounted () {
// get Notion blocks from the API via a Notion pageId
this.loading = true
getPageBlocks(this.pageId, this.apiUrl).then((blocks) => {
this.blockMap = blocks
this.loading = false
})
},
methods: {}
}
</script>
<style lang="scss">
@import "vue-notion/src/styles.css"; /* optional Notion-like styles */
.notion-blue {
@apply text-nt-blue;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,369 @@
<template>
<table :id="'table-'+tableHash" ref="table"
class="notion-table n-table whitespace-no-wrap bg-white dark:bg-notion-dark-light relative"
>
<thead :id="'table-header-'+tableHash" ref="header"
class="n-table-head top-0"
:class="{'absolute': data.length !== 0}"
style="will-change: transform; transform: translate3d(0px, 0px, 0px)"
>
<tr class="n-table-row overflow-x-hidden">
<resizable-th v-for="col, index in form.properties" :id="'table-head-cell-' + col.id" :key="col.id"
scope="col" :allow-resize="allowResize" :width="(col.width ? col.width + 'px':'auto')"
class="n-table-cell p-0 relative"
@resize-width="resizeCol(col, $event)"
>
<p
:class="{'border-r': index < form.properties.length - 1 || hasActions}"
class="bg-gray-50 dark:bg-notion-dark truncate sticky top-0 border-b border-gray-200 dark:border-gray-800 px-4 py-2 text-gray-500 font-semibold tracking-wider uppercase text-xs"
>
{{ col.name }}
</p>
</resizable-th>
<th v-if="hasActions" class="n-table-cell p-0 relative" style="width: 91px">
<p
class="bg-gray-50 dark:bg-notion-dark truncate sticky top-0 border-b border-gray-200 dark:border-gray-800 px-4 py-2 text-gray-500 font-semibold tracking-wider uppercase text-xs">
Actions
</p>
</th>
</tr>
</thead>
<tbody v-if="data.length > 0" class="n-table-body bg-white dark:bg-notion-dark-light">
<tr v-if="$slots.hasOwnProperty('actions')"
:id="'table-actions-'+tableHash"
ref="actions-row"
class="action-row absolute w-full"
style="will-change: transform; transform: translate3d(0px, 32px, 0px)"
>
<td :colspan="form.properties.length" class="p-1">
<slot name="actions"/>
</td>
</tr>
<tr v-for="row, index in data" :key="row.id" class="n-table-row" :class="{'first':index===0}">
<td v-for="col, colIndex in form.properties"
:key="col.id"
:style="{width: col.width + 'px'}"
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 overflow-hidden"
:class="[{'border-b': index !== data.length - 1, 'border-r': colIndex !== form.properties.length - 1 || hasActions},
colClasses(col)]"
>
<component :is="fieldComponents[col.type]" class="border-gray-100 dark:border-gray-900"
:property="col" :value="row[col.id]"
/>
</td>
</tr>
<tr v-if="loading" class="n-table-row border-t bg-gray-50 dark:bg-gray-900">
<td :colspan="form.properties.length" class="p-8 w-full">
<loader class="w-4 h-4 mx-auto"/>
</td>
</tr>
</tbody>
<tbody v-else key="body-content" class="n-table-body">
<tr class="n-table-row loader w-full">
<td :colspan="form.properties.length" class="n-table-cell w-full p-8">
<loader v-if="loading" class="w-4 h-4 mx-auto"/>
<p v-else class="text-gray-500 text-center">
No data found.
</p>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import OpenText from './components/OpenText'
import OpenUrl from './components/OpenUrl'
import OpenSelect from './components/OpenSelect'
import OpenDate from './components/OpenDate'
import OpenFile from './components/OpenFile'
import OpenCheckbox from './components/OpenCheckbox'
import ResizableTh from './components/ResizableTh'
import clonedeep from 'clone-deep'
const cyrb53 = function (str, seed = 0) {
let h1 = 0xdeadbeef ^ seed
let h2 = 0x41c6ce57 ^ seed
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i)
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
}
export default {
components: {ResizableTh},
props: {
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
allowResize: {
required: false,
default: true,
type: Boolean
},
},
data() {
return {
tableHash: null,
skip: false
}
},
computed: {
form: {
get() {
return this.$store.state['open/working_form'].content
},
set(value) {
this.$store.commit('open/working_form/set', value)
}
},
hasActions() {
return false
},
fieldComponents() {
return {
text: OpenText,
number: OpenText,
select: OpenSelect,
multi_select: OpenSelect,
date: OpenDate,
files: OpenFile,
checkbox: OpenCheckbox,
url: OpenUrl,
email: OpenText,
phone_number: OpenText,
}
},
},
watch: {
'form.properties': {
handler() {
this.onStructureChange()
},
deep: true
},
data() {
this.$nextTick(() => {
this.handleScroll()
})
}
},
mounted() {
const parent = document.getElementById('table-page')
this.tableHash = cyrb53(JSON.stringify(this.form.properties))
parent.addEventListener('scroll', this.handleScroll, {passive: true})
window.addEventListener('resize', this.handleScroll)
this.onStructureChange()
this.handleScroll()
},
beforeDestroy() {
const parent = document.getElementById('table-page')
parent.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleScroll)
},
methods: {
colClasses(col) {
let colAlign, colColor, colFontWeight, colWrap
// Column align
colAlign = `text-${col.alignment ? col.alignment : 'left'}`
// Column color
colColor = null
if (!col.hasOwnProperty('color') || col.color === 'default') {
colColor = 'text-gray-700 dark:text-gray-300'
}
colColor = `text-${col.color}`
// Column font weight
if (col.hasOwnProperty('bold') && col.bold) {
colFontWeight = 'font-semibold'
}
// Column wrapping
if (!col.hasOwnProperty('wrap_text') || !col.wrap_text) {
colWrap = 'truncate'
}
return [colAlign, colColor, colWrap, colFontWeight]
},
onStructureChange() {
if (this.form.properties) {
this.$nextTick(() => {
this.form.properties.forEach(col => {
if (!col.hasOwnProperty('width')) {
if (this.allowResize && this.form !== null && document.getElementById('table-head-cell-' + col.id)) {
// Within editor
this.resizeCol(col, document.getElementById('table-head-cell-' + col.id).offsetWidth)
}
}
})
})
}
},
resizeCol(col, width) {
if (!this.form) return
const columns = clonedeep(this.form.properties)
const index = this.form.properties.findIndex(c => c.id === col.id)
columns[index].width = width
this.$set(this.form, 'properties', columns)
this.$nextTick(() => {
this.$emit('resize')
})
},
handleScroll() {
const parent = document.getElementById('table-page')
const posTop = parent.getBoundingClientRect().top
const tablePosition = Math.max(0, posTop - this.$refs.table.getBoundingClientRect().top)
const tableHeader = document.getElementById('table-header-' + this.tableHash)
// Set position of table header
if (tableHeader) {
tableHeader.style.transform = `translate3d(0px, ${tablePosition}px, 0px)`
if (tablePosition > 0) {
tableHeader.classList.add('border-t')
} else {
tableHeader.classList.remove('border-t')
}
}
// Set position of actions row
if (this.$slots.hasOwnProperty('actions')) {
const tableActionsRow = document.getElementById('table-actions-' + this.tableHash)
if (tableActionsRow) {
if (tablePosition > 100) {
tableActionsRow.style.transform = `translate3d(0px, ${tablePosition + 33}px, 0px)`
} else {
const parentContainer = document.getElementById('table-page')
tableActionsRow.style.transform = `translate3d(0px, ${parentContainer.offsetHeight + (posTop - this.$refs.table.getBoundingClientRect().top) - 35}px, 0px)`
}
}
}
},
}
}
</script>
<style lang="scss">
.n-table {
.n-table-head {
height: 33px;
.resize-handler {
height: 33px;
width: 5px;
margin-left: -3px;
}
}
.n-table-row {
display: flex;
&.first, &.loader {
margin-top: 33px;
}
}
.n-table-cell {
min-width: 80px;
}
}
.notion-table {
td {
&.text-gray {
color: #787774;
}
&.text-brown {
color: #9f6b53;
}
&.text-orange {
color: #d9730d;
}
&.text-yellow {
color: #cb912f;
}
&.text-green {
color: #448361;
}
&.text-blue {
color: #337ea9;
}
&.text-purple {
color: #9065b0;
}
&.text-pink {
color: #c14c8a;
}
&.text-red {
color: #d44c47;
}
}
}
.dark {
.notion-table {
td {
&.text-gray {
color: #9b9b9b;
}
&.text-brown {
color: #ba856f;
}
&.text-orange {
color: #c77d48;
}
&.text-yellow {
color: #ca9849;
}
&.text-green {
color: #529e72;
}
&.text-blue {
color: #5e87c9;
}
&.text-purple {
color: #9d68d3;
}
&.text-pink {
color: #d15796;
}
&.text-red {
color: #df5452;
}
}
}
}
</style>

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 class="-mb-2" v-if="value">
<template v-if="valueIsObject">
<open-tag v-for="val,index in value" :key="index" :opt="val" />
</template>
<open-tag v-else :opt="value" />
</span>
</template>
<script>
import OpenTag from './OpenTag'
export default {
components: { OpenTag },
props: {
value: {
type: Object | null,
required: true
}
},
data () {
return {}
},
computed: {
valueIsObject () {
if (typeof this.value === 'object' && this.value !== null) {
return true
}
return false
}
},
mounted () {
},
methods: {}
}
</script>

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>

View File

@@ -0,0 +1,63 @@
<template>
<th ref="th" :style="{width: width}">
<slot />
<div v-if="allowResize" class="absolute right-0 top-0 w-0 z-10">
<div class="resize-handler bg-transparent cursor-move hover:bg-blue-500 opacity-80 transition-colors"
@mousedown="mouseDownHandler"
/>
</div>
</th>
</template>
<script>
export default {
components: {},
props: {
allowResize: {
required: true
},
width: {
required: true
}
},
data () {
return {
x: 0,
w: 0
}
},
computed: {
},
mounted () {
},
methods: {
mouseDownHandler (e) {
// Get the current mouse position
this.x = e.clientX
// Calculate the dimension of element
const styles = window.getComputedStyle(this.$refs.th)
this.w = parseInt(styles.width, 10)
// Attach the listeners to `document`
document.addEventListener('mousemove', this.mouseMoveHandler)
document.addEventListener('mouseup', this.mouseUpHandler)
},
mouseMoveHandler (e) {
// How far the mouse has been moved
const dx = e.clientX - this.x
// Adjust the dimension of element
this.$emit('resize-width', this.w + dx)
},
mouseUpHandler () {
// Remove the handlers of `mousemove` and `mouseup`
document.removeEventListener('mousemove', this.mouseMoveHandler)
document.removeEventListener('mouseup', this.mouseUpHandler)
}
}
}
</script>

View File

@@ -0,0 +1,183 @@
<template>
<div class="w-full bg-gray-50 dark:bg-notion-dark p-10">
<div class="px-4 py-6 w-full md:max-w-3xl md:mx-auto md:px-24 lg:px-8">
<div class="flex flex-wrap">
<div class="md:max-w-md lg:col-span-2 mr-2 pr-2">
<a href="/" aria-label="Go home" title="Company" class="inline-flex items-center">
<img :src="asset('img/logo.svg')" alt="notion tools logo" class="w-8 h-8 inline">
<span class="ml-2 text-xl font-bold tracking-wide text-gray-800 dark:text-gray-200">OpnForm</span>
</a>
</div>
<div class="grid grid-cols-2 gap-5 row-gap-8 lg:col-span-4 md:grid-cols-3">
<div>
<p class="font-semibold tracking-wide text-gray-800 dark:text-gray-200">
Resources
</p>
<ul class="mt-2 space-y-2">
<!-- <li>-->
<!-- <router-link :to="{name:'pricing'}"-->
<!-- class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"-->
<!-- >-->
<!-- Pricing-->
<!-- </router-link>-->
<!-- </li>-->
<li>
<a target="_blank" :href="helpUrl"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
Help
</a>
</li>
<!-- <li>-->
<!-- <router-link :to="{name:'guides'}"-->
<!-- class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"-->
<!-- >-->
<!-- Guides-->
<!-- </router-link>-->
<!-- </li>-->
<li>
<router-link :to="{name:'integrations'}"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
Integrations
</router-link>
</li>
<!-- <li id="changelog" data-canny-changelog class="relative block">-->
<!-- <p id="changelog-trigger"-->
<!-- class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue cursor-pointer"-->
<!-- >-->
<!-- Product Updates-->
<!-- </p>-->
<!-- </li>-->
<li class="relative block">
<a target="_blank" :href="featureRequestsUrl"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
Feature Requests
</a>
</li>
</ul>
</div>
<div>
<p class="font-semibold tracking-wide text-gray-800 dark:text-gray-200">
Community
</p>
<ul class="mt-2 space-y-2">
<li>
<a target="_blank" :href="facebookGroupUrl"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>Facebook
Group</a>
</li>
<li>
<a target="_blank" :href="twitterUrl"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>Twitter</a>
</li>
<!-- <li>-->
<!-- <router-link :to="{name:'discount-community'}"-->
<!-- class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"-->
<!-- >-->
<!-- Student Discount-->
<!-- </router-link>-->
<!-- </li>-->
<!-- <li>-->
<!-- <router-link :to="{name:'discount-community'}"-->
<!-- class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"-->
<!-- >-->
<!-- Academic Discount-->
<!-- </router-link>-->
<!-- </li>-->
<!-- <li>-->
<!-- <router-link :to="{name:'discount-community'}"-->
<!-- class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"-->
<!-- >-->
<!-- NGO Discount-->
<!-- </router-link>-->
<!-- </li>-->
<!-- <li>-->
<!-- <router-link :to="{name:'partners'}"-->
<!-- class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"-->
<!-- >-->
<!-- Our Partners-->
<!-- </router-link>-->
<!-- </li>-->
<!-- <li>-->
<!-- <router-link :to="{name:'ambassadors'}"-->
<!-- class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"-->
<!-- >-->
<!-- Notion Ambassadors-->
<!-- </router-link>-->
<!-- </li>-->
</ul>
</div>
<div class="ml-auto">
<p class="font-semibold tracking-wide text-gray-800 dark:text-gray-200">
Legal
</p>
<ul class="mt-2 space-y-2">
<li>
<router-link :to="{name:'privacy-policy'}"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
Privacy Policy
</router-link>
</li>
<li>
<router-link :to="{name:'terms-conditions'}"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
Terms & Conditions
</router-link>
</li>
</ul>
</div>
</div>
</div>
<div class="flex flex-col justify-between pt-5 pb-10 border-t mt-4 sm:flex-row">
<p class="text-sm text-gray-600 dark:text-gray-400 text-center w-full">
© Copyright 2022 OpnForm. All rights reserved.
</p>
</div>
</div>
</div>
</template>
<script>
export default {
data: () => ({}),
computed: {
helpUrl: () => window.config.links.help_url,
changelogUrl: () => window.config.links.changelog_url,
facebookGroupUrl: () => window.config.links.facebook_group,
twitterUrl: () => window.config.links.twitter,
featureRequestsUrl: () => window.config.links.feature_requests
},
mounted () {
this.loadCannyChangelog()
},
methods: {
loadCannyChangelog () {
this.$loadScript('https://canny.io/sdk.js')
.then(() => {
window.Canny('initChangelog', {
appID: '6267ca97f968c052891e7f8b',
position: 'top',
align: 'left'
})
})
}
}
}
</script>
<style>
.Canny_Badge {
background-color: #3B82F6 !important;
top: 5px !important;
right: 12px !important;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<transition name="fade">
<button v-if="isImpersonating"
class="cursor-pointer group hover:bg-blue-50 text-gray-600 py-2 px-5 fixed bottom-0 left-0 rounded-tr-md bg-white border-t border-r"
@click="reverseImpersonation"
>
<template v-if="!loading">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -mt-1 group-hover:text-blue-500 inline text-gray-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round"
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>
Stop Impersonation
</template>
<template v-else>
<div class="px-6">
<loader class="h-4 w-4 inline" />
</div>
</template>
</button>
</transition>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
data: () => ({
loading: false
}),
computed: {
...mapGetters({
isImpersonating: 'auth/isImpersonating'
})
},
mounted () {
},
methods: {
reverseImpersonation () {
this.loading = true
this.$store.dispatch('auth/stopImpersonating')
.then(() => {
this.$store.commit('open/workspaces/set', [])
this.$router.push({ name: 'settings.admin' })
this.loading = false
})
}
}
}
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div id="new-features"
class="w-full bg-gray-50 dark:bg-gray-800 border rounded-lg mt-4"
>
<div class="border-b">
<div v-track.new_in_notionforms_click
class="relative flex items-center cursor-pointer hover:bg-gray-100 p-4" role="button"
@click.prevent="showNewFeatures=!showNewFeatures"
>
<div class="text-gray-700 dark:text-gray-300 pr-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
/>
</svg>
</div>
<div>
<p class="text-gray-700 dark:text-gray-300 font-semibold">
New in OpnForm
</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
Click here to see our new features
</p>
</div>
</div>
<v-transition>
<ul v-if="showNewFeatures" class="list-disc list-inside border-t pt-2 p-4">
<li v-for="changelog in changelogEntries" :key="changelog.id" v-track.new_feature_click class="text-sm">
<a :href="changelog.url" target="_blank" class="text-gray-700 dark:text-gray-300">{{ changelog.title }}</a>
</li>
<li v-track.new_feature_read_more_click class="text-sm">
<a class="text-gray-700 dark:text-gray-300" :href="changelogLink" target="_blank">Read more</a>
</li>
</ul>
</v-transition>
</div>
<div class="relative flex items-center cursor-pointer hover:bg-gray-100 p-4">
<a v-track.feature_request_click="{user_has_forms:user.has_forms}" :href="requestFeatureLink"
class="absolute inset-0" target="_blank"
/>
<div class="text-gray-700 dark:text-gray-300 pr-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</div>
<div>
<p class="text-gray-700 dark:text-gray-300 font-semibold">
An idea for a new feature?
</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
Click here to request a new feature
</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import { mapGetters } from 'vuex'
import VTransition from '../../common/transitions/VTransition'
export default {
components: { VTransition },
props: {},
data: () => ({
changelogEntries: [],
showNewFeatures: false
}),
mounted () {
this.loadChangelogEntries()
},
computed: {
...mapGetters({
user: 'auth/user'
}),
requestFeatureLink () {
return window.config.links.feature_requests
},
changelogLink () {
return window.config.links.changelog_url
}
},
methods: {
loadChangelogEntries () {
axios.get('/api/content/changelog/entries').then(response => {
this.changelogEntries = response.data.splice(0, 3)
})
}
}
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<modal :show="show" @close="$emit('close')">
<div id="form-prefill-url-content" ref="content" class="px-4">
<h2 class="text-nt-blue text-3xl font-bold mb-4 flex items-center">
<span>Url Form Prefill</span>
<pro-tag class="ml-4 pb-3" />
</h2>
<p>
Create dynamic links when sharing your form (whether it's embedded or not), that allows you to prefill
your form fields. You can use this to personalize the form when sending it to multiple contacts for instance.
</p>
<h3 class="mt-6 border-t text-xl font-semibold mb-4 pt-6">
How does it work?
</h3>
<p>
Complete your form below and fill only the fields you want to prefill. You can even leave the required fields empty.
</p>
<div class="rounded-lg p-5 bg-gray-100 dark:bg-gray-900 mt-4">
<open-form v-if="form" :theme="theme" :loading="false" :show-hidden="true" :form="form" :fields="form.properties" @submit="generateUrl">
<template #submit-btn="{submitForm}">
<v-button class="mt-2 px-8 mx-1" @click.prevent="submitForm">
Generate Pre-filled URL
</v-button>
</template>
</open-form>
</div>
<template v-if="prefillFormData">
<h3 class="mt-6 text-xl font-semibold mb-4 pt-6">
Your Prefill url
</h3>
<form-url-prefill :form="form" :form-data="prefillFormData" />
</template>
<div class="flex justify-end mt-4">
<v-button color="gray" shade="light" @click="$emit('close')">Close</v-button>
</div>
</div>
</modal>
</template>
<script>
import FormUrlPrefill from '../../open/forms/components/FormUrlPrefill'
import ProTag from '../../common/ProTag'
import OpenForm from '../../open/forms/OpenForm'
import { themes } from '~/config/form-themes'
export default {
name: 'UrlFormPrefillModal',
components: { FormUrlPrefill, ProTag, OpenForm },
props: {
show: { type: Boolean, required: true },
form: { type: Object, required: true }
},
data: () => ({
prefillFormData: null,
theme: themes.default
}),
computed: {},
methods: {
generateUrl (formData, onFailure) {
this.prefillFormData = formData
this.$nextTick().then(() => {
this.$refs.content.parentElement.parentElement.parentElement.scrollTop = (this.$refs.content.offsetHeight - this.$refs.content.parentElement.parentElement.parentElement.offsetHeight + 50)
})
}
}
}
</script>

View File

@@ -0,0 +1,202 @@
<template>
<div id="features" class="px-4 mx-auto sm:max-w-xl md:max-w-full lg:max-w-screen-xl md:px-24 lg:px-8">
<div v-if="!featuresOnly" :class="{'mb-10 md:mb-12':!featuresOnly }" class="max-w-xl md:mx-auto sm:text-center lg:max-w-2xl ">
<div>
<p
class="inline-block px-3 py-px mb-4 text-xs font-semibold tracking-wider text-nt-blue uppercase rounded-full bg-nt-blue-lighter"
>
100% Free
</p>
</div>
<h2
class="max-w-lg mb-6 font-sans text-3xl font-bold leading-none tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl md:mx-auto"
>
<span class="relative inline-block">
<svg viewBox="0 0 52 24" fill="currentColor"
class="text-nt-blue-light absolute top-0 left-0 z-0 hidden w-32 -mt-8 -ml-20 text-blue-gray-100 lg:w-32 lg:-ml-28 lg:-mt-10 sm:block"
>
<defs>
<pattern id="27df4f81-c854-45de-942a-fe90f7a300f9" x="0" y="0" width=".135" height=".30">
<circle cx="1" cy="1" r=".7" />
</pattern>
</defs>
<rect fill="url(#27df4f81-c854-45de-942a-fe90f7a300f9)" width="52" height="24" />
</svg>
<span class="relative">The</span>
</span>
easiest way to create forms for free
</h2>
<p class="text-base text-gray-700 dark:text-gray-300 md:text-lg">
You've been paying too much for too long. OpnForm is the first open-source form builder. Need a contact
form? Doing a survey? Create a form in 3 minutes and start receiving submissions.
</p>
</div>
<div class="grid max-w-screen-lg gap-8 row-gap-10 mx-auto md:grid-cols-2">
<div class="flex flex-col max-w-md sm:mx-auto sm:flex-row">
<div class="mr-4">
<div class="flex items-center justify-center w-12 h-12 mb-4 rounded-full bg-nt-blue-lighter">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14v6m-3-3h6M6 10h2a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2zm10 0h2a2 2 0 002-2V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v2a2 2 0 002 2zM6 20h2a2 2 0 002-2v-2a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2z" />
</svg>
</div>
</div>
<div>
<h6 class="mb-3 text-xl font-bold leading-5">
Infinite Number of Fields
</h6>
<p class="mb-3 text-sm dark:text-gray-100 text-gray-900">
There are no limits on the number of input fields in your forms. Organize fields and decide which are required.
</p>
</div>
</div>
<div class="flex flex-col max-w-md sm:mx-auto sm:flex-row">
<div class="mr-4">
<div class="flex items-center justify-center w-12 h-12 mb-4 rounded-full bg-nt-blue-lighter">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-nt-blue" 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>
</div>
</div>
<div>
<h6 class="mb-3 text-xl font-bold leading-5">
Infinite Number of Forms
</h6>
<p class="mb-3 text-sm dark:text-gray-100 text-gray-900">
You can create as many forms as you need. Forms everywhere, for everything!
</p>
</div>
</div>
<div class="flex flex-col max-w-md sm:mx-auto sm:flex-row">
<div class="mr-4">
<div class="flex items-center justify-center w-12 h-12 mb-4 rounded-full bg-nt-blue-lighter">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<h6 class="mb-3 text-xl font-bold leading-5">
Infinite Responses
</h6>
<p class="mb-3 text-sm dark:text-gray-100 text-gray-900">
All of you forms can have unlimited responses, no need to worry about quotas and other stressful metrics.
</p>
</div>
</div>
<div class="flex flex-col max-w-md sm:mx-auto sm:flex-row">
<div class="mr-4">
<div class="flex items-center justify-center w-12 h-12 mb-4 rounded-full bg-nt-blue-lighter">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
</div>
<div>
<h6 class="mb-3 text-xl font-bold leading-5">
Notifications
</h6>
<p class="mb-3 text-sm dark:text-gray-100 text-gray-900">
Receive notifications directly in Slack or in your mailbox whenever your from has a new submission (if you want to).
</p>
</div>
</div>
<div class="flex flex-col max-w-md sm:mx-auto sm:flex-row">
<div class="mr-4">
<div class="flex items-center justify-center w-12 h-12 mb-4 rounded-full bg-nt-blue-lighter">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
</div>
<div>
<h6 class="mb-3 text-xl font-bold leading-5">
Integrate Anywhere
</h6>
<p class="mb-3 text-sm dark:text-gray-100 text-gray-900">
You can integrate your form anywhere: on your website, or even within a Notion Page.
</p>
</div>
</div>
<div class="flex flex-col max-w-md sm:mx-auto sm:flex-row">
<div class="mr-4">
<div class="flex items-center justify-center w-12 h-12 mb-4 rounded-full bg-nt-blue-lighter">
<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 text-nt-blue" >
<path stroke-linecap="round" stroke-linejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
</svg>
</div>
</div>
<div>
<h6 class="mb-3 text-xl font-bold leading-5">
Customize Everything
</h6>
<p class="mb-3 text-sm dark:text-gray-100 text-gray-900">
Change form themes, change texts, colors, add images, add custom thank you pages and much more.
</p>
</div>
</div>
<div class="flex flex-col max-w-md sm:mx-auto sm:flex-row">
<div class="mr-4">
<div class="flex items-center justify-center w-12 h-12 mb-4 rounded-full bg-nt-blue-lighter">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-nt-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div>
<h6 class="mb-3 text-xl font-bold leading-5">
File Uploads
</h6>
<p class="mb-3 text-sm dark:text-gray-100 text-gray-900">
Easily add file upload inputs to your forms. Uploaded files are securely stored for you. Up to 5mb!
</p>
</div>
</div>
<div class="flex flex-col max-w-md sm:mx-auto sm:flex-row">
<div class="mr-4">
<div class="flex items-center justify-center w-12 h-12 mb-4 rounded-full bg-nt-blue-lighter">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-nt-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</div>
<div>
<h6 class="mb-3 text-xl font-bold leading-5">
Advanced features
</h6>
<p class="mb-3 text-sm dark:text-gray-100 text-gray-900">
Form logic, URL pre-fill, hidden fields, unique submission id, form password, webhooks, custom code, closing date, etc. It's all there!
</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
featuresOnly: {
type: Boolean,
default: false
}
},
data: () => ({}),
methods: {}
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<iframe v-if="!isDarkMode" id="testimonialto-carousel-all-notionforms"
loading="lazy"
src="https://embed.testimonial.to/carousel/all/notionforms?theme=light&autoplay=on&showmore=on&one-row=on&same-height=off"
frameBorder="0" scrolling="no" width="100%"
/>
<iframe v-else id="testimonialto-carousel-all-notionforms" src="https://embed.testimonial.to/carousel/all/notionforms?theme=dark&autoplay=on&showmore=on&one-row=on&same-height=off" frameborder="0" scrolling="no" width="100%" />
</template>
<script>
export default {
props: {
featuresOnly: {
type: Boolean,
default: false
}
},
data: () => ({}),
computed: {
isDarkMode () {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
}
},
mounted () {
this.loadScript()
},
methods: {
loadScript () {
const script = document.createElement('script')
script.setAttribute('src', 'https://testimonial.to/js/iframeResizer.min.js')
document.head.appendChild(script)
script.addEventListener('load', function () {
window.iFrameResize({
log: false,
checkOrigin: false
}, '#testimonialto-carousel-all-notionforms')
})
}
}
}
</script>

View File

@@ -0,0 +1,97 @@
<template />
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Amplitude',
data: function () {
return {
loaded: false,
amplitudeInstance: null
}
},
computed: {
...mapGetters({
authenticated: 'auth/check',
user: 'auth/user'
})
},
watch: {
$route () {
this.loadAmplitude()
},
authenticated () {
this.authenticateUser()
}
},
mounted () {},
methods: {
authenticateUser () {
if (this.loaded && this.authenticated) {
this.amplitudeInstance.setUserId(this.user.id)
this.amplitudeInstance.setUserProperties({
email: this.user.email,
subscribed: this.user.is_subscribed,
enterprise_subscription: this.user.has_enterprise_subscription
})
}
},
loadAmplitude () {
if (this.loaded || !typeof window.amplitude === 'undefined') return
(function (e, t) {
const n = e.amplitude || { _q: [], _iq: {} }; const r = t.createElement('script')
r.type = 'text/javascript'
r.integrity = 'sha384-+EO59vL/X7v6VE2s6/F4HxfHlK0nDUVWKVg8K9oUlvffAeeaShVBmbORTC2D3UF+'
r.crossOrigin = 'anonymous'; r.async = true
r.src = 'https://cdn.amplitude.com/libs/amplitude-8.17.0-min.gz.js'
r.onload = function () {
if (!e.amplitude.runQueuedFunctions) {
console.log('[Amplitude] Error: could not load SDK')
}
}
const i = t.getElementsByTagName('script')[0]; i.parentNode.insertBefore(r, i)
function s (e, t) {
e.prototype[t] = function () {
this._q.push([t].concat(Array.prototype.slice.call(arguments, 0))); return this
}
}
const o = function () { this._q = []; return this }
const a = ['add', 'append', 'clearAll', 'prepend', 'set', 'setOnce', 'unset', 'preInsert', 'postInsert', 'remove']
for (let c = 0; c < a.length; c++) { s(o, a[c]) }n.Identify = o; const u = function () {
this._q = []
return this
}
const l = ['setProductId', 'setQuantity', 'setPrice', 'setRevenueType', 'setEventProperties']
for (let p = 0; p < l.length; p++) { s(u, l[p]) }n.Revenue = u
const d = ['init', 'logEvent', 'logRevenue', 'setUserId', 'setUserProperties', 'setOptOut', 'setVersionName', 'setDomain', 'setDeviceId', 'enableTracking', 'setGlobalUserProperties', 'identify', 'clearUserProperties', 'setGroup', 'logRevenueV2', 'regenerateDeviceId', 'groupIdentify', 'onInit', 'logEventWithTimestamp', 'logEventWithGroups', 'setSessionId', 'resetSessionId']
function v (e) {
function t (t) {
e[t] = function () {
e._q.push([t].concat(Array.prototype.slice.call(arguments, 0)))
}
}
for (let n = 0; n < d.length; n++) { t(d[n]) }
}v(n); n.getInstance = function (e) {
e = (!e || e.length === 0 ? '$default_instance' : e).toLowerCase()
if (!Object.prototype.hasOwnProperty.call(n._iq, e)) {
n._iq[e] = { _q: [] }; v(n._iq[e])
} return n._iq[e]
}; e.amplitude = n
})(window, document)
this.amplitudeInstance = window.amplitude.getInstance()
this.amplitudeInstance.init('9952c8b914ce3f2bd494fce2dba18243')
this.loaded = true
this.authenticateUser()
}
}
}
</script>

View File

@@ -0,0 +1,34 @@
<template />
<script>
export default {
name: 'Crisp',
computed: {
isIframe () {
return window.location !== window.parent.location || window.frameElement
}
},
watch: {},
mounted () {
this.loadCrisp()
},
methods: {
loadCrisp () {
if (this.isIframe) return
window.$crisp = []
window.CRISP_WEBSITE_ID = '94219d77-06ff-4aec-b07a-5bf26ec8fde1'
const script = document.createElement('script')
script.setAttribute('src', 'https://client.crisp.chat/l.js')
script.setAttribute('id', 'crisp-widget')
script.setAttribute('async', 1)
document.head.appendChild(script)
}
}
}
</script>

View File

@@ -0,0 +1,49 @@
<template />
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Hotjar',
watch: {
authenticated () {
if (this.authenticated) {
this.loadHotjar()
}
}
},
mounted () {
this.loadHotjar()
},
methods: {
loadHotjar () {
if (!this.authenticated || this.isIframe) return
(function (h, o, t, j, a, r) {
h.hj = h.hj || function () {
(h.hj.q = h.hj.q || []).push(arguments)
}
h._hjSettings = { hjid: 2449591, hjsv: 6 }
a = o.getElementsByTagName('head')[0]
r = o.createElement('script')
r.async = 1
r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv
a.appendChild(r)
})(window, document, 'https://static.hotjar.com/c/hotjar-', '.js?sv=')
}
},
computed: {
...mapGetters({
authenticated: 'auth/check'
}),
isIframe () {
return window.location !== window.parent.location || window.frameElement
}
}
}
</script>

81
resources/js/config/form-themes.js vendored Normal file
View File

@@ -0,0 +1,81 @@
/**
Input classes for each supported form themes
*/
export const themes = {
default: {
default: {
label: 'text-gray-700 dark:text-gray-300 font-bold',
input: 'rounded-lg border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full py-2 px-4 bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100',
help: 'text-gray-400 dark:text-gray-500'
},
Button: {
body: 'transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg filter hover:brightness-110'
},
CodeInput: {
label: 'text-gray-700 dark:text-gray-300 font-bold',
input: 'rounded-lg border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full 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 p-2',
help: 'text-gray-400 dark:text-gray-500'
},
RichTextAreaInput: {
label: 'text-gray-700 dark:text-gray-300 font-bold',
input: 'rounded-lg border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full 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-1 focus:ring-opacity-100 focus:border-transparent focus:ring-2',
help: 'text-gray-400 dark:text-gray-500'
},
SelectInput: {
label: 'text-gray-700 dark:text-gray-300 font-bold',
input: 'relative w-full rounded-lg border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full px-4 bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent',
help: 'text-gray-400 dark:text-gray-500'
}
},
simple: {
default: {
label: 'text-gray-700 dark:text-gray-300 font-bold',
input: 'border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full py-2 px-2 bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100',
help: 'text-gray-400 dark:text-gray-500'
},
Button: {
body: 'transition ease-in duration-200 text-center font-semibold focus:outline-none focus:ring-2 focus:ring-offset-2 filter hover:brightness-110'
},
SelectInput: {
label: 'text-gray-700 dark:text-gray-300 font-bold',
input: 'relative w-full border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full px-2 bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 text-base focus:outline-none focus:ring-2 focus:border-transparent',
help: 'text-gray-400 dark:text-gray-500'
},
CodeInput: {
label: 'text-gray-700 dark:text-gray-300 font-bold',
input: 'border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-2 focus:border-transparent p-2',
help: 'text-gray-400 dark:text-gray-500'
},
RichTextAreaInput: {
label: 'text-gray-700 dark:text-gray-300 font-bold',
input: 'border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-1 focus:ring-opacity-100 focus:border-transparent focus:ring-2',
help: 'text-gray-400 dark:text-gray-500'
}
},
notion: {
default: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded border-transparent flex-1 appearance-none shadow-inner-notion w-full py-2 px-2 bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion',
help: 'text-notion-input-help dark:text-gray-500'
},
Button: {
body: 'rounded-md transition ease-in duration-200 text-center font-semibold shadow shadow-inner-notion focus:outline-none focus:ring-2 focus:ring-offset-2 filter hover:brightness-110'
},
SelectInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded relative w-full border-transparent flex-1 appearance-none bg-notion-input-background shadow-inner-notion w-full px-2 text-gray-900 placeholder-gray-400 dark:bg-notion-dark-light dark:placeholder-gray-500 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion',
help: 'text-notion-input-help dark:text-gray-500'
},
CodeInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded border-transparent flex-1 appearance-none shadow-inner-notion border border-gray-300 dark:border-gray-600 w-full text-gray-900 bg-notion-input-background dark:bg-notion-dark-light shadow-inner dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion p-2',
help: 'text-notion-input-help dark:text-gray-500'
},
RichTextAreaInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded border-transparent flex-1 appearance-none shadow-inner-notion border border-gray-300 dark:border-gray-600 w-full text-gray-900 bg-notion-input-background dark:bg-notion-dark-light shadow-inner dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:ring-opacity-100 focus:border-transparent focus:ring-0 focus:shadow-focus-notion',
help: 'text-notion-input-help dark:text-gray-500'
}
}
}

View File

@@ -0,0 +1,276 @@
export function conditionsMet (conditions, formData) {
if (conditions === undefined || conditions === null) {
return false
}
// If it's not a group, just a single condition
if (conditions.operatorIdentifier === undefined) {
return propertyConditionMet(conditions.value, conditions.value ? formData[conditions.value.property_meta.id] : null)
}
if (conditions.operatorIdentifier === 'and') {
let isvalid = true
conditions.children.forEach(childrenCondition => {
if (!conditionsMet(childrenCondition, formData)) {
isvalid = false
}
})
return isvalid
} else if (conditions.operatorIdentifier === 'or') {
let isvalid = false
conditions.children.forEach(childrenCondition => {
if (conditionsMet(childrenCondition, formData)) {
isvalid = true
}
})
return isvalid
}
throw new Error('Unexcepted operatorIdentifier:' + conditions.operatorIdentifier)
}
function propertyConditionMet (propertyCondition, value) {
if (!propertyCondition) {
return false
}
switch (propertyCondition.property_meta.type) {
case 'text':
case 'url':
case 'email':
case 'phone_number':
return textConditionMet(propertyCondition, value)
case 'number':
return numberConditionMet(propertyCondition, value)
case 'checkbox':
return checkboxConditionMet(propertyCondition, value)
case 'select':
return selectConditionMet(propertyCondition, value)
case 'date':
return dateConditionMet(propertyCondition, value)
case 'multi_select':
return multiSelectConditionMet(propertyCondition, value)
case 'files':
return filesConditionMet(propertyCondition, value)
}
return false
}
function checkEquals (condition, fieldValue) {
return condition.value === fieldValue
}
function checkContains (condition, fieldValue) {
return (fieldValue) ? fieldValue.includes(condition.value) : false
}
function checkListContains (condition, fieldValue) {
return (fieldValue && fieldValue.length > 0) ? condition.value.every(r => fieldValue.includes(r)) : false
}
function checkStartsWith (condition, fieldValue) {
return fieldValue.startsWith(condition.value)
}
function checkendsWith (condition, fieldValue) {
return fieldValue && fieldValue.endsWith(condition.value)
}
function checkIsEmpty (condition, fieldValue) {
return (!fieldValue || fieldValue.length === 0)
}
function checkGreaterThan (condition, fieldValue) {
return (condition.value && fieldValue && parseFloat(fieldValue) > parseFloat(condition.value))
}
function checkGreaterThanEqual (condition, fieldValue) {
return (condition.value && fieldValue && parseFloat(fieldValue) >= parseFloat(condition.value))
}
function checkLessThan (condition, fieldValue) {
return (condition.value && fieldValue && parseFloat(fieldValue) < parseFloat(condition.value))
}
function checkLessThanEqual (condition, fieldValue) {
return (condition.value && fieldValue && parseFloat(fieldValue) <= parseFloat(condition.value))
}
function checkBefore (condition, fieldValue) {
return (condition.value && fieldValue && fieldValue > condition.value)
}
function checkAfter (condition, fieldValue) {
return (condition.value && fieldValue && fieldValue < condition.value)
}
function checkOnOrBefore (condition, fieldValue) {
return (condition.value && fieldValue && fieldValue >= condition.value)
}
function checkOnOrAfter (condition, fieldValue) {
return (condition.value && fieldValue && fieldValue <= condition.value)
}
function checkPastWeek (condition, fieldValue) {
if (!fieldValue) return false
const fieldDate = new Date(fieldValue)
const today = new Date()
return (fieldDate <= today && fieldDate >= new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7))
}
function checkPastMonth (condition, fieldValue) {
if (!fieldValue) return false
const fieldDate = new Date(fieldValue)
const today = new Date()
return (fieldDate <= today && fieldDate >= new Date(today.getFullYear(), today.getMonth() - 1, today.getDate()))
}
function checkPastYear (condition, fieldValue) {
if (!fieldValue) return false
const fieldDate = new Date(fieldValue)
const today = new Date()
return (fieldDate <= today && fieldDate >= new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()))
}
function checkNextWeek (condition, fieldValue) {
if (!fieldValue) return false
const fieldDate = new Date(fieldValue)
const today = new Date()
return (fieldDate >= today && fieldDate <= new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7))
}
function checkNextMonth (condition, fieldValue) {
if (!fieldValue) return false
const fieldDate = new Date(fieldValue)
const today = new Date()
return (fieldDate >= today && fieldDate <= new Date(today.getFullYear(), today.getMonth() + 1, today.getDate()))
}
function checkNextYear (condition, fieldValue) {
if (!fieldValue) return false
const fieldDate = new Date(fieldValue)
const today = new Date()
return (fieldDate >= today && fieldDate <= new Date(today.getFullYear() + 1, today.getMonth(), today.getDate()))
}
function textConditionMet (propertyCondition, value) {
switch (propertyCondition.operator) {
case 'equals':
return checkEquals(propertyCondition, value)
case 'does_not_equal':
return !checkEquals(propertyCondition, value)
case 'contains':
return checkContains(propertyCondition, value)
case 'does_not_contain':
return !checkContains(propertyCondition, value)
case 'starts_with':
return checkStartsWith(propertyCondition, value)
case 'ends_with':
return checkendsWith(propertyCondition, value)
case 'is_empty':
return checkIsEmpty(propertyCondition, value)
case 'is_not_empty':
return !checkIsEmpty(propertyCondition, value)
}
return false
}
function numberConditionMet (propertyCondition, value) {
switch (propertyCondition.operator) {
case 'equals':
return checkEquals(propertyCondition, value)
case 'does_not_equal':
return !checkEquals(propertyCondition, value)
case 'greater_than':
return checkGreaterThan(propertyCondition, value)
case 'less_than':
return checkLessThan(propertyCondition, value)
case 'greater_than_or_equal_to':
return checkGreaterThanEqual(propertyCondition, value)
case 'less_than_or_equal_to':
return checkLessThanEqual(propertyCondition, value)
case 'is_empty':
return checkIsEmpty(propertyCondition, value)
case 'is_not_empty':
return !checkIsEmpty(propertyCondition, value)
}
return false
}
function checkboxConditionMet (propertyCondition, value) {
switch (propertyCondition.operator) {
case 'equals':
return checkEquals(propertyCondition, value)
case 'does_not_equal':
return !checkEquals(propertyCondition, value)
}
return false
}
function selectConditionMet (propertyCondition, value) {
switch (propertyCondition.operator) {
case 'equals':
return checkEquals(propertyCondition, value)
case 'does_not_equal':
return !checkEquals(propertyCondition, value)
case 'is_empty':
return checkIsEmpty(propertyCondition, value)
case 'is_not_empty':
return !checkIsEmpty(propertyCondition, value)
}
return false
}
function dateConditionMet (propertyCondition, value) {
switch (propertyCondition.operator) {
case 'equals':
return checkEquals(propertyCondition, value)
case 'before':
return checkBefore(propertyCondition, value)
case 'after':
return checkAfter(propertyCondition, value)
case 'on_or_before':
return checkOnOrBefore(propertyCondition, value)
case 'on_or_after':
return checkOnOrAfter(propertyCondition, value)
case 'is_empty':
return checkIsEmpty(propertyCondition, value)
case 'past_week':
return checkPastWeek(propertyCondition, value)
case 'past_month':
return checkPastMonth(propertyCondition, value)
case 'past_year':
return checkPastYear(propertyCondition, value)
case 'next_week':
return checkNextWeek(propertyCondition, value)
case 'next_month':
return checkNextMonth(propertyCondition, value)
case 'next_year':
return checkNextYear(propertyCondition, value)
}
return false
}
function multiSelectConditionMet (propertyCondition, value) {
switch (propertyCondition.operator) {
case 'contains':
return checkListContains(propertyCondition, value)
case 'does_not_contain':
return !checkListContains(propertyCondition, value)
case 'is_empty':
return checkIsEmpty(propertyCondition, value)
case 'is_not_empty':
return !checkIsEmpty(propertyCondition, value)
}
return false
}
function filesConditionMet (propertyCondition, value) {
switch (propertyCondition.operator) {
case 'is_empty':
return checkIsEmpty(propertyCondition, value)
case 'is_not_empty':
return !checkIsEmpty(propertyCondition, value)
}
return false
}

View File

@@ -0,0 +1,45 @@
import { conditionsMet } from './FormLogicConditionChecker'
class FormLogicPropertyResolver {
conditionsMet = conditionsMet;
property = null;
formData = null;
logic = false;
constructor (property, formData) {
this.property = property
this.formData = formData
this.logic = (property.logic !== undefined) ? property.logic : false
}
isHidden () {
if (!this.logic) {
return this.property.hidden
}
const conditionsMet = this.conditionsMet(this.logic.conditions, this.formData)
if (conditionsMet && this.property.hidden && this.logic.actions.length > 0 && this.logic.actions.includes('show-block')) {
return false
} else if (conditionsMet && !this.property.hidden && this.logic.actions.length > 0 && this.logic.actions.includes('hide-block')) {
return true
} else {
return this.property.hidden
}
}
isRequired () {
if (!this.logic) {
return this.property.required
}
const conditionsMet = this.conditionsMet(this.logic.conditions, this.formData)
if (conditionsMet && this.property.required && this.logic.actions.length > 0 && this.logic.actions.includes('make-it-optional')) {
return false
} else if (conditionsMet && !this.property.required && this.logic.actions.length > 0 && this.logic.actions.includes('require-answer')) {
return true
} else {
return this.property.required
}
}
}
export default FormLogicPropertyResolver

39
resources/js/lang/en.json Normal file
View File

@@ -0,0 +1,39 @@
{
"ok": "Ok",
"cancel": "Cancel",
"error_alert_title": "Oops...",
"error_alert_text": "Something went wrong! Please try again.",
"token_expired_alert_title": "Session Expired!",
"token_expired_alert_text": "Please log in again to continue.",
"login": "Log In",
"register": "Register",
"page_not_found": "Page Not Found",
"go_home": "Go Home",
"logout": "Logout",
"email": "Email",
"remember_me": "Remember Me",
"password": "Password",
"forgot_password": "Forgot Your Password?",
"confirm_password": "Confirm Password",
"name": "Name",
"toggle_navigation": "Toggle navigation",
"home": "Home",
"you_are_logged_in": "You are logged in!",
"reset_password": "Reset Password",
"send_password_reset_link": "Send Password Reset Link",
"settings": "Settings",
"profile": "Profile",
"your_info": "Your Info",
"info_updated": "Your info has been updated!",
"update": "Update",
"your_password": "Your Password",
"password_updated": "Your password has been updated!",
"new_password": "New Password",
"login_with": "Login with",
"register_with": "Register with",
"verify_email": "Verify Email",
"send_verification_link": "Send Verification Link",
"resend_verification_link": "Resend Verification Link ?",
"failed_to_verify_email": "Failed to verify email.",
"verify_email_address": "We sent you an email with an the verification link."
}

34
resources/js/lang/es.json Normal file
View File

@@ -0,0 +1,34 @@
{
"ok": "De Acuerdo",
"cancel": "Cancelar",
"error_alert_title": "Ha ocurrido un problema",
"error_alert_text": "¡Algo salió mal! Inténtalo de nuevo.",
"token_expired_alert_title": "!Sesión Expirada!",
"token_expired_alert_text": "Por favor inicie sesión de nuevo para continuar.",
"login": "Iniciar Sesión",
"register": "Registro",
"page_not_found": "Página No Encontrada",
"go_home": "Ir a Inicio",
"logout": "Cerrar Sesión",
"email": "Correo Electrónico",
"remember_me": "Recuérdame",
"password": "Contraseña",
"forgot_password": "¿Olvidaste tu contraseña?",
"confirm_password": "Confirmar Contraseña",
"name": "Nombre",
"toggle_navigation": "Cambiar Navegación",
"home": "Inicio",
"you_are_logged_in": "¡Has iniciado sesión!",
"reset_password": "Restablecer la contraseña",
"send_password_reset_link": "Enviar Enlace de Restablecimiento de Contraseña",
"settings": "Configuraciones",
"profile": "Perfil",
"your_info": "Tu Información",
"info_updated": "¡Tu información ha sido actualizada!",
"update": "Actualizar",
"your_password": "Tu Contraseña",
"password_updated": "¡Tu contraseña ha sido actualizada!",
"new_password": "Nueva Contraseña",
"login_with": "Iniciar Sesión con",
"register_with": "Registro con"
}

39
resources/js/lang/fr.json Normal file
View File

@@ -0,0 +1,39 @@
{
"ok": "Ok",
"cancel": "Annuler",
"error_alert_title": "Oups...",
"error_alert_text": "Quelque chose a mal tourné ! Veuillez réessayer.",
"token_expired_alert_title": "Session expirée !",
"token_expired_alert_text": "Veuillez vous reconnecter pour continuer.",
"login": "Connexion",
"register": "Inscription",
"page_not_found": "Page non trouvée",
"go_home": "Retour à l'accueil",
"logout": "Déconnexion",
"email": "Email",
"remember_me": "Se souvenir de moi",
"password": "Mot de passe",
"forgot_password": "Vous avez oublié votre mot de passe ?",
"confirm_password": "Confirmer le mot de passe",
"name": "Nom",
"toggle_navigation": "Basculer la navigation",
"home": "Accueil",
"you_are_logged_in": "Vous êtes connecté !",
"reset_password": "Réinitialisation du mot de passe",
"send_password_reset_link": "Envoyer le lien de réinitialisation du mot de passe",
"settings": "Paramètres",
"profile": "Profil",
"your_info": "Vos informations",
"info_updated": "Vos informations ont été mises à jour !",
"update": "Mettre à jour",
"your_password": "Votre mot de passe",
"password_updated": "Votre mot de passe a été mis à jour !",
"new_password": "Nouveau mot de passe",
"login_with": "Connectez-vous avec",
"register_with": "S'inscrire avec",
"verify_email": "Vérifier l'e-mail",
"send_verification_link": "Envoyer le lien de vérification",
"resend_verification_link": "Renvoyer le lien de vérification ?",
"failed_to_verify_email": "Nous n'avons pas réussi à vérifier votre email.",
"verify_email_address": "Nous vous avons envoyé un e-mail avec un lien de vérification."
}

View File

@@ -0,0 +1,39 @@
{
"ok": "Ok",
"cancel": "Cancelar",
"error_alert_title": "Oops...",
"error_alert_text": "Algo deu errado! Por favor, tente novamente.",
"token_expired_alert_title": "Sessão expirada!",
"token_expired_alert_text": "Faça login novamente para continuar.",
"login": "Entrar",
"register": "Cadastrar",
"page_not_found": "Página não encontrada",
"go_home": "Inicio",
"logout": "Sair",
"email": "Email",
"remember_me": "Lembre-me",
"password": "Senha",
"forgot_password": "Esqueceu sua senha?",
"confirm_password": "Confirmar Senha",
"name": "Nome",
"toggle_navigation": "Alternar de navegação",
"home": "Inicio",
"you_are_logged_in": "Você está logado!",
"reset_password": "Trocar Senha",
"send_password_reset_link": "Enviar link de redefinição de senha",
"settings": "Configurações",
"profile": "Perfil",
"your_info": "Suas informações",
"info_updated": "Suas informações foram atualizadas!",
"update": "Atualizar",
"your_password": "Sua senha",
"password_updated": "Sua senha foi atualizada!",
"new_password": "Nova Senha",
"login_with": "Entrar",
"register_with": "Registre-se",
"verify_email": "verificar email",
"send_verification_link": "Enviar link de verificação",
"resend_verification_link": "Reenviar link de verificação?",
"failed_to_verify_email": "Falha ao verificar o email.",
"verify_email_address": "Enviamos um e-mail com o link de verificação."
}

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