Initial commit
This commit is contained in:
402
resources/data/open_filters.json
Normal file
402
resources/data/open_filters.json
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1373
resources/data/timezones.json
Normal file
1373
resources/data/timezones.json
Normal file
File diff suppressed because it is too large
Load Diff
29
resources/js/app.js
vendored
Normal file
29
resources/js/app.js
vendored
Normal 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
98
resources/js/base.js
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
resources/js/components/App.vue
Normal file
142
resources/js/components/App.vue
Normal 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>
|
||||
13
resources/js/components/Child.vue
Normal file
13
resources/js/components/Child.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<transition name="page" mode="out-in">
|
||||
<slot>
|
||||
<router-view />
|
||||
</slot>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Child'
|
||||
}
|
||||
</script>
|
||||
101
resources/js/components/Loading.vue
Normal file
101
resources/js/components/Loading.vue
Normal 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>
|
||||
41
resources/js/components/LocaleDropdown.vue
Normal file
41
resources/js/components/LocaleDropdown.vue
Normal 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>
|
||||
92
resources/js/components/LoginWithGithub.vue
Normal file
92
resources/js/components/LoginWithGithub.vue
Normal 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>
|
||||
133
resources/js/components/Modal.vue
Normal file
133
resources/js/components/Modal.vue
Normal 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>
|
||||
221
resources/js/components/Navbar.vue
Normal file
221
resources/js/components/Navbar.vue
Normal 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>
|
||||
99
resources/js/components/WorkspaceDropdown.vue
Normal file
99
resources/js/components/WorkspaceDropdown.vue
Normal 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>
|
||||
95
resources/js/components/common/Alert.vue
Normal file
95
resources/js/components/common/Alert.vue
Normal 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>
|
||||
41
resources/js/components/common/Breadcrumb.vue
Normal file
41
resources/js/components/common/Breadcrumb.vue
Normal 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>
|
||||
100
resources/js/components/common/Button.vue
Normal file
100
resources/js/components/common/Button.vue
Normal 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>
|
||||
27
resources/js/components/common/Card.vue
Normal file
27
resources/js/components/common/Card.vue
Normal 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>
|
||||
40
resources/js/components/common/Collapse.vue
Normal file
40
resources/js/components/common/Collapse.vue
Normal 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>
|
||||
53
resources/js/components/common/Dropdown.vue
Normal file
53
resources/js/components/common/Dropdown.vue
Normal 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>
|
||||
57
resources/js/components/common/EditableDiv.vue
Normal file
57
resources/js/components/common/EditableDiv.vue
Normal 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>
|
||||
103
resources/js/components/common/FancyLink.vue
Normal file
103
resources/js/components/common/FancyLink.vue
Normal 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>
|
||||
13
resources/js/components/common/Loader.vue
Normal file
13
resources/js/components/common/Loader.vue
Normal 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>
|
||||
79
resources/js/components/common/ProTag.vue
Normal file
79
resources/js/components/common/ProTag.vue
Normal 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>
|
||||
184
resources/js/components/common/ScrollShadow.vue
Normal file
184
resources/js/components/common/ScrollShadow.vue
Normal 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>
|
||||
44
resources/js/components/common/Steps.vue
Normal file
44
resources/js/components/common/Steps.vue
Normal 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
15
resources/js/components/common/index.js
vendored
Normal 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)
|
||||
})
|
||||
19
resources/js/components/common/transitions/VTransition.vue
Normal file
19
resources/js/components/common/transitions/VTransition.vue
Normal 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>
|
||||
29
resources/js/components/forms/CheckboxInput.vue
Normal file
29
resources/js/components/forms/CheckboxInput.vue
Normal 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>
|
||||
53
resources/js/components/forms/CodeInput.vue
Normal file
53
resources/js/components/forms/CodeInput.vue
Normal 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>
|
||||
25
resources/js/components/forms/ColorInput.vue
Normal file
25
resources/js/components/forms/ColorInput.vue
Normal 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>
|
||||
78
resources/js/components/forms/DateInput.vue
Normal file
78
resources/js/components/forms/DateInput.vue
Normal 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>
|
||||
244
resources/js/components/forms/FileInput.vue
Normal file
244
resources/js/components/forms/FileInput.vue
Normal 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>
|
||||
86
resources/js/components/forms/FlatSelectInput.vue
Normal file
86
resources/js/components/forms/FlatSelectInput.vue
Normal 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>
|
||||
188
resources/js/components/forms/ImageInput.vue
Normal file
188
resources/js/components/forms/ImageInput.vue
Normal 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>
|
||||
67
resources/js/components/forms/RatingInput.vue
Normal file
67
resources/js/components/forms/RatingInput.vue
Normal 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>
|
||||
70
resources/js/components/forms/RichTextAreaInput.vue
Normal file
70
resources/js/components/forms/RichTextAreaInput.vue
Normal 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>
|
||||
109
resources/js/components/forms/SelectInput.vue
Normal file
109
resources/js/components/forms/SelectInput.vue
Normal 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>
|
||||
29
resources/js/components/forms/TextAreaInput.vue
Normal file
29
resources/js/components/forms/TextAreaInput.vue
Normal 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>
|
||||
95
resources/js/components/forms/TextInput.vue
Normal file
95
resources/js/components/forms/TextInput.vue
Normal 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>
|
||||
88
resources/js/components/forms/components/VCheckbox.vue
Normal file
88
resources/js/components/forms/components/VCheckbox.vue
Normal 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>
|
||||
223
resources/js/components/forms/components/VSelect.vue
Normal file
223
resources/js/components/forms/components/VSelect.vue
Normal 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>
|
||||
79
resources/js/components/forms/components/VSwitch.vue
Normal file
79
resources/js/components/forms/components/VSwitch.vue
Normal 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
40
resources/js/components/forms/index.js
vendored
Normal 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)
|
||||
})
|
||||
21
resources/js/components/forms/validation/Alert.js
vendored
Normal file
21
resources/js/components/forms/validation/Alert.js
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
resources/js/components/forms/validation/AlertError.vue
Normal file
29
resources/js/components/forms/validation/AlertError.vue
Normal 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">×</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>
|
||||
37
resources/js/components/forms/validation/AlertSuccess.vue
Normal file
37
resources/js/components/forms/validation/AlertSuccess.vue
Normal 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>
|
||||
43
resources/js/components/forms/validation/HasError.vue
Normal file
43
resources/js/components/forms/validation/HasError.vue
Normal 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
17
resources/js/components/index.js
vendored
Normal 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)
|
||||
})
|
||||
52
resources/js/components/open/NotionPage.vue
Normal file
52
resources/js/components/open/NotionPage.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<notion-renderer v-if="!loading" :block-map="blockMap" />
|
||||
<div v-else class="my-10 py-20 flex items-center justify-center">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NotionRenderer, getPageBlocks } from 'vue-notion'
|
||||
|
||||
export default {
|
||||
name: 'NotionPage',
|
||||
components: { NotionRenderer },
|
||||
props: {
|
||||
pageId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
blockMap: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
apiUrl: () => window.config.notion.worker
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
// get Notion blocks from the API via a Notion pageId
|
||||
this.loading = true
|
||||
getPageBlocks(this.pageId, this.apiUrl).then((blocks) => {
|
||||
this.blockMap = blocks
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "vue-notion/src/styles.css"; /* optional Notion-like styles */
|
||||
|
||||
.notion-blue {
|
||||
@apply text-nt-blue;
|
||||
}
|
||||
</style>
|
||||
239
resources/js/components/open/forms/OpenCompleteForm.vue
Normal file
239
resources/js/components/open/forms/OpenCompleteForm.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div v-if="form" class="open-complete-form">
|
||||
<h1 v-if="!form.hide_title" class="mb-4 px-2" v-text="form.title" />
|
||||
|
||||
<div v-if="isPublicFormPage && form.is_password_protected">
|
||||
<p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2">
|
||||
This form is protected by a password.
|
||||
</p>
|
||||
<div class="form-group flex flex-wrap w-full">
|
||||
<div class="relative mb-3 w-full px-2">
|
||||
<text-input :form="passwordForm" name="password" native-type="password" label="Password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center w-full text-center">
|
||||
<v-button @click="passwordEntered">
|
||||
Submit
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-transition>
|
||||
<div v-if="!form.is_password_protected && form.password && !hidePasswordDisabledMsg"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
|
||||
>
|
||||
<div class="flex flex-grow">
|
||||
<p class="mb-0 py-2 px-4 text-yellow-600">
|
||||
We disabled the password protection for this form because you are an owner of it.
|
||||
</p>
|
||||
<v-button color="yellow" @click="hidePasswordDisabledMsg=true">
|
||||
OK
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-transition>
|
||||
|
||||
<div v-if="isPublicFormPage && form.is_closed"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<p class="mb-0 py-2 px-4 text-yellow-600" v-html="form.closed_text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPublicFormPage && form.max_number_of_submissions_reached"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<p class="mb-0 py-2 px-4 text-yellow-600" v-html="form.max_submissions_reached_text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getFormCleaningsMsg"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<p class="mb-0 py-2 px-4 text-yellow-600">
|
||||
You're seeing this because you are an owner of this form. <br>
|
||||
All your Pro features are de-activated when sharing this form: <br>
|
||||
|
||||
<span v-html="getFormCleaningsMsg" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<v-button color="yellow" shade="light" @click="form.cleanings=false">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached))"
|
||||
enter-active-class="duration-500 ease-out"
|
||||
enter-class="translate-x-full opacity-0"
|
||||
enter-to-class="translate-x-0 opacity-100"
|
||||
leave-active-class="duration-500 ease-in"
|
||||
leave-class="translate-x-0 opacity-100"
|
||||
leave-to-class="translate-x-full opacity-0"
|
||||
mode="out-in"
|
||||
>
|
||||
<div v-if="!submitted" key="form">
|
||||
<p v-if="form.description && form.description !==''"
|
||||
class="form-description mb-4 text-gray-700 dark:text-gray-300 whitespace-pre-wrap px-2"
|
||||
v-html="form.description"
|
||||
/>
|
||||
<open-form v-if="form"
|
||||
:form="form"
|
||||
:loading="loading"
|
||||
:fields="form.properties"
|
||||
:theme="theme"
|
||||
@submit="submitForm"
|
||||
>
|
||||
<template #submit-btn="{submitForm}">
|
||||
<open-form-button :loading="loading" :theme="theme" :color="form.color" class="mt-2 px-8 mx-1"
|
||||
@click="submitForm"
|
||||
>
|
||||
{{ form.submit_button_text }}
|
||||
</open-form-button>
|
||||
</template>
|
||||
</open-form>
|
||||
<p v-if="!form.no_branding" class="text-center w-full mt-2">
|
||||
<a href="https://opnform.com"
|
||||
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs"
|
||||
target="_blank"
|
||||
>Powered by OpnForm</a>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else key="submitted" class="px-2">
|
||||
<p class="form-description text-gray-700 dark:text-gray-300 whitespace-pre-wrap" v-html="form.submitted_text " />
|
||||
<open-form-button v-if="form.re_fillable" :theme="theme" :color="form.color" class="my-4" @click="restart">
|
||||
{{ form.re_fill_button_text }}
|
||||
</open-form-button>
|
||||
<p v-if="!form.no_branding" class="mt-5">
|
||||
<a target="_parent" href="https://opnform.com/" class="text-nt-blue hover:underline">Create your form for free with OpnForm</a>
|
||||
</p>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import OpenForm from './OpenForm'
|
||||
import OpenFormButton from './OpenFormButton'
|
||||
import { themes } from '~/config/form-themes'
|
||||
import VButton from '../../common/Button'
|
||||
import VTransition from '../../common/transitions/VTransition'
|
||||
|
||||
export default {
|
||||
components: { VTransition, VButton, OpenFormButton, OpenForm },
|
||||
|
||||
props: {
|
||||
form: { type: Object, required: true },
|
||||
creating: { type: Boolean, default: false } // If true, fake form submit
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
submitted: false,
|
||||
themes: themes,
|
||||
passwordForm: new Form({
|
||||
password: null
|
||||
}),
|
||||
hidePasswordDisabledMsg: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
isIframe () {
|
||||
return window.location !== window.parent.location || window.frameElement
|
||||
},
|
||||
theme () {
|
||||
return this.themes[this.themes.hasOwnProperty(this.form.theme) ? this.form.theme : 'default']
|
||||
},
|
||||
getFormCleaningsMsg () {
|
||||
if (this.form.cleanings && Object.keys(this.form.cleanings).length > 0) {
|
||||
let message = ''
|
||||
Object.keys(this.form.cleanings).forEach((key) => {
|
||||
const fieldName = key.charAt(0).toUpperCase() + key.slice(1)
|
||||
let fieldInfo = '<br/>' + fieldName + "<br/><ul class='list-disc list-inside'>"
|
||||
this.form.cleanings[key].forEach((msg) => {
|
||||
fieldInfo = fieldInfo + '<li>' + msg + '</li>'
|
||||
})
|
||||
message = message + fieldInfo + '<ul/>'
|
||||
})
|
||||
|
||||
return message
|
||||
}
|
||||
return false
|
||||
},
|
||||
isPublicFormPage () {
|
||||
return this.$route.name === 'forms.show_public'
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
submitForm (form, onFailure) {
|
||||
if (this.creating) {
|
||||
this.submitted = true
|
||||
this.$emit('submitted', true)
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.closeAlert()
|
||||
form.post('/api/forms/' + this.form.slug + '/answer').then((response) => {
|
||||
this.$logEvent('form_submission', {
|
||||
workspace_id: this.form.workspace_id,
|
||||
form_id: this.form.id
|
||||
})
|
||||
if (response.data.redirect && response.data.redirect_url) {
|
||||
window.location.href = response.data.redirect_url
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
this.submitted = true
|
||||
this.$emit('submitted', true)
|
||||
}).catch((error) => {
|
||||
if (error.response.data && error.response.data.message) {
|
||||
this.alertError(error.response.data.message)
|
||||
}
|
||||
this.loading = false
|
||||
onFailure()
|
||||
})
|
||||
},
|
||||
restart () {
|
||||
this.submitted = false
|
||||
this.$emit('restarted', true)
|
||||
},
|
||||
passwordEntered () {
|
||||
if (this.passwordForm.password !== '' && this.passwordForm.password !== null) {
|
||||
this.$emit('password-entered', this.passwordForm.password)
|
||||
} else {
|
||||
this.addPasswordError('The Password field is required.')
|
||||
}
|
||||
},
|
||||
addPasswordError (msg) {
|
||||
this.passwordForm.errors.set('password', msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.open-complete-form {
|
||||
.form-description {
|
||||
ol {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc list-inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
398
resources/js/components/open/forms/OpenForm.vue
Normal file
398
resources/js/components/open/forms/OpenForm.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<form v-if="dataForm" @submit.prevent="">
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<template v-for="group, groupIndex in fieldGroups">
|
||||
<div v-if="currentFieldGroupIndex===groupIndex" :key="groupIndex" class="form-group flex flex-wrap w-full">
|
||||
<template v-for="field in group">
|
||||
<component :is="getFieldComponents(field)" v-if="getFieldComponents(field)"
|
||||
:key="field.id + formVersionId" :class="getFieldClasses(field)"
|
||||
v-bind="inputProperties(field)" :required="isFieldRequired[field.id]"
|
||||
/>
|
||||
<template v-else>
|
||||
<div v-if="field.type === 'nf-text' && field.content" :id="field.id" :key="field.id" class="nf-text w-full px-2 mb-3"
|
||||
v-html="field.content"
|
||||
/>
|
||||
<div v-if="field.type === 'nf-divider'" :id="field.id" :key="field.id" class="border-b my-4 w-full mx-2" />
|
||||
<div v-if="field.type === 'nf-image' && (field.image_block || !isPublicFormPage)" :id="field.id" :key="field.id" class="my-4 w-full px-2">
|
||||
<div v-if="!field.image_block" class="p-4 border border-dashed">
|
||||
Open <b>{{ field.name }}'s</b> block settings to upload image.
|
||||
</div>
|
||||
<img v-else :alt="field.name" :src="field.image_block" class="max-w-full">
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</transition>
|
||||
|
||||
<!-- Captcha -->
|
||||
<template v-if="form.use_captcha && isLastPage">
|
||||
<div class="mb-3 px-2 mt-2 mx-auto w-max">
|
||||
<vue-hcaptcha ref="hcaptcha" :sitekey="hCaptchaSiteKey" :theme="darkModeEnabled?'dark':'light'" />
|
||||
<has-error :form="dataForm" field="h-captcha-response" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Submit, Next and previous buttons -->
|
||||
<div class="flex flex-wrap justify-center w-full">
|
||||
<open-form-button v-if="currentFieldGroupIndex>0 && previousFieldsPageBreak && !loading" native-type="button"
|
||||
:color="form.color" :theme="theme" class="mt-2 px-8 mx-1" @click="previousPage"
|
||||
>
|
||||
{{ previousFieldsPageBreak.previous_btn_text }}
|
||||
</open-form-button>
|
||||
|
||||
<slot v-if="isLastPage" name="submit-btn" :submitForm="submitForm" />
|
||||
<open-form-button v-else native-type="button" :color="form.color" :theme="theme" class="mt-2 px-8 mx-1"
|
||||
@click="nextPage"
|
||||
>
|
||||
{{ currentFieldsPageBreak.next_btn_text }}
|
||||
</open-form-button>
|
||||
<div v-if="!currentFieldsPageBreak && !isLastPage">
|
||||
Something is wrong with this form structure. If you're the form owner please contact us.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import OpenFormButton from './OpenFormButton'
|
||||
import clonedeep from 'clone-deep'
|
||||
import FormLogicPropertyResolver from '../../../forms/FormLogicPropertyResolver'
|
||||
const VueHcaptcha = () => import('@hcaptcha/vue-hcaptcha')
|
||||
|
||||
export default {
|
||||
name: 'OpenForm',
|
||||
components: { OpenFormButton, VueHcaptcha },
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
showHidden: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
dataForm: null,
|
||||
currentFieldGroupIndex: 0,
|
||||
|
||||
/**
|
||||
* Used to force refresh components by changing their keys
|
||||
*/
|
||||
formVersionId: 1,
|
||||
darkModeEnabled: document.body.classList.contains('dark')
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hCaptchaSiteKey: () => window.config.hCaptchaSiteKey,
|
||||
actualFields () {
|
||||
return this.fields.filter((field) => {
|
||||
return this.showHidden || !this.isFieldHidden[field.id]
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Create field groups (or Page) using page breaks if any
|
||||
*/
|
||||
fieldGroups () {
|
||||
if (!this.actualFields) return []
|
||||
const groups = []
|
||||
let currentGroup = []
|
||||
this.actualFields.forEach((field) => {
|
||||
currentGroup.push(field)
|
||||
if (field.type === 'nf-page-break') {
|
||||
groups.push(currentGroup)
|
||||
currentGroup = []
|
||||
}
|
||||
})
|
||||
groups.push(currentGroup)
|
||||
return groups
|
||||
},
|
||||
currentFields () {
|
||||
return this.fieldGroups[this.currentFieldGroupIndex]
|
||||
},
|
||||
/**
|
||||
* Returns the page break block for the current group of fields
|
||||
*/
|
||||
currentFieldsPageBreak () {
|
||||
const block = this.currentFields[this.currentFields.length - 1]
|
||||
if (block && block.type === 'nf-page-break') return block
|
||||
return null
|
||||
},
|
||||
previousFieldsPageBreak () {
|
||||
if (this.currentFieldGroupIndex === 0) return null
|
||||
const previousFields = this.fieldGroups[this.currentFieldGroupIndex - 1]
|
||||
const block = previousFields[previousFields.length - 1]
|
||||
if (block && block.type === 'nf-page-break') return block
|
||||
return null
|
||||
},
|
||||
/**
|
||||
* Returns true if we're on the last page
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLastPage () {
|
||||
return this.currentFieldGroupIndex === (this.fieldGroups.length - 1)
|
||||
},
|
||||
fieldComponents () {
|
||||
return {
|
||||
text: 'TextInput',
|
||||
number: 'TextInput',
|
||||
select: 'SelectInput',
|
||||
multi_select: 'SelectInput',
|
||||
date: 'DateInput',
|
||||
files: 'FileInput',
|
||||
checkbox: 'CheckboxInput',
|
||||
url: 'TextInput',
|
||||
email: 'TextInput',
|
||||
phone_number: 'TextInput',
|
||||
}
|
||||
},
|
||||
isPublicFormPage () {
|
||||
return this.$route.name === 'forms.show_public'
|
||||
},
|
||||
dataFormValue () {
|
||||
// For get values instead of Id for select/multi select options
|
||||
const data = this.dataForm.data()
|
||||
const selectionFields = this.fields.filter((field) => {
|
||||
return ['select', 'multi_select'].includes(field.type)
|
||||
})
|
||||
selectionFields.forEach((field) => {
|
||||
if (data[field.id] !== undefined && data[field.id] !== null && Array.isArray(data[field.id])) {
|
||||
data[field.id] = data[field.id].map(option_nfid => {
|
||||
const tmpop = field[field.type].options.find((op) => { return (op.id === option_nfid) })
|
||||
return (tmpop) ? tmpop.name : option_nfid
|
||||
})
|
||||
}
|
||||
})
|
||||
return data
|
||||
},
|
||||
isFieldHidden () {
|
||||
const fieldsHidden = {}
|
||||
this.fields.forEach((field) => {
|
||||
fieldsHidden[field.id] = (new FormLogicPropertyResolver(field, this.dataFormValue)).isHidden()
|
||||
})
|
||||
return fieldsHidden
|
||||
},
|
||||
isFieldRequired () {
|
||||
const fieldsRequired = {}
|
||||
this.fields.forEach((field) => {
|
||||
fieldsRequired[field.id] = (new FormLogicPropertyResolver(field, this.dataFormValue)).isRequired()
|
||||
})
|
||||
return fieldsRequired
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
form: {
|
||||
deep: true,
|
||||
handler () {
|
||||
this.initForm()
|
||||
}
|
||||
},
|
||||
fields: {
|
||||
deep: true,
|
||||
handler () {
|
||||
this.initForm()
|
||||
}
|
||||
},
|
||||
theme: {
|
||||
handler () {
|
||||
this.formVersionId++
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.initForm()
|
||||
},
|
||||
|
||||
methods: {
|
||||
submitForm () {
|
||||
if (this.currentFieldGroupIndex !== this.fieldGroups.length - 1) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.form.use_captcha) {
|
||||
this.dataForm['h-captcha-response'] = document.getElementsByName('h-captcha-response')[0].value
|
||||
this.$refs.hcaptcha.reset()
|
||||
}
|
||||
|
||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||
},
|
||||
/**
|
||||
* If more than one page, show first page with error
|
||||
*/
|
||||
onSubmissionFailure () {
|
||||
if (this.fieldGroups.length > 1) {
|
||||
// Find first mistake and show page
|
||||
let pageChanged = false
|
||||
this.fieldGroups.forEach((group, groupIndex) => {
|
||||
group.forEach((field) => {
|
||||
if (pageChanged) return
|
||||
|
||||
if (!pageChanged && this.dataForm.errors.has(field.id)) {
|
||||
this.currentFieldGroupIndex = groupIndex
|
||||
pageChanged = true
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Scroll to error
|
||||
const elements = document.getElementsByClassName('has-error')
|
||||
if (elements.length > 0) {
|
||||
window.scroll({
|
||||
top: window.scrollY + elements[0].getBoundingClientRect().top - 60,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
},
|
||||
initForm () {
|
||||
const formData = clonedeep(this.dataForm ? this.dataForm.data() : {})
|
||||
let urlPrefill = null
|
||||
if (this.isPublicFormPage && this.form.is_pro) {
|
||||
urlPrefill = new URLSearchParams(window.location.search)
|
||||
}
|
||||
|
||||
this.fields.forEach((field) => {
|
||||
if (field.type.startsWith('nf-')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (urlPrefill && urlPrefill.has(field.id)) {
|
||||
// Url prefills
|
||||
if (field.type === 'checkbox') {
|
||||
if (urlPrefill.get(field.id) === 'false' || urlPrefill.get(field.id) === '0') {
|
||||
formData[field.id] = false
|
||||
} else if (urlPrefill.get(field.id) === 'true' || urlPrefill.get(field.id) === '1') {
|
||||
formData[field.id] = true
|
||||
}
|
||||
} else {
|
||||
formData[field.id] = urlPrefill.get(field.id)
|
||||
}
|
||||
} else if (urlPrefill && urlPrefill.has(field.id + '[]')) {
|
||||
// Array url prefills
|
||||
formData[field.id] = urlPrefill.getAll(field.id + '[]')
|
||||
} else { // Default prefill if any
|
||||
formData[field.id] = field.prefill
|
||||
}
|
||||
|
||||
})
|
||||
this.dataForm = new Form(formData)
|
||||
},
|
||||
/**
|
||||
* Get the right input component for the field/options combination
|
||||
*/
|
||||
getFieldComponents (field) {
|
||||
if (field.type === 'text' && field.multi_lines) {
|
||||
return 'TextAreaInput'
|
||||
}
|
||||
if (field.type === 'url' && field.file_upload) {
|
||||
return 'FileInput'
|
||||
}
|
||||
if (field.type === 'number' && field.is_rating && field.rating_max_value) {
|
||||
return 'RatingInput'
|
||||
}
|
||||
if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) {
|
||||
return 'FlatSelectInput'
|
||||
}
|
||||
return this.fieldComponents[field.type]
|
||||
},
|
||||
getFieldClasses (field) {
|
||||
if (!field.width || field.width === 'full') return 'w-full px-2'
|
||||
else if (field.width === '1/2') {
|
||||
return 'w-full sm:w-1/2 px-2'
|
||||
} else if (field.width === '1/3') {
|
||||
return 'w-full sm:w-1/3 px-2'
|
||||
} else if (field.width === '2/3') {
|
||||
return 'w-full sm:w-2/3 px-2'
|
||||
} else if (field.width === '1/4') {
|
||||
return 'w-full sm:w-1/4 px-2'
|
||||
} else if (field.width === '3/4') {
|
||||
return 'w-full sm:w-3/4 px-2'
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the right input component options for the field/options
|
||||
*/
|
||||
inputProperties (field) {
|
||||
const inputProperties = {
|
||||
key: field.id,
|
||||
name: field.id,
|
||||
form: this.dataForm,
|
||||
label: (field.hide_field_name) ? null : field.name + (this.isFieldHidden[field.id] ? ' (Hidden Field)' : ''),
|
||||
color: this.form.color,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
uppercaseLabels: this.form.uppercase_labels,
|
||||
theme: this.theme,
|
||||
maxCharLimit: (field.max_char_limit) ? parseInt(field.max_char_limit) : 2000,
|
||||
showCharLimit: field.show_char_limit || false
|
||||
}
|
||||
|
||||
if (['select', 'multi_select'].includes(field.type)) {
|
||||
inputProperties.options = (field.hasOwnProperty(field.type))
|
||||
? field[field.type].options.map(option => {
|
||||
return {
|
||||
name: option.name,
|
||||
value: option.name
|
||||
}
|
||||
})
|
||||
: []
|
||||
inputProperties.multiple = (field.type === 'multi_select')
|
||||
inputProperties.allowCreation = (field.allow_creation === true)
|
||||
inputProperties.searchable = (inputProperties.options.length > 4)
|
||||
} else if (field.type === 'date') {
|
||||
if (field.with_time) {
|
||||
inputProperties.withTime = true
|
||||
} else if (field.date_range) {
|
||||
inputProperties.dateRange = true
|
||||
}
|
||||
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
|
||||
inputProperties.multiple = (field.multiple !== undefined && field.multiple)
|
||||
inputProperties.mbLimit = 5
|
||||
inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : ""
|
||||
} else if (field.type === 'number' && field.is_rating) {
|
||||
inputProperties.numberOfStars = parseInt(field.rating_max_value)
|
||||
}
|
||||
|
||||
return inputProperties
|
||||
},
|
||||
previousPage () {
|
||||
this.currentFieldGroupIndex -= 1
|
||||
return false
|
||||
},
|
||||
nextPage () {
|
||||
this.currentFieldGroupIndex += 1
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.nf-text {
|
||||
ol {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc list-inside;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
resources/js/components/open/forms/OpenFormButton.vue
Normal file
84
resources/js/components/open/forms/OpenFormButton.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<button :type="nativeType" :disabled="loading" :class="`py-${sizes['p-y']} px-${sizes['p-x']} text-${sizes['font']} ${theme.Button.body}`" :style="buttonStyle"
|
||||
class="btn" @click="$emit('click',$event)"
|
||||
>
|
||||
<template v-if="!loading">
|
||||
<slot />
|
||||
</template>
|
||||
<loader v-else class="h-6 w-6 text-white mx-auto" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { themes } from '~/config/form-themes'
|
||||
|
||||
export default {
|
||||
name: 'OpenFormButton',
|
||||
|
||||
props: {
|
||||
color: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium'
|
||||
},
|
||||
|
||||
nativeType: {
|
||||
type: String,
|
||||
default: 'submit'
|
||||
},
|
||||
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
theme: { type: Object, default: () => themes.default }
|
||||
},
|
||||
|
||||
computed: {
|
||||
buttonStyle () {
|
||||
return {
|
||||
backgroundColor: this.color,
|
||||
color: this.getTextColor(this.color),
|
||||
'--tw-ring-color': this.color
|
||||
}
|
||||
},
|
||||
sizes () {
|
||||
if (this.size === 'small') {
|
||||
return {
|
||||
font: 'sm',
|
||||
'p-y': '1',
|
||||
'p-x': '2'
|
||||
}
|
||||
}
|
||||
return {
|
||||
font: 'base',
|
||||
'p-y': '2',
|
||||
'p-x': '4'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getTextColor (bgColor, lightColor = '#FFFFFF', darkColor = '#000000') {
|
||||
const color = (bgColor.charAt(0) === '#') ? bgColor.substring(1, 7) : bgColor
|
||||
const r = parseInt(color.substring(0, 2), 16) // hexToR
|
||||
const g = parseInt(color.substring(2, 4), 16) // hexToG
|
||||
const b = parseInt(color.substring(4, 6), 16) // hexToB
|
||||
const uicolors = [r / 255, g / 255, b / 255]
|
||||
const c = uicolors.map((col) => {
|
||||
if (col <= 0.03928) {
|
||||
return col / 12.92
|
||||
}
|
||||
return Math.pow((col + 0.055) / 1.055, 2.4)
|
||||
})
|
||||
const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2])
|
||||
return (L > 0.45) ? darkColor : lightColor
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div
|
||||
class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="select-all text-nt-blue flex-grow">
|
||||
{{ embedCode }}
|
||||
</p>
|
||||
<div class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer" @click="copyToClipboard">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EmbedFormCode',
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
embedCode () {
|
||||
return '<iframe style="border:none;width:100%;" height="' + this.formHeight + 'px" src="' + this.form.share_url + '"></iframe>'
|
||||
},
|
||||
formHeight () {
|
||||
let height = 200
|
||||
if (!this.form.hide_title) {
|
||||
height += 60
|
||||
}
|
||||
height += this.form.properties.filter((property) => {
|
||||
return !property.hidden
|
||||
}).length * 70
|
||||
|
||||
return height
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
copyToClipboard () {
|
||||
const str = this.embedCode
|
||||
const el = document.createElement('textarea')
|
||||
el.value = str
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
157
resources/js/components/open/forms/components/FormEditor.vue
Normal file
157
resources/js/components/open/forms/components/FormEditor.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div v-if="form" id="form-editor" class="w-full flex border-t flex-grow relative overflow-x-hidden">
|
||||
<!-- Form fields selection -->
|
||||
<v-tour name="tutorial" :steps="steps" />
|
||||
<div class="w-full md:w-1/2 lg:w-2/5 border-r relative overflow-y-scroll md:max-w-sm flex-shrink-0">
|
||||
<div class="p-5 bg-blue-50 border-b text-nt-blue-dark md:hidden">
|
||||
We suggest you create this form on a device with a larger screen such as computed. That will allow you
|
||||
to preview your form changes.
|
||||
</div>
|
||||
<form-information />
|
||||
<form-structure />
|
||||
<form-customization />
|
||||
<form-about-submission />
|
||||
<form-notifications />
|
||||
<form-security-privacy />
|
||||
<form-custom-code />
|
||||
<form-integrations />
|
||||
</div>
|
||||
|
||||
<form-editor-preview />
|
||||
|
||||
<!-- Form Error Modal -->
|
||||
<form-error-modal :show="showFormErrorModal"
|
||||
:validation-error-response="validationErrorResponse"
|
||||
@close="showFormErrorModal=false"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex justify-center items-center">
|
||||
<loader class="w-6 h-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import FormErrorModal from './form-components/FormErrorModal'
|
||||
import FormInformation from './form-components/FormInformation'
|
||||
import FormStructure from './form-components/FormStructure'
|
||||
import FormCustomization from './form-components/FormCustomization'
|
||||
import FormCustomCode from './form-components/FormCustomCode'
|
||||
import FormAboutSubmission from './form-components/FormAboutSubmission'
|
||||
import FormNotifications from './form-components/FormNotifications'
|
||||
import FormIntegrations from './form-components/FormIntegrations'
|
||||
import FormEditorPreview from './form-components/FormEditorPreview'
|
||||
import FormSecurityPrivacy from './form-components/FormSecurityPrivacy'
|
||||
|
||||
export default {
|
||||
name: 'FormEditor',
|
||||
components: {
|
||||
FormEditorPreview,
|
||||
FormIntegrations,
|
||||
FormNotifications,
|
||||
FormAboutSubmission,
|
||||
FormCustomCode,
|
||||
FormCustomization,
|
||||
FormStructure,
|
||||
FormInformation,
|
||||
FormErrorModal,
|
||||
FormSecurityPrivacy
|
||||
},
|
||||
props: {
|
||||
validationErrorResponse: {
|
||||
required: false,
|
||||
type: Object
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
showFormErrorModal: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
user: 'auth/user'
|
||||
}),
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
steps () {
|
||||
return [
|
||||
{
|
||||
target: '#v-step-0',
|
||||
header: {
|
||||
title: 'Welcome to the OpenForm Editor!'
|
||||
},
|
||||
content: 'Discover <strong>your form Editor</strong>!'
|
||||
},
|
||||
{
|
||||
target: '#v-step-1',
|
||||
header: {
|
||||
title: 'Change your form fields'
|
||||
},
|
||||
content: 'Here you can decide which field to include or not, but also the ' +
|
||||
'order you want your fields to be and so on. You also have custom options available for each field, just ' +
|
||||
'click the blue cog.'
|
||||
},
|
||||
{
|
||||
target: '#v-step-2',
|
||||
header: {
|
||||
title: 'Notifications, Customizations and more!'
|
||||
},
|
||||
content: 'Many more options are available: change colors, texts and receive a ' +
|
||||
'notifications whenever someones submits your form.'
|
||||
},
|
||||
{
|
||||
target: '.v-last-step',
|
||||
header: {
|
||||
title: 'Create your form'
|
||||
},
|
||||
content: 'Click this button when you\'re done to save your form!'
|
||||
}
|
||||
]
|
||||
},
|
||||
helpUrl: () => window.config.links.help
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
this.$emit('mounted')
|
||||
this.startTour()
|
||||
},
|
||||
|
||||
methods: {
|
||||
startTour () {
|
||||
if (!this.user.has_forms) {
|
||||
this.$tours.tutorial.start()
|
||||
}
|
||||
},
|
||||
showValidationErrors () {
|
||||
this.showFormErrorModal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.v-step {
|
||||
color: white;
|
||||
|
||||
.v-step__header, .v-step__content {
|
||||
color: white;
|
||||
|
||||
div {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-button class="w-full mb-5" @click="showAddBlock=true">
|
||||
Add Block
|
||||
</v-button>
|
||||
<add-form-block-modal :form-blocks="formFields" :show="showAddBlock" @block-added="blockAdded"
|
||||
@close="showAddBlock=false"
|
||||
/>
|
||||
<template v-if="selectedFieldIndex !== null">
|
||||
<form-field-options-modal :field="formFields[selectedFieldIndex]"
|
||||
:show="!isNotAFormField(formFields[selectedFieldIndex]) && showEditFieldModal"
|
||||
:form="form" @close="closeInputOptionModal"
|
||||
@remove-block="removeBlock(selectedFieldIndex)"
|
||||
/>
|
||||
<form-block-options-modal :field="formFields[selectedFieldIndex]"
|
||||
:show="isNotAFormField(formFields[selectedFieldIndex]) && showEditFieldModal"
|
||||
:form="form"
|
||||
@remove-block="removeBlock(selectedFieldIndex)" @close="closeInputOptionModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<draggable v-model="formFields"
|
||||
class="border bg-white dark:bg-notion-dark-light border-nt-blue-light shadow rounded-md w-full mx-auto transition-colors overflow-hidden"
|
||||
ghost-class="bg-nt-blue-lighter" handle=".draggable" :animation="200"
|
||||
>
|
||||
<div v-for="(field,index) in formFields" :key="field.id"
|
||||
class="border-nt-blue-light w-full mx-auto transition-colors bg-white dark:bg-notion-dark-light"
|
||||
:class="{'bg-gray-200 dark:bg-gray-800':field.hidden, 'border-b': (index!== formFields.length -1), 'bg-blue-50 dark:bg-blue-900':field && field.type==='nf-page-break'}"
|
||||
>
|
||||
<div v-if="field" class="flex items-center space-x-1 group py-2 pr-4">
|
||||
<!-- Drag handler -->
|
||||
<div class="cursor-move draggable p-2 -mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Field name and type -->
|
||||
<div class="flex flex-col flex-grow truncate">
|
||||
|
||||
<editable-div class="truncate" :value="field.name" @input="onChangeName(field, $event)">
|
||||
<label class="cursor-pointer truncate w-full">
|
||||
{{ field.name }}
|
||||
</label>
|
||||
|
||||
<span v-if="field.required" class="text-red-500 required-dot">*</span>
|
||||
<svg v-if="field.hidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
</editable-div>
|
||||
|
||||
<p class="text-xs text-gray-400 w-full truncate pl-2">
|
||||
<span class="capitalize">{{ formatType(field) }}</span>
|
||||
</p>
|
||||
<template slot="popover">
|
||||
<p class="text-white">
|
||||
{{ field.name }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Field options -->
|
||||
|
||||
<div class="flex-grow" v-if="['files'].includes(field.type) || field.type.startsWith('nf-')">
|
||||
<pro-tag/>
|
||||
</div>
|
||||
|
||||
<button v-if="!field.type.startsWith('nf-')"
|
||||
class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-1 hidden md:group-hover:block"
|
||||
:class="{'text-blue-500': !field.hidden, 'text-gray-500': field.hidden}"
|
||||
@click="toggleHidden(field)"
|
||||
>
|
||||
<template v-if="!field.hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
|
||||
<path fill-rule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
<button v-if="!field.type.startsWith('nf-')"
|
||||
class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-1 hidden md:group-hover:block"
|
||||
@click="toggleRequired(field)"
|
||||
>
|
||||
<div class="w-6 h-6 text-center font-bold text-3xl"
|
||||
:class="{'text-red-500': field.required, 'text-gray-500': !field.required}"
|
||||
>
|
||||
*
|
||||
</div>
|
||||
</button>
|
||||
<button class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer p-1"
|
||||
@click="editOptions(index)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fill-rule="evenodd"
|
||||
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable'
|
||||
import FormFieldOptionsModal from '../fields/FormFieldOptionsModal'
|
||||
import AddFormBlockModal from './form-components/AddFormBlockModal'
|
||||
import FormBlockOptionsModal from '../fields/FormBlockOptionsModal'
|
||||
import ProTag from '../../../common/ProTag'
|
||||
import clonedeep from 'clone-deep'
|
||||
import EditableDiv from '../../../common/EditableDiv'
|
||||
|
||||
export default {
|
||||
name: 'FormFieldsEditor',
|
||||
components: {
|
||||
ProTag,
|
||||
FormBlockOptionsModal,
|
||||
AddFormBlockModal,
|
||||
FormFieldOptionsModal,
|
||||
draggable,
|
||||
EditableDiv
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
formFields: [],
|
||||
selectedFieldIndex: null,
|
||||
showEditFieldModal: false,
|
||||
showAddBlock: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get() {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set(value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
formFields: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.$set(this.form, 'properties', this.formFields)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChangeName(field, newName) {
|
||||
this.$set(field, 'name', newName)
|
||||
},
|
||||
toggleHidden(field) {
|
||||
this.$set(field, 'hidden', !field.hidden)
|
||||
if (field.hidden) {
|
||||
this.$set(field, 'required', false)
|
||||
} else {
|
||||
this.$set(field, 'generates_uuid', false)
|
||||
this.$set(field, 'generates_auto_increment_id', false)
|
||||
}
|
||||
},
|
||||
toggleRequired(field) {
|
||||
this.$set(field, 'required', !field.required)
|
||||
if (field.required) {
|
||||
this.$set(field, 'hidden', false)
|
||||
}
|
||||
},
|
||||
getDefaultFields() {
|
||||
return [
|
||||
{
|
||||
"name": "Name",
|
||||
"type": "text",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"id": this.generateUUID(),
|
||||
},
|
||||
{
|
||||
"name": "Email",
|
||||
"type": "email",
|
||||
"hidden": false,
|
||||
"id": this.generateUUID(),
|
||||
},
|
||||
{
|
||||
"name": "Message",
|
||||
"type": "text",
|
||||
"hidden": false,
|
||||
"multi_lines": true,
|
||||
"id": this.generateUUID(),
|
||||
}
|
||||
];
|
||||
},
|
||||
init() {
|
||||
if (this.$route.name === 'forms.create') { // Set Default fields
|
||||
this.formFields = this.getDefaultFields()
|
||||
} else {
|
||||
this.formFields = clonedeep(this.form.properties).map((field) => {
|
||||
// Add more field properties
|
||||
field.placeholder = field.placeholder || null
|
||||
field.prefill = field.prefill || null
|
||||
field.help = field.help || null
|
||||
|
||||
return field
|
||||
})
|
||||
}
|
||||
this.$set(this.form, 'properties', this.formFields)
|
||||
},
|
||||
generateUUID() {
|
||||
let d = new Date().getTime()// Timestamp
|
||||
let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0// Time in microseconds since page-load or 0 if unsupported
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
let r = Math.random() * 16// random number between 0 and 16
|
||||
if (d > 0) { // Use timestamp until depleted
|
||||
r = (d + r) % 16 | 0
|
||||
d = Math.floor(d / 16)
|
||||
} else { // Use microseconds since page-load if supported
|
||||
r = (d2 + r) % 16 | 0
|
||||
d2 = Math.floor(d2 / 16)
|
||||
}
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
|
||||
})
|
||||
},
|
||||
formatType(field) {
|
||||
let type = field.type.replace('_', ' ')
|
||||
if (!type.startsWith('nf')) {
|
||||
type = type + ' Input'
|
||||
} else {
|
||||
type = type.replace('nf-', '')
|
||||
}
|
||||
if (field.generates_uuid || field.generates_auto_increment_id) {
|
||||
type = type + ' - Auto ID'
|
||||
}
|
||||
return type
|
||||
},
|
||||
isNotAFormField(block) {
|
||||
return block && block.type.startsWith('nf')
|
||||
},
|
||||
editOptions(index) {
|
||||
this.selectedFieldIndex = index
|
||||
this.showEditFieldModal = true
|
||||
},
|
||||
blockAdded(block) {
|
||||
this.formFields.push(block)
|
||||
},
|
||||
removeBlock(blockIndex) {
|
||||
this.closeInputOptionModal()
|
||||
this.selectedFieldIndex = null
|
||||
const newFields = clonedeep(this.formFields)
|
||||
newFields.splice(blockIndex, 1)
|
||||
this.$set(this, 'formFields', newFields)
|
||||
},
|
||||
closeInputOptionModal() {
|
||||
this.showEditFieldModal = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.v-popover {
|
||||
.trigger {
|
||||
@apply truncate w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
resources/js/components/open/forms/components/FormStats.vue
Normal file
121
resources/js/components/open/forms/components/FormStats.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all">
|
||||
<div v-if="!form.is_pro" class="relative">
|
||||
<div class="absolute inset-0 z-10">
|
||||
<div class="p-5 max-w-md mx-auto mt-5">
|
||||
<p class="text-center">
|
||||
You need a <pro-tag class="mx-1" /> subscription to access your form analytics.
|
||||
</p>
|
||||
<p class="mt-5 text-center">
|
||||
<fancy-link :to="{name:'pricing'}">
|
||||
Subscribe
|
||||
</fancy-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<img :src="asset('img/pages/forms/blurred_graph.png')"
|
||||
alt="Sample Graph"
|
||||
class="mx-auto filter blur-md z-0"
|
||||
>
|
||||
</div>
|
||||
<loader v-else-if="isLoading" class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
<LineChart v-else
|
||||
:chart-options="chartOptions"
|
||||
:chart-data="chartData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import { Line as LineChart } from 'vue-chartjs/legacy'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
PointElement
|
||||
} from 'chart.js'
|
||||
import ProTag from '../../../common/ProTag'
|
||||
import FancyLink from '../../../common/FancyLink'
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
PointElement
|
||||
)
|
||||
|
||||
export default {
|
||||
name: 'FormStats',
|
||||
components: {
|
||||
FancyLink,
|
||||
ProTag,
|
||||
LineChart
|
||||
},
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
chartData: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Form Views',
|
||||
backgroundColor: 'rgba(59, 130, 246, 1)',
|
||||
borderColor: 'rgba(59, 130, 246, 1)',
|
||||
data: []
|
||||
},
|
||||
{
|
||||
label: 'Form Submissions',
|
||||
backgroundColor: 'rgba(16, 185, 129, 1)',
|
||||
borderColor: 'rgba(16, 185, 129, 1)',
|
||||
data: []
|
||||
}
|
||||
]
|
||||
},
|
||||
chartOptions: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.getChartData()
|
||||
},
|
||||
methods: {
|
||||
getChartData () {
|
||||
if (!this.form || !this.form.is_pro) { return null }
|
||||
this.isLoading = true
|
||||
axios.get('/api/open/workspaces/' + this.form.workspace_id + '/form-stats/' + this.form.id).then((response) => {
|
||||
const statsData = response.data
|
||||
if (statsData && statsData.views !== undefined) {
|
||||
this.chartData.labels = Object.keys(statsData.views)
|
||||
this.chartData.datasets[0].data = statsData.views
|
||||
this.chartData.datasets[1].data = statsData.submissions
|
||||
this.isLoading = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div
|
||||
class="my-4 w-full mx-auto">
|
||||
<h3 class="font-semibold mb-4">
|
||||
Form Submissions <span v-if="form && !isLoading && tableData.length > 0"
|
||||
class="text-right text-xs uppercase mb-2">
|
||||
- <a :href="exportUrl" target="_blank">Export as CSV</a>
|
||||
</span>
|
||||
</h3>
|
||||
<loader v-if="!form || isLoading" class="h-6 w-6 text-nt-blue mx-auto"/>
|
||||
<div v-else>
|
||||
<scroll-shadow
|
||||
ref="shadows"
|
||||
class="border max-h-full h-full notion-database-renderer"
|
||||
:shadow-top-offset="0"
|
||||
:hide-scrollbar="true"
|
||||
>
|
||||
<open-table
|
||||
ref="table"
|
||||
class="max-h-full"
|
||||
:data="tableData"
|
||||
:loading="isLoading"
|
||||
@resize="dataChanged()"
|
||||
>
|
||||
</open-table>
|
||||
</scroll-shadow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import ScrollShadow from '../../../common/ScrollShadow'
|
||||
import OpenTable from '../../tables/OpenTable'
|
||||
import clonedeep from "clone-deep";
|
||||
|
||||
export default {
|
||||
name: 'FormSubmissions',
|
||||
components: {ScrollShadow, OpenTable},
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
formInitDone: false,
|
||||
isLoading: false,
|
||||
tableData: [],
|
||||
currentPage: 1,
|
||||
fullyLoaded: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initFormStructure()
|
||||
this.getSubmissionsData()
|
||||
},
|
||||
computed: {
|
||||
form: {
|
||||
get() {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
tableStructure() {
|
||||
if (!this.form) {
|
||||
return []
|
||||
}
|
||||
let tmp = this.form.properties.filter(property => !property.hasOwnProperty('hidden') || !property.hidden)
|
||||
tmp.push({
|
||||
"name": "Create Date",
|
||||
"id": "create_date",
|
||||
"type": "date"
|
||||
});
|
||||
return tmp
|
||||
},
|
||||
exportUrl() {
|
||||
if (!this.form) {
|
||||
return ''
|
||||
}
|
||||
return '/api/open/forms/' + this.form.id + '/submissions/export'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initFormStructure() {
|
||||
if (!this.form || this.formInitDone) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add a "created at" column
|
||||
const columns = clonedeep(this.form.properties)
|
||||
columns.push({
|
||||
"name": "Created at",
|
||||
"id": "created_at",
|
||||
"type": "date",
|
||||
"width": 140,
|
||||
})
|
||||
this.$set(this.form, 'properties', columns)
|
||||
this.formInitDone = true
|
||||
},
|
||||
getSubmissionsData() {
|
||||
if (!this.form || this.fullyLoaded) {
|
||||
return
|
||||
}
|
||||
this.isLoading = true
|
||||
axios.get('/api/open/forms/' + this.form.id + '/submissions?page=' + this.currentPage).then((response) => {
|
||||
const resData = response.data;
|
||||
|
||||
this.tableData = this.tableData.concat(resData.data.map((record) => record.data))
|
||||
|
||||
if (this.currentPage < resData.meta.last_page) {
|
||||
this.currentPage += 1
|
||||
this.getSubmissionsData()
|
||||
} else {
|
||||
this.isLoading = false
|
||||
this.fullyLoaded = true
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
this.isLoading = false
|
||||
})
|
||||
},
|
||||
dataChanged() {
|
||||
this.$refs.shadows.toggleShadow()
|
||||
this.$refs.shadows.calcDimensions()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div
|
||||
class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light shadow rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="select-all flex-grow break-all" v-html="preFillUrl" />
|
||||
<div class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer" @click="copyToClipboard">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormUrlPrefill',
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
formData: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
preFillUrl () {
|
||||
const url = this.form.share_url
|
||||
const uriComponents = new URLSearchParams()
|
||||
this.form.properties.filter((property) => {
|
||||
return this.formData.hasOwnProperty(property.id) && this.formData[property.id] !== null
|
||||
}).forEach((property) => {
|
||||
if (Array.isArray(this.formData[property.id])) {
|
||||
this.formData[property.id].forEach((value) => {
|
||||
uriComponents.append(property.id + '[]', value)
|
||||
})
|
||||
} else {
|
||||
uriComponents.append(property.id, this.formData[property.id])
|
||||
}
|
||||
})
|
||||
|
||||
return url + '?' + uriComponents
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
getPropertyUriComponent (property) {
|
||||
const prefillValue = encodeURIComponent(this.formData[property.id])
|
||||
return encodeURIComponent(property.id) + '=' + prefillValue
|
||||
},
|
||||
copyToClipboard () {
|
||||
const str = this.preFillUrl
|
||||
const el = document.createElement('textarea')
|
||||
el.value = str
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all">
|
||||
<div class="flex items-center">
|
||||
<p class="select-all text-nt-blue flex-grow truncate">
|
||||
<a v-if="link" :href="form.share_url" target="_blank">
|
||||
{{ form.share_url }}
|
||||
</a>
|
||||
<span v-else>
|
||||
{{ form.share_url }}
|
||||
</span>
|
||||
</p>
|
||||
<div class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer" @click="copyToClipboard(form.share_url)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ShareFormUrl',
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
watch: {
|
||||
},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
copyToClipboard (str) {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = str
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<modal :show="show" @close="close">
|
||||
|
||||
<p class="text-gray-500 uppercase text-xs font-semibold mb-2">Input Blocks</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
|
||||
<!-- Text Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('text')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Text Input</p>
|
||||
</div>
|
||||
<!-- Date Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('date')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Date Input</p>
|
||||
</div>
|
||||
<!-- Url Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('url')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">URL Input</p>
|
||||
</div>
|
||||
<!-- Phone Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('phone_number')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Phone Input</p>
|
||||
</div>
|
||||
|
||||
<!-- email Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('email')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Email Input</p>
|
||||
</div>
|
||||
|
||||
<!-- checkbox Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('checkbox')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Checkbox Input</p>
|
||||
</div>
|
||||
|
||||
<!-- select Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('select')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Select Input</p>
|
||||
</div>
|
||||
|
||||
<!-- multiselect Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('multi_select')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold">Multi-select Input</p>
|
||||
</div>
|
||||
|
||||
<!-- number Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('number')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Number Input</p>
|
||||
</div>
|
||||
|
||||
<!-- files Input -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('files')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">File Input</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="text-gray-500 uppercase text-xs font-semibold mb-2 mt-6">Layout Blocks</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
|
||||
<!-- Text Block -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('nf-text')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Text Block</p>
|
||||
</div>
|
||||
<!-- Page Break Block -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('nf-page-break')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold">Page-break Block</p>
|
||||
</div>
|
||||
<!-- Divider Block -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('nf-divider')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Divider block</p>
|
||||
</div>
|
||||
<!-- Image Block -->
|
||||
<div
|
||||
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 p-2 flex flex-col"
|
||||
role="button" @click.prevent="addBlock('nf-image')"
|
||||
>
|
||||
<div class="mx-auto py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mb-4">Image Block</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<v-button color="gray" shade="light" @click="close">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Form from 'vform'
|
||||
import VButton from '../../../../common/Button'
|
||||
|
||||
export default {
|
||||
name: 'AddFormBlockModal',
|
||||
components: {VButton},
|
||||
props: {
|
||||
formBlocks: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
blockForm: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
defaultBlockNames() {
|
||||
return {
|
||||
'text': 'Your name',
|
||||
'date': 'Date',
|
||||
'url': 'Link',
|
||||
'phone_number': 'Phone Number',
|
||||
'number': 'Number',
|
||||
'email': 'Email',
|
||||
'checkbox': 'Checkbox',
|
||||
'select': 'Select',
|
||||
'multi_select': 'Multi Select',
|
||||
'files': 'Files',
|
||||
'nf-text': 'Text Block',
|
||||
'nf-page-break': 'Page Break',
|
||||
'nf-divider': 'Divider',
|
||||
'nf-image': 'Image',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted() {
|
||||
this.reset()
|
||||
},
|
||||
|
||||
methods: {
|
||||
reset() {
|
||||
this.blockForm = new Form({
|
||||
type: null,
|
||||
name: null
|
||||
})
|
||||
},
|
||||
addBlock(type) {
|
||||
this.blockForm.type = type
|
||||
this.blockForm.name = this.defaultBlockNames[type]
|
||||
const data = this.prefillDefault(this.blockForm.data())
|
||||
data.id = this.generateUUID()
|
||||
data.hidden = false
|
||||
if (['select', 'multi_select'].includes(this.blockForm.type)) {
|
||||
data[this.blockForm.type] = {'options': []}
|
||||
}
|
||||
this.$emit('block-added', data)
|
||||
this.close()
|
||||
},
|
||||
generateUUID() {
|
||||
let d = new Date().getTime()// Timestamp
|
||||
let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0// Time in microseconds since page-load or 0 if unsupported
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
let r = Math.random() * 16// random number between 0 and 16
|
||||
if (d > 0) { // Use timestamp until depleted
|
||||
r = (d + r) % 16 | 0
|
||||
d = Math.floor(d / 16)
|
||||
} else { // Use microseconds since page-load if supported
|
||||
r = (d2 + r) % 16 | 0
|
||||
d2 = Math.floor(d2 / 16)
|
||||
}
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
|
||||
})
|
||||
},
|
||||
prefillDefault(data) {
|
||||
if (data.type === 'nf-text') {
|
||||
data.content = '<p>This is a text block.</p>'
|
||||
} else if (data.type === 'nf-page-break') {
|
||||
data.next_btn_text = 'Next'
|
||||
data.previous_btn_text = 'Previous'
|
||||
}
|
||||
return data
|
||||
},
|
||||
close() {
|
||||
this.$emit('close')
|
||||
this.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<collapse class="p-5 w-full" :default-value="true">
|
||||
<template #title>
|
||||
<h3 class="font-semibold text-lg relative">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
About Submission
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<text-input name="submit_button_text" class="mt-4"
|
||||
:form="form"
|
||||
label="Text of submit button"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
<select-input :form="submissionOptions" name="databaseAction" label="Database Submission Action"
|
||||
:options="[
|
||||
{name:'Create new record (default)', value:'create'},
|
||||
{name:'Update Record (if any)', value:'update'}
|
||||
]" :required="true" help="Create a new record or update an existing one"
|
||||
>
|
||||
<template #selected="{option,optionName}">
|
||||
<div class="flex items-center truncate mr-6">
|
||||
{{ optionName }}
|
||||
<pro-tag v-if="option === 'update'" class="ml-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{option, selected}">
|
||||
<span class="flex hover:text-white">
|
||||
<p class="flex-grow hover:text-white">
|
||||
{{ option.name }} <template v-if="option.value === 'update'"><pro-tag /></template>
|
||||
</p>
|
||||
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</select-input>
|
||||
|
||||
<v-transition>
|
||||
<div v-if="submissionOptions.databaseAction == 'update' && filterableFields.length">
|
||||
<select-input v-if="filterableFields.length" :form="form" name="database_fields_update"
|
||||
label="Properties to check on update" :options="filterableFields" :required="true"
|
||||
:multiple="true"
|
||||
/>
|
||||
<div class="-mt-3 mb-3 text-gray-400 dark:text-gray-500">
|
||||
<small>If the submission has the same value(s) as a previous one for the selected
|
||||
column(s), we will update it, instead of creating a new one.
|
||||
<a href="#" @click.prevent="$getCrisp().push(['do', 'helpdesk:article:open', ['en', 'how-to-update-a-page-on-form-submission-1t1jwmn']])">More info here.</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</v-transition>
|
||||
|
||||
<select-input :form="submissionOptions" name="submissionMode" label="Post Submission Action"
|
||||
:options="[
|
||||
{name:'Show Success page', value:'default'},
|
||||
{name:'Redirect', value:'redirect'}
|
||||
]" :required="true" help="Show a message, or redirect to a URL"
|
||||
>
|
||||
<template #selected="{option,optionName}">
|
||||
<div class="flex items-center truncate mr-6">
|
||||
{{ optionName }}
|
||||
<pro-tag v-if="option === 'redirect'" class="ml-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{option, selected}">
|
||||
<span class="flex hover:text-white">
|
||||
<p class="flex-grow hover:text-white">
|
||||
{{ option.name }} <template v-if="option.value === 'redirect'"><pro-tag /></template>
|
||||
</p>
|
||||
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</select-input>
|
||||
<template v-if="submissionOptions.submissionMode === 'redirect'">
|
||||
<text-input name="redirect_url"
|
||||
:form="form"
|
||||
label="Redirect URL"
|
||||
:required="true" help="On submit, redirects to that URL"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<pro-tag class="float-right" />
|
||||
<checkbox-input name="use_captcha" :form="form" class="mt-4"
|
||||
label="Protect your form with a Captcha"
|
||||
help="If enabled we will make sure respondant is a human"
|
||||
/>
|
||||
<checkbox-input name="re_fillable" :form="form" class="mt-4"
|
||||
label="Allow users to fill the form again"
|
||||
/>
|
||||
<text-input v-if="form.re_fillable" name="re_fill_button_text"
|
||||
:form="form"
|
||||
label="Text of re-start button"
|
||||
:required="true"
|
||||
/>
|
||||
<rich-text-area-input name="submitted_text"
|
||||
:form="form"
|
||||
label="Text after submission"
|
||||
:required="false"
|
||||
/>
|
||||
<date-input :with-time="true" name="closes_at"
|
||||
:form="form"
|
||||
label="Closing date"
|
||||
help="If filled, then the form won't accept submissions after the given date"
|
||||
:required="false"
|
||||
/>
|
||||
<rich-text-area-input v-if="form.closes_at" name="closed_text"
|
||||
:form="form"
|
||||
label="Closed form text"
|
||||
help="This message will be shown when the form will be closed"
|
||||
:required="false"
|
||||
/>
|
||||
<text-input name="max_submissions_count" native-type="number" :min="1" :form="form"
|
||||
label="Max number of submissions"
|
||||
help="If filled, the form will only accept X number of submissions"
|
||||
:required="false"
|
||||
/>
|
||||
<rich-text-area-input v-if="form.max_submissions_count && form.max_submissions_count > 0" name="max_submissions_reached_text"
|
||||
:form="form"
|
||||
label="Max Submissions reached text"
|
||||
help="This message will be shown when the form will have the maximum number of submissions"
|
||||
:required="false"
|
||||
/>
|
||||
</template>
|
||||
</collapse>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapse from '../../../../common/Collapse'
|
||||
import ProTag from '../../../../common/ProTag'
|
||||
import VTransition from '../../../../common/transitions/VTransition'
|
||||
|
||||
export default {
|
||||
components: { Collapse, ProTag, VTransition },
|
||||
props: {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
submissionOptions: {}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Used for the update record on submission. Lists all visible fields on which you can filter records to update
|
||||
* on submission instead of creating
|
||||
*/
|
||||
filterableFields () {
|
||||
if (this.submissionOptions.databaseAction !== 'update') return []
|
||||
return this.form.properties.filter((field) => {
|
||||
return !field.hidden && window.config.notion.database_filterable_types.includes(field.type)
|
||||
}).map((field) => {
|
||||
const fieldName = (field.name !== field.notion_name) ? (field.name + ' (' + field.notion_name + ')') : field.name
|
||||
return {
|
||||
name: fieldName,
|
||||
value: field.id
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
form: {
|
||||
handler () {
|
||||
if (this.form) {
|
||||
this.submissionOptions = {
|
||||
submissionMode: this.form.redirect_url ? 'redirect' : 'default',
|
||||
databaseAction: this.form.database_fields_update ? 'update' : 'create'
|
||||
}
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
submissionOptions: {
|
||||
deep: true,
|
||||
handler: function (val) {
|
||||
if (val.submissionMode === 'default') {
|
||||
this.$set(this.form, 'redirect_url', null)
|
||||
}
|
||||
|
||||
if (val.databaseAction === 'create') {
|
||||
this.$set(this.form, 'database_fields_update', null)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<collapse class="p-5 w-full border-b" :default-value="false">
|
||||
<template #title>
|
||||
<h3 class="font-semibold text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
Custom Code
|
||||
<pro-tag />
|
||||
</h3>
|
||||
</template>
|
||||
<p class="mt-4">
|
||||
The code will be injected in the <b>head</b> section of your form page. <a href="#" class="text-gray-500"
|
||||
@click.prevent="$getCrisp().push(['do', 'helpdesk:article:open', ['en', 'how-to-inject-custom-code-in-my-form-1amadj3']])"
|
||||
>Click
|
||||
here to get an example CSS code.</a>
|
||||
</p>
|
||||
<code-input name="custom_code" class="mt-4"
|
||||
:form="form" help="Custom code cannot be previewed in our editor. Please test your code using
|
||||
your actual form page (save changes beforehand)."
|
||||
label="Custom Code"
|
||||
/>
|
||||
</collapse>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapse from '../../../../common/Collapse'
|
||||
import ProTag from '../../../../common/ProTag'
|
||||
import CodeInput from '../../../../forms/CodeInput'
|
||||
|
||||
export default {
|
||||
components: { Collapse, ProTag, CodeInput },
|
||||
props: {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<collapse class="p-5 w-full border-b" :default-value="true">
|
||||
<template #title>
|
||||
<h3 class="font-semibold text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Customization
|
||||
<pro-tag />
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<select-input name="theme" class="mt-4"
|
||||
:options="[
|
||||
{name:'Default',value:'default'},
|
||||
{name:'Simple',value:'simple'},
|
||||
{name:'Notion',value:'notion'},
|
||||
]"
|
||||
:form="form" label="Form Theme"
|
||||
/>
|
||||
<div class="-mt-3 mb-3 text-gray-400 dark:text-gray-500">
|
||||
<small>
|
||||
Need another theme? <a href="#" @click.prevent="openChat">Send us some suggestions!</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<select-input name="width" class="mt-4"
|
||||
:options="[
|
||||
{name:'Centered',value:'centered'},
|
||||
{name:'Full Width',value:'full'},
|
||||
]"
|
||||
:form="form" label="Form Width" help="Useful when embedding your form"
|
||||
/>
|
||||
|
||||
<image-input name="cover_picture" class="mt-4"
|
||||
:form="form" label="Cover Picture" help="Not visible when form is embedded"
|
||||
:required="false"
|
||||
/>
|
||||
|
||||
<image-input name="logo_picture" class="mt-4"
|
||||
:form="form" label="Logo" help="Not visible when form is embedded"
|
||||
:required="false"
|
||||
/>
|
||||
|
||||
<select-input name="dark_mode" class="mt-4"
|
||||
help="To see changes, save your form and open it"
|
||||
:options="[
|
||||
{name:'Auto - use Device System Preferences',value:'auto'},
|
||||
{name:'Light Mode',value:'light'},
|
||||
{name:'Dark Mode',value:'dark'}
|
||||
]"
|
||||
:form="form" label="Dark Mode"
|
||||
/>
|
||||
<color-input name="color" class="mt-4"
|
||||
:form="form"
|
||||
label="Color (for buttons & inputs border)"
|
||||
/>
|
||||
<checkbox-input name="hide_title" :form="form" class="mt-4"
|
||||
label="Hide Title"
|
||||
/>
|
||||
<checkbox-input name="no_branding" :form="form" class="mt-4"
|
||||
label="Remove OpnForm Branding"
|
||||
/>
|
||||
<checkbox-input name="uppercase_labels" :form="form" class="mt-4"
|
||||
label="Uppercase Input Labels"
|
||||
/>
|
||||
<checkbox-input name="transparent_background" :form="form" class="mt-4"
|
||||
label="Transparent Background" help="Only applies when form is embedded"
|
||||
/>
|
||||
</collapse>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapse from '../../../../common/Collapse'
|
||||
import ProTag from '../../../../common/ProTag'
|
||||
|
||||
export default {
|
||||
components: { Collapse, ProTag },
|
||||
props: {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
openChat () {
|
||||
window.$crisp.push(['do', 'chat:show'])
|
||||
window.$crisp.push(['do', 'chat:open'])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<!-- Form Preview (desktop only) -->
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-notion-dark-light hidden md:flex flex-grow p-5 flex-col items-center overflow-y-scroll shadow-inner"
|
||||
>
|
||||
<p class="mb-4 mt-2 text-center text-gray-400">
|
||||
Preview Full Page
|
||||
<v-switch v-model="previewEmbed" class="inline px-2" />
|
||||
Preview Embed
|
||||
</p>
|
||||
<p class="font-semibold">
|
||||
<span v-if="creating" class="font-normal text-gray-400">Answers won't really be saved</span>
|
||||
<span v-if="previewFormSubmitted && !form.re_fillable">
|
||||
<a href="#" @click.prevent="$refs['form-preview'].restart()">Restart Form
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-nt-blue inline" viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fill-rule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
<div class="border rounded-lg bg-white dark:bg-notion-dark w-full block shadow-sm transition-all"
|
||||
:class="{'max-w-lg':previewEmbed,'max-w-5xl':!previewEmbed}"
|
||||
>
|
||||
<transition enter-active-class="linear duration-100 overflow-hidden"
|
||||
enter-class="max-h-0"
|
||||
enter-to-class="max-h-56"
|
||||
leave-active-class="linear duration-100 overflow-hidden"
|
||||
leave-class="max-h-56"
|
||||
leave-to-class="max-h-0"
|
||||
>
|
||||
<div v-if="!previewEmbed && (form.logo_picture || form.cover_picture)">
|
||||
<div v-if="form.cover_picture">
|
||||
<div id="cover-picture"
|
||||
class="max-h-56 rounded-t-lg w-full overflow-hidden flex items-center justify-center"
|
||||
>
|
||||
<img alt="Cover Picture" :src="coverPictureSrc(form.cover_picture)" class="w-full">
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="form.logo_picture" class="w-full mx-auto p-5 relative"
|
||||
:class="{'pt-20':!form.cover_picture, 'max-w-lg': form && (form.width === 'centered')}"
|
||||
>
|
||||
<img alt="Logo Picture" :src="coverPictureSrc(form.logo_picture)"
|
||||
:class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}"
|
||||
class="w-20 h-20 absolute left-5 transition-all"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<open-complete-form ref="form-preview" class="w-full mx-auto py-5 px-3" :class="{'max-w-lg': form && (form.width === 'centered')}"
|
||||
:creating="creating"
|
||||
:form="form"
|
||||
@restarted="previewFormSubmitted=false"
|
||||
@submitted="previewFormSubmitted=true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VSwitch from '../../../../forms/components/VSwitch'
|
||||
import OpenCompleteForm from '../../OpenCompleteForm'
|
||||
|
||||
export default {
|
||||
components: { OpenCompleteForm, VSwitch },
|
||||
props: {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
previewFormSubmitted: false,
|
||||
previewEmbed: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
creating () { // returns true if we are creating a form
|
||||
return !this.form.hasOwnProperty('id')
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
coverPictureSrc (val) {
|
||||
try {
|
||||
// Is valid url
|
||||
new URL(val)
|
||||
} catch (_) {
|
||||
// Is file
|
||||
return URL.createObjectURL(val)
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<modal :show="show" @close="$emit('close')">
|
||||
<div class="-mx-5">
|
||||
<h2 class="text-red-400 text-2xl font-bold mb-4 px-4">
|
||||
Error saving your form
|
||||
</h2>
|
||||
|
||||
<div v-if="validationErrorResponse" class="p-4 border-b border-t">
|
||||
<p v-if="validationErrorResponse.message" v-text="validationErrorResponse.message" />
|
||||
<ul class="list-disc list-inside">
|
||||
<li v-for="err, key in validationErrorResponse.errors" :key="key">
|
||||
{{ Array.isArray(err)?err[0]:err }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pt-4 text-right">
|
||||
<v-button color="gray" shade="light" @click="$emit('close')">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormErrorModal',
|
||||
components: {},
|
||||
props: {
|
||||
show: { type: Boolean, required: true },
|
||||
validationErrorResponse: { type: Object, required: false }
|
||||
},
|
||||
data: () => ({}),
|
||||
|
||||
computed: {},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<collapse class="p-5 w-full border-b" :default-value="true">
|
||||
<template #title class="test">
|
||||
<h3 id="v-step-0" class="font-semibold text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Information
|
||||
</h3>
|
||||
</template>
|
||||
<text-input name="title" class="mt-4"
|
||||
:form="form"
|
||||
label="Title of your form"
|
||||
:required="true"
|
||||
/>
|
||||
<rich-text-area-input name="description"
|
||||
:form="form"
|
||||
label="Description"
|
||||
:required="false"
|
||||
/>
|
||||
<select-input name="tags" label="Tags" :form="form" class="mt-3 mb-6"
|
||||
help="To organize your forms (hidden to respondents)"
|
||||
placeholder="Select Tag(s)" :multiple="true" :allowCreation="true"
|
||||
:options="allTagsOptions"
|
||||
/>
|
||||
<button
|
||||
v-if="copyFormOptions.length > 0"
|
||||
class="group mt-3 cursor-pointer relative w-full rounded-lg border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full py-2 px-4 bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100"
|
||||
@click.prevent="showCopyFormSettingsModal=true"
|
||||
>
|
||||
Copy another form's settings
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -mt-1 text-nt-blue inline" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<modal :show="showCopyFormSettingsModal" @close="showCopyFormSettingsModal=false">
|
||||
<div class="-m-4 sm:-mx-6">
|
||||
<div class="p-4 border-b">
|
||||
<h2 class="text-2xl font-bold z-10 truncate -mt-2 text-nt-blue">
|
||||
Copy Settings from another form
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-gray-600">
|
||||
If you already have another form that you like to use as a base for this form, you can do that here.
|
||||
Select another form, confirm, and we will copy all of the other form settings (except the form structure)
|
||||
to this form.
|
||||
</p>
|
||||
<select-input v-model="copyFormId" name="copy_form_id"
|
||||
label="Copy settings from" class="mt-3 mb-6"
|
||||
placeholder="Choose a form" :searchable="copyFormOptions.length > 5"
|
||||
:options="copyFormOptions"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<v-button color="blue" shade="light" @click="copySettings">
|
||||
Confirm & Copy settings
|
||||
</v-button>
|
||||
<v-button color="gray" shade="light" class="ml-1" @click="showCopyFormSettingsModal=false">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</collapse>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapse from '../../../../common/Collapse'
|
||||
import SelectInput from '../../../../forms/SelectInput'
|
||||
import { mapState } from 'vuex'
|
||||
import clonedeep from 'clone-deep'
|
||||
|
||||
export default {
|
||||
components: { SelectInput, Collapse },
|
||||
props: {},
|
||||
data () {
|
||||
return {
|
||||
showCopyFormSettingsModal: false,
|
||||
copyFormId: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
copyFormOptions () {
|
||||
return this.forms.filter((form) => {
|
||||
return this.form.id !== form.id
|
||||
}).map((form) => {
|
||||
return {
|
||||
name: form.title,
|
||||
value: form.id
|
||||
}
|
||||
})
|
||||
},
|
||||
...mapState({
|
||||
forms: state => state['open/forms'].content
|
||||
}),
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
allTagsOptions () {
|
||||
return this.$store.getters['open/forms/getAllTags'].map((tagname) => {
|
||||
return {
|
||||
name: tagname,
|
||||
value: tagname
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
copySettings () {
|
||||
if (this.copyFormId == null) return
|
||||
const copyForm = clonedeep(this.forms.find((form) => form.id === this.copyFormId))
|
||||
if (!copyForm) return
|
||||
|
||||
// Clean copy from form
|
||||
['title', 'description', 'properties', 'cleanings', 'views_count', 'submissions_count', 'workspace', 'workspace_id', 'updated_at',
|
||||
'share_url', 'slug', 'notion_database_url', 'id', 'database_id', 'database_fields_update', 'creator',
|
||||
'created_at', 'deleted_at'].forEach((property) => {
|
||||
if (copyForm.hasOwnProperty(property)) {
|
||||
delete copyForm[property]
|
||||
}
|
||||
})
|
||||
|
||||
// Apply changes
|
||||
Object.keys(copyForm).forEach((property) => {
|
||||
this.form[property] = copyForm[property]
|
||||
})
|
||||
this.showCopyFormSettingsModal = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<collapse class="p-5 w-full border-b">
|
||||
<template #title>
|
||||
<h3 class="font-semibold text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
Integrations
|
||||
<pro-tag />
|
||||
</h3>
|
||||
</template>
|
||||
<text-input name="webhook_url" class="mt-4"
|
||||
:form="form" help="We will post form submissions to this endpoint."
|
||||
label="Webhook URL"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<span class="text-uppercase font-semibold text-blue-500">NEW</span> - our Zapier integration is available for
|
||||
beta testers! During the beta, <b>you don't need a Pro subscription</b> to try it out.
|
||||
</p>
|
||||
<p class="w-full text-center mt-5">
|
||||
<a :href="zapierUrl" target="_blank">
|
||||
<v-button color="gray" shade="lighter">
|
||||
<svg class="h-5 w-5 inline text-yellow-500" fill="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M318 256c0 19-4 36-10 52-16 7-34 10-52 10-19 0-36-3-52-9-7-17-10-34-10-53 0-18 3-36 10-52 16-6 33-10 52-10 18 0 36 4 52 10 6 16 10 34 10 52zm182-41H355l102-102c-8-11-17-22-26-32-10-9-21-18-32-26L297 157V12c-13-2-27-3-41-3s-28 1-41 3v145L113 55c-12 8-22 17-32 26-10 10-19 21-27 32l102 102H12s-3 27-3 41 1 28 3 41h144L54 399c16 23 36 43 59 59l102-102v144c13 2 27 3 41 3s28-1 41-3V356l102 102c11-8 22-17 32-27 9-10 18-20 26-32L355 297h145c2-13 3-27 3-41s-1-28-3-41z"
|
||||
/>
|
||||
</svg>
|
||||
Zapier Integration
|
||||
</v-button>
|
||||
</a>
|
||||
</p>
|
||||
</collapse>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapse from '../../../../common/Collapse'
|
||||
import ProTag from '../../../../common/ProTag'
|
||||
|
||||
export default {
|
||||
components: { Collapse, ProTag },
|
||||
props: {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
zapierUrl: () => window.config.links.zapier_integration
|
||||
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<collapse class="p-5 w-full border-t border-b" :default-value="true">
|
||||
<template #title>
|
||||
<h3 id="v-step-2" class="font-semibold text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Notifications
|
||||
<pro-tag />
|
||||
</h3>
|
||||
</template>
|
||||
<checkbox-input name="notifies" :form="form" class="mt-4"
|
||||
label="Receive email notifications on submission"
|
||||
/>
|
||||
<text-area-input v-if="form.notifies" name="notification_emails" :form="form" class="mt-4"
|
||||
label="Notification Emails" help="Add one email per line"
|
||||
/>
|
||||
<checkbox-input :disabled="emailSubmissionConfirmationField===null" name="send_submission_confirmation"
|
||||
:form="form" class="mt-4"
|
||||
label="Send submission confirmation" :help="emailSubmissionConfirmationHelp"
|
||||
/>
|
||||
<text-input v-if="form.send_submission_confirmation" name="notification_sender"
|
||||
:form="form" class="mt-4"
|
||||
label="Confirmation Email Sender Name" help="Emails will be sent from our email address but you can customize the name of the Sender"
|
||||
/>
|
||||
<text-input v-if="form.send_submission_confirmation" name="notification_subject"
|
||||
:form="form" class="mt-4"
|
||||
label="Confirmation email subject" help="Subject of the confirmation email that will be sent"
|
||||
/>
|
||||
<rich-text-area-input v-if="form.send_submission_confirmation" name="notification_body"
|
||||
:form="form" class="mt-4"
|
||||
label="Confirmation email content" help="Content of the confirmation email that will be sent"
|
||||
/>
|
||||
<checkbox-input v-if="form.send_submission_confirmation" name="notifications_include_submission"
|
||||
:form="form" class="mt-4"
|
||||
label="Include submission data" help="If enabled the confirmation email will contain form submission answers"
|
||||
/>
|
||||
</collapse>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapse from '../../../../common/Collapse'
|
||||
import ProTag from '../../../../common/ProTag'
|
||||
|
||||
export default {
|
||||
components: { Collapse, ProTag },
|
||||
props: {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
emailSubmissionConfirmationField () {
|
||||
const emailFields = this.form.properties.filter((field) => {
|
||||
return field.type === 'email' && !field.hidden
|
||||
})
|
||||
if (emailFields.length === 1) return emailFields[0]
|
||||
return null
|
||||
},
|
||||
emailSubmissionConfirmationHelp () {
|
||||
if (this.emailSubmissionConfirmationField) {
|
||||
return 'Confirmation will be sent to the email in the "' + this.emailSubmissionConfirmationField.name + '" field.'
|
||||
}
|
||||
return 'Only available if your form contains 1 email field.'
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
emailSubmissionConfirmationField (val) {
|
||||
if (val === null) {
|
||||
this.$set(this.form, 'send_submission_confirmation', false)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<collapse class="p-5 w-full border-b" :default-value="false">
|
||||
<template #title>
|
||||
<h3 id="v-step-2" class="font-semibold text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Security & Privacy
|
||||
</h3>
|
||||
</template>
|
||||
<checkbox-input name="can_be_indexed" :form="form" class="mt-4"
|
||||
label="Indexable by Google"
|
||||
help="If enabled, your form can appear in the search results of Google"
|
||||
/>
|
||||
<pro-tag class="float-right" />
|
||||
<text-input name="password" :form="form" class="mt-4"
|
||||
label="Form Password" help="Leave empty to disable password"
|
||||
/>
|
||||
</collapse>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapse from '../../../../common/Collapse'
|
||||
import ProTag from '../../../../common/ProTag'
|
||||
|
||||
export default {
|
||||
components: { Collapse, ProTag },
|
||||
props: {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
methods: {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<collapse class="p-5 w-full border-b" :default-value="true">
|
||||
<template #title>
|
||||
<div class="flex">
|
||||
<h3 id="v-step-1" class="font-semibold block text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg> Form Structure
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
<form-fields-editor class="mt-5" />
|
||||
</collapse>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapse from '../../../../common/Collapse'
|
||||
import FormFieldsEditor from '../FormFieldsEditor'
|
||||
|
||||
export default {
|
||||
components: { Collapse, FormFieldsEditor },
|
||||
props: {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,167 @@
|
||||
|
||||
<template>
|
||||
<div v-if="isMounted" class="flex flex-wrap">
|
||||
<div class="w-full font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ property.name }}
|
||||
</div>
|
||||
<SelectInput v-model="content.operator" class="w-full" :options="operators"
|
||||
:name="'operator_'+property.id" placeholder="Comparison operator"
|
||||
@input="operatorChanged()"
|
||||
/>
|
||||
|
||||
<template v-if="hasInput">
|
||||
<component :is="inputComponentData.component" v-model="content.value" class="w-full"
|
||||
:name="'value_'+property.id" v-bind="inputComponentData" placeholder="Filter Value"
|
||||
@input="$emit('input',castContent(content))"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OpenFilters from '../../../../../../data/open_filters.json'
|
||||
|
||||
export default {
|
||||
components: { },
|
||||
props: {
|
||||
value: { required: true }
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
content: { ...this.value },
|
||||
available_filters: OpenFilters,
|
||||
isMounted: false,
|
||||
hasInput: false,
|
||||
inputComponent: {
|
||||
text: 'TextInput',
|
||||
number: 'TextInput',
|
||||
select: 'SelectInput',
|
||||
multi_select: 'SelectInput',
|
||||
date: 'DateInput',
|
||||
files: 'FileInput',
|
||||
checkbox: 'CheckboxInput',
|
||||
url: 'TextInput',
|
||||
email: 'TextInput',
|
||||
phone_number: 'TextInput',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// Return type of input, and props for that input
|
||||
inputComponentData () {
|
||||
const componentData = {
|
||||
component: this.inputComponent[this.property.type],
|
||||
name: this.property.id,
|
||||
required: true
|
||||
}
|
||||
|
||||
if (['select', 'multi_select'].includes(this.property.type)) {
|
||||
componentData.multiple = (this.property.type == 'multi_select')
|
||||
componentData.options = this.property[this.property.type].options.map(option => {
|
||||
return {
|
||||
name: option.name,
|
||||
value: option.name
|
||||
}
|
||||
})
|
||||
} else if (this.property.type === 'date') {
|
||||
// componentData.withTime = true
|
||||
} else if (this.property.type === 'checkbox') {
|
||||
componentData.label = this.property.name
|
||||
}
|
||||
|
||||
return componentData
|
||||
},
|
||||
operators () {
|
||||
return Object.keys(this.available_filters[this.property.type].comparators).map(key => {
|
||||
return {
|
||||
value: key,
|
||||
name: this.optionFilterNames(key, this.property.type)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
if (!this.content.operator) {
|
||||
this.content.operator = this.operators[0].value
|
||||
this.operatorChanged()
|
||||
} else {
|
||||
this.hasInput = this.needsInput()
|
||||
}
|
||||
|
||||
this.content.property_meta = {
|
||||
id: this.property.id,
|
||||
type: this.property.type,
|
||||
}
|
||||
this.isMounted = true
|
||||
},
|
||||
|
||||
methods: {
|
||||
castContent (content) {
|
||||
if (this.property.type === 'number' && content.value) {
|
||||
content.value = Number(content.value)
|
||||
}
|
||||
|
||||
const operator = this.selectedOperator()
|
||||
if (operator.expected_type === 'boolean') {
|
||||
content.value = Boolean(content.value)
|
||||
}
|
||||
|
||||
return content
|
||||
},
|
||||
operatorChanged () {
|
||||
if (!this.content.operator) {
|
||||
return
|
||||
}
|
||||
|
||||
const operator = this.selectedOperator()
|
||||
const operatorFormat = operator.format
|
||||
this.hasInput = this.needsInput()
|
||||
|
||||
if (operator.expected_type === 'boolean' && operatorFormat.type === 'enum' && operatorFormat.values.length === 1) {
|
||||
this.content.value = operator.format.values[0]
|
||||
} else if (operator.expected_type === 'object' && operatorFormat.type === 'empty' && operatorFormat.values === '{}') {
|
||||
this.content.value = {}
|
||||
} else if (typeof this.content.value === 'boolean' || typeof this.content.value === 'object') {
|
||||
this.content.value = null
|
||||
}
|
||||
this.$emit('input', this.castContent(this.content))
|
||||
},
|
||||
needsInput () {
|
||||
const operator = this.selectedOperator()
|
||||
if (!operator) {
|
||||
return false
|
||||
}
|
||||
const operatorFormat = operator.format
|
||||
if (!operatorFormat) return true
|
||||
|
||||
if (operator.expected_type === 'boolean' && operatorFormat.type === 'enum' && operatorFormat.values.length === 1) {
|
||||
return false
|
||||
} else if (operator.expected_type === 'object' && operatorFormat.type === 'empty' && operatorFormat.values === '{}') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
selectedOperator () {
|
||||
if (!this.content.operator) {
|
||||
return null
|
||||
}
|
||||
return this.available_filters[this.property.type].comparators[this.content.operator]
|
||||
},
|
||||
optionFilterNames (key, propertyType) {
|
||||
if (propertyType === 'checkbox') {
|
||||
return {
|
||||
equals: 'Is checked',
|
||||
does_not_equal: 'Is not checked'
|
||||
}[key]
|
||||
}
|
||||
return key.split('_').map(function (item) {
|
||||
return item.charAt(0).toUpperCase() + item.substring(1)
|
||||
}).join(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<query-builder v-model="query" :rules="rules" :config="config" @input="onChange">
|
||||
<template #groupOperator="props">
|
||||
<div class="query-builder-group-slot__group-selection flex items-center px-5 border-b py-1 mb-1 flex">
|
||||
<p class="mr-2 font-semibold">
|
||||
Operator
|
||||
</p>
|
||||
<select-input
|
||||
wrapper-class="relative"
|
||||
:value="props.currentOperator"
|
||||
:options="props.operators"
|
||||
emit-key="identifier"
|
||||
option-key="identifier"
|
||||
name="operator-input"
|
||||
margin-bottom=""
|
||||
@input="props.updateCurrentOperator($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #groupControl="props">
|
||||
<group-control-slot :group-ctrl="props" />
|
||||
</template>
|
||||
<template #rule="ruleCtrl">
|
||||
<component
|
||||
:is="ruleCtrl.ruleComponent"
|
||||
:value="ruleCtrl.ruleData"
|
||||
@input="ruleCtrl.updateRuleData"
|
||||
/>
|
||||
</template>
|
||||
</query-builder>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import QueryBuilder from 'query-builder-vue'
|
||||
import ColumnCondition from './ColumnCondition'
|
||||
import Vue from 'vue'
|
||||
import GroupControlSlot from './GroupControlSlot'
|
||||
|
||||
export default {
|
||||
|
||||
components: {
|
||||
GroupControlSlot,
|
||||
QueryBuilder,
|
||||
ColumnCondition
|
||||
},
|
||||
|
||||
props: {
|
||||
form: { type: Object, required: true },
|
||||
value: { required: false }
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
query: this.value
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
rules () {
|
||||
return this.form.properties.filter((property) => {
|
||||
return !property.type.startsWith('nf-')
|
||||
}).map((property) => {
|
||||
const workspaceId = this.form.workspace_id
|
||||
const formSlug = this.form.slug
|
||||
return {
|
||||
identifier: property.id,
|
||||
name: property.name,
|
||||
component: (function () {
|
||||
return Vue.extend(ColumnCondition).extend({
|
||||
computed: {
|
||||
property () {
|
||||
return property
|
||||
},
|
||||
viewContext () {
|
||||
return {
|
||||
form_slug: formSlug,
|
||||
workspace_id: workspaceId
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
config () {
|
||||
return {
|
||||
operators: [
|
||||
{
|
||||
name: 'And',
|
||||
identifier: 'and'
|
||||
},
|
||||
{
|
||||
name: 'Or',
|
||||
identifier: 'or'
|
||||
}
|
||||
],
|
||||
rules: this.rules,
|
||||
colors: ['#ef4444', '#22c55e', '#f97316', '#0ea5e9', '#8b5cf6', '#ec4899']
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
value () {
|
||||
this.query = this.value
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChange () {
|
||||
this.$emit('input', this.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div v-if="logic" :key="resetKey" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Logic
|
||||
<pro-tag />
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-5">
|
||||
Add some logic to this block. Start by adding some conditions, and then add some actions.
|
||||
</p>
|
||||
<div class="relative">
|
||||
<v-button size="small" @click="showCopyFormModal=true">
|
||||
Copy from...
|
||||
</v-button>
|
||||
<v-button color="red" shade="light" size="small" class="ml-1" @click="clearAll">
|
||||
Clear All
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<h5 class="font-semibold mt-4">
|
||||
1. Conditions
|
||||
</h5>
|
||||
<condition-editor ref="filter-editor" v-model="logic.conditions" class="mt-4 border-t border" :form="form" />
|
||||
|
||||
<h5 class="font-semibold mt-4">
|
||||
2. Actions
|
||||
</h5>
|
||||
<select-input :key="resetKey" v-model="logic.actions" name="actions"
|
||||
:multiple="true" class="mt-4" placeholder="Actions..."
|
||||
help="Action(s) triggerred when above conditions are true"
|
||||
:options="actionOptions"
|
||||
@input="onActionInput"
|
||||
/>
|
||||
|
||||
<modal :show="showCopyFormModal" @close="showCopyFormModal">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Copy logic from another field
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-5">
|
||||
Select another field/block to copy its logic and apply it to "{{ field.name }}".
|
||||
</p>
|
||||
<select-input v-model="copyFrom" name="copy_from" emit-key="value"
|
||||
label="Copy logic from" placeholder="Choose a field/block..."
|
||||
:options="copyFromOptions" :searchable="copyFromOptions && copyFromOptions.options > 5"
|
||||
/>
|
||||
<div class="flex justify-between mb-6">
|
||||
<v-button color="blue" shade="light" @click="copyLogic">
|
||||
Confirm & Copy
|
||||
</v-button>
|
||||
<v-button color="gray" shade="light" class="ml-1" @click="showCopyFormModal=false">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ProTag from '../../../../common/ProTag'
|
||||
import ConditionEditor from './ConditionEditor'
|
||||
import Modal from '../../../../Modal'
|
||||
import SelectInput from '../../../../forms/SelectInput'
|
||||
import clonedeep from 'clone-deep'
|
||||
|
||||
export default {
|
||||
name: 'FormBlockLogicEditor',
|
||||
components: { SelectInput, Modal, ProTag, ConditionEditor },
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
resetKey: 0,
|
||||
logic: this.field.logic || {
|
||||
conditions: null,
|
||||
actions: []
|
||||
},
|
||||
showCopyFormModal: false,
|
||||
copyFrom: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
copyFromOptions () {
|
||||
return this.form.properties.filter((field) => {
|
||||
return field.id !== this.field.id
|
||||
}).map((field) => {
|
||||
return { name: field.name, value: field.id }
|
||||
})
|
||||
},
|
||||
actionOptions () {
|
||||
if (['nf-text', 'nf-page-break', 'nf-divider', 'nf-image'].includes(this.field.type)) {
|
||||
return [{ name: 'Hide Block', value: 'hide-block' }]
|
||||
}
|
||||
|
||||
if (this.field.hidden) {
|
||||
return [
|
||||
{ name: 'Show Block', value: 'show-block' },
|
||||
{ name: 'Require answer', value: 'require-answer' }
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{ name: 'Hide Block', value: 'hide-block' },
|
||||
(this.field.required
|
||||
? { name: 'Make it optional', value: 'make-it-optional' }
|
||||
: {
|
||||
name: 'Require answer',
|
||||
value: 'require-answer'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
logic: {
|
||||
handler () {
|
||||
this.$set(this.field, 'logic', this.logic)
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
'field.required': {
|
||||
handler () {
|
||||
this.cleanConditions()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
if (!this.field.hasOwnProperty('logic')) {
|
||||
this.$set(this.field, 'logic', this.logic)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
clearAll () {
|
||||
this.$set(this.logic, 'conditions', null)
|
||||
this.$set(this.logic, 'actions', [])
|
||||
this.refreshActions()
|
||||
},
|
||||
onActionInput () {
|
||||
if (this.logic.actions.length >= 2) {
|
||||
if (this.logic.actions[1] === 'require-answer' && this.logic.actions[0] === 'hide-block') {
|
||||
this.$set(this.logic, 'actions', ['require-answer'])
|
||||
} else if (this.logic.actions[1] === 'hide-block' && this.logic.actions[0] === 'require-answer') {
|
||||
this.$set(this.logic, 'actions', ['hide-block'])
|
||||
}
|
||||
this.refreshActions()
|
||||
}
|
||||
},
|
||||
cleanConditions () {
|
||||
if (this.required && this.logic.actions.includes('require-answer')) {
|
||||
this.$set(this.logic, 'actions', this.logic.actions.filter((action) => action !== 'require-answer'))
|
||||
} else if (!this.required && this.logic.actions.includes('make-it-optional')) {
|
||||
this.$set(this.logic, 'actions', this.logic.actions.filter((action) => action !== 'make-it-optional'))
|
||||
}
|
||||
this.resetKey++
|
||||
},
|
||||
refreshActions () {
|
||||
this.resetKey++
|
||||
},
|
||||
copyLogic () {
|
||||
if (this.copyFrom) {
|
||||
const property = this.form.properties.find((property) => {
|
||||
return property.id === this.copyFrom
|
||||
})
|
||||
if (property && property.logic) {
|
||||
this.$set(this, 'logic', clonedeep(property.logic))
|
||||
this.cleanConditions()
|
||||
}
|
||||
}
|
||||
this.showCopyFormModal = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex px-4 py-1">
|
||||
<select-input ref="ruleSelect" v-model="selectedRule" class="flex-grow mr-1"
|
||||
wrapper-class="relative" placeholder="Add condition on input field"
|
||||
:options="groupCtrl.rules" margin-bottom=""
|
||||
emit-key="identifier"
|
||||
option-key="identifier"
|
||||
name="group-control-slot-rule"
|
||||
/>
|
||||
<v-button class="ml-1" color="blue" size="small" :disabled="selectedRule === ''" @click="addRule">
|
||||
Add Condition
|
||||
</v-button>
|
||||
<v-button class="ml-1" color="green" size="small" @click="groupCtrl.newGroup">
|
||||
Add Group
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: { groupCtrl: { type: Object, required: true } },
|
||||
data () {
|
||||
return {
|
||||
selectedRule: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addRule () {
|
||||
if (this.selectedRule) {
|
||||
this.groupCtrl.addRule(this.selectedRule)
|
||||
this.$refs.ruleSelect.content = null
|
||||
this.selectedRule = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<modal :show="show" @close="close">
|
||||
<div v-if="field">
|
||||
<div class="flex">
|
||||
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue flex-grow">
|
||||
Configure "<span class="truncate">{{ field.name }}</span>" block
|
||||
</h2>
|
||||
<div>
|
||||
<v-button color="red" size="small" @click="removeBlock">
|
||||
Remove Block
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="field.type == 'nf-text'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
|
||||
<rich-text-area-input name="content"
|
||||
:form="field"
|
||||
label="Content"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="field.type == 'nf-page-break'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
|
||||
<text-input name="next_btn_text"
|
||||
:form="field"
|
||||
label="Text of next button"
|
||||
:required="true"
|
||||
/>
|
||||
<text-input name="previous_btn_text"
|
||||
:form="field"
|
||||
label="Text of previous button"
|
||||
help="Shown on the next page"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="field.type == 'nf-page-body-input'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
|
||||
<div class="-mx-4 sm:-mx-6 p-5 pt-0 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
General
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-5">
|
||||
Exclude this field or make it required.
|
||||
</p>
|
||||
<v-checkbox v-model="field.hidden" class="mb-3"
|
||||
:name="field.id+'_hidden'"
|
||||
@input="onFieldHiddenChange"
|
||||
>
|
||||
Hidden
|
||||
</v-checkbox>
|
||||
<v-checkbox v-model="field.required"
|
||||
:name="field.id+'_required'"
|
||||
@input="onFieldRequiredChange"
|
||||
>
|
||||
Required
|
||||
</v-checkbox>
|
||||
</div>
|
||||
<div class="-mx-4 sm:-mx-6 p-5">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Customization
|
||||
<pro-tag/>
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 mb-5">
|
||||
Change your form field name, pre-fill a value, add hints.
|
||||
</p>
|
||||
|
||||
<text-input name="name" class="mt-4"
|
||||
:form="field" :required="true"
|
||||
label="Field Name"
|
||||
/>
|
||||
<text-area-input name="prefill" class="mt-4"
|
||||
:form="field"
|
||||
label="Pre-filled value"
|
||||
/>
|
||||
|
||||
<!-- Placeholder -->
|
||||
<text-input name="placeholder" class="mt-4"
|
||||
:form="field"
|
||||
label="Empty Input Text (Placeholder)"
|
||||
/>
|
||||
|
||||
<!-- Help -->
|
||||
<text-input name="help" class="mt-4"
|
||||
:form="field"
|
||||
label="Field Help"
|
||||
|
||||
help="Your field help will be shown below the field, just like this message."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="field.type == 'nf-divider'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
|
||||
<text-input name="name" class="mt-4"
|
||||
:form="field" :required="true"
|
||||
label="Field Name"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="field.type == 'nf-image'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
|
||||
<text-input name="name" class="mt-4"
|
||||
:form="field" :required="true"
|
||||
label="Field Name"
|
||||
/>
|
||||
<image-input name="image_block" class="mt-4"
|
||||
:form="field" label="Upload Image" :required="false"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="-mx-4 sm:-mx-6 p-5 border-b border-t">
|
||||
<p>No settings found.</p>
|
||||
</div>
|
||||
|
||||
<!-- Logic Block -->
|
||||
<form-block-logic-editor :form="form" :field="field" v-model="form"/>
|
||||
|
||||
<div class="pt-5 text-right">
|
||||
<v-button color="gray" shade="light" @click="close">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center p-10">
|
||||
Field not found.
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ProTag from '../../../common/ProTag'
|
||||
import FormBlockLogicEditor from '../components/form-logic-components/FormBlockLogicEditor'
|
||||
|
||||
export default {
|
||||
name: 'FormBlockOptionsModal',
|
||||
components: {ProTag, FormBlockLogicEditor},
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted() {
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit('close')
|
||||
},
|
||||
removeBlock() {
|
||||
this.close()
|
||||
this.$emit('remove-block', this.field)
|
||||
},
|
||||
onFieldRequiredChange(val) {
|
||||
this.$set(this.field, 'required', val)
|
||||
if (this.field.required) {
|
||||
this.$set(this.field, 'hidden', false)
|
||||
}
|
||||
},
|
||||
onFieldHiddenChange(val) {
|
||||
this.$set(this.field, 'hidden', val)
|
||||
if (this.field.hidden) {
|
||||
this.$set(this.field, 'required', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<modal :show="show" @close="close">
|
||||
<div v-if="field">
|
||||
<div class="flex">
|
||||
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue flex-grow">
|
||||
Configure "<span class="truncate">{{ field.name }}</span>" block
|
||||
</h2>
|
||||
<div>
|
||||
<v-button color="red" size="small" @click="removeBlock">
|
||||
Remove Block
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General -->
|
||||
<div class="-mx-4 sm:-mx-6 p-5 border-b border-t">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
General
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-5">
|
||||
Exclude this field or make it required.
|
||||
</p>
|
||||
<v-checkbox v-model="field.hidden" class="mb-3"
|
||||
:name="field.id+'_hidden'"
|
||||
@input="onFieldHiddenChange"
|
||||
>
|
||||
Hidden
|
||||
</v-checkbox>
|
||||
<v-checkbox v-model="field.required" class="mb-3"
|
||||
:name="field.id+'_required'"
|
||||
@input="onFieldRequiredChange"
|
||||
>
|
||||
Required
|
||||
</v-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- File Uploads -->
|
||||
<div v-if="field.type === 'files'" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
File uploads
|
||||
</h3>
|
||||
<v-checkbox v-model="field.multiple" class="mt-4"
|
||||
:name="field.id+'_multiple'"
|
||||
>
|
||||
Allow multiple files
|
||||
</v-checkbox>
|
||||
<text-input name="allowed_file_types" class="mt-4" :form="field"
|
||||
label="Allowed file types" placeholder="jpg,jpeg,png,gif"
|
||||
help="Comma separated values, leave blank to allow all file types"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Number Options -->
|
||||
<div v-if="field.type === 'number'" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Number Options
|
||||
<pro-tag />
|
||||
</h3>
|
||||
<v-checkbox v-model="field.is_rating" class="mt-4"
|
||||
:name="field.id+'_is_rating'" @input="initRating"
|
||||
>
|
||||
Rating
|
||||
</v-checkbox>
|
||||
<p class="text-gray-400 mb-5">
|
||||
If enabled then this field will be star rating input.
|
||||
</p>
|
||||
|
||||
<text-input v-if="field.is_rating" name="rating_max_value" native-type="number" :min="1" class="mt-4"
|
||||
:form="field" required
|
||||
label="Max rating value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Text Options -->
|
||||
<div v-if="field.type === 'text' && displayBasedOnAdvanced" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Text Options
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-5">
|
||||
Keep it simple or make it a multi-lines input.
|
||||
</p>
|
||||
<v-checkbox v-model="field.multi_lines"
|
||||
:name="field.id+'_multi_lines'"
|
||||
@input="$set(field,'multi_lines',$event)"
|
||||
>
|
||||
Multi-lines input
|
||||
</v-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Date Options -->
|
||||
<div v-if="field.type === 'date'" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Date Options
|
||||
<pro-tag />
|
||||
</h3>
|
||||
<v-checkbox v-model="field.date_range" class="mt-4"
|
||||
:name="field.id+'_date_range'"
|
||||
@input="onFieldDateRangeChange"
|
||||
>
|
||||
Date Range
|
||||
</v-checkbox>
|
||||
<p class="text-gray-400 mb-5">
|
||||
Adds an end date. This cannot be used with the time option yet.
|
||||
</p>
|
||||
<v-checkbox v-model="field.with_time"
|
||||
:name="field.id+'_with_time'"
|
||||
@input="onFieldWithTimeChange"
|
||||
>
|
||||
Date with time
|
||||
</v-checkbox>
|
||||
<p class="text-gray-400 mb-5">
|
||||
Include time. Or not. This cannot be used with the date range option yet.
|
||||
</p>
|
||||
|
||||
<select-input v-if="field.with_time" name="timezone" class="mt-4"
|
||||
:form="field" :options="timezonesOptions"
|
||||
label="Timezone" :searchable="true"
|
||||
help="Make sure to select correct timezone. Leave blank otherwise."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- select/multiselect Options -->
|
||||
<div v-if="['select','multi_select'].includes(field.type)" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Select Options
|
||||
<pro-tag />
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-5">
|
||||
Advanced options for your select/multiselect fields.
|
||||
</p>
|
||||
<text-area-input v-model="optionsText" :name="field.id+'_options_text'" class="mt-4"
|
||||
@input="onFieldOptionsChange"
|
||||
label="Set selection options"
|
||||
help="Add one option per line"
|
||||
/>
|
||||
<v-checkbox v-model="field.allow_creation"
|
||||
name="allow_creation" @input="onFieldAllowCreationChange" help=""
|
||||
>
|
||||
Allow respondent to create new options
|
||||
</v-checkbox>
|
||||
<v-checkbox v-model="field.without_dropdown" class="mt-4"
|
||||
name="without_dropdown" @input="onFieldWithoutDropdownChange" help=""
|
||||
>
|
||||
Always show all select options
|
||||
</v-checkbox>
|
||||
<p class="text-gray-400 mb-5">Options won't be in a dropdown anymore, but will all be visible</p>
|
||||
</div>
|
||||
|
||||
<!-- Customization - Placeholder, Prefill, Relabel, Field Help -->
|
||||
<div v-if="displayBasedOnAdvanced" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Customization
|
||||
<pro-tag />
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 mb-5">
|
||||
Change your form field name, pre-fill a value, add hints.
|
||||
</p>
|
||||
|
||||
<text-input name="name" class="mt-4"
|
||||
:form="field" :required="true"
|
||||
label="Field Name"
|
||||
/>
|
||||
|
||||
<v-checkbox v-model="field.hide_field_name" class="mb-3"
|
||||
:name="field.id+'_hide_field_name'"
|
||||
>
|
||||
Hide field name
|
||||
</v-checkbox>
|
||||
|
||||
<!-- Pre-fill depends on type -->
|
||||
<v-checkbox v-if="field.type=='checkbox'" v-model="field.prefill" class="mt-4"
|
||||
:name="field.id+'_prefill'"
|
||||
@input="$set(field,'prefill',$event)"
|
||||
>
|
||||
Pre-filled value
|
||||
</v-checkbox>
|
||||
<select-input v-else-if="['select','multi_select'].includes(field.type)" name="prefill" class="mt-4"
|
||||
:form="field" :options="prefillSelectsOptions"
|
||||
label="Pre-filled value"
|
||||
:multiple="field.type==='multi_select'"
|
||||
/>
|
||||
<text-area-input v-else-if="field.type === 'text' && field.multi_lines"
|
||||
name="prefill" class="mt-4"
|
||||
:form="field"
|
||||
label="Pre-filled value"
|
||||
/>
|
||||
<text-input v-else-if="field.type!=='files'" name="prefill" class="mt-4"
|
||||
:form="field"
|
||||
label="Pre-filled value"
|
||||
/>
|
||||
<div v-if="['select','multi_select'].includes(field.type)" class="-mt-3 mb-3 text-gray-400 dark:text-gray-500">
|
||||
<small>
|
||||
A problem? <a href="#" @click.prevent="field.prefill=null">Click here to clear your pre-fill</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder -->
|
||||
<text-input v-if="hasPlaceholder" name="placeholder" class="mt-4"
|
||||
:form="field"
|
||||
label="Empty Input Text (Placeholder)"
|
||||
/>
|
||||
|
||||
<!-- Help -->
|
||||
<text-input name="help" class="mt-4"
|
||||
:form="field"
|
||||
label="Field Help"
|
||||
|
||||
help="Your field help will be shown below the field, just like this message."
|
||||
/>
|
||||
|
||||
<select-input name="width" class="mt-4"
|
||||
:options="[
|
||||
{name:'Full',value:'full'},
|
||||
{name:'1/2 (half width)',value:'1/2'},
|
||||
{name:'1/3 (a third of the width)',value:'1/3'},
|
||||
{name:'2/3 (two thirds of the width)',value:'2/3'},
|
||||
{name:'1/4 (a quarter of the width)',value:'1/4'},
|
||||
{name:'3/4 (three quarters of the width)',value:'3/4'},
|
||||
]"
|
||||
:form="field" label="Field Width"
|
||||
/>
|
||||
|
||||
<template v-if="['text','number','url','email','phone_number'].includes(field.type)">
|
||||
<text-input v-model="field.max_char_limit" name="max_char_limit" native-type="number" :min="1" :max="2000" :form="field"
|
||||
label="Max character limit"
|
||||
help="Maximum character limit of 2000"
|
||||
:required="false"
|
||||
/>
|
||||
<checkbox-input name="show_char_limit" :form="field" class="mt-4"
|
||||
label="Always show character limit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options -->
|
||||
<div v-if="field.type === 'text'" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Advanced Options
|
||||
<pro-tag />
|
||||
</h3>
|
||||
|
||||
<v-checkbox v-model="field.generates_uuid"
|
||||
:name="field.id+'_generates_uuid'"
|
||||
@input="onFieldGenUIdChange"
|
||||
>
|
||||
Generates a unique id on submission
|
||||
</v-checkbox>
|
||||
<p class="text-gray-400 mb-5">
|
||||
If you enable this, we will hide this field and fill it a unique id (UUID format) on each new form submission
|
||||
</p>
|
||||
|
||||
<v-checkbox v-model="field.generates_auto_increment_id"
|
||||
:name="field.id+'_generates_auto_increment_id'"
|
||||
@input="onFieldGenAutoIdChange"
|
||||
>
|
||||
Generates an auto-incremented id on submission
|
||||
</v-checkbox>
|
||||
<p class="text-gray-400 mb-5">
|
||||
If you enable this, we will hide this field and fill it a unique number on each new form submission
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Logic Block -->
|
||||
<form-block-logic-editor v-model="form" :form="form" :field="field" />
|
||||
|
||||
<div class="pt-5 text-right">
|
||||
<v-button color="red" @click="removeBlock">
|
||||
Remove Field
|
||||
</v-button>
|
||||
<v-button color="gray" shade="light" @click="close">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center p-10">
|
||||
Field not found.
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import VButton from '../../../common/Button'
|
||||
import ProTag from '../../../common/ProTag'
|
||||
import TextInput from '../../../forms/TextInput'
|
||||
import TextAreaInput from '../../../forms/TextAreaInput'
|
||||
import timezones from '../../../../../data/timezones.json'
|
||||
import FormBlockLogicEditor from '../components/form-logic-components/FormBlockLogicEditor'
|
||||
|
||||
export default {
|
||||
name: 'FormFieldOptionsModal',
|
||||
components: { TextAreaInput, TextInput, ProTag, VButton, FormBlockLogicEditor },
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
typesWithoutPlaceholder: ['date', 'checkbox', 'files']
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasPlaceholder () {
|
||||
return !this.typesWithoutPlaceholder.includes(this.field.type)
|
||||
},
|
||||
prefillSelectsOptions () {
|
||||
if (!['select', 'multi_select'].includes(this.field.type)) return {}
|
||||
|
||||
return this.field[this.field.type].options.map(option => {
|
||||
return {
|
||||
name: option.name,
|
||||
value: option.id
|
||||
}
|
||||
})
|
||||
},
|
||||
timezonesOptions () {
|
||||
if (this.field.type !== 'date') return []
|
||||
return timezones.map((timezone) => {
|
||||
return {
|
||||
name: timezone.text,
|
||||
value: timezone.utc[0]
|
||||
}
|
||||
})
|
||||
},
|
||||
displayBasedOnAdvanced () {
|
||||
if (this.field.generates_uuid || this.field.generates_auto_increment_id) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
optionsText(){
|
||||
return this.field[this.field.type].options.map(option => {
|
||||
return option.name
|
||||
}).join("\n")
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted () {
|
||||
if(['text','number','url','email','phone_number'].includes(this.field.type) && !this.field.max_char_limit){
|
||||
this.field.max_char_limit = 2000
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
close () {
|
||||
this.$emit('close')
|
||||
},
|
||||
removeBlock () {
|
||||
this.close()
|
||||
this.$emit('remove-block', this.field)
|
||||
},
|
||||
onFieldRequiredChange (val) {
|
||||
this.$set(this.field, 'required', val)
|
||||
if (this.field.required) {
|
||||
this.$set(this.field, 'hidden', false)
|
||||
}
|
||||
},
|
||||
onFieldHiddenChange (val) {
|
||||
this.$set(this.field, 'hidden', val)
|
||||
if (this.field.hidden) {
|
||||
this.$set(this.field, 'required', false)
|
||||
} else {
|
||||
this.$set(this.field, 'generates_uuid', false)
|
||||
this.$set(this.field, 'generates_auto_increment_id', false)
|
||||
}
|
||||
},
|
||||
onFieldDateRangeChange (val) {
|
||||
this.$set(this.field, 'date_range', val)
|
||||
if (this.field.date_range) {
|
||||
this.$set(this.field, 'with_time', false)
|
||||
}
|
||||
},
|
||||
onFieldWithTimeChange (val) {
|
||||
this.$set(this.field, 'with_time', val)
|
||||
if (this.field.with_time) {
|
||||
this.$set(this.field, 'date_range', false)
|
||||
}
|
||||
},
|
||||
onFieldGenUIdChange (val) {
|
||||
this.$set(this.field, 'generates_uuid', val)
|
||||
if (this.field.generates_uuid) {
|
||||
this.$set(this.field, 'generates_auto_increment_id', false)
|
||||
this.$set(this.field, 'hidden', true)
|
||||
}
|
||||
},
|
||||
onFieldGenAutoIdChange (val) {
|
||||
this.$set(this.field, 'generates_auto_increment_id', val)
|
||||
if (this.field.generates_auto_increment_id) {
|
||||
this.$set(this.field, 'generates_uuid', false)
|
||||
this.$set(this.field, 'hidden', true)
|
||||
}
|
||||
},
|
||||
initRating () {
|
||||
if (this.field.is_rating && !this.field.rating_max_value) {
|
||||
this.$set(this.field, 'rating_max_value', 5)
|
||||
}
|
||||
},
|
||||
onFieldOptionsChange (val) {
|
||||
const vals = (val) ? val.trim().split("\n") : []
|
||||
const tmpOpts = vals.map(name => {
|
||||
return {
|
||||
name: name,
|
||||
id: name
|
||||
}
|
||||
})
|
||||
this.$set(this.field, this.field.type, {'options': tmpOpts})
|
||||
},
|
||||
onFieldAllowCreationChange (val) {
|
||||
this.$set(this.field, 'allow_creation', val)
|
||||
if(this.field.allow_creation){
|
||||
this.$set(this.field, 'without_dropdown', false)
|
||||
}
|
||||
},
|
||||
onFieldWithoutDropdownChange (val) {
|
||||
this.$set(this.field, 'without_dropdown', val)
|
||||
if(this.field.without_dropdown){
|
||||
this.$set(this.field, 'allow_creation', false)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
369
resources/js/components/open/tables/OpenTable.vue
Normal file
369
resources/js/components/open/tables/OpenTable.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<table :id="'table-'+tableHash" ref="table"
|
||||
class="notion-table n-table whitespace-no-wrap bg-white dark:bg-notion-dark-light relative"
|
||||
>
|
||||
<thead :id="'table-header-'+tableHash" ref="header"
|
||||
class="n-table-head top-0"
|
||||
:class="{'absolute': data.length !== 0}"
|
||||
style="will-change: transform; transform: translate3d(0px, 0px, 0px)"
|
||||
>
|
||||
<tr class="n-table-row overflow-x-hidden">
|
||||
<resizable-th v-for="col, index in form.properties" :id="'table-head-cell-' + col.id" :key="col.id"
|
||||
scope="col" :allow-resize="allowResize" :width="(col.width ? col.width + 'px':'auto')"
|
||||
class="n-table-cell p-0 relative"
|
||||
@resize-width="resizeCol(col, $event)"
|
||||
>
|
||||
<p
|
||||
:class="{'border-r': index < form.properties.length - 1 || hasActions}"
|
||||
class="bg-gray-50 dark:bg-notion-dark truncate sticky top-0 border-b border-gray-200 dark:border-gray-800 px-4 py-2 text-gray-500 font-semibold tracking-wider uppercase text-xs"
|
||||
>
|
||||
{{ col.name }}
|
||||
</p>
|
||||
</resizable-th>
|
||||
<th v-if="hasActions" class="n-table-cell p-0 relative" style="width: 91px">
|
||||
<p
|
||||
class="bg-gray-50 dark:bg-notion-dark truncate sticky top-0 border-b border-gray-200 dark:border-gray-800 px-4 py-2 text-gray-500 font-semibold tracking-wider uppercase text-xs">
|
||||
Actions
|
||||
</p>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="data.length > 0" class="n-table-body bg-white dark:bg-notion-dark-light">
|
||||
<tr v-if="$slots.hasOwnProperty('actions')"
|
||||
:id="'table-actions-'+tableHash"
|
||||
ref="actions-row"
|
||||
class="action-row absolute w-full"
|
||||
style="will-change: transform; transform: translate3d(0px, 32px, 0px)"
|
||||
>
|
||||
<td :colspan="form.properties.length" class="p-1">
|
||||
<slot name="actions"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="row, index in data" :key="row.id" class="n-table-row" :class="{'first':index===0}">
|
||||
<td v-for="col, colIndex in form.properties"
|
||||
:key="col.id"
|
||||
:style="{width: col.width + 'px'}"
|
||||
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 overflow-hidden"
|
||||
:class="[{'border-b': index !== data.length - 1, 'border-r': colIndex !== form.properties.length - 1 || hasActions},
|
||||
colClasses(col)]"
|
||||
>
|
||||
<component :is="fieldComponents[col.type]" class="border-gray-100 dark:border-gray-900"
|
||||
:property="col" :value="row[col.id]"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loading" class="n-table-row border-t bg-gray-50 dark:bg-gray-900">
|
||||
<td :colspan="form.properties.length" class="p-8 w-full">
|
||||
<loader class="w-4 h-4 mx-auto"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else key="body-content" class="n-table-body">
|
||||
<tr class="n-table-row loader w-full">
|
||||
<td :colspan="form.properties.length" class="n-table-cell w-full p-8">
|
||||
<loader v-if="loading" class="w-4 h-4 mx-auto"/>
|
||||
<p v-else class="text-gray-500 text-center">
|
||||
No data found.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OpenText from './components/OpenText'
|
||||
import OpenUrl from './components/OpenUrl'
|
||||
import OpenSelect from './components/OpenSelect'
|
||||
import OpenDate from './components/OpenDate'
|
||||
import OpenFile from './components/OpenFile'
|
||||
import OpenCheckbox from './components/OpenCheckbox'
|
||||
import ResizableTh from './components/ResizableTh'
|
||||
import clonedeep from 'clone-deep'
|
||||
|
||||
const cyrb53 = function (str, seed = 0) {
|
||||
let h1 = 0xdeadbeef ^ seed
|
||||
let h2 = 0x41c6ce57 ^ seed
|
||||
for (let i = 0, ch; i < str.length; i++) {
|
||||
ch = str.charCodeAt(i)
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761)
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677)
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
|
||||
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {ResizableTh},
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowResize: {
|
||||
required: false,
|
||||
default: true,
|
||||
type: Boolean
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
tableHash: null,
|
||||
skip: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
form: {
|
||||
get() {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
hasActions() {
|
||||
return false
|
||||
},
|
||||
fieldComponents() {
|
||||
return {
|
||||
text: OpenText,
|
||||
number: OpenText,
|
||||
select: OpenSelect,
|
||||
multi_select: OpenSelect,
|
||||
date: OpenDate,
|
||||
files: OpenFile,
|
||||
checkbox: OpenCheckbox,
|
||||
url: OpenUrl,
|
||||
email: OpenText,
|
||||
phone_number: OpenText,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'form.properties': {
|
||||
handler() {
|
||||
this.onStructureChange()
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
data() {
|
||||
this.$nextTick(() => {
|
||||
this.handleScroll()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const parent = document.getElementById('table-page')
|
||||
this.tableHash = cyrb53(JSON.stringify(this.form.properties))
|
||||
parent.addEventListener('scroll', this.handleScroll, {passive: true})
|
||||
window.addEventListener('resize', this.handleScroll)
|
||||
this.onStructureChange()
|
||||
this.handleScroll()
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
const parent = document.getElementById('table-page')
|
||||
parent.removeEventListener('scroll', this.handleScroll)
|
||||
window.removeEventListener('resize', this.handleScroll)
|
||||
},
|
||||
|
||||
methods: {
|
||||
colClasses(col) {
|
||||
let colAlign, colColor, colFontWeight, colWrap
|
||||
|
||||
// Column align
|
||||
colAlign = `text-${col.alignment ? col.alignment : 'left'}`
|
||||
|
||||
// Column color
|
||||
colColor = null
|
||||
if (!col.hasOwnProperty('color') || col.color === 'default') {
|
||||
colColor = 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
colColor = `text-${col.color}`
|
||||
|
||||
// Column font weight
|
||||
if (col.hasOwnProperty('bold') && col.bold) {
|
||||
colFontWeight = 'font-semibold'
|
||||
}
|
||||
|
||||
// Column wrapping
|
||||
if (!col.hasOwnProperty('wrap_text') || !col.wrap_text) {
|
||||
colWrap = 'truncate'
|
||||
}
|
||||
|
||||
return [colAlign, colColor, colWrap, colFontWeight]
|
||||
},
|
||||
onStructureChange() {
|
||||
if (this.form.properties) {
|
||||
this.$nextTick(() => {
|
||||
this.form.properties.forEach(col => {
|
||||
if (!col.hasOwnProperty('width')) {
|
||||
if (this.allowResize && this.form !== null && document.getElementById('table-head-cell-' + col.id)) {
|
||||
// Within editor
|
||||
this.resizeCol(col, document.getElementById('table-head-cell-' + col.id).offsetWidth)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
resizeCol(col, width) {
|
||||
if (!this.form) return
|
||||
const columns = clonedeep(this.form.properties)
|
||||
const index = this.form.properties.findIndex(c => c.id === col.id)
|
||||
columns[index].width = width
|
||||
this.$set(this.form, 'properties', columns)
|
||||
this.$nextTick(() => {
|
||||
this.$emit('resize')
|
||||
})
|
||||
},
|
||||
handleScroll() {
|
||||
const parent = document.getElementById('table-page')
|
||||
const posTop = parent.getBoundingClientRect().top
|
||||
const tablePosition = Math.max(0, posTop - this.$refs.table.getBoundingClientRect().top)
|
||||
const tableHeader = document.getElementById('table-header-' + this.tableHash)
|
||||
|
||||
// Set position of table header
|
||||
if (tableHeader) {
|
||||
tableHeader.style.transform = `translate3d(0px, ${tablePosition}px, 0px)`
|
||||
if (tablePosition > 0) {
|
||||
tableHeader.classList.add('border-t')
|
||||
} else {
|
||||
tableHeader.classList.remove('border-t')
|
||||
}
|
||||
}
|
||||
|
||||
// Set position of actions row
|
||||
if (this.$slots.hasOwnProperty('actions')) {
|
||||
const tableActionsRow = document.getElementById('table-actions-' + this.tableHash)
|
||||
if (tableActionsRow) {
|
||||
if (tablePosition > 100) {
|
||||
tableActionsRow.style.transform = `translate3d(0px, ${tablePosition + 33}px, 0px)`
|
||||
} else {
|
||||
const parentContainer = document.getElementById('table-page')
|
||||
tableActionsRow.style.transform = `translate3d(0px, ${parentContainer.offsetHeight + (posTop - this.$refs.table.getBoundingClientRect().top) - 35}px, 0px)`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.n-table {
|
||||
.n-table-head {
|
||||
height: 33px;
|
||||
|
||||
.resize-handler {
|
||||
height: 33px;
|
||||
width: 5px;
|
||||
margin-left: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.n-table-row {
|
||||
display: flex;
|
||||
|
||||
&.first, &.loader {
|
||||
margin-top: 33px;
|
||||
}
|
||||
}
|
||||
|
||||
.n-table-cell {
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.notion-table {
|
||||
|
||||
td {
|
||||
&.text-gray {
|
||||
color: #787774;
|
||||
}
|
||||
|
||||
&.text-brown {
|
||||
color: #9f6b53;
|
||||
}
|
||||
|
||||
&.text-orange {
|
||||
color: #d9730d;
|
||||
}
|
||||
|
||||
&.text-yellow {
|
||||
color: #cb912f;
|
||||
}
|
||||
|
||||
&.text-green {
|
||||
color: #448361;
|
||||
}
|
||||
|
||||
&.text-blue {
|
||||
color: #337ea9;
|
||||
}
|
||||
|
||||
&.text-purple {
|
||||
color: #9065b0;
|
||||
}
|
||||
|
||||
&.text-pink {
|
||||
color: #c14c8a;
|
||||
}
|
||||
|
||||
&.text-red {
|
||||
color: #d44c47;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.notion-table {
|
||||
td {
|
||||
&.text-gray {
|
||||
color: #9b9b9b;
|
||||
}
|
||||
|
||||
&.text-brown {
|
||||
color: #ba856f;
|
||||
}
|
||||
|
||||
&.text-orange {
|
||||
color: #c77d48;
|
||||
}
|
||||
|
||||
&.text-yellow {
|
||||
color: #ca9849;
|
||||
}
|
||||
|
||||
&.text-green {
|
||||
color: #529e72;
|
||||
}
|
||||
|
||||
&.text-blue {
|
||||
color: #5e87c9;
|
||||
}
|
||||
|
||||
&.text-purple {
|
||||
color: #9d68d3;
|
||||
}
|
||||
|
||||
&.text-pink {
|
||||
color: #d15796;
|
||||
}
|
||||
|
||||
&.text-red {
|
||||
color: #df5452;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<svg v-if="value===true" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mx-auto" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<svg v-else-if="value===false" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mx-auto" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
35
resources/js/components/open/tables/components/OpenDate.vue
Normal file
35
resources/js/components/open/tables/components/OpenDate.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<span v-if="valueIsObject">
|
||||
<template v-if="value[0]">{{ value[0] }}</template>
|
||||
<template v-if="value[1]"><b>to</b> {{ value[1] }}</template>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
valueIsObject () {
|
||||
if (typeof this.value === 'object' && this.value !== null) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
71
resources/js/components/open/tables/components/OpenFile.vue
Normal file
71
resources/js/components/open/tables/components/OpenFile.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<p class="text-xs">
|
||||
<span v-for="file in value" :key="file.file_url"
|
||||
class="whitespace-nowrap rounded-md transition-colors hover:decoration-none"
|
||||
:class="{'open-file text-gray-700 dark:text-gray-300 truncate':!isImage(file.file_url), 'open-file-img':isImage(file.file_url)}"
|
||||
>
|
||||
<a class="text-gray-700 dark:text-gray-300" :href="file.file_url" target="_blank"
|
||||
rel="nofollow"
|
||||
>
|
||||
<div v-if="isImage(file.file_url)" class="w-8 h-8">
|
||||
<img class="object-cover h-full w-full rounded" :src="file.file_url">
|
||||
</div>
|
||||
<span v-else
|
||||
class="py-1 px-2"
|
||||
>
|
||||
<a :href="file.file_url" target="_blank" download>{{ displayedFileName(file.file_name) }}</a>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {},
|
||||
mounted() {
|
||||
},
|
||||
|
||||
methods: {
|
||||
isImage(url) {
|
||||
return ['png', 'gif', 'jpg', 'jpeg', 'tif'].some((suffix) => {
|
||||
return url && url.endsWith(suffix)
|
||||
})
|
||||
},
|
||||
displayedFileName(fileName) {
|
||||
const extension = fileName.substr(fileName.lastIndexOf(".") + 1)
|
||||
const filename = fileName.substr(0, fileName.lastIndexOf("."))
|
||||
|
||||
if (filename.length > 12) {
|
||||
return filename.substr(0, 12) + '(...).' + extension
|
||||
}
|
||||
return filename + '.' + extension
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.open-file {
|
||||
max-width: 120px;
|
||||
background-color: #e3e2e0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.open-file {
|
||||
background-color: #5a5a5a;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<span class="-mb-2" v-if="value">
|
||||
<template v-if="valueIsObject">
|
||||
<open-tag v-for="val,index in value" :key="index" :opt="val" />
|
||||
</template>
|
||||
<open-tag v-else :opt="value" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OpenTag from './OpenTag'
|
||||
|
||||
export default {
|
||||
components: { OpenTag },
|
||||
props: {
|
||||
value: {
|
||||
type: Object | null,
|
||||
required: true
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
valueIsObject () {
|
||||
if (typeof this.value === 'object' && this.value !== null) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
101
resources/js/components/open/tables/components/OpenTag.vue
Normal file
101
resources/js/components/open/tables/components/OpenTag.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<span :id="opt"
|
||||
class="py-1 px-2 mb-1 open-tag default mr-2 text-gray-700 dark:text-gray-300 text-xs whitespace-nowrap rounded-md transition-colors"
|
||||
>
|
||||
{{ opt }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
opt: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.open-tag {
|
||||
display: inline-block;
|
||||
&.gray {
|
||||
background-color: #e3e2e0;
|
||||
}
|
||||
&.light-gray,&.default {
|
||||
background-color: #e3e2e080;
|
||||
}
|
||||
&.brown {
|
||||
background-color: #eee0da;
|
||||
}
|
||||
&.orange {
|
||||
background-color: #fadec9;
|
||||
}
|
||||
&.yellow {
|
||||
background-color: #fdecc8;
|
||||
}
|
||||
&.green {
|
||||
background-color: #dbeddb;
|
||||
}
|
||||
&.blue {
|
||||
background-color: #d3e5ef;
|
||||
}
|
||||
&.purple {
|
||||
background-color: #e8deee;
|
||||
}
|
||||
&.pink {
|
||||
background-color: #f5e0e9;
|
||||
}
|
||||
&.red {
|
||||
background-color: #ffe2dd;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.open-tag {
|
||||
&.gray {
|
||||
background-color: #5a5a5a;
|
||||
}
|
||||
&.light-gray,&.default {
|
||||
background-color: #ffffff21;
|
||||
}
|
||||
&.brown {
|
||||
background-color: #603b2c;
|
||||
}
|
||||
&.orange {
|
||||
background-color: #854c1d;
|
||||
}
|
||||
&.yellow {
|
||||
background-color: #89632a;
|
||||
}
|
||||
&.green {
|
||||
background-color: #2b593f;
|
||||
}
|
||||
&.blue {
|
||||
background-color: #28456c;
|
||||
}
|
||||
&.purple {
|
||||
background-color: #492f64;
|
||||
}
|
||||
&.pink {
|
||||
background-color: #69314c;
|
||||
}
|
||||
&.red {
|
||||
background-color: #6e3630;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
99
resources/js/components/open/tables/components/OpenText.vue
Normal file
99
resources/js/components/open/tables/components/OpenText.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<span v-if="!valueIsObject">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<span
|
||||
v-for="(item, i) in value.responseData"
|
||||
:key="i"
|
||||
:class="{
|
||||
'font-semibold': item.annotations.bold && !item.annotations.code,
|
||||
italic: item.annotations.italic,
|
||||
'line-through': item.annotations.strikethrough,
|
||||
underline: item.annotations.underline,
|
||||
'bg-pink-100 py-1 px-2 rounded-lg text-pink-500': item.annotations.code,
|
||||
'font-serif': item.type == 'equation',
|
||||
}"
|
||||
:style="{
|
||||
color:
|
||||
item.annotations.color != 'default'
|
||||
? getColor(item.annotations.color)
|
||||
: null,
|
||||
'background-color':
|
||||
item.annotations.color != 'default' &&
|
||||
item.annotations.color.split('_')[1]
|
||||
? getBgColor(item.annotations.color.split('_')[0])
|
||||
: 'none',
|
||||
}"
|
||||
>
|
||||
<a
|
||||
v-if="item.href"
|
||||
:href="item.href"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
class="text-blue-600 underline"
|
||||
>{{ item.plain_text }}</a>
|
||||
<span v-else-if="!item.href">{{ item.plain_text }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
valueIsObject () {
|
||||
if (
|
||||
typeof this.value === 'object' &&
|
||||
!Array.isArray(this.value) &&
|
||||
this.value !== null
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
getColor (color) {
|
||||
return {
|
||||
red: '#e03e3e',
|
||||
gray: '#9b9a97',
|
||||
brown: '#64473a',
|
||||
orange: '#d9730d',
|
||||
yellow: '#dfab01',
|
||||
teal: '#0f7b6c',
|
||||
blue: '#0b6e99',
|
||||
purple: '#6940a5',
|
||||
pink: '#ad1a72'
|
||||
}[color]
|
||||
},
|
||||
getBgColor (color) {
|
||||
return {
|
||||
red: '#fbe4e4',
|
||||
gray: '#ebeced',
|
||||
brown: '#e9e5e3',
|
||||
orange: '#faebdd',
|
||||
yellow: '#fbf3db',
|
||||
teal: '#ddedea',
|
||||
blue: '#ddebf1',
|
||||
purple: '#eae4f2',
|
||||
pink: '#f4dfeb'
|
||||
}[color]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
26
resources/js/components/open/tables/components/OpenUrl.vue
Normal file
26
resources/js/components/open/tables/components/OpenUrl.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<a class="text-gray-700 dark:text-gray-300 hover:underline" :href="value" target="_blank" rel="nofollow">{{ value }}</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<th ref="th" :style="{width: width}">
|
||||
<slot />
|
||||
<div v-if="allowResize" class="absolute right-0 top-0 w-0 z-10">
|
||||
<div class="resize-handler bg-transparent cursor-move hover:bg-blue-500 opacity-80 transition-colors"
|
||||
@mousedown="mouseDownHandler"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
allowResize: {
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
x: 0,
|
||||
w: 0
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
mouseDownHandler (e) {
|
||||
// Get the current mouse position
|
||||
this.x = e.clientX
|
||||
|
||||
// Calculate the dimension of element
|
||||
const styles = window.getComputedStyle(this.$refs.th)
|
||||
this.w = parseInt(styles.width, 10)
|
||||
|
||||
// Attach the listeners to `document`
|
||||
document.addEventListener('mousemove', this.mouseMoveHandler)
|
||||
document.addEventListener('mouseup', this.mouseUpHandler)
|
||||
},
|
||||
mouseMoveHandler (e) {
|
||||
// How far the mouse has been moved
|
||||
const dx = e.clientX - this.x
|
||||
|
||||
// Adjust the dimension of element
|
||||
this.$emit('resize-width', this.w + dx)
|
||||
},
|
||||
mouseUpHandler () {
|
||||
// Remove the handlers of `mousemove` and `mouseup`
|
||||
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
||||
document.removeEventListener('mouseup', this.mouseUpHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
183
resources/js/components/pages/OpenFormFooter.vue
Normal file
183
resources/js/components/pages/OpenFormFooter.vue
Normal 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>
|
||||
55
resources/js/components/pages/StopImpersonation.vue
Normal file
55
resources/js/components/pages/StopImpersonation.vue
Normal 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>
|
||||
102
resources/js/components/pages/forms/NewFeatures.vue
Normal file
102
resources/js/components/pages/forms/NewFeatures.vue
Normal 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>
|
||||
78
resources/js/components/pages/forms/UrlFormPrefillModal.vue
Normal file
78
resources/js/components/pages/forms/UrlFormPrefillModal.vue
Normal 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>
|
||||
202
resources/js/components/pages/welcome/Features.vue
Normal file
202
resources/js/components/pages/welcome/Features.vue
Normal 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>
|
||||
45
resources/js/components/pages/welcome/Testimonials.vue
Normal file
45
resources/js/components/pages/welcome/Testimonials.vue
Normal 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>
|
||||
97
resources/js/components/service/Amplitude.vue
Normal file
97
resources/js/components/service/Amplitude.vue
Normal 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>
|
||||
34
resources/js/components/service/Crisp.vue
Normal file
34
resources/js/components/service/Crisp.vue
Normal 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>
|
||||
49
resources/js/components/service/Hotjar.vue
Normal file
49
resources/js/components/service/Hotjar.vue
Normal 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
81
resources/js/config/form-themes.js
vendored
Normal 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'
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
276
resources/js/forms/FormLogicConditionChecker.js
vendored
Normal file
276
resources/js/forms/FormLogicConditionChecker.js
vendored
Normal 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
|
||||
}
|
||||
45
resources/js/forms/FormLogicPropertyResolver.js
vendored
Normal file
45
resources/js/forms/FormLogicPropertyResolver.js
vendored
Normal 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
39
resources/js/lang/en.json
Normal 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
34
resources/js/lang/es.json
Normal 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
39
resources/js/lang/fr.json
Normal 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."
|
||||
}
|
||||
39
resources/js/lang/pt-BR.json
Normal file
39
resources/js/lang/pt-BR.json
Normal 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
Reference in New Issue
Block a user