This commit is contained in:
Julien Nahum 2024-04-18 09:51:53 +02:00
commit fe367a784c
228 changed files with 17050 additions and 8786 deletions

19
client/.eslintrc.cjs Normal file
View File

@ -0,0 +1,19 @@
module.exports = {
root: true,
extends: ['@nuxt/eslint-config'],
parser: 'vue-eslint-parser',
env: {
browser: true,
node: true,
},
extends: ['@nuxt/eslint-config'],
rules: {
'vue/require-default-prop': 'off',
'vue/no-mutating-props': 'off',
semi: ['error', 'never'],
'vue/no-v-html': 'off',
'prefer-rest-params': 'off',
'vue/valid-template-root': 'off',
'no-undef': 'off',
},
}

View File

@ -1,103 +1,123 @@
<template> <template>
<div id="app" class="bg-white dark:bg-notion-dark"> <div
<transition enter-active-class="linear duration-200 overflow-hidden" id="app"
enter-from-class="max-h-0" class="bg-white dark:bg-notion-dark"
enter-to-class="max-h-screen" >
leave-active-class="linear duration-200 overflow-hidden" <transition
leave-from-class="max-h-screen" enter-active-class="linear duration-200 overflow-hidden"
leave-to-class="max-h-0" enter-from-class="max-h-0"
enter-to-class="max-h-screen"
leave-active-class="linear duration-200 overflow-hidden"
leave-from-class="max-h-screen"
leave-to-class="max-h-0"
> >
<div v-if="announcement && !isIframe" class="bg-nt-blue text-white text-center p-3 relative"> <div
<a class="text-white font-semibold" href="" target="_blank">🚨 v-if="announcement && !isIframe"
OpnForm beta is over 🚨</a> class="bg-nt-blue text-white text-center p-3 relative"
<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"> <a
<path fill-rule="evenodd" class="text-white font-semibold"
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" href=""
clip-rule="evenodd" 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> </svg>
</div> </div>
</div> </div>
</transition> </transition>
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLoadingIndicator color="#2563eb"/>
<NuxtLayout> <NuxtLayout>
<NuxtPage/> <NuxtPage />
</NuxtLayout> </NuxtLayout>
<ToolsStopImpersonation/> <ToolsStopImpersonation />
<Notifications /> <Notifications />
<feature-base/> <feature-base />
</div> </div>
</template> </template>
<script> <script>
import {computed} from 'vue' import { computed } from "vue"
import {useAppStore} from '~/stores/app' import { useAppStore } from "~/stores/app"
import FeatureBase from "~/components/vendor/FeatureBase.vue"; import FeatureBase from "~/components/vendor/FeatureBase.vue"
export default { export default {
el: '#app', el: "#app",
name: 'OpnForm', name: "OpnForm",
components: {FeatureBase}, components: { FeatureBase },
setup() { setup() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
useOpnSeoMeta({ useOpnSeoMeta({
title: 'OpnForm', title: "OpnForm",
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.', description:
ogImage: '/img/social-preview.jpg', "Create beautiful forms for free. Unlimited fields, unlimited submissions. It's free and it takes less than 1 minute to create your first form.",
ogImage: "/img/social-preview.jpg",
robots: () => { robots: () => {
return config.public.env === 'production' ? null : 'noindex, nofollow' return config.public.env === "production" ? null : "noindex, nofollow"
} },
}) })
useHead({ useHead({
titleTemplate: (titleChunk) => { titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} - OpnForm` : 'OpnForm'; return titleChunk ? `${titleChunk} - OpnForm` : "OpnForm"
} },
}) })
const appStore = useAppStore() const appStore = useAppStore()
return { return {
layout: computed(() => appStore.layout), layout: computed(() => appStore.layout),
isIframe: useIsIframe() isIframe: useIsIframe(),
} }
}, },
mounted() {
useCrisp().showChat()
},
data: () => ({ data: () => ({
announcement: false, announcement: false,
alert: { alert: {
type: null, type: null,
autoClose: 0, autoClose: 0,
message: '', message: "",
confirmationProceed: null, confirmationProceed: null,
confirmationCancel: null confirmationCancel: null,
}, },
navbarHidden: false navbarHidden: false,
}), }),
computed: { computed: {
isOnboardingPage() { isOnboardingPage() {
return this.$route.name === 'onboarding' return this.$route.name === "onboarding"
}, },
}, },
mounted() {
useCrisp().showChat()
},
methods: { methods: {
workspaceAdded() { workspaceAdded() {
this.$router.push({name: 'home'}) this.$router.push({ name: "home" })
}, },
hideNavbar(hidden = true) { hideNavbar(hidden = true) {
this.navbarHidden = hidden this.navbarHidden = hidden
} },
} },
} }
</script> </script>

View File

@ -4,9 +4,18 @@
<span /> <span />
</template> </template>
<v-checkbox :id="id?id:name" v-model="compVal" :disabled="disabled?true:null" :name="name"> <v-checkbox
:id="id ? id : name"
v-model="compVal"
:disabled="disabled ? true : null"
:name="name"
>
<slot name="label"> <slot name="label">
{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span> {{ label }}
<span
v-if="required"
class="text-red-500 required-dot"
>*</span>
</slot> </slot>
</v-checkbox> </v-checkbox>
@ -21,28 +30,28 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from './useFormInput.js' import { inputProps, useFormInput } from "./useFormInput.js"
import VCheckbox from './components/VCheckbox.vue' import VCheckbox from "./components/VCheckbox.vue"
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from "./components/InputWrapper.vue"
export default { export default {
name: 'CheckboxInput', name: "CheckboxInput",
components: { InputWrapper, VCheckbox }, components: { InputWrapper, VCheckbox },
props: { props: {
...inputProps ...inputProps,
}, },
setup (props, context) { setup(props, context) {
return { return {
...useFormInput(props, context) ...useFormInput(props, context),
} }
}, },
mounted () { mounted() {
if (!this.compVal) { if (!this.compVal) {
this.compVal = false this.compVal = false
} }
} },
} }
</script> </script>

View File

@ -1,7 +1,5 @@
<template> <template>
<input-wrapper <input-wrapper v-bind="inputWrapperProps">
v-bind="inputWrapperProps"
>
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
@ -11,12 +9,23 @@
</template> </template>
<div <div
:class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]" :class="[
theme.CodeInput.input,
{
'!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled,
},
]"
> >
<codemirror :id="id?id:name" v-model="compVal" :disabled="disabled?true:null" <codemirror
:extensions="extensions" :id="id ? id : name"
:style="inputStyle" :name="name" :tab-size="4" v-model="compVal"
:placeholder="placeholder" :disabled="disabled ? true : null"
:extensions="extensions"
:style="inputStyle"
:name="name"
:tab-size="4"
:placeholder="placeholder"
/> />
</div> </div>
@ -27,25 +36,25 @@
</template> </template>
<script> <script>
import { Codemirror } from 'vue-codemirror' import { Codemirror } from "vue-codemirror"
import {html} from '@codemirror/lang-html' import { html } from "@codemirror/lang-html"
import { inputProps, useFormInput } from './useFormInput.js' import { inputProps, useFormInput } from "./useFormInput.js"
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from "./components/InputWrapper.vue"
export default { export default {
components: { InputWrapper, Codemirror }, components: { InputWrapper, Codemirror },
props: { props: {
...inputProps ...inputProps,
}, },
setup (props, context) { setup(props, context) {
const extensions = [html()] const extensions = [html()]
return { return {
...useFormInput(props, context), ...useFormInput(props, context),
extensions extensions,
} }
} },
} }
</script> </script>

View File

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

View File

@ -3,60 +3,117 @@
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
<div v-if="cameraUpload && isInWebcam" class="hidden sm:block w-full min-h-40"> <div
<camera-upload v-if="cameraUpload" @uploadImage="cameraFileUpload" @stopWebcam="isInWebcam=false" :theme="theme"/> v-if="cameraUpload && isInWebcam"
</div> class="hidden sm:block w-full min-h-40"
<div v-else class="flex flex-col w-full items-center justify-center transition-colors duration-40"
:class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
[theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent,
['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading}, theme.fileInput.input]"
@dragover.prevent="uploadDragoverEvent=true"
@dragleave.prevent="uploadDragoverEvent=false"
@drop.prevent="onUploadDropEvent"
@click="openFileUpload"
> >
<div class="flex w-full items-center justify-center"> <camera-upload
<div v-if="cameraUpload"
v-if="loading" :theme="theme"
class="text-gray-600 dark:text-gray-400" @upload-image="cameraFileUpload"
> @stop-webcam="isInWebcam=false"
<Loader class="mx-auto h-6 w-6" /> />
<p class="mt-2 text-center text-sm text-gray-500">
Uploading your file...
</p>
</div>
<template v-else>
<div class="text-center">
<input ref="actual-input" class="hidden" :multiple="multiple" type="file" :name="name"
:accept="acceptExtensions" @change="manualFileUpload">
<div v-if="files.length" class="flex flex-wrap items-center justify-center gap-4">
<uploaded-file v-for="file in files" :key="file.url" :file="file" :theme="theme"
@remove="clearFile(file)" />
</div>
<template v-else>
<div class="text-gray-500 w-full flex justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
</div>
<p class="mt-2 text-sm text-gray-500 font-semibold select-none">
Click to choose {{ multiple ? 'file(s)' : 'a file' }} or drag here
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-600 select-none">
Size limit: {{ mbLimit }}MB per file
</p>
</template>
</div>
</template>
</div> </div>
<div
v-else
class="flex flex-col w-full items-center justify-center transition-colors duration-40"
:class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
[theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent,
['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading}, theme.fileInput.input]"
@dragover.prevent="uploadDragoverEvent=true"
@dragleave.prevent="uploadDragoverEvent=false"
@drop.prevent="onUploadDropEvent"
@click="openFileUpload"
>
<div class="flex w-full items-center justify-center">
<div
v-if="loading"
class="text-gray-600 dark:text-gray-400"
>
<Loader class="mx-auto h-6 w-6" />
<p class="mt-2 text-center text-sm text-gray-500">
Uploading your file...
</p>
</div>
<template v-else>
<div class="text-center">
<input
ref="actual-input"
class="hidden"
:multiple="multiple"
type="file"
:name="name"
:accept="acceptExtensions"
@change="manualFileUpload"
>
<div
v-if="files.length"
class="flex flex-wrap items-center justify-center gap-4"
>
<uploaded-file
v-for="file in files"
:key="file.url"
:file="file"
:theme="theme"
@remove="clearFile(file)"
/>
</div>
<template v-else>
<div class="text-gray-500 w-full flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
</div>
<p class="mt-2 text-sm text-gray-500 font-semibold select-none">
Click to choose {{ multiple ? 'file(s)' : 'a file' }} or drag here
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-600 select-none">
Size limit: {{ mbLimit }}MB per file
</p>
</template>
</div>
</template>
</div>
<div class="w-full items-center justify-center mt-2 hidden sm:flex"> <div class="w-full items-center justify-center mt-2 hidden sm:flex">
<open-form-button native-type="button" :loading="loading" :theme="theme" :color="color" class="py-2 p-1 px-2" @click.stop="openWebcam" v-if="cameraUpload"> <open-form-button
<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"> v-if="cameraUpload"
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" /> native-type="button"
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" /> :loading="loading"
:theme="theme"
:color="color"
class="py-2 p-1 px-2"
@click.stop="openWebcam"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
/>
</svg> </svg>
</open-form-button> </open-form-button>
</div> </div>
@ -134,14 +191,14 @@ export default {
deep: true, deep: true,
handler(newVal, oldVal) { handler(newVal, oldVal) {
if (!oldVal) { if (!oldVal) {
this.handleCompValChange(); this.handleCompValChange()
} }
} }
} }
}, },
mounted() { mounted() {
this.handleCompValChange(); this.handleCompValChange()
}, },
methods: { methods: {
@ -199,13 +256,13 @@ export default {
}, },
openWebcam(){ openWebcam(){
if(!this.cameraUpload){ if(!this.cameraUpload){
return; return
} }
this.isInWebcam = true; this.isInWebcam = true
}, },
cameraFileUpload(file){ cameraFileUpload(file){
this.isInWebcam = false this.isInWebcam = false
this.isUploading = false; this.isUploading = false
this.uploadFileToServer(file) this.uploadFileToServer(file)
}, },
uploadFileToServer(file) { uploadFileToServer(file) {
@ -254,7 +311,7 @@ export default {
} }
const response = await fetch(url) const response = await fetch(url)
const data = await response.blob() const data = await response.blob()
const name = url.replace(/^.*(\\|\/|\:)/, '') const name = url.replace(/^.*(\\|\/|:)/, '')
return new File([data], name, { return new File([data], name, {
type: data.type || defaultType type: data.type || defaultType
}) })

View File

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

View File

@ -1,37 +1,68 @@
<template> <template>
<input-wrapper <input-wrapper v-bind="inputWrapperProps">
v-bind="inputWrapperProps"
>
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
<span class="inline-block w-full rounded-md shadow-sm"> <span class="inline-block w-full rounded-md shadow-sm">
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" <button
class="cursor-pointer relative w-full" :class="[theme.default.input,{'ring-red-500 ring-2': hasError}]" type="button"
:style="inputStyle" @click.prevent="showUploadModal=true" 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': hasError }]"
:style="inputStyle"
@click.prevent="showUploadModal = true"
> >
<div v-if="currentUrl==null" class="h-6 text-gray-600 dark:text-gray-400"> <div
Upload image <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" v-if="currentUrl == null"
stroke="currentColor" 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" <path
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" 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> </svg>
</div> </div>
<div v-else class="h-6 text-gray-600 dark:text-gray-400 flex"> <div
v-else
class="h-6 text-gray-600 dark:text-gray-400 flex"
>
<div class="flex-grow"> <div class="flex-grow">
<img :src="currentUrl" class="h-6 rounded shadow-md"/> <img
</div> :src="currentUrl"
<a href="#" class="hover:text-nt-blue flex" @click.prevent="clearUrl"> class="h-6 rounded shadow-md"
<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" </div>
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" <a
/> href="#"
</svg></a> 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> </div>
</button> </button>
</span> </span>
@ -44,13 +75,18 @@
</template> </template>
<!-- Modal --> <!-- Modal -->
<modal :show="showUploadModal" @close="showUploadModal=false"> <modal
:show="showUploadModal"
@close="showUploadModal = false"
>
<h2 class="text-lg font-semibold"> <h2 class="text-lg font-semibold">
Upload an image Upload an image
</h2> </h2>
<div class="max-w-3xl mx-auto lg:max-w-none"> <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="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 class="mt-2 sm:mt-0 sm:col-span-2 mb-5">
<div <div
v-cloak v-cloak
@ -58,7 +94,10 @@
@dragover.prevent="onUploadDragoverEvent($event)" @dragover.prevent="onUploadDragoverEvent($event)"
@drop.prevent="onUploadDropEvent($event)" @drop.prevent="onUploadDropEvent($event)"
> >
<div v-if="loading" class="text-gray-600 dark:text-gray-400"> <div
v-if="loading"
class="text-gray-600 dark:text-gray-400"
>
<Loader class="h-6 w-6 mx-auto m-10" /> <Loader class="h-6 w-6 mx-auto m-10" />
<p class="text-center mt-6"> <p class="text-center mt-6">
Uploading your file... Uploading your file...
@ -69,18 +108,30 @@
class="absolute rounded-full bg-gray-100 h-20 w-20 z-10 transition-opacity duration-500 ease-in-out" class="absolute rounded-full bg-gray-100 h-20 w-20 z-10 transition-opacity duration-500 ease-in-out"
:class="{ :class="{
'opacity-100': uploadDragoverTracking, 'opacity-100': uploadDragoverTracking,
'opacity-0': !uploadDragoverTracking 'opacity-0': !uploadDragoverTracking,
}" }"
/> />
<div class="relative z-20 text-center"> <div class="relative z-20 text-center">
<input ref="actual-input" class="hidden" type="file" :name="name" <input
accept="image/png, image/gif, image/jpeg, image/bmp, image/svg+xml" @change="manualFileUpload" ref="actual-input"
class="hidden"
type="file"
:name="name"
accept="image/png, image/gif, image/jpeg, image/bmp, image/svg+xml"
@change="manualFileUpload"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-24 w-24 text-gray-200" fill="none" <svg
viewBox="0 0 24 24" stroke="currentColor" 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" <path
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" 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> </svg>
<p class="mt-5 text-sm text-gray-600"> <p class="mt-5 text-sm text-gray-600">
@ -107,22 +158,22 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from './useFormInput.js' import { inputProps, useFormInput } from "./useFormInput.js"
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from "./components/InputWrapper.vue"
import Modal from '../global/Modal.vue' import Modal from "../global/Modal.vue"
import {storeFile} from "~/lib/file-uploads.js" import { storeFile } from "~/lib/file-uploads.js"
export default { export default {
name: 'ImageInput', name: "ImageInput",
components: { InputWrapper, Modal }, components: { InputWrapper, Modal },
mixins: [], mixins: [],
props: { props: {
...inputProps ...inputProps,
}, },
setup (props, context) { setup(props, context) {
return { return {
...useFormInput(props, context) ...useFormInput(props, context),
} }
}, },
@ -132,87 +183,96 @@ export default {
file: [], file: [],
uploadDragoverTracking: false, uploadDragoverTracking: false,
uploadDragoverEvent: false, uploadDragoverEvent: false,
loading: false loading: false,
}), }),
computed: { computed: {
currentUrl () { currentUrl() {
return this.compVal return this.compVal
} },
}, },
watch: { watch: {
showUploadModal: { showUploadModal: {
handler (val) { handler() {
if (import.meta.server) return if (import.meta.server) return
document.removeEventListener('paste', this.onUploadPasteEvent) document.removeEventListener("paste", this.onUploadPasteEvent)
if (this.showUploadModal) { if (this.showUploadModal) {
document.addEventListener('paste', this.onUploadPasteEvent) document.addEventListener("paste", this.onUploadPasteEvent)
} }
} },
} },
}, },
methods: { methods: {
clearUrl () { clearUrl() {
this.form[this.name] = null this.form[this.name] = null
}, },
onUploadDragoverEvent (e) { onUploadDragoverEvent() {
this.uploadDragoverEvent = true this.uploadDragoverEvent = true
this.uploadDragoverTracking = true this.uploadDragoverTracking = true
}, },
onUploadDropEvent (e) { onUploadDropEvent(e) {
this.uploadDragoverEvent = false this.uploadDragoverEvent = false
this.uploadDragoverTracking = false this.uploadDragoverTracking = false
this.droppedFiles(e.dataTransfer.files) this.droppedFiles(e.dataTransfer.files)
}, },
onUploadPasteEvent (e) { onUploadPasteEvent(e) {
if (!this.showUploadModal) return if (!this.showUploadModal) return
this.uploadDragoverEvent = false this.uploadDragoverEvent = false
this.uploadDragoverTracking = false this.uploadDragoverTracking = false
this.droppedFiles(e.clipboardData.files) this.droppedFiles(e.clipboardData.files)
}, },
droppedFiles (droppedFiles) { droppedFiles(droppedFiles) {
if (!droppedFiles) return if (!droppedFiles) return
this.file = droppedFiles[0] this.file = droppedFiles[0]
this.uploadFileToServer() this.uploadFileToServer()
}, },
openFileUpload () { openFileUpload() {
this.$refs['actual-input'].click() this.$refs["actual-input"].click()
}, },
manualFileUpload (e) { manualFileUpload(e) {
this.file = e.target.files[0] this.file = e.target.files[0]
this.uploadFileToServer() this.uploadFileToServer()
}, },
uploadFileToServer () { uploadFileToServer() {
this.loading = true this.loading = true
// Store file in s3 // Store file in s3
storeFile(this.file).then(response => { storeFile(this.file)
// Move file to permanent storage for form assets .then((response) => {
opnFetch('/open/forms/assets/upload', { // Move file to permanent storage for form assets
method: 'POST', opnFetch("/open/forms/assets/upload", {
body: { method: "POST",
url: this.file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension body: {
} url:
}).then(moveFileResponseData => { this.file.name.split(".").slice(0, -1).join(".") +
if (!this.multiple) { "_" +
this.files = [] response.uuid +
} "." +
this.compVal = moveFileResponseData.url response.extension,
this.showUploadModal = false },
this.loading = false })
}).catch((error) => { .then((moveFileResponseData) => {
if (!this.multiple) {
this.files = []
}
this.compVal = moveFileResponseData.url
this.showUploadModal = false
this.loading = false
})
.catch(() => {
this.compVal = null
this.showUploadModal = false
this.loading = false
})
})
.catch(() => {
this.compVal = null this.compVal = null
this.showUploadModal = false this.showUploadModal = false
this.loading = false this.loading = false
}) })
}).catch((error) => { },
this.compVal = null },
this.showUploadModal = false
this.loading = false
})
}
}
} }
</script> </script>

View File

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

View File

@ -1,20 +1,31 @@
<template> <template>
<input-wrapper <input-wrapper v-bind="inputWrapperProps">
v-bind="inputWrapperProps"
>
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
<div class="stars-outer"> <div class="stars-outer">
<div v-for="i in starsCount" :key="i" <div
class="cursor-pointer inline-block text-gray-200 dark:text-gray-800" v-for="i in starsCount"
:class="{'!text-yellow-400 active-star':i<=compVal, '!text-yellow-200 !dark:text-yellow-800 hover-star':i>compVal && i<=hoverRating, '!cursor-not-allowed':disabled}" :key="i"
role="button" @click="setRating(i)" class="cursor-pointer inline-block text-gray-200 dark:text-gray-800"
@mouseenter="onMouseHover(i)" :class="{
@mouseleave="hoverRating = -1" '!text-yellow-400 active-star': i <= compVal,
'!text-yellow-200 !dark:text-yellow-800 hover-star':
i > compVal && i <= hoverRating,
'!cursor-not-allowed': disabled,
}"
role="button"
@click="setRating(i)"
@mouseenter="onMouseHover(i)"
@mouseleave="hoverRating = -1"
> >
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> <svg
class="w-8 h-8"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path <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" 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"
/> />
@ -32,37 +43,27 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from './useFormInput.js' import { inputProps, useFormInput } from "./useFormInput.js"
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from "./components/InputWrapper.vue"
export default { export default {
name: 'RatingInput', name: "RatingInput",
components: { InputWrapper }, components: { InputWrapper },
props: { props: {
...inputProps, ...inputProps,
numberOfStars: { type: Number, default: 5 } numberOfStars: { type: Number, default: 5 },
}, },
setup (props, context) { setup(props, context) {
return { return {
...useFormInput(props, context) ...useFormInput(props, context),
} }
}, },
data () { data() {
return { return {
hoverRating: -1 hoverRating: -1,
}
},
mounted () {
if (!this.compVal) this.compVal = 0
},
updated () {
if (!this.compVal) {
this.compVal = 0
} }
}, },
@ -72,14 +73,24 @@ export default {
return 5 return 5
} }
return this.numberOfStars return this.numberOfStars
},
},
mounted() {
if (!this.compVal) this.compVal = 0
},
updated() {
if (!this.compVal) {
this.compVal = 0
} }
}, },
methods: { methods: {
onMouseHover (i) { onMouseHover(i) {
this.hoverRating = (this.disabled) ? -1 : i this.hoverRating = this.disabled ? -1 : i
}, },
setRating (val) { setRating(val) {
if (this.disabled) { if (this.disabled) {
return return
} }
@ -88,7 +99,7 @@ export default {
} else { } else {
this.compVal = val this.compVal = val
} }
} },
} },
} }
</script> </script>

View File

@ -1,15 +1,25 @@
<template> <template>
<input-wrapper <input-wrapper v-bind="inputWrapperProps">
v-bind="inputWrapperProps"
>
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
<vue-editor :id="id?id:name" ref="editor" v-model="compVal" :disabled="disabled?true:null" <vue-editor
:placeholder="placeholder" :class="[{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200':disabled }, theme.RichTextAreaInput.input]" :id="id ? id : name"
:editor-toolbar="editorToolbar" class="rich-editor resize-y" ref="editor"
:style="inputStyle" v-model="compVal"
:disabled="disabled ? true : null"
:placeholder="placeholder"
:class="[
{
'!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled,
},
theme.RichTextAreaInput.input,
]"
:editor-toolbar="editorToolbar"
class="rich-editor resize-y"
:style="inputStyle"
/> />
<template #help> <template #help>
@ -22,14 +32,14 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from './useFormInput.js' import { inputProps, useFormInput } from "./useFormInput.js"
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from "./components/InputWrapper.vue"
import { VueEditor, Quill } from 'vue3-editor' import { VueEditor, Quill } from "vue3-editor"
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion') Quill.imports["formats/link"].PROTOCOL_WHITELIST.push("notion")
export default { export default {
name: 'RichTextAreaInput', name: "RichTextAreaInput",
components: { InputWrapper, VueEditor }, components: { InputWrapper, VueEditor },
props: { props: {
@ -39,20 +49,19 @@ export default {
default: () => { default: () => {
return [ return [
[{ header: 1 }, { header: 2 }], [{ header: 1 }, { header: 2 }],
['bold', 'italic', 'underline', 'link'], ["bold", "italic", "underline", "link"],
[{ list: 'ordered' }, { list: 'bullet' }], [{ list: "ordered" }, { list: "bullet" }],
[{ color: [] }] [{ color: [] }],
] ]
} },
} },
}, },
setup (props, context) { setup(props, context) {
return { return {
...useFormInput(props, context) ...useFormInput(props, context),
} }
} },
} }
</script> </script>
@ -74,7 +83,20 @@ export default {
border-left: 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 { .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; @apply text-nt-blue;
} }
} }

View File

@ -1,16 +1,21 @@
<template> <template>
<input-wrapper <input-wrapper v-bind="inputWrapperProps">
v-bind="inputWrapperProps"
>
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
<div class="rectangle-outer grid grid-cols-5 gap-2"> <div class="rectangle-outer grid grid-cols-5 gap-2">
<div v-for="i in scaleList" :key="i" <div
:class="[{'font-semibold':compVal===i},theme.ScaleInput.button, compVal!==i ? unselectedButtonClass: '']" v-for="i in scaleList"
:style="btnStyle(i===compVal)" :key="i"
role="button" @click="setScale(i)" :class="[
{ 'font-semibold': compVal === i },
theme.ScaleInput.button,
compVal !== i ? unselectedButtonClass : '',
]"
:style="btnStyle(i === compVal)"
role="button"
@click="setScale(i)"
> >
{{ i }} {{ i }}
</div> </div>
@ -26,43 +31,44 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from './useFormInput.js' import { inputProps, useFormInput } from "./useFormInput.js"
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from "./components/InputWrapper.vue"
export default { export default {
name: 'ScaleInput', name: "ScaleInput",
components: { InputWrapper }, components: { InputWrapper },
props: { props: {
...inputProps, ...inputProps,
minScale: { type: Number, default: 1 }, minScale: { type: Number, default: 1 },
maxScale: { type: Number, default: 5 }, maxScale: { type: Number, default: 5 },
stepScale: { type: Number, default: 1 } stepScale: { type: Number, default: 1 },
}, },
setup (props, context) { setup(props, context) {
return { return {
...useFormInput(props, context) ...useFormInput(props, context),
} }
}, },
data () { data() {
return {} return {}
}, },
computed: { computed: {
scaleList () { scaleList() {
const list = [] const list = []
for (let i = this.minScale; i <= this.maxScale; i += this.stepScale) { for (let i = this.minScale; i <= this.maxScale; i += this.stepScale) {
list.push(i) list.push(i)
} }
return list return list
}, },
unselectedButtonClass () { unselectedButtonClass() {
return this.theme.ScaleInput.unselectedButton return this.theme.ScaleInput.unselectedButton
}, },
textColor () { textColor() {
const color = (this.color.charAt(0) === '#') ? this.color.substring(1, 7) : this.color const color =
this.color.charAt(0) === "#" ? this.color.substring(1, 7) : this.color
const r = parseInt(color.substring(0, 2), 16) // hexToR const r = parseInt(color.substring(0, 2), 16) // hexToR
const g = parseInt(color.substring(2, 4), 16) // hexToG const g = parseInt(color.substring(2, 4), 16) // hexToG
const b = parseInt(color.substring(4, 6), 16) // hexToB const b = parseInt(color.substring(4, 6), 16) // hexToB
@ -73,26 +79,26 @@ export default {
} }
return Math.pow((col + 0.055) / 1.055, 2.4) return Math.pow((col + 0.055) / 1.055, 2.4)
}) })
const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2]) const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
return (L > 0.55) ? '#000000' : '#FFFFFF' return L > 0.55 ? "#000000" : "#FFFFFF"
} },
}, },
mounted () { mounted() {
if (this.compVal && typeof this.compVal === 'string') { if (this.compVal && typeof this.compVal === "string") {
this.compVal = parseInt(this.compVal) this.compVal = parseInt(this.compVal)
} }
}, },
methods: { methods: {
btnStyle (isSelected) { btnStyle(isSelected) {
if (!isSelected) return {} if (!isSelected) return {}
return { return {
color: this.textColor, color: this.textColor,
backgroundColor: this.color backgroundColor: this.color,
} }
}, },
setScale (val) { setScale(val) {
if (this.disabled) { if (this.disabled) {
return return
} }
@ -101,7 +107,7 @@ export default {
} else { } else {
this.compVal = val this.compVal = val
} }
} },
} },
} }
</script> </script>

View File

@ -3,17 +3,38 @@
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
<v-select v-model="compVal" :data="finalOptions" :label="label" :option-key="optionKey" :emit-key="emitKey" <v-select
:required="required" :multiple="multiple" :searchable="searchable" :loading="loading" :color="color" v-model="compVal"
:placeholder="placeholder" :uppercase-labels="uppercaseLabels" :theme="theme" :has-error="hasError" :data="finalOptions"
:allow-creation="allowCreation" :disabled="disabled ? true : null" :help="help" :help-position="helpPosition" :label="label"
@update-options="updateOptions" @update:model-value="updateModelValue"> :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="hasError"
:allow-creation="allowCreation"
:disabled="disabled ? true : null"
:help="help"
:help-position="helpPosition"
@update-options="updateOptions"
@update:model-value="updateModelValue"
>
<template #selected="{ option }"> <template #selected="{ option }">
<slot name="selected" :option="option" :optionName="getOptionName(option)"> <slot
name="selected"
:option="option"
:option-name="getOptionName(option)"
>
<template v-if="multiple"> <template v-if="multiple">
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
<span class="truncate"> <span class="truncate">
{{ getOptionNames(selectedValues).join(', ') }} {{ getOptionNames(selectedValues).join(", ") }}
</span> </span>
</div> </div>
</template> </template>
@ -25,16 +46,29 @@
</slot> </slot>
</template> </template>
<template #option="{ option, selected }"> <template #option="{ option, selected }">
<slot name="option" :option="option" :selected="selected"> <slot
name="option"
:option="option"
:selected="selected"
>
<span class="flex group-hover:text-white"> <span class="flex group-hover:text-white">
<p class="flex-grow group-hover:text-white"> <p class="flex-grow group-hover:text-white">
{{ option.name }} {{ option.name }}
</p> </p>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white"> <span
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> v-if="selected"
<path fill-rule="evenodd" 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" 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" /> clip-rule="evenodd"
/>
</svg> </svg>
</span> </span>
</span> </span>
@ -53,31 +87,31 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from './useFormInput.js' import { inputProps, useFormInput } from "./useFormInput.js"
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from "./components/InputWrapper.vue"
/** /**
* Options: {name,value} objects * Options: {name,value} objects
*/ */
export default { export default {
name: 'SelectInput', name: "SelectInput",
components: { InputWrapper }, components: { InputWrapper },
props: { props: {
...inputProps, ...inputProps,
options: { type: Array, required: true }, options: { type: Array, required: true },
optionKey: { type: String, default: 'value' }, optionKey: { type: String, default: "value" },
emitKey: { type: String, default: 'value' }, emitKey: { type: String, default: "value" },
displayKey: { type: String, default: 'name' }, displayKey: { type: String, default: "name" },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
multiple: { type: Boolean, default: false }, multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: false }, searchable: { type: Boolean, default: false },
allowCreation: { type: Boolean, default: false } allowCreation: { type: Boolean, default: false },
}, },
setup(props, context) { setup(props, context) {
return { return {
...useFormInput(props, context) ...useFormInput(props, context),
} }
}, },
@ -91,7 +125,7 @@ export default {
computed: { computed: {
finalOptions() { finalOptions() {
return this.options.concat(this.additionalOptions) return this.options.concat(this.additionalOptions)
} },
}, },
methods: { methods: {
getOptionName(val) { getOptionName(val) {
@ -102,7 +136,7 @@ export default {
return null return null
}, },
getOptionNames(values) { getOptionNames(values) {
return values.map(val => { return values.map((val) => {
return this.getOptionName(val) return this.getOptionName(val)
}) })
}, },
@ -114,7 +148,7 @@ export default {
if (newItem) { if (newItem) {
this.additionalOptions.push(newItem) this.additionalOptions.push(newItem)
} }
} },
} },
} }
</script> </script>

View File

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

View File

@ -5,30 +5,28 @@
</template> </template>
<div class="flex space-x-2"> <div class="flex space-x-2">
<div class="flex-1 relative"> <div class="flex-1 relative">
<div class="font-medium text-sm absolute -top-[6px]" <div
:style="labelStyle" class="font-medium text-sm absolute -top-[6px]"
:style="labelStyle"
> >
<div class=""> <div class="">
{{ compVal }} {{ compVal }}
</div> </div>
</div> </div>
<input <input
v-model="compVal"
type="range" type="range"
class="w-full mt-3" class="w-full mt-3"
:disabled="disabled" :disabled="disabled"
:min="minSlider" :min="minSlider"
:max="maxSlider" :max="maxSlider"
:step="stepSlider" :step="stepSlider"
v-model="compVal" >
/>
<div class="grid grid-cols-3 gap-2 -mt-1"> <div class="grid grid-cols-3 gap-2 -mt-1">
<div <div
v-for="i in sliderLabelsList" v-for="i in sliderLabelsList"
:key="i" :key="i"
:class="[ :class="[theme.SliderInput.stepLabel, i.style]"
theme.SliderInput.stepLabel,
i.style,
]"
> >
{{ i.label }} {{ i.label }}
</div> </div>
@ -46,8 +44,8 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from "./useFormInput.js"; import { inputProps, useFormInput } from "./useFormInput.js"
import InputWrapper from "./components/InputWrapper.vue"; import InputWrapper from "./components/InputWrapper.vue"
export default { export default {
name: "SliderInput", name: "SliderInput",
@ -63,18 +61,20 @@ export default {
setup(props, context) { setup(props, context) {
return { return {
...useFormInput(props, context), ...useFormInput(props, context),
}; }
}, },
computed: { computed: {
labelStyle() { labelStyle() {
const ratio = ((this.compVal-this.minSlider) / (this.maxSlider-this.minSlider)) * 100 const ratio =
((this.compVal - this.minSlider) / (this.maxSlider - this.minSlider)) *
100
return { return {
left: `${ratio}%`, left: `${ratio}%`,
marginLeft: `-${ratio/100*15}px` marginLeft: `-${(ratio / 100) * 15}px`,
} }
}, },
sliderLabelsList() { sliderLabelsList() {
const midPoint = (this.maxSlider - this.minSlider) / 2 + this.minSlider; const midPoint = (this.maxSlider - this.minSlider) / 2 + this.minSlider
const labels = [ const labels = [
{ {
label: `${this.minSlider}`, label: `${this.minSlider}`,
@ -88,14 +88,12 @@ export default {
label: `${this.maxSlider}`, label: `${this.maxSlider}`,
style: "flex items-center justify-end", style: "flex items-center justify-end",
}, },
]; ]
return labels; return labels
}, },
}, },
mounted() { mounted() {
this.compVal = parseInt(this.compVal ?? this.minSlider); this.compVal = parseInt(this.compVal ?? this.minSlider)
}, },
}
};
</script> </script>

View File

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

View File

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

View File

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

View File

@ -1,143 +1,218 @@
<template> <template>
<div class="relative border"> <div class="relative border">
<video id="webcam" autoplay playsinline :class="[{ 'hidden': !isCapturing },theme.fileInput.cameraInput]" width="1280" height="720"></video> <video
<canvas id="canvas" :class="{ 'hidden': !capturedImage }"></canvas> id="webcam"
<div v-if="cameraPermissionStatus === 'allowed'" class="absolute inset-x-0 grid place-content-center bottom-2"> autoplay
<div class=" p-2 px-4 flex items-center justify-center text-xs space-x-2" v-if="isCapturing"> playsinline
<span class="cursor-pointer rounded-full w-14 h-14 border-2 grid place-content-center" :class="[{ hidden: !isCapturing }, theme.fileInput.cameraInput]"
@click="processCapturedImage"> width="1280"
<span class="cursor-pointer bg-gray-100 rounded-full w-10 h-10 grid place-content-center"> height="720"
</span> />
</span> <canvas
<span class="text-white cursor-pointer" @click="cancelCamera"> id="canvas"
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" :class="{ hidden: !capturedImage }"
stroke="currentColor" class="w-8 h-8"> />
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> <div
</svg> v-if="cameraPermissionStatus === 'allowed'"
</span> class="absolute inset-x-0 grid place-content-center bottom-2"
</div> >
</div> <div
<div v-else-if="cameraPermissionStatus === 'blocked'" v-if="isCapturing"
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full" class="p-2 px-4 flex items-center justify-center text-xs space-x-2"
@click="openCameraUpload"> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <span
class="w-6 h-6"> class="cursor-pointer rounded-full w-14 h-14 border-2 grid place-content-center"
<path stroke-linecap="round" stroke-linejoin="round" @click="processCapturedImage"
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" /> >
<path stroke-linecap="round" stroke-linejoin="round" <span
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" /> class="cursor-pointer bg-gray-100 rounded-full w-10 h-10 grid place-content-center"
</svg> />
<p class="text-center font-bold"> </span>
Allow Camera Permission <span
</p> class="text-white cursor-pointer"
<p class="text-xs">You need to allow camera permission before you can take pictures. Go to browser settings to enable camera permission on this page.</p> @click="cancelCamera"
<button class="text-xs p-1 px-2 bg-blue-600 rounded" type="button" @click.stop="cancelCamera">Got it!</button> >
</div> <svg
xmlns="http://www.w3.org/2000/svg"
<div v-else-if="cameraPermissionStatus === 'loading'" fill="none"
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full" viewBox="0 0 24 24"
> stroke-width="1.5"
<div class="w-6 h-6"> stroke="currentColor"
<Loader /> class="w-8 h-8"
</div> >
</div> <path
<div v-else stroke-linecap="round"
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full" stroke-linejoin="round"
@click="openCameraUpload"> d="M6 18 18 6M6 6l12 12"
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> />
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M12 18.75H4.5a2.25 2.25 0 0 1-2.25-2.25V9m12.841 9.091L16.5 19.5m-1.409-1.409c.407-.407.659-.97.659-1.591v-9a2.25 2.25 0 0 0-2.25-2.25h-9c-.621 0-1.184.252-1.591.659m12.182 12.182L2.909 5.909M1.5 4.5l1.409 1.409" /> </svg>
</svg> </span>
</div>
<p class="text-center font-bold">
Camera Device Error
</p>
<p class="text-xs">An unknown error occurred when trying to start Webcam device.</p>
<button class="text-xs p-1 px-2 bg-blue-600 rounded" type="button" @click.stop="cancelCamera">Go back</button>
</div>
</div> </div>
<div
v-else-if="cameraPermissionStatus === 'blocked'"
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full"
@click="openCameraUpload"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
/>
</svg>
<p class="text-center font-bold">
Allow Camera Permission
</p>
<p class="text-xs">
You need to allow camera permission before you can take pictures. Go to
browser settings to enable camera permission on this page.
</p>
<button
class="text-xs p-1 px-2 bg-blue-600 rounded"
type="button"
@click.stop="cancelCamera"
>
Got it!
</button>
</div>
<div
v-else-if="cameraPermissionStatus === 'loading'"
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full"
>
<div class="w-6 h-6">
<Loader />
</div>
</div>
<div
v-else
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full"
@click="openCameraUpload"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M12 18.75H4.5a2.25 2.25 0 0 1-2.25-2.25V9m12.841 9.091L16.5 19.5m-1.409-1.409c.407-.407.659-.97.659-1.591v-9a2.25 2.25 0 0 0-2.25-2.25h-9c-.621 0-1.184.252-1.591.659m12.182 12.182L2.909 5.909M1.5 4.5l1.409 1.409"
/>
</svg>
<p class="text-center font-bold">
Camera Device Error
</p>
<p class="text-xs">
An unknown error occurred when trying to start Webcam device.
</p>
<button
class="text-xs p-1 px-2 bg-blue-600 rounded"
type="button"
@click.stop="cancelCamera"
>
Go back
</button>
</div>
</div>
</template> </template>
<script> <script>
import Webcam from 'webcam-easy'; import Webcam from "webcam-easy"
import { themes } from '~/lib/forms/form-themes.js' import { themes } from "~/lib/forms/form-themes.js"
export default { export default {
name: 'FileInput', name: "FileInput",
props:{ props: {
theme: { type: Object, default: () => themes.default } theme: { type: Object, default: () => themes.default },
},
emits: ['stopWebcam', 'uploadImage'],
data: () => ({
webcam: null,
isCapturing: false,
capturedImage: null,
cameraPermissionStatus: "loading",
}),
computed: {
videoDisplay() {
return this.isCapturing ? "" : "hidden"
}, },
data: () => ({ canvasDisplay() {
webcam: null, return !this.isCapturing && this.capturedImage ? "" : "hidden"
isCapturing: false,
capturedImage: null,
cameraPermissionStatus: 'loading',
}),
computed: {
videoDisplay() {
return this.isCapturing ? '' : 'hidden';
},
canvasDisplay() {
return (!this.isCapturing && this.capturedImage) ? '' : 'hidden'
}
}, },
mounted() { },
const webcamElement = document.getElementById('webcam'); mounted() {
const canvasElement = document.getElementById('canvas'); const webcamElement = document.getElementById("webcam")
this.webcam = new Webcam(webcamElement, 'user', canvasElement); const canvasElement = document.getElementById("canvas")
this.openCameraUpload() this.webcam = new Webcam(webcamElement, "user", canvasElement)
this.openCameraUpload()
},
methods: {
openCameraUpload() {
this.isCapturing = true
this.capturedImage = null
this.webcam
.start()
.then(() => {
this.cameraPermissionStatus = "allowed"
})
.catch((err) => {
console.error(err)
if (err.toString() === "NotAllowedError: Permission denied") {
this.cameraPermissionStatus = "blocked"
return
}
this.cameraPermissionStatus = "unknown"
})
}, },
cancelCamera() {
this.isCapturing = false
this.capturedImage = null
this.webcam.stop()
this.$emit("stopWebcam")
},
processCapturedImage() {
this.capturedImage = this.webcam.snap()
this.isCapturing = false
this.webcam.stop()
const byteCharacters = atob(this.capturedImage.split(",")[1])
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
methods: { const byteNumbers = new Array(slice.length)
openCameraUpload() { for (let i = 0; i < slice.length; i++) {
this.isCapturing = true; byteNumbers[i] = slice.charCodeAt(i)
this.capturedImage = null;
this.webcam.start()
.then(result => {
this.cameraPermissionStatus = 'allowed';
})
.catch(err => {
console.error(err)
if(err.toString() === 'NotAllowedError: Permission denied'){
this.cameraPermissionStatus = 'blocked';
return;
}
this.cameraPermissionStatus = 'unknown';
});
},
cancelCamera() {
this.isCapturing = false;
this.capturedImage = null;
this.webcam.stop()
this.$emit('stopWebcam')
},
processCapturedImage() {
this.capturedImage = this.webcam.snap();
this.isCapturing = false;
this.webcam.stop()
const byteCharacters = atob(this.capturedImage.split(',')[1]);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
// Create Blob from binary data
const blob = new Blob(byteArrays, { type: 'image/png' });
const filename = Date.now()
// Create a File object from the Blob
const file = new File([blob], `${filename}.png`, { type: 'image/png' });
this.$emit('uploadImage', file)
} }
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
} // Create Blob from binary data
const blob = new Blob(byteArrays, { type: "image/png" })
const filename = Date.now()
// Create a File object from the Blob
const file = new File([blob], `${filename}.png`, { type: "image/png" })
this.$emit("uploadImage", file)
},
},
} }
</script> </script>

View File

@ -8,19 +8,19 @@
<span <span
v-if="help" v-if="help"
class="field-help" class="field-help"
v-html="help"/> v-html="help"
/>
</slot> </slot>
</small> </small>
<slot name="after-help"> <slot name="after-help">
<small class="flex-grow"/> <small class="flex-grow" />
</slot> </slot>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({ defineProps({
helpClasses: {type: String, default: 'text-gray-400 dark:text-gray-500'}, helpClasses: { type: String, default: "text-gray-400 dark:text-gray-500" },
help: {type: String, required: false} help: { type: String, required: false },
}) })
</script> </script>

View File

@ -19,7 +19,7 @@
<script> <script>
export default { export default {
name: 'InputLabel', name: "InputLabel",
props: { props: {
nativeFor: { type: String, default: null }, nativeFor: { type: String, default: null },

View File

@ -50,8 +50,8 @@
</template> </template>
<script setup> <script setup>
import InputLabel from './InputLabel.vue' import InputLabel from "./InputLabel.vue"
import InputHelp from './InputHelp.vue' import InputHelp from "./InputHelp.vue"
defineProps({ defineProps({
id: { type: String, required: false }, id: { type: String, required: false },
@ -62,7 +62,7 @@ defineProps({
wrapperClass: { type: String, required: false }, wrapperClass: { type: String, required: false },
inputStyle: { type: Object, required: false }, inputStyle: { type: Object, required: false },
help: { type: String, required: false }, help: { type: String, required: false },
helpPosition: { type: String, default: 'below_input' }, helpPosition: { type: String, default: "below_input" },
uppercaseLabels: { type: Boolean, default: true }, uppercaseLabels: { type: Boolean, default: true },
hideFieldName: { type: Boolean, default: true }, hideFieldName: { type: Boolean, default: true },
required: { type: Boolean, default: false }, required: { type: Boolean, default: false },

View File

@ -3,15 +3,32 @@
:class="[theme.fileInput.uploadedFile, 'overflow-hidden']" :class="[theme.fileInput.uploadedFile, 'overflow-hidden']"
:title="file.file.name" :title="file.file.name"
> >
<div v-if="file.src && !isImageHide" class="h-20 overflow-hidden flex"> <div
<img class="block object-cover object-center w-full" :src="file.src" @error="isImageHide=true"/> v-if="file.src && !isImageHide"
</div> class="h-20 overflow-hidden flex"
<div v-else class="h-20 flex items-center justify-center"> >
<svg class="w-10 h-10 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <img
stroke-width="0.8" stroke="currentColor" class="block object-cover object-center w-full"
:src="file.src"
@error="isImageHide = true"
> >
<path stroke-linecap="round" stroke-linejoin="round" </div>
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" <div
v-else
class="h-20 flex items-center justify-center"
>
<svg
class="w-10 h-10 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="0.8"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/> />
</svg> </svg>
</div> </div>
@ -47,17 +64,17 @@
<script> <script>
export default { export default {
name: 'UploadedFile', name: "UploadedFile",
props: { props: {
file: { default: null }, file: { type:Object, default: null },
theme: { type: Object } theme: { type: Object },
}, },
emits: ['remove'],
data: () => ({ data: () => ({
isImageHide: false isImageHide: false,
}), }),
computed: {} computed: {},
} }
</script> </script>

View File

@ -27,21 +27,21 @@ import {
onMounted, onMounted,
ref, ref,
watch, watch,
} from 'vue' } from "vue"
defineOptions({ defineOptions({
name: 'VCheckbox', name: "VCheckbox",
}) })
const props = defineProps({ const props = defineProps({
id: { type: String, default: null }, id: { type: String, default: null },
name: { type: String, default: 'checkbox' }, name: { type: String, default: "checkbox" },
modelValue: { type: [Boolean, String], default: false }, modelValue: { type: [Boolean, String], default: false },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
sizeClasses: { type: String, default: 'w-4 h-4' }, sizeClasses: { type: String, default: "w-4 h-4" },
}) })
const emit = defineEmits(['update:modelValue', 'click']) const emit = defineEmits(["update:modelValue", "click"])
const internalValue = ref(props.modelValue) const internalValue = ref(props.modelValue)
@ -62,18 +62,14 @@ watch(
watch( watch(
() => internalValue.value, () => internalValue.value,
(val, oldVal) => { (val, oldVal) => {
if (val === 0 || val === '0') if (val === 0 || val === "0") val = false
val = false if (val === 1 || val === "1") val = true
if (val === 1 || val === '1')
val = true
if (val !== oldVal) if (val !== oldVal) emit("update:modelValue", val)
emit('update:modelValue', val)
}, },
) )
onMounted(() => { onMounted(() => {
if (internalValue.value === null) if (internalValue.value === null) internalValue.value = false
internalValue.value = false
}) })
</script> </script>

View File

@ -12,7 +12,16 @@
aria-labelledby="listbox-label" aria-labelledby="listbox-label"
class="cursor-pointer" class="cursor-pointer"
:style="inputStyle" :style="inputStyle"
:class="[theme.SelectInput.input, { 'py-2': !multiple || loading, 'py-1': multiple, '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200': disabled }, inputClass]" :class="[
theme.SelectInput.input,
{
'py-2': !multiple || loading,
'py-1': multiple,
'!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled,
},
inputClass,
]"
@click="toggleDropdown" @click="toggleDropdown"
> >
<div :class="{ 'h-6': !multiple, 'min-h-8': multiple && !loading }"> <div :class="{ 'h-6': !multiple, 'min-h-8': multiple && !loading }">
@ -43,14 +52,17 @@
<slot name="placeholder"> <slot name="placeholder">
<div <div
class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3" class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
:class="{ 'py-1': multiple && !loading }"> :class="{ 'py-1': multiple && !loading }"
>
{{ placeholder }} {{ placeholder }}
</div> </div>
</slot> </slot>
</div> </div>
</transition> </transition>
</div> </div>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span
class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"
>
<svg <svg
class="h-5 w-5 text-gray-400" class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -67,13 +79,32 @@
</span> </span>
</button> </button>
</span> </span>
<collapsible v-model="isOpen" @click-away="onClickAway" <collapsible
class="absolute mt-1 rounded-md bg-white dark:bg-notion-dark-light shadow-xl z-10" :class="dropdownClass"> v-model="isOpen"
<ul tabindex="-1" role="listbox" class="absolute mt-1 rounded-md bg-white dark:bg-notion-dark-light shadow-xl z-10"
:class="dropdownClass"
@click-away="onClickAway"
>
<ul
tabindex="-1"
role="listbox"
class="rounded-md text-base leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative" class="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 }"> :class="{
<div v-if="isSearchable" class="px-2 pt-2 sticky top-0 bg-white dark-bg-notion-dark-light z-10"> 'max-h-42 py-1': !isSearchable,
<text-input v-model="searchTerm" name="search" :color="color" :theme="theme" placeholder="Search..." /> 'max-h-48 pb-1': isSearchable,
}"
>
<div
v-if="isSearchable"
class="px-2 pt-2 sticky top-0 bg-white dark-bg-notion-dark-light z-10"
>
<text-input
v-model="searchTerm"
name="search"
:color="color"
:theme="theme"
placeholder="Search..."
/>
</div> </div>
<div <div
v-if="loading" v-if="loading"
@ -89,7 +120,8 @@
:style="optionStyle" :style="optionStyle"
:class="{ 'px-3 pr-9': multiple, 'px-3': !multiple }" :class="{ 'px-3 pr-9': multiple, 'px-3': !multiple }"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue" class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
@click="select(item)"> @click="select(item)"
>
<slot <slot
name="option" name="option"
:option="item" :option="item"
@ -101,7 +133,11 @@
v-else-if="!loading && !(allowCreation && searchTerm)" v-else-if="!loading && !(allowCreation && searchTerm)"
class="w-full text-gray-500 text-center py-2" class="w-full text-gray-500 text-center py-2"
> >
{{ (allowCreation ? 'Type something to add an option' : 'No option available') }}. {{
allowCreation
? "Type something to add an option"
: "No option available"
}}.
</p> </p>
<li <li
v-if="allowCreation && searchTerm" v-if="allowCreation && searchTerm"
@ -109,8 +145,12 @@
:style="optionStyle" :style="optionStyle"
:class="{ 'px-3 pr-9': multiple, 'px-3': !multiple }" :class="{ 'px-3 pr-9': multiple, 'px-3': !multiple }"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white dark:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue" class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white dark:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
@click="createOption(searchTerm)"> @click="createOption(searchTerm)"
Create <b class="px-1 bg-gray-300 rounded group-hover-text-black">{{ searchTerm }}</b> >
Create
<b class="px-1 bg-gray-300 rounded group-hover-text-black">{{
searchTerm
}}</b>
</li> </li>
</ul> </ul>
</collapsible> </collapsible>
@ -118,54 +158,54 @@
</template> </template>
<script> <script>
import Collapsible from '~/components/global/transitions/Collapsible.vue' import Collapsible from "~/components/global/transitions/Collapsible.vue"
import { themes} from "~/lib/forms/form-themes.js" import { themes } from "~/lib/forms/form-themes.js"
import TextInput from '../TextInput.vue' import TextInput from "../TextInput.vue"
import debounce from 'lodash/debounce' import debounce from "lodash/debounce"
import Fuse from 'fuse.js' import Fuse from "fuse.js"
export default { export default {
name: 'VSelect', name: "VSelect",
components: { Collapsible, TextInput }, components: { Collapsible, TextInput },
directives: {}, directives: {},
props: { props: {
data: Array, data: Array,
modelValue: { default: null, type: [String, Number, Array, Object] }, modelValue: { default: null, type: [String, Number, Array, Object] },
inputClass: { type: String, default: null }, inputClass: { type: String, default: null },
dropdownClass: { type: String, default: 'w-full' }, dropdownClass: { type: String, default: "w-full" },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
required: { type: Boolean, default: false }, required: { type: Boolean, default: false },
multiple: { type: Boolean, default: false }, multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: false }, searchable: { type: Boolean, default: false },
hasError: { type: Boolean, default: false }, hasError: { type: Boolean, default: false },
remote: { type: Function, default: null }, remote: { type: Function, default: null },
searchKeys: { type: Array, default: () => ['name'] }, searchKeys: { type: Array, default: () => ["name"] },
optionKey: { type: String, default: 'id' }, optionKey: { type: String, default: "id" },
emitKey: { type: String, default: null }, emitKey: { type: String, default: null },
color: { type: String, default: '#3B82F6' }, color: { type: String, default: "#3B82F6" },
placeholder: { type: String, default: null }, placeholder: { type: String, default: null },
uppercaseLabels: { type: Boolean, default: true }, uppercaseLabels: { type: Boolean, default: true },
theme: { type: Object, default: () => themes.default }, theme: { type: Object, default: () => themes.default },
allowCreation: { type: Boolean, default: false }, allowCreation: { type: Boolean, default: false },
disabled: { type: Boolean, default: false } disabled: { type: Boolean, default: false },
}, },
emits: ['update:modelValue', 'update-options'], emits: ["update:modelValue", "update-options"],
data() { data() {
return { return {
isOpen: false, isOpen: false,
searchTerm: '', searchTerm: "",
defaultValue: this.modelValue ?? null defaultValue: this.modelValue ?? null,
} }
}, },
computed: { computed: {
optionStyle() { optionStyle() {
return { return {
'--bg-form-color': this.color "--bg-form-color": this.color,
} }
}, },
inputStyle() { inputStyle() {
return { return {
'--tw-ring-color': this.color "--tw-ring-color": this.color,
} }
}, },
debouncedRemote() { debouncedRemote() {
@ -176,13 +216,13 @@ export default {
}, },
filteredOptions() { filteredOptions() {
if (!this.data) return [] if (!this.data) return []
if (!this.searchable || this.remote || this.searchTerm === '') { if (!this.searchable || this.remote || this.searchTerm === "") {
return this.data return this.data
} }
// Fuse search // Fuse search
const fuzeOptions = { const fuzeOptions = {
keys: this.searchKeys keys: this.searchKeys,
} }
const fuse = new Fuse(this.data, fuzeOptions) const fuse = new Fuse(this.data, fuzeOptions)
return fuse.search(this.searchTerm).map((res) => { return fuse.search(this.searchTerm).map((res) => {
@ -191,15 +231,19 @@ export default {
}, },
isSearchable() { isSearchable() {
return this.searchable || this.remote !== null || this.allowCreation return this.searchable || this.remote !== null || this.allowCreation
} },
}, },
watch: { watch: {
searchTerm(val) { searchTerm(val) {
if (!this.debouncedRemote) return if (!this.debouncedRemote) return
if ((this.remote && val) || (val === '' && !this.modelValue) || (val === '' && this.isOpen)) { if (
(this.remote && val) ||
(val === "" && !this.modelValue) ||
(val === "" && this.isOpen)
) {
return this.debouncedRemote(val) return this.debouncedRemote(val)
} }
} },
}, },
methods: { methods: {
onClickAway(event) { onClickAway(event) {
@ -227,7 +271,7 @@ export default {
this.isOpen = !this.isOpen this.isOpen = !this.isOpen
} }
if (!this.isOpen) { if (!this.isOpen) {
this.searchTerm = '' this.searchTerm = ""
} }
}, },
select(value) { select(value) {
@ -241,25 +285,33 @@ export default {
} }
if (this.multiple) { if (this.multiple) {
const emitValue = Array.isArray(this.modelValue) ? [...this.modelValue] : [] const emitValue = Array.isArray(this.modelValue)
? [...this.modelValue]
: []
if (this.isSelected(value)) { if (this.isSelected(value)) {
this.$emit('update:modelValue', emitValue.filter((item) => { this.$emit(
if (this.emitKey) { "update:modelValue",
return item !== value emitValue.filter((item) => {
} if (this.emitKey) {
return item[this.optionKey] !== value && item[this.optionKey] !== value[this.optionKey] return item !== value
})) }
return (
item[this.optionKey] !== value &&
item[this.optionKey] !== value[this.optionKey]
)
}),
)
return return
} }
emitValue.push(value) emitValue.push(value)
this.$emit('update:modelValue', emitValue) this.$emit("update:modelValue", emitValue)
} else { } else {
if (this.modelValue === value) { if (this.modelValue === value) {
this.$emit('update:modelValue', this.defaultValue ?? null) this.$emit("update:modelValue", this.defaultValue ?? null)
} else { } else {
this.$emit('update:modelValue', value) this.$emit("update:modelValue", value)
} }
} }
}, },
@ -268,13 +320,13 @@ export default {
const newItem = { const newItem = {
name: newOption, name: newOption,
value: newOption, value: newOption,
id: newOption id: newOption,
} }
this.$emit('update-options', newItem) this.$emit("update-options", newItem)
this.select(newItem) this.select(newItem)
this.searchTerm = '' this.searchTerm = ""
} }
} },
} },
} }
</script> </script>

View File

@ -16,17 +16,16 @@
</template> </template>
<script setup> <script setup>
import { defineEmits, defineProps } from 'vue' import { defineEmits, defineProps } from "vue"
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(["update:modelValue"])
function onClick() { function onClick() {
if (props.disabled) if (props.disabled) return
return emit("update:modelValue", !props.modelValue)
emit('update:modelValue', !props.modelValue)
} }
</script> </script>

View File

@ -1,8 +1,8 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from "vue"
import { themes } from '~/lib/forms/form-themes.js' import { themes } from "~/lib/forms/form-themes.js"
import {default as _get} from 'lodash/get' import { default as _get } from "lodash/get"
import {default as _set} from 'lodash/set' import { default as _set } from "lodash/set"
import { default as _has } from 'lodash/has' import { default as _has } from "lodash/has"
export const inputProps = { export const inputProps = {
id: { type: String, default: null }, id: { type: String, default: null },
@ -17,22 +17,26 @@ export const inputProps = {
uppercaseLabels: { type: Boolean, default: false }, uppercaseLabels: { type: Boolean, default: false },
hideFieldName: { type: Boolean, default: false }, hideFieldName: { type: Boolean, default: false },
help: { type: String, default: null }, help: { type: String, default: null },
helpPosition: { type: String, default: 'below_input' }, helpPosition: { type: String, default: "below_input" },
color: { type: String, default: '#3B82F6' }, color: { type: String, default: "#3B82F6" },
wrapperClass: { type: String, default: 'relative mb-3' } wrapperClass: { type: String, default: "relative mb-3" },
} }
export function useFormInput (props, context, formPrefixKey = null) { export function useFormInput(props, context, formPrefixKey = null) {
const content = ref(props.modelValue) const content = ref(props.modelValue)
const inputStyle = computed(() => { const inputStyle = computed(() => {
return { return {
'--tw-ring-color': props.color "--tw-ring-color": props.color,
} }
}) })
const hasValidation = computed(() => { const hasValidation = computed(() => {
return props.form !== null && props.form !== undefined && _has(props.form, 'errors') return (
props.form !== null &&
props.form !== undefined &&
_has(props.form, "errors")
)
}) })
const hasError = computed(() => { const hasError = computed(() => {
@ -42,13 +46,13 @@ export function useFormInput (props, context, formPrefixKey = null) {
const compVal = computed({ const compVal = computed({
get: () => { get: () => {
if (props.form) { if (props.form) {
return _get(props.form, (formPrefixKey || '') + props.name) return _get(props.form, (formPrefixKey || "") + props.name)
} }
return content.value return content.value
}, },
set: (val) => { set: (val) => {
if (props.form) { if (props.form) {
_set(props.form, (formPrefixKey || '') + props.name, val) _set(props.form, (formPrefixKey || "") + props.name, val)
} else { } else {
content.value = val content.value = val
} }
@ -57,14 +61,14 @@ export function useFormInput (props, context, formPrefixKey = null) {
props.form.errors.clear(props.name) props.form.errors.clear(props.name)
} }
context.emit('update:modelValue', compVal.value) context.emit("update:modelValue", compVal.value)
} },
}) })
const inputWrapperProps = computed(() => { const inputWrapperProps = computed(() => {
const wrapperProps = {} const wrapperProps = {}
Object.keys(inputProps).forEach((key) => { Object.keys(inputProps).forEach((key) => {
if (!['modelValue', 'disabled', 'placeholder', 'color'].includes(key)) { if (!["modelValue", "disabled", "placeholder", "color"].includes(key)) {
wrapperProps[key] = props[key] wrapperProps[key] = props[key]
} }
}) })
@ -78,7 +82,7 @@ export function useFormInput (props, context, formPrefixKey = null) {
if (content.value !== newValue) { if (content.value !== newValue) {
content.value = newValue content.value = newValue
} }
} },
) )
return { return {
@ -86,6 +90,6 @@ export function useFormInput (props, context, formPrefixKey = null) {
inputStyle, inputStyle,
hasValidation, hasValidation,
hasError, hasError,
inputWrapperProps inputWrapperProps,
} }
} }

View File

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

View File

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

View File

@ -1,37 +1,46 @@
<template> <template>
<transition name="fade"> <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" <div
role="alert"> v-if="form.successful"
<button v-if="dismissible" class="bg-green-200 border-green-600 text-green-600 border-l-4 p-4 relative rounded-lg"
type="button" role="alert"
@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"> <button
<span class="sr-only"> v-if="dismissible"
Dismiss type="button"
</span> 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"
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="h-6 w-6 text-green-500" @click.prevent="dismiss()"
viewBox="0 0 1792 1792"> >
<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 <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"> 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> </svg>
</button> </button>
<p class="font-bold"> <p class="font-bold">
Success Success
</p> </p>
<div v-html="message"/> <div v-html="message" />
</div> </div>
</transition> </transition>
</template> </template>
<script> <script>
import Alert from './Alert.js' import Alert from "./Alert.js"
export default { export default {
name: 'AlertSuccess', name: "AlertSuccess",
extends: Alert, extends: Alert,
props: { props: {
message: { type: String, default: '' } message: { type: String, default: "" },
} },
} }
</script> </script>

View File

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

View File

@ -1,51 +1,66 @@
<template> <template>
<div :class="classes"> <div :class="classes">
<Icon v-if="beforeIcon" :name="beforeIcon" :class="iconClasses"/> <Icon
<slot></slot> v-if="beforeIcon"
<Icon v-if="afterIcon" :name="afterIcon" :class="iconClasses"/> :name="beforeIcon"
:class="iconClasses"
/>
<slot />
<Icon
v-if="afterIcon"
:name="afterIcon"
:class="iconClasses"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { default as _has } from 'lodash/has' import { default as _has } from "lodash/has"
const props = defineProps({ const props = defineProps({
color: { color: {
type: String, type: String,
default: 'green' default: "green",
}, },
beforeIcon: { beforeIcon: {
type: String, type: String,
default: null default: null,
}, },
afterIcon: { afterIcon: {
type: String, type: String,
default: null default: null,
} },
}) })
const baseClasses = { const baseClasses = {
'green': ['bg-green-100', 'border', 'border-green-300', 'text-green-700'], green: ["bg-green-100", "border", "border-green-300", "text-green-700"],
'red': ['bg-red-100', 'border', 'border-red-300', 'text-red-700'], red: ["bg-red-100", "border", "border-red-300", "text-red-700"],
'gray': ['bg-gray-100', 'border', 'border-gray-300', 'text-gray-700'], gray: ["bg-gray-100", "border", "border-gray-300", "text-gray-700"],
} }
const iconBaseClasses = { const iconBaseClasses = {
'green': ['text-green-500'], green: ["text-green-500"],
'red': ['text-red-500'], red: ["text-red-500"],
'gray': ['text-gray-500'], gray: ["text-gray-500"],
} }
const activeColor = computed(() => { const activeColor = computed(() => {
return _has(baseClasses, props.color) ? props.color : 'gray' return _has(baseClasses, props.color) ? props.color : "gray"
}) })
const classes = computed(() => { const classes = computed(() => {
const classes = ['border', 'text-xs', 'px-2', 'inline-flex', 'items-center', 'rounded-full'].concat(baseClasses[activeColor.value]) const classes = [
return classes.join(' ') "border",
"text-xs",
"px-2",
"inline-flex",
"items-center",
"rounded-full",
].concat(baseClasses[activeColor.value])
return classes.join(" ")
}) })
const iconClasses = computed(() => { const iconClasses = computed(() => {
return iconBaseClasses[activeColor.value].concat(['w-2 h-2 mr-1']).join(' ') return iconBaseClasses[activeColor.value].concat(["w-2 h-2 mr-1"]).join(" ")
}) })
</script> </script>

View File

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

View File

@ -1,8 +1,12 @@
<template> <template>
<div class="flex flex-col w-full bg-white rounded-lg shadow" <div
:class="{'px-4 py-8 sm:px-6 md:px-8 lg:px-10':padding}" 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"> <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 }} {{ title }}
</div> </div>
<slot /> <slot />
@ -11,17 +15,17 @@
<script> <script>
export default { export default {
name: 'Card', name: "Card",
props: { props: {
padding: { padding: {
type: Boolean, type: Boolean,
default: true default: true,
}, },
title: { title: {
type: String, type: String,
default: null default: null,
} },
} },
} }
</script> </script>

View File

@ -1,22 +1,36 @@
<template> <template>
<div> <div>
<div class="w-full relative"> <div class="w-full relative">
<div class="cursor-pointer" @click="trigger"> <div
class="cursor-pointer"
@click="trigger"
>
<slot name="title" /> <slot name="title" />
</div> </div>
<div class="text-gray-400 hover:text-gray-600 absolute -right-2 -top-1 cursor-pointer p-2" @click="trigger"> <div
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition transform duration-500" class="text-gray-400 hover:text-gray-600 absolute -right-2 -top-1 cursor-pointer p-2"
:class="{'rotate-180':showContent}" viewBox="0 0 20 20" fill="currentColor" @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" <path
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" fill-rule="evenodd"
clip-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> </svg>
</div> </div>
</div> </div>
<VTransition> <VTransition>
<div v-if="showContent" class="w-full"> <div
v-if="showContent"
class="w-full"
>
<slot /> <slot />
</div> </div>
</VTransition> </VTransition>
@ -24,18 +38,18 @@
</template> </template>
<script setup> <script setup>
import VTransition from './transitions/VTransition.vue' import VTransition from "./transitions/VTransition.vue"
import { ref, defineProps, defineEmits } from 'vue' import { ref, defineProps, defineEmits } from "vue"
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: null } modelValue: { type: Boolean, default: null },
}) })
const showContent = ref(props.modelValue) const showContent = ref(props.modelValue)
const emit = defineEmits() const emit = defineEmits(['update:modelValue'])
const trigger = () => { const trigger = () => {
showContent.value = !showContent.value showContent.value = !showContent.value
emit('update:modelValue', showContent.value) emit("update:modelValue", showContent.value)
} }
</script> </script>

View File

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

View File

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

View File

@ -1,13 +1,29 @@
<template> <template>
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> class="animate-spin"
<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> 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"
/>
<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"
/>
</svg> </svg>
</template> </template>
<script> <script>
export default { export default {
name: 'Loader', name: "Loader",
props: {} props: {},
} }
</script> </script>

View File

@ -1,49 +1,90 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<transition @leave="onLeave"> <transition @leave="onLeave">
<div v-if="show" ref="backdrop" <div
class="fixed z-30 top-0 inset-0 px-4 sm:px-0 flex items-top justify-center bg-gray-700/75 w-full h-screen overflow-y-scroll" v-if="show"
:class="{'backdrop-blur-sm':backdropBlur}" ref="backdrop"
@click.self="close" class="fixed z-30 top-0 inset-0 px-4 sm:px-0 flex items-top justify-center bg-gray-700/75 w-full h-screen overflow-y-scroll"
:class="{ 'backdrop-blur-sm': backdropBlur }"
@click.self="close"
> >
<div ref="content" <div
class="self-start bg-white dark:bg-notion-dark w-full relative my-6 rounded-xl shadow-xl" ref="content"
:class="maxWidthClass" class="self-start bg-white dark:bg-notion-dark w-full relative my-6 rounded-xl shadow-xl"
:class="maxWidthClass"
> >
<div v-if="closeable" class="absolute top-4 right-4"> <div
<button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click="close()"> v-if="closeable"
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> class="absolute top-4 right-4"
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" >
stroke-linejoin="round" <button
class="text-gray-500 hover:text-gray-900 cursor-pointer"
@click="close()"
>
<svg
class="h-6 w-6"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/> />
</svg> </svg>
</button> </button>
</div> </div>
<div class="flex border-b pb-4" <div
v-if="_has($slots,'icon') || _has($slots,'title')" v-if="_has($slots, 'icon') || _has($slots, 'title')"
:class="[{'flex-col sm:items-start':!compactHeader, 'items-center justify-center py-6 gap-x-4':compactHeader},headerInnerPadding]"> class="flex border-b pb-4"
<div v-if="_has($slots,'icon')" :class="{'w-full mb-4 flex justify-center':!compactHeader}"> :class="[
<div class="w-14 h-14 rounded-full flex justify-center items-center" {
:class="'bg-'+iconColor+'-100 text-'+iconColor+'-600'" 'flex-col sm:items-start': !compactHeader,
'items-center justify-center py-6 gap-x-4': compactHeader,
},
headerInnerPadding,
]"
>
<div
v-if="_has($slots, 'icon')"
:class="{ 'w-full mb-4 flex justify-center': !compactHeader }"
>
<div
class="w-14 h-14 rounded-full flex justify-center items-center"
:class="'bg-' + iconColor + '-100 text-' + iconColor + '-600'"
> >
<slot name="icon"/> <slot name="icon" />
</div> </div>
</div> </div>
<div class="mt-3 text-center sm:mt-0" :class="{'w-full':!compactHeader}"> <div
<h2 v-if="_has($slots,'title')" class="mt-3 text-center sm:mt-0"
class="text-2xl font-semibold text-center text-gray-900" :class="{ 'w-full': !compactHeader }"
>
<h2
v-if="_has($slots, 'title')"
class="text-2xl font-semibold text-center text-gray-900"
> >
<slot name="title"/> <slot name="title" />
</h2> </h2>
</div> </div>
</div> </div>
<div class="w-full" :class="innerPadding"> <div
<slot/> class="w-full"
:class="innerPadding"
>
<slot />
</div> </div>
<div v-if="_has($slots,'footer')" class="bg-gray-50 border-t rounded-b-xl text-right" :class="footerInnerPadding"> <div
<slot name="footer"/> v-if="_has($slots, 'footer')"
class="bg-gray-50 border-t rounded-b-xl text-right"
:class="footerInnerPadding"
>
<slot name="footer" />
</div> </div>
</div> </div>
</div> </div>
@ -52,77 +93,84 @@
</template> </template>
<script setup> <script setup>
import {watch} from "vue"; import { watch } from "vue"
import { default as _has } from 'lodash/has' import { default as _has } from "lodash/has"
const props = defineProps({ const props = defineProps({
show: { show: {
default: false type: Boolean,
default: false,
}, },
backdropBlur: { backdropBlur: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
iconColor: { iconColor: {
default: 'blue' type: String,
default: "blue",
}, },
maxWidth: { maxWidth: {
default: '2xl' type: String,
default: "2xl",
}, },
innerPadding: { innerPadding: {
default: 'p-6' type: String,
default: "p-6",
}, },
headerInnerPadding: { headerInnerPadding: {
default: 'p-6' type: String,
default: "p-6",
}, },
footerInnerPadding: { footerInnerPadding: {
default: 'p-6' type: String,
default: "p-6",
}, },
closeable: { closeable: {
default: true type: Boolean,
default: true,
}, },
compactHeader: { compactHeader: {
default: false, default: false,
type: Boolean type: Boolean,
}, },
}) })
const emit = defineEmits(['close']) const emit = defineEmits(["close"])
useHead({ useHead({
bodyAttrs: computed(() => { bodyAttrs: computed(() => {
return { return {
class: { class: {
'overflow-hidden': props.show "overflow-hidden": props.show,
} },
} }
}) }),
}) })
const closeOnEscape = (e) => { const closeOnEscape = (e) => {
if (e.key === 'Escape' && props.show) { if (e.key === "Escape" && props.show) {
close() close()
} }
} }
onMounted(() => { onMounted(() => {
if (import.meta.server) return if (import.meta.server) return
document.addEventListener('keydown', closeOnEscape) document.addEventListener("keydown", closeOnEscape)
initMotions() initMotions()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (import.meta.server) return if (import.meta.server) return
document.removeEventListener('keydown', closeOnEscape) document.removeEventListener("keydown", closeOnEscape)
}) })
const maxWidthClass = computed(() => { const maxWidthClass = computed(() => {
return { return {
sm: 'sm:max-w-sm', sm: "sm:max-w-sm",
md: 'sm:max-w-md', md: "sm:max-w-md",
lg: 'sm:max-w-lg', lg: "sm:max-w-lg",
xl: 'sm:max-w-xl', xl: "sm:max-w-xl",
'2xl': 'sm:max-w-2xl' "2xl": "sm:max-w-2xl",
}[props.maxWidth] }[props.maxWidth]
}) })
@ -132,15 +180,15 @@ const motionFadeIn = {
transition: { transition: {
delay: 100, delay: 100,
duration: 200, duration: 200,
ease: 'easeIn' ease: "easeIn",
} },
}, },
enter: { enter: {
opacity: 1, opacity: 1,
transition: { transition: {
duration: 200 duration: 200,
} },
} },
} }
const motionSlideBottom = { const motionSlideBottom = {
@ -148,30 +196,29 @@ const motionSlideBottom = {
y: 150, y: 150,
opacity: 0, opacity: 0,
transition: { transition: {
ease: 'easeIn', ease: "easeIn",
duration: 200 duration: 200,
} },
}, },
enter: { enter: {
y: 0, y: 0,
opacity: 1, opacity: 1,
transition: { transition: {
duration: 250, duration: 250,
ease: 'easeOut', ease: "easeOut",
delay: 100 delay: 100,
} },
} },
} }
const onLeave = (el, done) => { const onLeave = (el, done) => {
contentMotion.value.leave(() => { contentMotion.value.leave(() => {})
})
backdropMotion.value.leave(done) backdropMotion.value.leave(done)
} }
const close = () => { const close = () => {
if (props.closeable) { if (props.closeable) {
emit('close') emit("close")
} }
} }
@ -190,5 +237,4 @@ const initMotions = () => {
} }
watch(() => props.show, initMotions) watch(() => props.show, initMotions)
</script> </script>

View File

@ -1,152 +1,258 @@
<template> <template>
<nav v-if="hasNavbar" class="bg-white dark:bg-notion-dark border-b"> <nav
v-if="hasNavbar"
class="bg-white dark:bg-notion-dark border-b"
>
<div class="max-w-7xl mx-auto px-8"> <div class="max-w-7xl mx-auto px-8">
<div class="flex items-center justify-between h-16"> <div class="flex items-center justify-between h-16">
<div class="flex items-center"> <div class="flex items-center">
<NuxtLink :to="{ name: user ? 'home' : 'index' }" <NuxtLink
class="flex-shrink-0 font-semibold hover:no-underline flex items-center"> :to="{ name: user ? 'home' : 'index' }"
<img src="/img/logo.svg" alt="notion tools logo" class="w-8 h-8"/> class="flex-shrink-0 font-semibold hover:no-underline flex items-center"
<span class="ml-2 text-md hidden sm:inline text-black dark:text-white">OpnForm</span> >
<img
src="/img/logo.svg"
alt="notion tools logo"
class="w-8 h-8"
>
<span
class="ml-2 text-md hidden sm:inline text-black dark:text-white"
>OpnForm</span>
</NuxtLink> </NuxtLink>
<workspace-dropdown class="ml-6"/> <workspace-dropdown class="ml-6" />
</div> </div>
<div v-if="showAuth" class="hidden md:block ml-auto relative"> <div
<NuxtLink v-if="$route.name !== 'templates'" :to="{name:'templates'}" v-if="showAuth"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8" class="hidden md:block ml-auto relative"
>
<NuxtLink
v-if="$route.name !== 'templates'"
:to="{ name: 'templates' }"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
> >
Templates Templates
</NuxtLink> </NuxtLink>
<template v-if="featureBaseEnabled"> <template v-if="featureBaseEnabled">
<button v-if="user" @click.prevent="openChangelog" <button
class="text-sm text-gray-600 dark:text-white hidden sm:inline hover:text-gray-800 cursor-pointer mt-1 mr-8" v-if="user"
class="text-sm text-gray-600 dark:text-white hidden sm:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
@click.prevent="openChangelog"
> >
What's new? <span id="fb-update-badge"></span> What's new? <span id="fb-update-badge" />
</button> </button>
<a :href="opnformConfig.links.changelog_url" target="_blank" v-else <a
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8" v-else
:href="opnformConfig.links.changelog_url"
target="_blank"
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
> >
What's new? What's new?
</a> </a>
</template> </template>
<NuxtLink v-if="$route.name !== 'ai-form-builder' && user === null" :to="{name:'ai-form-builder'}" <NuxtLink
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8" v-if="$route.name !== 'ai-form-builder' && user === null"
:to="{ name: 'ai-form-builder' }"
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
> >
AI Form Builder AI Form Builder
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
v-if="paidPlansEnabled && (user===null || (user && workspace && !workspace.is_pro)) && $route.name !== 'pricing'" v-if="
:to="{name:'pricing'}" paidPlansEnabled &&
(user === null || (user && workspace && !workspace.is_pro)) &&
$route.name !== 'pricing'
"
:to="{ name: 'pricing' }"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8" class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
> >
<span v-if="user">Upgrade</span> <span v-if="user">Upgrade</span>
<span v-else>Pricing</span> <span v-else>Pricing</span>
</NuxtLink> </NuxtLink>
<a v-if="hasCrisp" href="#" <a
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1" v-if="hasCrisp"
@click.prevent="openHelpdesk" href="#"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1"
@click.prevent="openHelpdesk"
> >
Help Help
</a> </a>
<NuxtLink v-else :href="helpUrl" <NuxtLink
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1" v-else
target="_blank" :href="helpUrl"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1"
target="_blank"
> >
Help Help
</NuxtLink> </NuxtLink>
</div> </div>
<div v-if="showAuth" class="hidden md:block pl-5 border-gray-300 border-r h-5"/> <div
<div v-if="showAuth" class="block"> v-if="showAuth"
class="hidden md:block pl-5 border-gray-300 border-r h-5"
/>
<div
v-if="showAuth"
class="block"
>
<div class="flex items-center"> <div class="flex items-center">
<div class="ml-3 mr-4 relative"> <div class="ml-3 mr-4 relative">
<div class="relative inline-block text-left"> <div class="relative inline-block text-left">
<dropdown v-if="user" dusk="nav-dropdown"> <dropdown
<template #trigger="{toggle}"> v-if="user"
<button id="dropdown-menu-button" type="button" dusk="nav-dropdown"
class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500" >
dusk="nav-dropdown-button" @click.stop="toggle()" <template #trigger="{ toggle }">
<button
id="dropdown-menu-button"
type="button"
class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500"
dusk="nav-dropdown-button"
@click.stop="toggle()"
> >
<img :src="user.photo_url" class="rounded-full w-6 h-6"/> <img
:src="user.photo_url"
class="rounded-full w-6 h-6"
>
<p class="ml-2 hidden sm:inline"> <p class="ml-2 hidden sm:inline">
{{ user.name }} {{ user.name }}
</p> </p>
</button> </button>
</template> </template>
<NuxtLink v-if="userOnboarded" :to="{ name: 'home' }" <NuxtLink
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" 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" <svg
stroke="currentColor" 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" <path
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" 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> </svg>
My Forms My Forms
</NuxtLink> </NuxtLink>
<NuxtLink v-if="userOnboarded" :to="{ name: 'templates-my-templates' }" <NuxtLink
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" v-if="userOnboarded"
:to="{ name: 'templates-my-templates' }"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" <svg
stroke-width="1.5" stroke="currentColor"> xmlns="http://www.w3.org/2000/svg"
<path stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 mr-2"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/> fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg> </svg>
My Templates My Templates
</NuxtLink> </NuxtLink>
<NuxtLink :to="{ name: 'settings-profile' }" <NuxtLink
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" :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" <svg
stroke="currentColor" 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" <path
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" 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" <path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/> />
</svg> </svg>
Settings Settings
</NuxtLink> </NuxtLink>
<NuxtLink :to="{ name: 'settings-admin' }" v-if="user.moderator" <NuxtLink
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" v-if="user.moderator"
:to="{ name: 'settings-admin' }"
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" fill="none" viewBox="0 0 24 24" stroke-width="1.5" <svg
stroke="currentColor" class="w-4 h-4 mr-2"> xmlns="http://www.w3.org/2000/svg"
<path stroke-linecap="round" stroke-linejoin="round" fill="none"
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/> viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 mr-2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
/>
</svg> </svg>
Admin Admin
</NuxtLink> </NuxtLink>
<a href="#" <a
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center" href="#"
@click.prevent="logout" class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@click.prevent="logout"
> >
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg
stroke="currentColor" 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" <path
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" 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> </svg>
Logout Logout
</a> </a>
</dropdown> </dropdown>
<div v-else class="flex gap-2"> <div
<NuxtLink v-if="$route.name !== 'login'" :to="{ name: 'login' }" v-else
class="text-gray-600 dark:text-white hover:text-gray-800 dark:hover:text-white px-0 sm:px-3 py-2 rounded-md text-sm" class="flex gap-2"
active-class="text-gray-800 dark:text-white" >
<NuxtLink
v-if="$route.name !== 'login'"
:to="{ name: 'login' }"
class="text-gray-600 dark:text-white hover:text-gray-800 dark:hover:text-white px-0 sm:px-3 py-2 rounded-md text-sm"
active-class="text-gray-800 dark:text-white"
> >
Login Login
</NuxtLink> </NuxtLink>
<v-button v-track.nav_create_form_click size="small" :to="{ name: 'forms-create-guest' }" <v-button
color="outline-blue" :arrow="true"> v-track.nav_create_form_click
size="small"
:to="{ name: 'forms-create-guest' }"
color="outline-blue"
:arrow="true"
>
Create a form Create a form
</v-button> </v-button>
</div> </div>
@ -160,20 +266,20 @@
</template> </template>
<script> <script>
import {computed} from 'vue' import { computed } from "vue"
import Dropdown from '~/components/global/Dropdown.vue' import Dropdown from "~/components/global/Dropdown.vue"
import WorkspaceDropdown from './WorkspaceDropdown.vue' import WorkspaceDropdown from "./WorkspaceDropdown.vue"
import opnformConfig from "~/opnform.config.js"; import opnformConfig from "~/opnform.config.js"
import {useRuntimeConfig} from "#app"; import { useRuntimeConfig } from "#app"
export default { export default {
components: { components: {
WorkspaceDropdown, WorkspaceDropdown,
Dropdown Dropdown,
}, },
async setup() { async setup() {
const {openHelpdesk} = useCrisp() const { openHelpdesk } = useCrisp()
const authStore = useAuthStore() const authStore = useAuthStore()
return { return {
authStore, authStore,
@ -193,7 +299,7 @@ export default {
return this.opnformConfig.links.help_url return this.opnformConfig.links.help_url
}, },
form() { form() {
if (this.$route.name && this.$route.name.startsWith('forms-slug')) { if (this.$route.name && this.$route.name.startsWith("forms-slug")) {
return this.formsStore.getByKey(this.$route.params.slug) return this.formsStore.getByKey(this.$route.params.slug)
} }
return null return null
@ -208,12 +314,12 @@ export default {
return this.config.public.featureBaseOrganization !== null return this.config.public.featureBaseOrganization !== null
}, },
showAuth() { showAuth() {
return this.$route.name && this.$route.name !== 'forms-slug' return this.$route.name && this.$route.name !== "forms-slug"
}, },
hasNavbar() { hasNavbar() {
if (this.isIframe) return false if (this.isIframe) return false
if (this.$route.name && this.$route.name === 'forms-slug') { if (this.$route.name && this.$route.name === "forms-slug") {
if (this.form) { if (this.form) {
// If there is a cover, or if branding is hidden remove nav // If there is a cover, or if branding is hidden remove nav
if (this.form.cover_picture || this.form.no_branding) { if (this.form.cover_picture || this.form.no_branding) {
@ -229,14 +335,17 @@ export default {
return this.user && this.user.has_forms === true return this.user && this.user.has_forms === true
}, },
hasCrisp() { hasCrisp() {
return this.config.public.crispWebsiteId && this.config.public.crispWebsiteId !== '' return (
} this.config.public.crispWebsiteId &&
this.config.public.crispWebsiteId !== ""
)
},
}, },
methods: { methods: {
openChangelog() { openChangelog() {
if (import.meta.server) return if (import.meta.server) return
window.Featurebase('manually_open_changelog_popup') window.Featurebase("manually_open_changelog_popup")
}, },
async logout() { async logout() {
// Log out the user. // Log out the user.
@ -248,8 +357,8 @@ export default {
// Redirect to login. // Redirect to login.
const router = useRouter() const router = useRouter()
router.push({name: 'login'}) router.push({ name: "login" })
}, },
} },
} }
</script> </script>

View File

@ -1,29 +1,60 @@
<template> <template>
<div class="fixed top-0 bottom-24 right-0 flex gap-y-4 items-start justify-end z-50 pointer-events-auto"> <div
class="fixed top-0 bottom-24 right-0 flex gap-y-4 items-start justify-end z-50 pointer-events-auto"
>
<NuxtNotifications> <NuxtNotifications>
<template #body="props"> <template #body="props">
<div class="p-2"> <div class="p-2">
<div <div
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden relative" class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden relative"
> >
<div class="flex justify-center items-center w-12" :class="notifTypes[props.item.type].background" <div
v-html="notifTypes[props.item.type].svg"/> class="flex justify-center items-center w-12"
:class="notifTypes[props.item.type].background"
v-html="notifTypes[props.item.type].svg"
/>
<div class="-mx-3 py-2 px-4"> <div class="-mx-3 py-2 px-4">
<div class="mx-3"> <div class="mx-3">
<span :class="notifTypes[props.item.type].text" class="font-semibold pr-6">{{ props.item.title }}</span> <span
<p class="text-gray-600 text-sm">{{ props.item.text }}</p> :class="notifTypes[props.item.type].text"
<div class="w-full flex gap-2 mt-1" v-if="props.item.type == 'confirm'"> class="font-semibold pr-6"
<v-button color="blue" size="small" @click.prevent="props.item.data.success();props.close()">Yes >{{ props.item.title }}</span>
<p class="text-gray-600 text-sm">
{{ props.item.text }}
</p>
<div
v-if="props.item.type == 'confirm'"
class="w-full flex gap-2 mt-1"
>
<v-button
color="blue"
size="small"
@click.prevent="
props.item.data.success();
props.close();
"
>
Yes
</v-button> </v-button>
<v-button color="white" size="small" <v-button
@click.prevent="props.item.data.failure();props.close()">No color="white"
size="small"
@click.prevent="
props.item.data.failure();
props.close();
"
>
No
</v-button> </v-button>
</div> </div>
</div> </div>
</div> </div>
<button @click="props.close()" class="absolute top-0 right-0 px-2 py-2 cursor-pointer"> <button
class="absolute top-0 right-0 px-2 py-2 cursor-pointer"
@click="props.close()"
>
<svg <svg
class="fill-current h-6 w-6 text-gray-300 hover:text-gray-500" class="fill-current h-6 w-6 text-gray-300 hover:text-gray-500"
role="button" role="button"
@ -45,60 +76,64 @@
<script> <script>
export default { export default {
name: 'Notifications', name: "Notifications",
data() { data() {
return { return {
notifTypes: { notifTypes: {
success: { success: {
background: 'bg-green-500', background: "bg-green-500",
text: 'text-green-500', text: "text-green-500",
svg: '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' + svg:
'<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' +
' <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />' + ' <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />' +
' </svg>' " </svg>",
}, },
warning: { warning: {
background: 'bg-yellow-500', background: "bg-yellow-500",
text: 'text-yellow-500', text: "text-yellow-500",
svg: '<svg' + svg:
"<svg" +
' class="h-6 w-6 fill-current text-white"' + ' class="h-6 w-6 fill-current text-white"' +
' viewBox="0 0 40 40"' + ' viewBox="0 0 40 40"' +
' xmlns="http://www.w3.org/2000/svg"' + ' xmlns="http://www.w3.org/2000/svg"' +
' >' + " >" +
' <path' + " <path" +
' d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"' + ' d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"' +
' />' + " />" +
' </svg>', " </svg>",
}, },
error: { error: {
background: 'bg-red-500', background: "bg-red-500",
text: 'text-red-500', text: "text-red-500",
svg: '<svg' + svg:
"<svg" +
' class="h-6 w-6 fill-current text-white"' + ' class="h-6 w-6 fill-current text-white"' +
' viewBox="0 0 40 40"' + ' viewBox="0 0 40 40"' +
' xmlns="http://www.w3.org/2000/svg"' + ' xmlns="http://www.w3.org/2000/svg"' +
' >' + " >" +
' <path' + " <path" +
' d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"' + ' d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"' +
' />' + " />" +
' </svg>' " </svg>",
}, },
confirm: { confirm: {
background: 'bg-blue-500', background: "bg-blue-500",
text: 'text-blue-500', text: "text-blue-500",
svg: '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' + svg:
'<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' +
' <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />' + ' <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />' +
' </svg>' " </svg>",
}, },
info: { info: {
background: 'bg-blue-500', background: "bg-blue-500",
text: 'text-blue-500', text: "text-blue-500",
svg: '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' + svg:
'<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">' +
' <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />' + ' <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />' +
' </svg>' " </svg>",
} },
},
}
} }
}, },
} }

View File

@ -1,24 +1,30 @@
<template> <template>
<notion-renderer v-if="!loading" :block-map="blockMap"/> <notion-renderer
<div class="p-6 flex items-center justify-center" v-else> v-if="!loading"
<loader class="w-6 h-6"/> :block-map="blockMap"
/>
<div
v-else
class="p-6 flex items-center justify-center"
>
<loader class="w-6 h-6" />
</div> </div>
</template> </template>
<script> <script>
import {NotionRenderer} from 'vue-notion' import { NotionRenderer } from "vue-notion"
export default { export default {
name: 'NotionPage', name: "NotionPage",
components: {NotionRenderer}, components: { NotionRenderer },
props: { props: {
blockMap: { blockMap: {
type: Object type: Object,
}, },
loading: { loading: {
type: Boolean, type: Boolean,
required: true required: true,
} },
}, },
} }
</script> </script>

View File

@ -1,35 +1,53 @@
<template> <template>
<div class="inline" v-if="shouldDisplayProTag"> <div
v-if="shouldDisplayProTag"
class="inline"
>
<UTooltip text="Upgrade to use this feature"> <UTooltip text="Upgrade to use this feature">
<div role="button" class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold cursor-pointer" <div
@click="showPremiumModal=true"> role="button"
class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold cursor-pointer"
@click="showPremiumModal = true"
>
PRO PRO
</div> </div>
<modal :show="showPremiumModal" @close="showPremiumModal=false"> <modal
:show="showPremiumModal"
@close="showPremiumModal = false"
>
<h2 class="text-nt-blue"> <h2 class="text-nt-blue">
OpnForm PRO OpnForm PRO
</h2> </h2>
<h4 v-if="user && user.is_subscribed" class="text-center mt-5"> <h4
We're happy to have you as a Pro customer. If you're having any issue with OpnForm, or if you have a v-if="user && user.is_subscribed"
feature request, please <a href="mailto:contact@opnform.com">contact us</a>. class="text-center mt-5"
>
We're happy to have you as a Pro customer. If you're having any issue
with OpnForm, or if you have a feature request, please
<a href="mailto:contact@opnform.com">contact us</a>.
</h4> </h4>
<div v-if="!user || !user.is_subscribed" class="mt-4"> <div
v-if="!user || !user.is_subscribed"
class="mt-4"
>
<p> <p>
All the features with a<span All the features with a<span
class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold mx-1" class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold mx-1"
> >
PRO PRO
</span> tag are available in the Pro plan of OpnForm. <b>You can play around and try all Pro features </span>
within tag are available in the Pro plan of OpnForm.
the form editor, but you can't use them in your real forms</b>. You can subscribe now to gain unlimited <b>You can play around and try all Pro features within the form
access editor, but you can't use them in your real forms</b>. You can subscribe now to gain unlimited access to all our pro
to features!
all our pro features!
</p> </p>
</div> </div>
<div class="my-4 text-center"> <div class="my-4 text-center">
<v-button color="white" @click="showPremiumModal=false"> <v-button
color="white"
@click="showPremiumModal = false"
>
Close Close
</v-button> </v-button>
</div> </div>
@ -39,7 +57,7 @@
</template> </template>
<script setup> <script setup>
import {computed} from 'vue' import { computed } from "vue"
const authStore = useAuthStore() const authStore = useAuthStore()
const workspacesStore = useWorkspacesStore() const workspacesStore = useWorkspacesStore()
@ -50,6 +68,6 @@ const showPremiumModal = ref(false)
const shouldDisplayProTag = computed(() => { const shouldDisplayProTag = computed(() => {
if (!useRuntimeConfig().public.paidPlansEnabled) return false if (!useRuntimeConfig().public.paidPlansEnabled) return false
if (!user.value || !workspace.value) return true if (!user.value || !workspace.value) return true
return !(workspace.value.is_pro) return !workspace.value.is_pro
}) })
</script> </script>

View File

@ -1,47 +1,58 @@
<template> <template>
<div class="scroll-shadow max-w-full" :class="[$style.wrap,{'w-max':!shadow.left && !shadow.right}]"> <div
class="scroll-shadow max-w-full"
:class="[$style.wrap, { 'w-max': !shadow.left && !shadow.right }]"
>
<div <div
ref="scrollContainer" ref="scrollContainer"
:class="[$style['scroll-container'],{'no-scrollbar':hideScrollbar}]" :class="[$style['scroll-container'], { 'no-scrollbar': hideScrollbar }]"
:style="{ width: width?width:'auto', height }" :style="{ width: width ? width : 'auto', height }"
@scroll.passive="throttled.toggleShadow" @scroll.passive="throttled.toggleShadow"
> >
<slot /> <slot />
<span :class="[$style['shadow-top'], shadow.top && $style['is-active']]" :style="{ <span
top: shadowTopOffset+'px', :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']]"
/> />
<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>
</div> </div>
</template> </template>
<script> <script>
import throttle from 'lodash/throttle' import throttle from "lodash/throttle"
function newResizeObserver (callback) { function newResizeObserver(callback) {
// Skip this feature for browsers which // Skip this feature for browsers which
// do not support ResizeObserver. // do not support ResizeObserver.
// https://caniuse.com/#search=resizeobserver // https://caniuse.com/#search=resizeobserver
if (typeof ResizeObserver === 'undefined') return if (typeof ResizeObserver === "undefined") return
return new ResizeObserver(e => e.map(callback)) return new ResizeObserver((e) => e.map(callback))
} }
export default { export default {
name: 'ScrollShadow', name: "ScrollShadow",
props: { props: {
hideScrollbar: { hideScrollbar: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
shadowTopOffset: { shadowTopOffset: {
type: Number, type: Number,
default: 0 default: 0,
} },
}, },
data () { data() {
return { return {
width: undefined, width: undefined,
height: undefined, height: undefined,
@ -49,21 +60,23 @@ export default {
top: false, top: false,
right: false, right: false,
bottom: false, bottom: false,
left: false left: false,
}, },
scrollContainerObserver: null, scrollContainerObserver: null,
wrapObserver: null, wrapObserver: null,
throttled: {} throttled: {},
} }
}, },
mounted () { mounted() {
this.throttled.toggleShadow = throttle(this.toggleShadow, 100); this.throttled.toggleShadow = throttle(this.toggleShadow, 100)
this.throttled.calcDimensions = throttle(this.calcDimensions, 100); this.throttled.calcDimensions = throttle(this.calcDimensions, 100)
window.addEventListener('resize', this.throttled.calcDimensions) window.addEventListener("resize", this.throttled.calcDimensions)
// Check if shadows are necessary after the element is resized. // Check if shadows are necessary after the element is resized.
const scrollContainerObserver = newResizeObserver(this.throttled.toggleShadow) const scrollContainerObserver = newResizeObserver(
this.throttled.toggleShadow,
)
if (scrollContainerObserver) { if (scrollContainerObserver) {
scrollContainerObserver.observe(this.$refs.scrollContainer) scrollContainerObserver.observe(this.$refs.scrollContainer)
} }
@ -74,8 +87,8 @@ export default {
this.wrapObserver.observe(this.$el) this.wrapObserver.observe(this.$el)
} }
}, },
unmounted () { unmounted() {
window.removeEventListener('resize', this.throttled.calcDimensions) window.removeEventListener("resize", this.throttled.calcDimensions)
// Cleanup when the component is unmounted. // Cleanup when the component is unmounted.
this.wrapObserver.disconnect() this.wrapObserver.disconnect()
if (this.scrollContainerObserver) { if (this.scrollContainerObserver) {
@ -83,7 +96,7 @@ export default {
} }
}, },
methods: { methods: {
async calcDimensions () { async calcDimensions() {
// Reset dimensions for correctly recalculating parent dimensions. // Reset dimensions for correctly recalculating parent dimensions.
this.width = undefined this.width = undefined
this.height = undefined this.height = undefined
@ -93,7 +106,7 @@ export default {
this.height = `${this.$el.clientHeight}px` this.height = `${this.$el.clientHeight}px`
}, },
// Check if shadows are needed. // Check if shadows are needed.
toggleShadow () { toggleShadow() {
if (!this.$refs.scrollContainer) return if (!this.$refs.scrollContainer) return
const hasHorizontalScrollbar = const hasHorizontalScrollbar =
this.$refs.scrollContainer.clientWidth < this.$refs.scrollContainer.clientWidth <
@ -122,8 +135,8 @@ export default {
this.shadow.bottom = hasVerticalScrollbar && !scrolledToBottom this.shadow.bottom = hasVerticalScrollbar && !scrolledToBottom
this.shadow.left = hasHorizontalScrollbar && !scrolledToLeft this.shadow.left = hasHorizontalScrollbar && !scrolledToLeft
}) })
} },
} },
} }
</script> </script>
@ -155,7 +168,7 @@ export default {
height: 1em; height: 1em;
border-top-right-radius: 0; border-top-right-radius: 0;
border-top-left-radius: 0; border-top-left-radius: 0;
background-image: linear-gradient(rgba(#555, 0.1) 0%, rgba(#FFF, 0) 100%); background-image: linear-gradient(rgba(#555, 0.1) 0%, rgba(#fff, 0) 100%);
} }
.shadow-top { .shadow-top {
@ -174,7 +187,11 @@ export default {
width: 1em; width: 1em;
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
background-image: linear-gradient(90deg, rgba(#555, 0.1) 0%, rgba(#FFF, 0) 100%); background-image: linear-gradient(
90deg,
rgba(#555, 0.1) 0%,
rgba(#fff, 0) 100%
);
} }
.shadow-right { .shadow-right {

View File

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

View File

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

View File

@ -1,37 +1,66 @@
<template> <template>
<dropdown v-if="user && workspaces && workspaces.length > 1" ref="dropdown" <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" v-if="user && workspaces && workspaces.length > 1"
dusk="workspace-dropdown" 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}"> <template
<div class="flex items-center cursor group" role="button" @click.stop="toggle()"> v-if="workspace"
#trigger="{ toggle }"
>
<div
class="flex items-center cursor group"
role="button"
@click.stop="toggle()"
>
<div class="rounded-full h-8 8"> <div class="rounded-full h-8 8">
<img v-if="isUrl(workspace.icon)" <img
:src="workspace.icon" v-if="isUrl(workspace.icon)"
:alt="workspace.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow" :src="workspace.icon"
/> :alt="workspace.name + ' icon'"
<div v-else class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
v-text="workspace.icon" >
<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> </div>
<p class="hidden group-hover:underline lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200"> <p
class="hidden group-hover:underline lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200"
>
{{ workspace.name }} {{ workspace.name }}
</p> </p>
</div> </div>
</template> </template>
<template v-for="worksp in workspaces" :key="worksp.id"> <template
<a href="#" v-for="worksp in workspaces"
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" :key="worksp.id"
:class="{'bg-blue-100 dark:bg-blue-900':workspace?.id === worksp?.id}" @click.prevent="switchWorkspace(worksp)" >
<a
href="#"
class="px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
:class="{
'bg-blue-100 dark:bg-blue-900': workspace?.id === worksp?.id,
}"
@click.prevent="switchWorkspace(worksp)"
> >
<div class="rounded-full h-8 w-8 flex-shrink-0" role="button"> <div
<img v-if="isUrl(worksp.icon)" class="rounded-full h-8 w-8 flex-shrink-0"
:src="worksp.icon" role="button"
:alt="worksp.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow" >
/> <img
<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-if="isUrl(worksp.icon)"
v-text="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> </div>
<p class="ml-4 truncate">{{ worksp.name }}</p> <p class="ml-4 truncate">{{ worksp.name }}</p>
@ -41,17 +70,16 @@
</template> </template>
<script> <script>
import { computed } from 'vue' import { computed } from "vue"
import Dropdown from '~/components/global/Dropdown.vue' import Dropdown from "~/components/global/Dropdown.vue"
export default { export default {
name: "WorkspaceDropdown",
name: 'WorkspaceDropdown',
components: { components: {
Dropdown Dropdown,
}, },
setup () { setup() {
const authStore = useAuthStore() const authStore = useAuthStore()
const formsStore = useFormsStore() const formsStore = useFormsStore()
const workspacesStore = useWorkspacesStore() const workspacesStore = useWorkspacesStore()
@ -60,45 +88,42 @@ export default {
workspacesStore, workspacesStore,
user: computed(() => authStore.user), user: computed(() => authStore.user),
workspaces: computed(() => workspacesStore.getAll), workspaces: computed(() => workspacesStore.getAll),
loading: computed(() => workspacesStore.loading) loading: computed(() => workspacesStore.loading),
} }
}, },
computed: { computed: {
workspace () { workspace() {
return this.workspacesStore.getCurrent return this.workspacesStore.getCurrent
} },
}, },
watch: { watch: {},
},
mounted () { mounted() {},
},
methods: { methods: {
switchWorkspace (workspace) { switchWorkspace(workspace) {
this.workspacesStore.setCurrentId(workspace.id) this.workspacesStore.setCurrentId(workspace.id)
this.formsStore.resetState() this.formsStore.resetState()
this.formsStore.loadAll(workspace.id) this.formsStore.loadAll(workspace.id)
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
if (route.name !== 'home') { if (route.name !== "home") {
router.push({ name: 'home' }) router.push({ name: "home" })
} }
this.formsStore.loadAll(workspace.id) this.formsStore.loadAll(workspace.id)
}, },
isUrl (str) { isUrl(str) {
try { try {
new URL(str) new URL(str)
} catch (_) { } catch (_) {
return false return false
} }
return true return true
} },
} },
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,23 +1,23 @@
<template> <template>
<transition @leave="onLeave"> <transition @leave="onLeave">
<div <div
ref="collapsible"
v-if="modelValue" v-if="modelValue"
ref="collapsible"
v-on-click-outside.bubble="onClickAway" v-on-click-outside.bubble="onClickAway"
> >
<slot/> <slot />
</div> </div>
</transition> </transition>
</template> </template>
<script setup> <script setup>
import {vOnClickOutside} from '@vueuse/components' import { vOnClickOutside } from "@vueuse/components"
const props = defineProps({ const props = defineProps({
modelValue: {type: Boolean}, modelValue: { type: Boolean },
maxHeight: {type: Number, default: 200}, maxHeight: { type: Number, default: 200 },
}) })
const emit = defineEmits(['click-away']) const emit = defineEmits(["click-away"])
const motion = ref(null) const motion = ref(null)
const collapsible = ref(null) const collapsible = ref(null)
@ -25,28 +25,31 @@ const variants = {
initial: { initial: {
opacity: 0, opacity: 0,
y: -10, y: -10,
transition: {duration: 75, ease: 'easeIn'} transition: { duration: 75, ease: "easeIn" },
}, },
enter: { enter: {
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: {duration: 150, ease: 'easeOut'} transition: { duration: 150, ease: "easeOut" },
} },
} }
watch(() => props.modelValue, (newValue) => { watch(
if (newValue) { () => props.modelValue,
nextTick(() => { (newValue) => {
motion.value = useMotion(collapsible.value, variants) if (newValue) {
}) nextTick(() => {
} motion.value = useMotion(collapsible.value, variants)
}) })
}
},
)
const onLeave = (el, done) => { const onLeave = (el, done) => {
motion.value.leave(done) motion.value.leave(done)
} }
const onClickAway = (event) => { const onClickAway = (event) => {
emit('click-away', event) emit("click-away", event)
} }
</script> </script>

View File

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

View File

@ -1,42 +1,58 @@
<template> <template>
<modal :show="show" max-width="lg" @close="emit('close')"> <modal
<open-form :theme="theme" :loading="false" :show-hidden="true" :form="form" :fields="form.properties" @submit="updateForm" :default-data-form="submission"> :show="show"
<template #submit-btn="{submitForm}"> max-width="lg"
<v-button :loading="loading" class="mt-2 px-8 mx-1" @click.prevent="submitForm"> @close="emit('close')"
Update Submission >
</v-button> <open-form
</template> :theme="theme"
</open-form> :loading="false"
</modal> :show-hidden="true"
:form="form"
:fields="form.properties"
:default-data-form="submission"
@submit="updateForm"
>
<template #submit-btn="{ submitForm }">
<v-button
:loading="loading"
class="mt-2 px-8 mx-1"
@click.prevent="submitForm"
>
Update Submission
</v-button>
</template>
</open-form>
</modal>
</template> </template>
<script setup> <script setup>
import {ref, defineProps, defineEmits, onMounted } from 'vue' import { ref, defineProps, defineEmits } from "vue"
import OpenForm from '../forms/OpenForm.vue'; import OpenForm from "../forms/OpenForm.vue"
import { themes } from '~/lib/forms/form-themes.js' import { themes } from "~/lib/forms/form-themes.js"
const props = defineProps({ const props = defineProps({
show: { type: Boolean, required: true }, show: { type: Boolean, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },
theme:{type:Object, default:themes.default}, theme: { type: Object, default: themes.default },
submission:{type:Object} submission: { type: Object },
}) })
let loading = ref(false) const loading = ref(false)
const emit = defineEmits(['close', 'updated']) const emit = defineEmits(["close", "updated"])
const updateForm = (form, onFailure) =>{ const updateForm = (form, onFailure) => {
loading.value = true loading.value = true
form.put('/open/forms/' + props.form.id + '/submissions/'+props.submission.id).then((res) => { form
useAlert().success(res.message) .put("/open/forms/" + props.form.id + "/submissions/" + props.submission.id)
loading.value = false .then((res) => {
emit('close') useAlert().success(res.message)
emit('updated', res.data.data) loading.value = false
emit("close")
}).catch((error) => { emit("updated", res.data.data)
console.error(error) })
loading.value = false .catch((error) => {
onFailure() console.error(error)
}) loading.value = false
onFailure()
})
} }
</script>
</script>

View File

@ -1,79 +1,115 @@
<template> <template>
<div class="flex items-center justify-center space-x-1"> <div class="flex items-center justify-center space-x-1">
<button v-track.delete_record_click <button
class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-blue-700" v-track.delete_record_click
@click="showEditSubmissionModal=true" class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-blue-700"
@click="showEditSubmissionModal = true"
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg> </svg>
</button> </button>
<button v-track.delete_record_click <button
class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-red-700" v-track.delete_record_click
@click="onDeleteClick" class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-red-700"
@click="onDeleteClick"
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <svg
class="w-4 h-4" xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
> >
<path stroke-linecap="round" stroke-linejoin="round" <path
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/> />
</svg> </svg>
</button> </button>
</div> </div>
<EditSubmissionModal :show="showEditSubmissionModal" :form="form" :submission="submission" @close="showEditSubmissionModal=false" @updated="(submission)=>$emit('updated', submission)"/> <EditSubmissionModal
:show="showEditSubmissionModal"
:form="form"
:submission="submission"
@close="showEditSubmissionModal = false"
@updated="(submission) => $emit('updated', submission)"
/>
</template> </template>
<script> <script>
import EditSubmissionModal from './EditSubmissionModal.vue' import EditSubmissionModal from "./EditSubmissionModal.vue"
export default { export default {
components: { EditSubmissionModal }, components: { EditSubmissionModal },
emits: ["updated", "deleted"],
props: { props: {
form: { form: {
type: Object, type: Object,
required: true required: true,
}, },
structure: { structure: {
type: Array, type: Array,
default: () => [] default: () => [],
}, },
submission: { submission: {
type: Object, type: Object,
default: () => {} default: () => {},
}
},
setup () {
return {
useAlert: useAlert()
}
},
data () {
return {
showEditSubmissionModal:false,
}
},
computed: {
},
mounted () {
},
methods: {
onDeleteClick () {
this.useAlert.confirm('Do you really want to delete this record?', this.deleteRecord)
}, },
async deleteRecord () { },
opnFetch('/open/forms/' + this.form.id + '/records/' + this.submission.id + '/delete', {method:'DELETE'}).then(async (data) => { emits: ["updated", "deleted"],
if (data.type === 'success') { setup() {
this.$emit('deleted',this.submission) return {
this.useAlert.success(data.message) useAlert: useAlert(),
} else {
this.useAlert.error('Something went wrong!')
}
}).catch((error) => {
this.useAlert.error(error.data.message)
})
} }
} },
data() {
return {
showEditSubmissionModal: false,
}
},
computed: {},
mounted() {},
methods: {
onDeleteClick() {
this.useAlert.confirm(
"Do you really want to delete this record?",
this.deleteRecord,
)
},
async deleteRecord() {
opnFetch(
"/open/forms/" +
this.form.id +
"/records/" +
this.submission.id +
"/delete",
{ method: "DELETE" },
)
.then(async (data) => {
if (data.type === "success") {
this.$emit("deleted", this.submission)
this.useAlert.success(data.message)
} else {
this.useAlert.error("Something went wrong!")
}
})
.catch((error) => {
this.useAlert.error(error.data.message)
})
},
},
} }
</script> </script>

View File

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

View File

@ -1,7 +1,9 @@
<template> <template>
<transition @leave="(el,done) => sidebarMotion?.leave(done)"> <transition @leave="(el, done) => sidebarMotion?.leave(done)">
<div v-if="show" ref="sidebar" <div
class="absolute shadow-lg shadow-gray-800/30 top-0 h-[calc(100vh-53px)] right-0 lg:shadow-none lg:relative bg-white w-full md:w-1/2 lg:w-2/5 border-l overflow-y-scroll md:max-w-[20rem] flex-shrink-0 z-30" v-if="show"
ref="sidebar"
class="absolute shadow-lg shadow-gray-800/30 top-0 h-[calc(100vh-53px)] right-0 lg:shadow-none lg:relative bg-white w-full md:w-1/2 lg:w-2/5 border-l overflow-y-scroll md:max-w-[20rem] flex-shrink-0 z-30"
> >
<slot /> <slot />
</div> </div>
@ -9,24 +11,26 @@
</template> </template>
<script setup> <script setup>
import {slideRight, useMotion} from "@vueuse/motion" import { slideRight, useMotion } from "@vueuse/motion"
import {watch} from "vue"; import { watch } from "vue"
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
default: false default: false,
} },
}) })
const sidebar = ref(null) const sidebar = ref(null)
const sidebarMotion = ref(null) const sidebarMotion = ref(null)
watch(() => props.show, (newVal) => { watch(
if (newVal) { () => props.show,
nextTick(() => { (newVal) => {
sidebarMotion.value = useMotion(sidebar.value, slideRight) if (newVal) {
}) nextTick(() => {
} sidebarMotion.value = useMotion(sidebar.value, slideRight)
}) })
}
},
)
</script> </script>

View File

@ -1,8 +1,19 @@
<template> <template>
<div v-if="form" class="open-complete-form"> <div
<h1 v-if="!isHideTitle" class="mb-4 px-2" :class="{'mt-4':isEmbedPopup}" v-text="form.title" /> v-if="form"
<div v-if="form.description" v-html="form.description" class="open-complete-form"
class="form-description mb-4 text-gray-700 dark:text-gray-300 whitespace-pre-wrap px-2"/> >
<h1
v-if="!isHideTitle"
class="mb-4 px-2"
:class="{'mt-4':isEmbedPopup}"
v-text="form.title"
/>
<div
v-if="form.description"
class="form-description mb-4 text-gray-700 dark:text-gray-300 whitespace-pre-wrap px-2"
v-html="form.description"
/>
<div v-if="isPublicFormPage && form.is_password_protected"> <div v-if="isPublicFormPage && form.is_password_protected">
<p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2"> <p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2">
@ -10,48 +21,77 @@
</p> </p>
<div class="form-group flex flex-wrap w-full"> <div class="form-group flex flex-wrap w-full">
<div class="relative mb-3 w-full px-2"> <div class="relative mb-3 w-full px-2">
<text-input :theme="theme" :form="passwordForm" name="password" native-type="password" label="Password" /> <text-input
:theme="theme"
:form="passwordForm"
name="password"
native-type="password"
label="Password"
/>
</div> </div>
</div> </div>
<div class="flex flex-wrap justify-center w-full text-center"> <div class="flex flex-wrap justify-center w-full text-center">
<open-form-button :theme="theme" :color="form.color" class="my-4" @click="passwordEntered"> <open-form-button
:theme="theme"
:color="form.color"
class="my-4"
@click="passwordEntered"
>
Submit Submit
</open-form-button> </open-form-button>
</div> </div>
</div> </div>
<v-transition> <v-transition>
<div v-if="!form.is_password_protected && form.password && !hidePasswordDisabledMsg" <div
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500" 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"> <div class="flex flex-grow">
<p class="mb-0 py-2 px-4 text-yellow-600 dark:text-yellow-600"> <p class="mb-0 py-2 px-4 text-yellow-600 dark:text-yellow-600">
We disabled the password protection for this form because you are an owner of it. We disabled the password protection for this form because you are an owner of it.
</p> </p>
<v-button color="yellow" @click="hidePasswordDisabledMsg=true"> <v-button
color="yellow"
@click="hidePasswordDisabledMsg=true"
>
OK OK
</v-button> </v-button>
</div> </div>
</div> </div>
</v-transition> </v-transition>
<div v-if="isPublicFormPage && (form.is_closed || form.visibility=='closed')" <div
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500" v-if="isPublicFormPage && (form.is_closed || form.visibility=='closed')"
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
> >
<div class="flex-grow"> <div class="flex-grow">
<p class="mb-0 py-2 px-4 text-yellow-600" v-html="form.closed_text" /> <p
class="mb-0 py-2 px-4 text-yellow-600"
v-html="form.closed_text"
/>
</div> </div>
</div> </div>
<div v-if="isPublicFormPage && form.max_number_of_submissions_reached" <div
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500" 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"> <div class="flex-grow">
<p class="mb-0 py-2 px-4 text-yellow-600" v-html="form.max_submissions_reached_text" /> <p
class="mb-0 py-2 px-4 text-yellow-600"
v-html="form.max_submissions_reached_text"
/>
</div> </div>
</div> </div>
<form-cleanings v-if="!adminPreview" :hideable="true" class="mb-4 mx-2" :form="form" :specify-form-owner="true" /> <form-cleanings
v-if="!adminPreview"
:hideable="true"
class="mb-4 mx-2"
:form="form"
:specify-form-owner="true"
/>
<transition <transition
v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached && form.visibility!='closed'))" v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached && form.visibility!='closed'))"
@ -63,45 +103,85 @@
leave-to-class="translate-x-full opacity-0" leave-to-class="translate-x-full opacity-0"
mode="out-in" mode="out-in"
> >
<div v-if="!submitted" key="form"> <div
<open-form v-if="form" v-if="!submitted"
:form="form" key="form"
:loading="loading" >
:fields="form.properties" <open-form
:theme="theme" v-if="form"
:dark-mode="darkMode" :form="form"
:admin-preview="adminPreview" :loading="loading"
@submit="submitForm" :fields="form.properties"
:theme="theme"
:dark-mode="darkMode"
:admin-preview="adminPreview"
@submit="submitForm"
> >
<template #submit-btn="{submitForm}"> <template #submit-btn="{submitForm}">
<open-form-button :loading="loading" :theme="theme" :color="form.color" class="mt-2 px-8 mx-1" <open-form-button
:class="submitButtonClass" @click.prevent="submitForm" :loading="loading"
:theme="theme"
:color="form.color"
class="mt-2 px-8 mx-1"
:class="submitButtonClass"
@click.prevent="submitForm"
> >
{{ form.submit_button_text }} {{ form.submit_button_text }}
</open-form-button> </open-form-button>
</template> </template>
</open-form> </open-form>
<p v-if="!form.no_branding" class="text-center w-full mt-2"> <p
<a href="https://opnform.com?utm_source=form&utm_content=powered_by" v-if="!form.no_branding"
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs" class="text-center w-full mt-2"
target="_blank" >
<a
href="https://opnform.com?utm_source=form&utm_content=powered_by"
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs"
target="_blank"
> >
Powered by <span class="font-semibold">OpnForm</span> Powered by <span class="font-semibold">OpnForm</span>
</a> </a>
</p> </p>
</div> </div>
<div v-else key="submitted" class="px-2"> <div
<p class="form-description text-gray-700 dark:text-gray-300 whitespace-pre-wrap" v-html="form.submitted_text " /> v-else
<open-form-button v-if="form.re_fillable" :theme="theme" :color="form.color" class="my-4" @click="restart"> 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 }} {{ form.re_fill_button_text }}
</open-form-button> </open-form-button>
<p v-if="form.editable_submissions && submissionId" class="mt-5"> <p
<a target="_parent" :href="form.share_url+'?submission_id='+submissionId" class="text-nt-blue hover:underline"> v-if="form.editable_submissions && submissionId"
class="mt-5"
>
<a
target="_parent"
:href="form.share_url+'?submission_id='+submissionId"
class="text-nt-blue hover:underline"
>
{{ form.editable_submissions_button_text }} {{ form.editable_submissions_button_text }}
</a> </a>
</p> </p>
<p v-if="!form.no_branding" class="mt-5"> <p
<a target="_parent" href="https://opnform.com/?utm_source=form&utm_content=create_form_free" class="text-nt-blue hover:underline"> v-if="!form.no_branding"
class="mt-5"
>
<a
target="_parent"
href="https://opnform.com/?utm_source=form&utm_content=create_form_free"
class="text-nt-blue hover:underline"
>
Create your form for free with OpnForm Create your form for free with OpnForm
</a> </a>
</p> </p>
@ -117,8 +197,8 @@ import { themes } from '~/lib/forms/form-themes.js'
import VButton from '~/components/global/VButton.vue' import VButton from '~/components/global/VButton.vue'
import FormCleanings from '../../pages/forms/show/FormCleanings.vue' import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
import VTransition from '~/components/global/transitions/VTransition.vue' import VTransition from '~/components/global/transitions/VTransition.vue'
import {pendingSubmission} from "~/composables/forms/pendingSubmission.js"; import {pendingSubmission} from "~/composables/forms/pendingSubmission.js"
import clonedeep from "clone-deep"; import clonedeep from "clone-deep"
import { default as _has } from 'lodash/has' import { default as _has } from 'lodash/has'
export default { export default {

View File

@ -4,34 +4,54 @@
<Loader class="h-6 w-6 text-nt-blue mx-auto" /> <Loader class="h-6 w-6 text-nt-blue mx-auto" />
</p> </p>
</div> </div>
<form v-else-if="dataForm" @submit.prevent=""> <form
<template v-if='form.show_progress_bar'> v-else-if="dataForm"
<div v-if='isIframe' class='mb-4 p-2'> @submit.prevent=""
<div class='w-full h-2 bg-gray-200 dark:bg-gray-600 relative border rounded-full overflow-hidden'> >
<div class='h-full transition-all duration-300 rounded-r-full' <template v-if="form.show_progress_bar">
:class="{ 'w-0': formProgress === 0 }" <div
:style="{ width: formProgress + '%', background: form.color }" v-if="isIframe"
class="mb-4 p-2"
>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-600 relative border rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300 rounded-r-full"
:class="{ 'w-0': formProgress === 0 }"
:style="{ width: formProgress + '%', background: form.color }"
/> />
</div> </div>
</div> </div>
<div v-else class='fixed top-0 left-0 right-0 z-50'> <div
<div class='w-full h-[0.2rem] bg-gray-200 dark:bg-gray-600 relative overflow-hidden'> v-else
<div class='h-full transition-all duration-300' class="fixed top-0 left-0 right-0 z-50"
:class="{ 'w-0': formProgress === 0 }" >
:style="{ width: formProgress + '%', background: form.color }" <div class="w-full h-[0.2rem] bg-gray-200 dark:bg-gray-600 relative overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="{ 'w-0': formProgress === 0 }"
:style="{ width: formProgress + '%', background: form.color }"
/> />
</div> </div>
</div> </div>
</template> </template>
<transition name="fade" mode="out-in"> <transition
<div :key="currentFieldGroupIndex" class="form-group flex flex-wrap w-full"> name="fade"
<draggable v-model="currentFields" mode="out-in"
item-key="id" >
class="flex flex-wrap transition-all w-full" <div
:class="{'-m-6 p-2 bg-gray-50 rounded-md':dragging}" :key="currentFieldGroupIndex"
ghost-class="ghost-item" class="form-group flex flex-wrap w-full"
handle=".draggable" :animation="200" >
@start="onDragStart" @end="onDragEnd" <draggable
v-model="currentFields"
item-key="id"
class="flex flex-wrap transition-all w-full"
:class="{'-m-6 p-2 bg-gray-50 rounded-md':dragging}"
ghost-class="ghost-item"
handle=".draggable"
:animation="200"
@start="onDragStart"
@end="onDragEnd"
> >
<template #item="{element}"> <template #item="{element}">
<open-form-field <open-form-field
@ -52,22 +72,43 @@
<!-- Captcha --> <!-- Captcha -->
<template v-if="form.use_captcha && isLastPage"> <template v-if="form.use_captcha && isLastPage">
<div class="mb-3 px-2 mt-2 mx-auto w-max"> <div class="mb-3 px-2 mt-2 mx-auto w-max">
<vue-hcaptcha ref="hcaptcha" :sitekey="hCaptchaSiteKey" :theme="darkMode?'dark':'light'" /> <vue-hcaptcha
<has-error :form="dataForm" field="h-captcha-response" /> ref="hcaptcha"
:sitekey="hCaptchaSiteKey"
:theme="darkMode?'dark':'light'"
/>
<has-error
:form="dataForm"
field="h-captcha-response"
/>
</div> </div>
</template> </template>
<!-- Submit, Next and previous buttons --> <!-- Submit, Next and previous buttons -->
<div class="flex flex-wrap justify-center w-full"> <div class="flex flex-wrap justify-center w-full">
<open-form-button v-if="currentFieldGroupIndex>0 && previousFieldsPageBreak && !loading" native-type="button" <open-form-button
:color="form.color" :theme="theme" class="mt-2 px-8 mx-1" @click="previousPage" 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 }} {{ previousFieldsPageBreak.previous_btn_text }}
</open-form-button> </open-form-button>
<slot v-if="isLastPage" name="submit-btn" :submitForm="submitForm" /> <slot
<open-form-button v-else-if="currentFieldsPageBreak" native-type="button" :color="form.color" :theme="theme" class="mt-2 px-8 mx-1" v-if="isLastPage"
@click.stop="nextPage" name="submit-btn"
:submit-form="submitForm"
/>
<open-form-button
v-else-if="currentFieldsPageBreak"
native-type="button"
:color="form.color"
:theme="theme"
class="mt-2 px-8 mx-1"
@click.stop="nextPage"
> >
{{ currentFieldsPageBreak.next_btn_text }} {{ currentFieldsPageBreak.next_btn_text }}
</open-form-button> </open-form-button>
@ -337,7 +378,7 @@ export default {
async initForm () { async initForm () {
if(this.defaultDataForm){ if(this.defaultDataForm){
this.dataForm = useForm(this.defaultDataForm) this.dataForm = useForm(this.defaultDataForm)
return; return
} }
if (this.isPublicFormPage && this.form.editable_submissions) { if (this.isPublicFormPage && this.form.editable_submissions) {
@ -351,7 +392,7 @@ export default {
} }
} }
if (this.isPublicFormPage && this.form.auto_save) { if (this.isPublicFormPage && this.form.auto_save) {
let pendingData = this.pendingSubmission.get() const pendingData = this.pendingSubmission.get()
if (pendingData !== null && pendingData && Object.keys(this.pendingSubmission.get()).length !== 0) { if (pendingData !== null && pendingData && Object.keys(this.pendingSubmission.get()).length !== 0) {
this.fields.forEach((field) => { this.fields.forEach((field) => {
if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today' if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today'

View File

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

View File

@ -1,52 +1,128 @@
<template> <template>
<div v-if="!isFieldHidden" :id="'block-' + field.id" :class="getFieldWidthClasses(field)"> <div
v-if="!isFieldHidden"
:id="'block-' + field.id"
:class="getFieldWidthClasses(field)"
>
<div :class="getFieldClasses(field)"> <div :class="getFieldClasses(field)">
<div v-if="adminPreview" <div
class="absolute -translate-x-full top-0 bottom-0 opacity-0 group-hover/nffield:opacity-100 transition-opacity mb-4"> v-if="adminPreview"
<div class="flex flex-col bg-white rounded-md" class="absolute -translate-x-full top-0 bottom-0 opacity-0 group-hover/nffield:opacity-100 transition-opacity mb-4"
:class="{ 'lg:flex-row': !fieldSideBarOpened, 'xl:flex-row': fieldSideBarOpened }"> >
<div class="p-2 -mr-3 -mb-2 text-gray-300 hover:text-blue-500 cursor-pointer hidden xl:block" role="button" <div
class="flex flex-col bg-white rounded-md"
:class="{ 'lg:flex-row': !fieldSideBarOpened, 'xl:flex-row': fieldSideBarOpened }"
>
<div
class="p-2 -mr-3 -mb-2 text-gray-300 hover:text-blue-500 cursor-pointer hidden xl:block"
role="button"
:class="{ 'lg:block': !fieldSideBarOpened, 'xl:block': fieldSideBarOpened }" :class="{ 'lg:block': !fieldSideBarOpened, 'xl:block': fieldSideBarOpened }"
@click.prevent="openAddFieldSidebar"> @click.prevent="openAddFieldSidebar"
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" >
stroke="currentColor" class="w-5 h-5"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="3"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg> </svg>
</div> </div>
<div class="p-2 text-gray-300 hover:text-blue-500 cursor-pointer" role="button" <div
class="p-2 text-gray-300 hover:text-blue-500 cursor-pointer"
role="button"
:class="{ 'lg:-mr-2': !fieldSideBarOpened, 'xl:-mr-2': fieldSideBarOpened }" :class="{ 'lg:-mr-2': !fieldSideBarOpened, 'xl:-mr-2': fieldSideBarOpened }"
@click.prevent="editFieldOptions"> @click.prevent="editFieldOptions"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5"> >
<path fill-rule="evenodd" <svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M11.828 2.25c-.916 0-1.699.663-1.85 1.567l-.091.549a.798.798 0 01-.517.608 7.45 7.45 0 00-.478.198.798.798 0 01-.796-.064l-.453-.324a1.875 1.875 0 00-2.416.2l-.243.243a1.875 1.875 0 00-.2 2.416l.324.453a.798.798 0 01.064.796 7.448 7.448 0 00-.198.478.798.798 0 01-.608.517l-.55.092a1.875 1.875 0 00-1.566 1.849v.344c0 .916.663 1.699 1.567 1.85l.549.091c.281.047.508.25.608.517.06.162.127.321.198.478a.798.798 0 01-.064.796l-.324.453a1.875 1.875 0 00.2 2.416l.243.243c.648.648 1.67.733 2.416.2l.453-.324a.798.798 0 01.796-.064c.157.071.316.137.478.198.267.1.47.327.517.608l.092.55c.15.903.932 1.566 1.849 1.566h.344c.916 0 1.699-.663 1.85-1.567l.091-.549a.798.798 0 01.517-.608 7.52 7.52 0 00.478-.198.798.798 0 01.796.064l.453.324a1.875 1.875 0 002.416-.2l.243-.243c.648-.648.733-1.67.2-2.416l-.324-.453a.798.798 0 01-.064-.796c.071-.157.137-.316.198-.478.1-.267.327-.47.608-.517l.55-.091a1.875 1.875 0 001.566-1.85v-.344c0-.916-.663-1.699-1.567-1.85l-.549-.091a.798.798 0 01-.608-.517 7.507 7.507 0 00-.198-.478.798.798 0 01.064-.796l.324-.453a1.875 1.875 0 00-.2-2.416l-.243-.243a1.875 1.875 0 00-2.416-.2l-.453.324a.798.798 0 01-.796.064 7.462 7.462 0 00-.478-.198.798.798 0 01-.517-.608l-.091-.55a1.875 1.875 0 00-1.85-1.566h-.344zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" d="M11.828 2.25c-.916 0-1.699.663-1.85 1.567l-.091.549a.798.798 0 01-.517.608 7.45 7.45 0 00-.478.198.798.798 0 01-.796-.064l-.453-.324a1.875 1.875 0 00-2.416.2l-.243.243a1.875 1.875 0 00-.2 2.416l.324.453a.798.798 0 01.064.796 7.448 7.448 0 00-.198.478.798.798 0 01-.608.517l-.55.092a1.875 1.875 0 00-1.566 1.849v.344c0 .916.663 1.699 1.567 1.85l.549.091c.281.047.508.25.608.517.06.162.127.321.198.478a.798.798 0 01-.064.796l-.324.453a1.875 1.875 0 00.2 2.416l.243.243c.648.648 1.67.733 2.416.2l.453-.324a.798.798 0 01.796-.064c.157.071.316.137.478.198.267.1.47.327.517.608l.092.55c.15.903.932 1.566 1.849 1.566h.344c.916 0 1.699-.663 1.85-1.567l.091-.549a.798.798 0 01.517-.608 7.52 7.52 0 00.478-.198.798.798 0 01.796.064l.453.324a1.875 1.875 0 002.416-.2l.243-.243c.648-.648.733-1.67.2-2.416l-.324-.453a.798.798 0 01-.064-.796c.071-.157.137-.316.198-.478.1-.267.327-.47.608-.517l.55-.091a1.875 1.875 0 001.566-1.85v-.344c0-.916-.663-1.699-1.567-1.85l-.549-.091a.798.798 0 01-.608-.517 7.507 7.507 0 00-.198-.478.798.798 0 01.064-.796l.324-.453a1.875 1.875 0 00-.2-2.416l-.243-.243a1.875 1.875 0 00-2.416-.2l-.453.324a.798.798 0 01-.796.064 7.462 7.462 0 00-.478-.198.798.798 0 01-.517-.608l-.091-.55a1.875 1.875 0 00-1.85-1.566h-.344zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z"
clip-rule="evenodd" /> clip-rule="evenodd"
/>
</svg> </svg>
</div> </div>
<div <div
class="px-2 xl:pl-0 lg:pr-1 lg:pt-2 pb-2 bg-white rounded-md text-gray-300 hover:text-gray-500 cursor-grab draggable" class="px-2 xl:pl-0 lg:pr-1 lg:pt-2 pb-2 bg-white rounded-md text-gray-300 hover:text-gray-500 cursor-grab draggable"
:class="{ 'lg:pr-1 lg:pl-0': !fieldSideBarOpened, 'xl:-mr-2': fieldSideBarOpened }" role="button"> :class="{ 'lg:pr-1 lg:pl-0': !fieldSideBarOpened, 'xl:-mr-2': fieldSideBarOpened }"
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" role="button"
stroke="currentColor"> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <svg
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" /> xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg> </svg>
</div> </div>
</div> </div>
</div> </div>
<component :is="getFieldComponents" v-if="getFieldComponents" v-bind="inputProperties(field)" <component
:required="isFieldRequired" :disabled="isFieldDisabled ? true : null" /> :is="getFieldComponents"
v-if="getFieldComponents"
v-bind="inputProperties(field)"
:required="isFieldRequired"
:disabled="isFieldDisabled ? true : null"
/>
<template v-else> <template v-else>
<div v-if="field.type === 'nf-text' && field.content" :id="field.id" :key="field.id" class="nf-text w-full mb-3" <div
:class="[getFieldAlignClasses(field)]" v-html="field.content" /> v-if="field.type === 'nf-text' && field.content"
<div v-if="field.type === 'nf-code' && field.content" :id="field.id" :key="field.id" :id="field.id"
class="nf-code w-full px-2 mb-3" v-html="field.content" /> :key="field.id"
<div v-if="field.type === 'nf-divider'" :id="field.id" :key="field.id" class="border-b my-4 w-full mx-2" /> class="nf-text w-full mb-3"
<div v-if="field.type === 'nf-image' && (field.image_block || !isPublicFormPage)" :id="field.id" :key="field.id" :class="[getFieldAlignClasses(field)]"
class="my-4 w-full px-2" :class="[getFieldAlignClasses(field)]"> v-html="field.content"
<div v-if="!field.image_block" class="p-4 border border-dashed"> />
<div
v-if="field.type === 'nf-code' && field.content"
:id="field.id"
:key="field.id"
class="nf-code w-full px-2 mb-3"
v-html="field.content"
/>
<div
v-if="field.type === 'nf-divider'"
:id="field.id"
:key="field.id"
class="border-b my-4 w-full mx-2"
/>
<div
v-if="field.type === 'nf-image' && (field.image_block || !isPublicFormPage)"
:id="field.id"
:key="field.id"
class="my-4 w-full px-2"
:class="[getFieldAlignClasses(field)]"
>
<div
v-if="!field.image_block"
class="p-4 border border-dashed"
>
Open <b>{{ field.name }}'s</b> block settings to upload image. Open <b>{{ field.name }}'s</b> block settings to upload image.
</div> </div>
<img v-else :alt="field.name" :src="field.image_block" class="max-w-full" /> <img
v-else
:alt="field.name"
:src="field.image_block"
class="max-w-full"
>
</div> </div>
</template> </template>
</div> </div>
@ -298,7 +374,7 @@ export default {
inputProperties.maxSlider = parseInt(field.slider_max_value) ?? 50 inputProperties.maxSlider = parseInt(field.slider_max_value) ?? 50
inputProperties.stepSlider = parseInt(field.slider_step_value) ?? 5 inputProperties.stepSlider = parseInt(field.slider_step_value) ?? 5
} else if (field.type === 'number' || (field.type === 'phone_number' && field.use_simple_text_input)) { } else if (field.type === 'number' || (field.type === 'phone_number' && field.use_simple_text_input)) {
inputProperties.pattern = '/\d*' inputProperties.pattern = '/d*'
} else if (field.type === 'phone_number' && !field.use_simple_text_input) { } else if (field.type === 'phone_number' && !field.use_simple_text_input) {
inputProperties.unavailableCountries = field.unavailable_countries ?? [] inputProperties.unavailableCountries = field.unavailable_countries ?? []
} }

View File

@ -1,66 +1,88 @@
<template> <template>
<collapse class="py-5 w-full" :model-value="false"> <collapse
class="py-5 w-full"
:model-value="false"
>
<template #title> <template #title>
<div class="flex"> <div class="flex">
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline text-gray-500 mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> xmlns="http://www.w3.org/2000/svg"
</svg> Show advanced sharing options class="h-5 w-5 inline text-gray-500 mr-2 -mt-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
Show advanced sharing options
</h3> </h3>
</div> </div>
</template> </template>
<toggle-switch-input :model-value="modelValue.hide_title" name="hide_title" class="mt-4" <toggle-switch-input
label="Hide Form Title" :model-value="modelValue.hide_title"
:disabled="(form.hide_title===true)?true:null" name="hide_title"
:help="hideTitleHelp" class="mt-4"
@update:model-value="onChangeHideTitle" label="Hide Form Title"
:disabled="form.hide_title === true ? true : null"
:help="hideTitleHelp"
@update:model-value="onChangeHideTitle"
/> />
<toggle-switch-input :model-value="modelValue.auto_submit" name="auto_submit" class="mt-4" <toggle-switch-input
label="Auto Submit Form" :model-value="modelValue.auto_submit"
help="Form will auto submit immediate after open URL" name="auto_submit"
@update:model-value="onChangeAutoSubmit" class="mt-4"
label="Auto Submit Form"
help="Form will auto submit immediate after open URL"
@update:model-value="onChangeAutoSubmit"
/> />
</collapse> </collapse>
</template> </template>
<script> <script>
import Collapse from '~/components/global/Collapse.vue' import Collapse from "~/components/global/Collapse.vue"
export default { export default {
name: 'AdvancedFormUrlSettings', name: "AdvancedFormUrlSettings",
components: { Collapse }, components: { Collapse },
props: { props: {
form: { form: {
type: Object, type: Object,
required: true required: true,
}, },
modelValue: { modelValue: {
type: Object, type: Object,
required: true required: true,
} },
}, },
data () { data() {
return { return {}
}
}, },
computed: { computed: {
hideTitleHelp () { hideTitleHelp() {
return this.form.hide_title ? 'This option is disabled because the form title is already hidden' : null return this.form.hide_title
} ? "This option is disabled because the form title is already hidden"
: null
},
}, },
watch: {}, watch: {},
mounted () {}, mounted() {},
methods: { methods: {
onChangeHideTitle (val) { onChangeHideTitle(val) {
this.modelValue.hide_title = val this.modelValue.hide_title = val
}, },
onChangeAutoSubmit (val) { onChangeAutoSubmit(val) {
this.modelValue.auto_submit = val this.modelValue.auto_submit = val
} },
} },
} }
</script> </script>

View File

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

View File

@ -1,13 +1,29 @@
<template> <template>
<div v-if="form" id="form-editor" class="relative flex w-full flex-col grow max-h-screen"> <div
v-if="form"
id="form-editor"
class="relative flex w-full flex-col grow max-h-screen"
>
<!-- Navbar --> <!-- Navbar -->
<div class="w-full border-b p-2 flex items-center justify-between bg-white"> <div class="w-full border-b p-2 flex items-center justify-between bg-white">
<a v-if="backButton" href="#" class="ml-2 flex text-blue font-semibold text-sm" <a
@click.prevent="$router.back()" v-if="backButton"
href="#"
class="ml-2 flex text-blue font-semibold text-sm"
@click.prevent="goBack"
> >
<svg class="w-3 h-3 text-blue mt-1 mr-1" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M5 9L1 5L5 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" class="w-3 h-3 text-blue mt-1 mr-1"
stroke-linejoin="round" viewBox="0 0 6 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 9L1 5L5 1"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/> />
</svg> </svg>
Go back Go back
@ -18,25 +34,39 @@
</h3> </h3>
</div> </div>
<div class="flex items-center" :class="{'mx-auto md:mx-0':!backButton}"> <div
class="flex items-center"
:class="{ 'mx-auto md:mx-0': !backButton }"
>
<div class="hidden md:block mr-10 relative"> <div class="hidden md:block mr-10 relative">
<a href="#" <a
class="text-sm px-3 py-2 hover:bg-gray-50 cursor-pointer rounded-md text-gray-500 px-0 sm:px-3 hover:text-gray-800 cursor-pointer mt-1" href="#"
@click.prevent="openCrisp" class="text-sm px-3 py-2 hover:bg-gray-50 cursor-pointer rounded-md text-gray-500 px-0 sm:px-3 hover:text-gray-800 cursor-pointer mt-1"
@click.prevent="openCrisp"
> >
Help Help
</a> </a>
</div> </div>
<v-button v-track.save_form_click size="small" class="w-full px-8 md:px-4 py-2" <v-button
:loading="updateFormLoading" :class="saveButtonClass" v-track.save_form_click
@click="saveForm" size="small"
class="w-full px-8 md:px-4 py-2"
:loading="updateFormLoading"
:class="saveButtonClass"
@click="saveForm"
> >
<svg class="w-4 h-4 text-white inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none" <svg
xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white inline mr-1 -mt-1"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
> >
<path <path
d="M17 21V13H7V21M7 3V8H15M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H16L21 8V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z" d="M17 21V13H7V21M7 3V8H15M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H16L21 8V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/> />
</svg> </svg>
<template v-if="form.visibility === 'public'"> <template v-if="form.visibility === 'public'">
@ -50,9 +80,12 @@
</div> </div>
<div class="w-full flex grow overflow-y-scroll relative bg-gray-50"> <div class="w-full flex grow overflow-y-scroll relative bg-gray-50">
<div class="relative w-full bg-white shrink-0 overflow-y-scroll border-r md:w-1/2 md:max-w-sm lg:w-2/5"> <div
class="relative w-full bg-white shrink-0 overflow-y-scroll border-r md:w-1/2 md:max-w-sm lg:w-2/5"
>
<div class="border-b bg-blue-50 p-5 text-nt-blue-dark md:hidden"> <div class="border-b bg-blue-50 p-5 text-nt-blue-dark md:hidden">
Please create this form on a device with a larger screen. That will allow you to preview your form changes. Please create this form on a device with a larger screen. That will
allow you to preview your form changes.
</div> </div>
<form-information /> <form-information />
@ -72,33 +105,36 @@
<form-error-modal <form-error-modal
:show="showFormErrorModal" :show="showFormErrorModal"
:form="form" :form="form"
@close="showFormErrorModal=false" @close="showFormErrorModal = false"
/> />
</div> </div>
</div> </div>
<div v-else class="flex justify-center items-center p-8"> <div
v-else
class="flex justify-center items-center p-8"
>
<Loader class="w-6 h-6" /> <Loader class="w-6 h-6" />
</div> </div>
</template> </template>
<script> <script>
import FormEditorSidebar from './form-components/FormEditorSidebar.vue' import FormEditorSidebar from "./form-components/FormEditorSidebar.vue"
import FormErrorModal from './form-components/FormErrorModal.vue' import FormErrorModal from "./form-components/FormErrorModal.vue"
import FormInformation from './form-components/FormInformation.vue' import FormInformation from "./form-components/FormInformation.vue"
import FormStructure from './form-components/FormStructure.vue' import FormStructure from "./form-components/FormStructure.vue"
import FormCustomization from './form-components/FormCustomization.vue' import FormCustomization from "./form-components/FormCustomization.vue"
import FormCustomCode from './form-components/FormCustomCode.vue' import FormCustomCode from "./form-components/FormCustomCode.vue"
import FormAboutSubmission from './form-components/FormAboutSubmission.vue' import FormAboutSubmission from "./form-components/FormAboutSubmission.vue"
import FormEditorPreview from './form-components/FormEditorPreview.vue' import FormEditorPreview from "./form-components/FormEditorPreview.vue"
import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue' import FormSecurityPrivacy from "./form-components/FormSecurityPrivacy.vue"
import FormCustomSeo from './form-components/FormCustomSeo.vue' import FormCustomSeo from "./form-components/FormCustomSeo.vue"
import FormAccess from './form-components/FormAccess.vue' import FormAccess from "./form-components/FormAccess.vue"
import {validatePropertiesLogic} from "~/composables/forms/validatePropertiesLogic.js" import { validatePropertiesLogic } from "~/composables/forms/validatePropertiesLogic.js"
import opnformConfig from "~/opnform.config.js"; import opnformConfig from "~/opnform.config.js"
import {captureException} from "@sentry/core"; import { captureException } from "@sentry/core"
export default { export default {
name: 'FormEditor', name: "FormEditor",
components: { components: {
FormEditorSidebar, FormEditorSidebar,
FormEditorPreview, FormEditorPreview,
@ -110,36 +146,38 @@ export default {
FormErrorModal, FormErrorModal,
FormSecurityPrivacy, FormSecurityPrivacy,
FormCustomSeo, FormCustomSeo,
FormAccess FormAccess,
}, },
props: { props: {
isEdit: { isEdit: {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false,
}, },
isGuest: { isGuest: {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false,
}, },
backButton: { backButton: {
required: false, required: false,
type: Boolean, type: Boolean,
default: true default: true,
}, },
saveButtonClass: { saveButtonClass: {
required: false, required: false,
type: String, type: String,
default: '' default: "",
} },
}, },
setup () { emits: ['mounted', 'on-save', 'openRegister'],
const {user} = storeToRefs(useAuthStore())
setup() {
const { user } = storeToRefs(useAuthStore())
const formsStore = useFormsStore() const formsStore = useFormsStore()
const {content: form} = storeToRefs(useWorkingFormStore()) const { content: form } = storeToRefs(useWorkingFormStore())
const {getCurrent: workspace} = storeToRefs(useWorkspacesStore()) const { getCurrent: workspace } = storeToRefs(useWorkspacesStore())
return { return {
appStore: useAppStore(), appStore: useAppStore(),
crisp: useCrisp(), crisp: useCrisp(),
@ -148,90 +186,103 @@ export default {
workspace, workspace,
formsStore, formsStore,
form, form,
user user,
} }
}, },
data () { data() {
return { return {
showFormErrorModal: false, showFormErrorModal: false,
validationErrorResponse: null, validationErrorResponse: null,
updateFormLoading: false, updateFormLoading: false,
createdFormSlug: null createdFormSlug: null,
} }
}, },
computed: { computed: {
createdForm () { createdForm() {
return this.formsStore.getByKey(this.createdFormSlug) return this.formsStore.getByKey(this.createdFormSlug)
}, },
steps () { steps() {
return [ return [
{ {
target: '#v-step-0', target: "#v-step-0",
header: { header: {
title: 'Welcome to the OpnForm Editor!' title: "Welcome to the OpnForm Editor!",
}, },
content: 'Discover <strong>your form Editor</strong>!' content: "Discover <strong>your form Editor</strong>!",
}, },
{ {
target: '#v-step-1', target: "#v-step-1",
header: { header: {
title: 'Change your form fields' title: "Change your form fields",
}, },
content: 'Here you can decide which field to include or not, but also the ' + content:
'order you want your fields to be and so on. You also have custom options available for each field, just ' + "Here you can decide which field to include or not, but also the " +
'click the blue cog.' "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', target: "#v-step-2",
header: { header: {
title: 'Notifications, Customizations and more!' title: "Notifications, Customizations and more!",
}, },
content: 'Many more options are available: change colors, texts and receive a ' + content:
'notifications whenever someones submits your form.' "Many more options are available: change colors, texts and receive a " +
"notifications whenever someones submits your form.",
}, },
{ {
target: '.v-last-step', target: ".v-last-step",
header: { header: {
title: 'Create your form' title: "Create your form",
}, },
content: 'Click this button when you\'re done to save your form!' content: "Click this button when you're done to save your form!",
} },
] ]
}, },
helpUrl () { helpUrl() {
return this.opnformConfig.links.help return this.opnformConfig.links.help
} },
}, },
watch: {}, watch: {},
mounted () { mounted() {
this.$emit('mounted') this.$emit("mounted")
this.appStore.hideNavbar() this.appStore.hideNavbar()
}, },
beforeUnmount () { beforeUnmount() {
this.appStore.showNavbar() this.appStore.showNavbar()
}, },
methods: { methods: {
displayFormModificationAlert (responseData) { goBack() {
if (this.isEdit) {
useRouter().push({ name: 'forms-slug-show-submissions', params: {slug:this.form.slug} })
} else {
useRouter().push({ name: 'home' })
}
},
displayFormModificationAlert(responseData) {
const alert = useAlert() const alert = useAlert()
if (responseData.form && responseData.form.cleanings && Object.keys(responseData.form.cleanings).length > 0) { if (
responseData.form &&
responseData.form.cleanings &&
Object.keys(responseData.form.cleanings).length > 0
) {
alert.warning(responseData.message) alert.warning(responseData.message)
} else if (responseData.message) { } else if (responseData.message) {
alert.success(responseData.message) alert.success(responseData.message)
} }
}, },
openCrisp () { openCrisp() {
this.crisp.openChat() this.crisp.openChat()
}, },
showValidationErrors () { showValidationErrors() {
this.showFormErrorModal = true this.showFormErrorModal = true
}, },
saveForm () { saveForm() {
this.form.properties = validatePropertiesLogic(this.form.properties) this.form.properties = validatePropertiesLogic(this.form.properties)
if (this.isGuest) { if (this.isGuest) {
this.saveFormGuest() this.saveFormGuest()
@ -241,63 +292,90 @@ export default {
this.saveFormCreate() this.saveFormCreate()
} }
}, },
saveFormEdit () { saveFormEdit() {
if (this.updateFormLoading) return if (this.updateFormLoading) return
this.updateFormLoading = true this.updateFormLoading = true
this.validationErrorResponse = null this.validationErrorResponse = null
this.form.put('/open/forms/{id}/'.replace('{id}', this.form.id)).then((data) => { this.form
this.formsStore.save(data.form) .put("/open/forms/{id}/".replace("{id}", this.form.id))
this.$emit('on-save') .then((data) => {
this.$router.push({ name: 'forms-slug-show-share', params: { slug: this.form.slug } }) this.formsStore.save(data.form)
this.amplitude.logEvent('form_saved', { form_id: this.form.id, form_slug: this.form.slug }) this.$emit("on-save")
this.displayFormModificationAlert(data) this.$router.push({
}).catch((error) => { name: "forms-slug-show-share",
if (error?.response?.status === 422) { params: { slug: this.form.slug },
this.validationErrorResponse = error.response.data })
this.showValidationErrors() this.amplitude.logEvent("form_saved", {
} else { form_id: this.form.id,
useAlert().error('An error occurred while saving the form, please try again.') form_slug: this.form.slug,
captureException(error) })
} this.displayFormModificationAlert(data)
}).finally(() => { })
this.updateFormLoading = false .catch((error) => {
}) if (error?.response?.status === 422) {
this.validationErrorResponse = error.response.data
this.showValidationErrors()
} else {
useAlert().error(
"An error occurred while saving the form, please try again.",
)
captureException(error)
}
})
.finally(() => {
this.updateFormLoading = false
})
}, },
saveFormCreate () { saveFormCreate() {
if (this.updateFormLoading) return if (this.updateFormLoading) return
this.form.workspace_id = this.workspace.id this.form.workspace_id = this.workspace.id
this.validationErrorResponse = null this.validationErrorResponse = null
this.updateFormLoading = true this.updateFormLoading = true
this.form.post('/open/forms').then((response) => { this.form
this.formsStore.save(response.form) .post("/open/forms")
this.$emit('on-save') .then((response) => {
this.createdFormSlug = response.form.slug this.formsStore.save(response.form)
this.$emit("on-save")
this.createdFormSlug = response.form.slug
this.amplitude.logEvent('form_created', { form_id: response.form.id, form_slug: response.form.slug }) this.amplitude.logEvent("form_created", {
this.crisp.pushEvent('form_created',{ form_id: response.form.id,
form_id: response.form.id, form_slug: response.form.slug,
form_slug: response.form.slug })
this.crisp.pushEvent("form_created", {
form_id: response.form.id,
form_slug: response.form.slug,
})
this.displayFormModificationAlert(response)
useRouter().push({
name: "forms-slug-show-share",
params: {
slug: this.createdFormSlug,
new_form: response.users_first_form,
},
})
})
.catch((error) => {
if (error?.response?.status === 422) {
this.validationErrorResponse = error.response
this.showValidationErrors()
} else {
useAlert().error(
"An error occurred while saving the form, please try again.",
)
captureException(error)
}
})
.finally(() => {
this.updateFormLoading = false
}) })
this.displayFormModificationAlert(response)
useRouter().push({ name: 'forms-slug-show-share', params: { slug: this.createdFormSlug, new_form: response.users_first_form } })
}).catch((error) => {
if (error?.response?.status === 422) {
this.validationErrorResponse = error.response
this.showValidationErrors()
} else {
useAlert().error('An error occurred while saving the form, please try again.')
captureException(error)
}
}).finally(() => {
this.updateFormLoading = false
})
}, },
saveFormGuest () { saveFormGuest() {
this.$emit('openRegister') this.$emit("openRegister")
} },
} },
} }
</script> </script>
@ -305,13 +383,13 @@ export default {
.v-step { .v-step {
color: white; color: white;
.v-step__header, .v-step__content { .v-step__header,
.v-step__content {
color: white; color: white;
div { div {
color: white; color: white;
} }
} }
} }
</style> </style>

View File

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

View File

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

View File

@ -1,5 +1,6 @@
<template> <template>
<div id="table-page" <div
id="table-page"
class="w-full flex flex-col" class="w-full flex flex-col"
> >
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4 pt-4"> <div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4 pt-4">
@ -8,15 +9,31 @@
</h3> </h3>
<!-- Table columns modal --> <!-- Table columns modal -->
<modal :show="showColumnsModal" @close="showColumnsModal=false"> <modal
:show="showColumnsModal"
@close="showColumnsModal=false"
>
<template #icon> <template #icon>
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
class="w-8 h-8"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M16 5H8C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5Z" d="M16 5H8C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path <path
d="M8 15C9.65685 15 11 13.6569 11 12C11 10.3431 9.65685 9 8 9C6.34315 9 5 10.3431 5 12C5 13.6569 6.34315 15 8 15Z" d="M8 15C9.65685 15 11 13.6569 11 12C11 10.3431 9.65685 9 8 9C6.34315 9 5 10.3431 5 12C5 13.6569 6.34315 15 8 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</template> </template>
<template #title> <template #title>
@ -29,14 +46,20 @@
Form Fields Form Fields
</h4> </h4>
<div class="border border-gray-300 rounded-md"> <div class="border border-gray-300 rounded-md">
<div v-for="(field,index) in candidatesProperties" :key="field.id" <div
class="p-2 border-gray-300 flex items-center" v-for="(field,index) in candidatesProperties"
:class="{'border-t':index!=0}"> :key="field.id"
class="p-2 border-gray-300 flex items-center"
:class="{'border-t':index!=0}"
>
<p class="flex-grow truncate"> <p class="flex-grow truncate">
{{ field.name }} {{ field.name }}
</p> </p>
<v-switch v-model="displayColumns[field.id]" class="float-right" <v-switch
@update:model-value="onChangeDisplayColumns"/> v-model="displayColumns[field.id]"
class="float-right"
@update:model-value="onChangeDisplayColumns"
/>
</div> </div>
</div> </div>
</template> </template>
@ -45,40 +68,65 @@
Removed Fields Removed Fields
</h4> </h4>
<div class="border border-gray-300 rounded-md"> <div class="border border-gray-300 rounded-md">
<div v-for="(field,index) in removed_properties" :key="field.id" <div
class="p-2 border-gray-300 flex items-center" :class="{'border-t':index!=0}"> v-for="(field,index) in removed_properties"
:key="field.id"
class="p-2 border-gray-300 flex items-center"
:class="{'border-t':index!=0}"
>
<p class="flex-grow truncate"> <p class="flex-grow truncate">
{{ field.name }} {{ field.name }}
</p> </p>
<v-switch v-model="displayColumns[field.id]" class="float-right" <v-switch
@update:model-value="onChangeDisplayColumns"/> v-model="displayColumns[field.id]"
class="float-right"
@update:model-value="onChangeDisplayColumns"
/>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
</modal> </modal>
<Loader v-if="!form" class="h-6 w-6 text-nt-blue mx-auto"/> <Loader
v-if="!form"
class="h-6 w-6 text-nt-blue mx-auto"
/>
<div v-else> <div v-else>
<div v-if="form && tableData.length > 0" class="flex flex-wrap items-end"> <div
v-if="form && tableData.length > 0"
class="flex flex-wrap items-end"
>
<div class="flex-grow"> <div class="flex-grow">
<text-input class="w-64" :form="searchForm" name="search" placeholder="Search..."/> <text-input
class="w-64"
:form="searchForm"
name="search"
placeholder="Search..."
/>
</div> </div>
<div class="font-semibold flex gap-4"> <div class="font-semibold flex gap-4">
<p class="float-right text-xs uppercase mb-2"> <p class="float-right text-xs uppercase mb-2">
<a <a
href="javascript:void(0);" class="text-gray-500" @click="showColumnsModal=true" href="javascript:void(0);"
class="text-gray-500"
@click="showColumnsModal=true"
>Display columns</a> >Display columns</a>
</p> </p>
<p class="text-right cursor-pointer text-xs uppercase"> <p
<a v-if="!exportLoading" v-if="!exportLoading"
@click.prevent="downloadAsCsv" href="#" class="text-right cursor-pointer text-xs uppercase"
>
<a
href="#"
@click.prevent="downloadAsCsv"
>Export as CSV</a> >Export as CSV</a>
<p v-else><loader class="w-3 h-3 text-blue-500" /></p> </p>
<p v-else>
<loader class="w-3 h-3 text-blue-500" />
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="px-4 pb-4 flex justify-center"> <div class="px-4 pb-4 flex justify-center">
@ -88,7 +136,8 @@
:shadow-top-offset="0" :shadow-top-offset="0"
:hide-scrollbar="true" :hide-scrollbar="true"
> >
<open-table v-if="form" <open-table
v-if="form"
ref="table" ref="table"
class="max-h-full" class="max-h-full"
:columns="properties" :columns="properties"
@ -247,7 +296,7 @@ export default {
this.recordStore.stopLoading() this.recordStore.stopLoading()
this.fullyLoaded = true this.fullyLoaded = true
} }
}).catch((error) => { }).catch(() => {
this.recordStore.startLoading() this.recordStore.startLoading()
}) })
}, },
@ -268,11 +317,11 @@ export default {
}) })
}, },
onUpdateRecord(submission) { onUpdateRecord(submission) {
this.recordStore.save(submission); this.recordStore.save(submission)
this.dataChanged() this.dataChanged()
}, },
onDeleteRecord(submission) { onDeleteRecord(submission) {
this.recordStore.remove(submission); this.recordStore.remove(submission)
this.dataChanged() this.dataChanged()
}, },
downloadAsCsv() { downloadAsCsv() {
@ -283,7 +332,7 @@ export default {
opnFetch(this.exportUrl, {responseType: "blob"}) opnFetch(this.exportUrl, {responseType: "blob"})
.then(blob => { .then(blob => {
const filename = `${this.form.slug}-${Date.now()}-submissions.csv` const filename = `${this.form.slug}-${Date.now()}-submissions.csv`
let a = document.createElement("a") const a = document.createElement("a")
document.body.appendChild(a) document.body.appendChild(a)
a.style = "display: none" a.style = "display: none"
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)

View File

@ -3,13 +3,26 @@
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" 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"> <div class="flex items-center">
<p class="select-all flex-grow break-all" v-html="preFillUrl" /> <p
<div class="hover:bg-nt-blue-lighter rounded transition-colors cursor-pointer" @click="copyToClipboard"> class="select-all flex-grow break-all"
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-nt-blue" fill="none" viewBox="0 0 24 24" v-html="preFillUrl"
stroke="currentColor" />
<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" <path
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" 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> </svg>
</div> </div>
@ -18,50 +31,57 @@
</template> </template>
<script setup> <script setup>
import { defineProps, computed } from 'vue' import { defineProps, computed } from "vue"
import { default as _has } from 'lodash/has' import { default as _has } from "lodash/has"
const { copy } = useClipboard() const { copy } = useClipboard()
const props = defineProps({ const props = defineProps({
form: { form: {
type: Object, type: Object,
required: true required: true,
}, },
formData: { formData: {
type: Object, type: Object,
required: true required: true,
}, },
extraQueryParam: { extraQueryParam: {
type: String, type: String,
default: '' default: "",
} },
}) })
const preFillUrl = computed(() => { const preFillUrl = computed(() => {
const url = props.form.share_url const url = props.form.share_url
const uriComponents = new URLSearchParams() const uriComponents = new URLSearchParams()
props.form.properties.filter((property) => { props.form.properties
return _has(props.formData, property.id) && props.formData[property.id] !== null .filter((property) => {
}).forEach((property) => { return (
if (Array.isArray(props.formData[property.id])) { _has(props.formData, property.id) &&
props.formData[property.id].forEach((value) => { props.formData[property.id] !== null
uriComponents.append(property.id + '[]', value) )
}) })
} else { .forEach((property) => {
uriComponents.append(property.id, props.formData[property.id]) if (Array.isArray(props.formData[property.id])) {
} props.formData[property.id].forEach((value) => {
}) uriComponents.append(property.id + "[]", value)
})
} else {
uriComponents.append(property.id, props.formData[property.id])
}
})
if(uriComponents.toString() !== ""){ if (uriComponents.toString() !== "") {
return (props.extraQueryParam) ? url + '?' + uriComponents + '&' + props.extraQueryParam : url + '?' + uriComponents return props.extraQueryParam
}else{ ? url + "?" + uriComponents + "&" + props.extraQueryParam
return (props.extraQueryParam) ? url + '?' + props.extraQueryParam : url : url + "?" + uriComponents
} else {
return props.extraQueryParam ? url + "?" + props.extraQueryParam : url
} }
}) })
const copyToClipboard = () => { const copyToClipboard = () => {
if (import.meta.server) return if (import.meta.server) return
copy(preFillUrl.value) copy(preFillUrl.value)
useAlert().success('Copied!') useAlert().success("Copied!")
} }
</script> </script>

View File

@ -1,103 +1,141 @@
<template> <template>
<modal :show="show" @close="emit('close')"> <modal
<template #icon> :show="show"
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8"> @close="emit('close')"
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z" /> >
</svg> <template #icon>
</template> <svg
<template #title> xmlns="http://www.w3.org/2000/svg"
Change form workspace fill="none"
</template> viewBox="0 0 24 24"
<div class="p-4"> stroke-width="1.5"
<div class="flex space-x-4 items-center"> stroke="currentColor"
<p>Current workspace:</p> class="w-8 h-8"
<div class="flex items-center cursor group p-2 rounded border"> >
<div class="rounded-full h-8 8"> <path
<img v-if="isUrl(workspace.icon)" stroke-linecap="round"
:src="workspace.icon" stroke-linejoin="round"
:alt="workspace.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z"
/> />
<div v-else class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow" </svg>
v-text="workspace.icon" </template>
/> <template #title>
</div> Change form workspace
<p class="lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200"> </template>
{{ workspace.name }} <div class="p-4">
</p> <div class="flex space-x-4 items-center">
</div> <p>Current workspace:</p>
</div> <div class="flex items-center cursor group p-2 rounded border">
<form @submit.prevent="onSubmit"> <div class="rounded-full h-8 8">
<div class=" my-4"> <img
<select-input name="workspace" class="" v-if="isUrl(workspace.icon)"
:options="workspacesSelectOptions" :src="workspace.icon"
v-model="selectedWorkspace" :alt="workspace.name + ' icon'"
:required="true" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
label="Select workspace" >
/> <div
</div> v-else
<div class="flex justify-end mt-4 pb-5"> class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
<v-button class="mr-2" :loading="loading"> v-text="workspace.icon"
Change workspace />
</v-button> </div>
<v-button color="white" @click.prevent="emit('close')"> <p
Close class="lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200"
</v-button> >
</div> {{ workspace.name }}
</form> </p>
</div> </div>
</modal> </div>
<form @submit.prevent="onSubmit">
<div class="my-4">
<select-input
v-model="selectedWorkspace"
name="workspace"
class=""
:options="workspacesSelectOptions"
:required="true"
label="Select workspace"
/>
</div>
<div class="flex justify-end mt-4 pb-5">
<v-button
class="mr-2"
:loading="loading"
>
Change workspace
</v-button>
<v-button
color="white"
@click.prevent="emit('close')"
>
Close
</v-button>
</div>
</form>
</div>
</modal>
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineEmits, computed } from 'vue' import { ref, defineProps, defineEmits, computed } from "vue"
const emit = defineEmits(['close']) const emit = defineEmits(["close"])
const workspacesStore = useWorkspacesStore() const workspacesStore = useWorkspacesStore()
const formsStore = useFormsStore() const formsStore = useFormsStore()
const selectedWorkspace = ref(null); const selectedWorkspace = ref(null)
const props = defineProps({ const props = defineProps({
show: { type: Boolean, required: true }, show: { type: Boolean, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },
}) })
const workspaces = computed(() => workspacesStore.getAll) const workspaces = computed(() => workspacesStore.getAll)
const workspace = computed(() => workspacesStore.getByKey(props.form?.workspace_id)) const workspace = computed(() =>
workspacesStore.getByKey(props.form?.workspace_id),
)
const loading = ref(false) const loading = ref(false)
const workspacesSelectOptions = computed(()=> workspaces.value.filter((w)=>{ const workspacesSelectOptions = computed(() =>
return w.id !== workspace.value.id workspaces.value
}).map(workspace => ({ name: workspace.name, value: workspace.id }))) .filter((w) => {
return w.id !== workspace.value.id
})
.map((workspace) => ({ name: workspace.name, value: workspace.id })),
)
const onSubmit = () => { const onSubmit = () => {
const endpoint = '/open/forms/' + props.form.id + '/workspace/' + selectedWorkspace.value const endpoint =
if(! selectedWorkspace.value) { "/open/forms/" + props.form.id + "/workspace/" + selectedWorkspace.value
useAlert().error('Please select a workspace!') if (!selectedWorkspace.value) {
return; useAlert().error("Please select a workspace!")
} return
opnFetch(endpoint, { method: 'POST' }).then(data => { }
loading.value = false; opnFetch(endpoint, { method: "POST" })
emit('close') .then(() => {
useAlert().success('Form workspace updated successfully.') loading.value = false
workspacesStore.setCurrentId(selectedWorkspace.value) emit("close")
formsStore.resetState() useAlert().success("Form workspace updated successfully.")
formsStore.loadAll(selectedWorkspace.value) workspacesStore.setCurrentId(selectedWorkspace.value)
const router = useRouter() formsStore.resetState()
const route = useRoute() formsStore.loadAll(selectedWorkspace.value)
if (route.name !== 'home') { const router = useRouter()
router.push({ name: 'home' }) const route = useRoute()
} if (route.name !== "home") {
formsStore.loadAll(selectedWorkspace.value) router.push({ name: "home" })
}).catch((error) => { }
useAlert().error(error?.data?.message ?? 'Something went wrong, please try again!') formsStore.loadAll(selectedWorkspace.value)
loading.value = false; })
.catch((error) => {
useAlert().error(
error?.data?.message ?? "Something went wrong, please try again!",
)
loading.value = false
}) })
} }
const isUrl = (str) => { const isUrl = (str) => {
try { try {
new URL(str) new URL(str)
} catch (_) { } catch (_) {
return false return false
} }
return true return true
} }
</script> </script>

View File

@ -2,10 +2,23 @@
<div> <div>
<div class="p-4 border-b sticky top-0 z-10 bg-white"> <div class="p-4 border-b sticky top-0 z-10 bg-white">
<div class="flex"> <div class="flex">
<button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click.prevent="closeSidebar"> <button
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> class="text-gray-500 hover:text-gray-900 cursor-pointer"
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" @click.prevent="closeSidebar"
stroke-linejoin="round" /> >
<svg
class="h-6 w-6"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</button> </button>
<div class="font-semibold inline ml-2 truncate flex-grow truncate"> <div class="font-semibold inline ml-2 truncate flex-grow truncate">
@ -20,14 +33,27 @@
Input Blocks Input Blocks
</p> </p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div v-for="(block, i) in inputBlocks" :key="block.name" <div
v-for="(block) in inputBlocks"
:key="block.name"
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col" class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
role="button" @click.prevent="addBlock(block.name)"> role="button"
@click.prevent="addBlock(block.name)"
>
<div class="mx-auto"> <div class="mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24" <svg
stroke="currentColor" stroke-width="2" v-html="block.icon"></svg> xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
v-html="block.icon"
/>
</div> </div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1"> <p
class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1"
>
{{ block.title }} {{ block.title }}
</p> </p>
</div> </div>
@ -38,14 +64,27 @@
Layout Blocks Layout Blocks
</p> </p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div v-for="(block, i) in layoutBlocks" :key="block.name" <div
v-for="(block) in layoutBlocks"
:key="block.name"
class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col" class="bg-gray-50 border hover:bg-gray-100 dark:bg-gray-900 rounded-md dark:hover:bg-gray-800 py-2 flex flex-col"
role="button" @click.prevent="addBlock(block.name)"> role="button"
@click.prevent="addBlock(block.name)"
>
<div class="mx-auto"> <div class="mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24" <svg
stroke="currentColor" stroke-width="2" v-html="block.icon"></svg> xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
v-html="block.icon"
/>
</div> </div>
<p class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1"> <p
class="w-full text-xs text-gray-500 uppercase text-center font-semibold mt-1"
>
{{ block.title }} {{ block.title }}
</p> </p>
</div> </div>
@ -56,11 +95,11 @@
</template> </template>
<script> <script>
import clonedeep from 'clone-deep' import clonedeep from "clone-deep"
import { computed } from 'vue' import { computed } from "vue"
export default { export default {
name: 'AddFormBlock', name: "AddFormBlock",
components: {}, components: {},
props: {}, props: {},
@ -70,140 +109,139 @@ export default {
return { return {
form, form,
workingFormStore, workingFormStore,
selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex) selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
} }
}, },
data() { data() {
return { return {
blockForm: null, blockForm: null,
inputBlocks: [ inputBlocks: [
{ {
name: 'text', name: "text",
title: 'Text Input', title: "Text Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>',
}, },
{ {
name: 'date', name: "date",
title: 'Date Input', title: "Date Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>',
}, },
{ {
name: 'url', name: "url",
title: 'URL Input', title: "URL Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>',
}, },
{ {
name: 'phone_number', name: "phone_number",
title: 'Phone Input', title: "Phone Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>',
}, },
{ {
name: 'email', name: "email",
title: 'Email Input', title: "Email Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"/>',
}, },
{ {
name: 'checkbox', name: "checkbox",
title: 'Checkbox Input', title: "Checkbox Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
}, },
{ {
name: 'select', name: "select",
title: 'Select Input', title: "Select Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>',
}, },
{ {
name: 'multi_select', name: "multi_select",
title: 'Multi-select Input', title: "Multi-select Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>',
}, },
{ {
name: 'number', name: "number",
title: 'Number Input', title: "Number Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>',
}, },
{ {
name: 'rating', name: "rating",
title: 'Rating Input', title: "Rating Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />',
}, },
{ {
name: 'scale', name: "scale",
title: 'Scale Input', title: "Scale Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />',
}, },
{ {
name: 'slider', name: "slider",
title: 'Slider Input', title: "Slider Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />',
}, },
{ {
name: 'files', name: "files",
title: 'File Input', title: "File Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
}, },
{ {
name: 'signature', name: "signature",
title: 'Signature Input', title: "Signature Input",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />',
} },
], ],
layoutBlocks: [ layoutBlocks: [
{ {
name: 'nf-text', name: "nf-text",
title: 'Text Block', title: "Text Block",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h8m-8 6h16" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h8m-8 6h16" />',
}, },
{ {
name: 'nf-page-break', name: "nf-page-break",
title: 'Page-break Block', title: "Page-break Block",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
}, },
{ {
name: 'nf-divider', name: "nf-divider",
title: 'Divider Block', title: "Divider Block",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />',
}, },
{ {
name: 'nf-image', name: "nf-image",
title: 'Image Block', title: "Image Block",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />',
}, },
{ {
name: 'nf-code', name: "nf-code",
title: 'Code Block', title: "Code Block",
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />' icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />',
} },
] ],
} }
}, },
computed: { computed: {
defaultBlockNames() { defaultBlockNames() {
return { return {
text: 'Your name', text: "Your name",
date: 'Date', date: "Date",
url: 'Link', url: "Link",
phone_number: 'Phone Number', phone_number: "Phone Number",
number: 'Number', number: "Number",
rating: 'Rating', rating: "Rating",
scale: 'Scale', scale: "Scale",
slider: 'Slider', slider: "Slider",
email: 'Email', email: "Email",
checkbox: 'Checkbox', checkbox: "Checkbox",
select: 'Select', select: "Select",
multi_select: 'Multi Select', multi_select: "Multi Select",
files: 'Files', files: "Files",
signature: 'Signature', signature: "Signature",
'nf-text': 'Text Block', "nf-text": "Text Block",
'nf-page-break': 'Page Break', "nf-page-break": "Page Break",
'nf-divider': 'Divider', "nf-divider": "Divider",
'nf-image': 'Image', "nf-image": "Image",
'nf-code': 'Code Block' "nf-code": "Code Block",
} }
} },
}, },
watch: {}, watch: {},
@ -219,7 +257,7 @@ export default {
reset() { reset() {
this.blockForm = useForm({ this.blockForm = useForm({
type: null, type: null,
name: null name: null,
}) })
}, },
addBlock(type) { addBlock(type) {
@ -228,28 +266,33 @@ export default {
const newBlock = this.prefillDefault(this.blockForm.data()) const newBlock = this.prefillDefault(this.blockForm.data())
newBlock.id = this.generateUUID() newBlock.id = this.generateUUID()
newBlock.hidden = false newBlock.hidden = false
if (['select', 'multi_select'].includes(this.blockForm.type)) { if (["select", "multi_select"].includes(this.blockForm.type)) {
newBlock[this.blockForm.type] = { options: [] } newBlock[this.blockForm.type] = { options: [] }
} }
if (this.blockForm.type === 'rating') { if (this.blockForm.type === "rating") {
newBlock.rating_max_value = 5 newBlock.rating_max_value = 5
} }
if (this.blockForm.type === 'scale') { if (this.blockForm.type === "scale") {
newBlock.scale_min_value = 1 newBlock.scale_min_value = 1
newBlock.scale_max_value = 5 newBlock.scale_max_value = 5
newBlock.scale_step_value = 1 newBlock.scale_step_value = 1
} }
if (this.blockForm.type === 'slider') { if (this.blockForm.type === "slider") {
newBlock.slider_min_value = 0 newBlock.slider_min_value = 0
newBlock.slider_max_value = 50 newBlock.slider_max_value = 50
newBlock.slider_step_value = 1 newBlock.slider_step_value = 1
} }
newBlock.help_position = 'below_input' newBlock.help_position = "below_input"
if (this.selectedFieldIndex === null || this.selectedFieldIndex === undefined) { if (
this.selectedFieldIndex === null ||
this.selectedFieldIndex === undefined
) {
const newFields = clonedeep(this.form.properties) const newFields = clonedeep(this.form.properties)
newFields.push(newBlock) newFields.push(newBlock)
this.form.properties = newFields this.form.properties = newFields
this.workingFormStore.openSettingsForField(this.form.properties.length - 1) this.workingFormStore.openSettingsForField(
this.form.properties.length - 1,
)
} else { } else {
const newFields = clonedeep(this.form.properties) const newFields = clonedeep(this.form.properties)
newFields.splice(this.selectedFieldIndex + 1, 0, newBlock) newFields.splice(this.selectedFieldIndex + 1, 0, newBlock)
@ -259,33 +302,43 @@ export default {
this.reset() this.reset()
}, },
generateUUID() { generateUUID() {
let d = new Date().getTime()// Timestamp 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 let d2 =
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { (typeof performance !== "undefined" &&
let r = Math.random() * 16// random number between 0 and 16 performance.now &&
if (d > 0) { // Use timestamp until depleted performance.now() * 1000) ||
r = (d + r) % 16 | 0 0 // Time in microseconds since page-load or 0 if unsupported
d = Math.floor(d / 16) return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
} else { // Use microseconds since page-load if supported /[xy]/g,
r = (d2 + r) % 16 | 0 function (c) {
d2 = Math.floor(d2 / 16) let r = Math.random() * 16 // random number between 0 and 16
} if (d > 0) {
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16) // 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) { prefillDefault(data) {
if (data.type === 'nf-text') { if (data.type === "nf-text") {
data.content = '<p>This is a text block.</p>' data.content = "<p>This is a text block.</p>"
} else if (data.type === 'nf-page-break') { } else if (data.type === "nf-page-break") {
data.next_btn_text = 'Next' data.next_btn_text = "Next"
data.previous_btn_text = 'Previous' data.previous_btn_text = "Previous"
} else if (data.type === 'nf-code') { } else if (data.type === "nf-code") {
data.content = '<div class="text-blue-500 italic">This is a code block.</div>' data.content =
} else if (data.type === 'signature') { '<div class="text-blue-500 italic">This is a code block.</div>'
data.help = 'Draw your signature above' } else if (data.type === "signature") {
data.help = "Draw your signature above"
} }
return data return data
} },
} },
} }
</script> </script>

View File

@ -1,55 +1,91 @@
<template> <template>
<editor-options-panel name="About Submissions" :already-opened="true"> <editor-options-panel
name="About Submissions"
:already-opened="true"
>
<template #icon> <template #icon>
<svg class="h-5 w-5" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
class="h-5 w-5"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M4.83333 6.08333H9M4.83333 9H11.5M4.83333 14V15.9463C4.83333 16.3903 4.83333 16.6123 4.92436 16.7263C5.00352 16.8255 5.12356 16.8832 5.25045 16.8831C5.39636 16.8829 5.56973 16.7442 5.91646 16.4668L7.90434 14.8765C8.31043 14.5517 8.51347 14.3892 8.73957 14.2737C8.94017 14.1712 9.15369 14.0963 9.37435 14.051C9.62306 14 9.88308 14 10.4031 14H12.5C13.9001 14 14.6002 14 15.135 13.7275C15.6054 13.4878 15.9878 13.1054 16.2275 12.635C16.5 12.1002 16.5 11.4001 16.5 10V5.5C16.5 4.09987 16.5 3.3998 16.2275 2.86502C15.9878 2.39462 15.6054 2.01217 15.135 1.77248C14.6002 1.5 13.9001 1.5 12.5 1.5H5.5C4.09987 1.5 3.3998 1.5 2.86502 1.77248C2.39462 2.01217 2.01217 2.39462 1.77248 2.86502C1.5 3.3998 1.5 4.09987 1.5 5.5V10.6667C1.5 11.4416 1.5 11.8291 1.58519 12.147C1.81635 13.0098 2.49022 13.6836 3.35295 13.9148C3.67087 14 4.05836 14 4.83333 14Z" d="M4.83333 6.08333H9M4.83333 9H11.5M4.83333 14V15.9463C4.83333 16.3903 4.83333 16.6123 4.92436 16.7263C5.00352 16.8255 5.12356 16.8832 5.25045 16.8831C5.39636 16.8829 5.56973 16.7442 5.91646 16.4668L7.90434 14.8765C8.31043 14.5517 8.51347 14.3892 8.73957 14.2737C8.94017 14.1712 9.15369 14.0963 9.37435 14.051C9.62306 14 9.88308 14 10.4031 14H12.5C13.9001 14 14.6002 14 15.135 13.7275C15.6054 13.4878 15.9878 13.1054 16.2275 12.635C16.5 12.1002 16.5 11.4001 16.5 10V5.5C16.5 4.09987 16.5 3.3998 16.2275 2.86502C15.9878 2.39462 15.6054 2.01217 15.135 1.77248C14.6002 1.5 13.9001 1.5 12.5 1.5H5.5C4.09987 1.5 3.3998 1.5 2.86502 1.77248C2.39462 2.01217 2.01217 2.39462 1.77248 2.86502C1.5 3.3998 1.5 4.09987 1.5 5.5V10.6667C1.5 11.4416 1.5 11.8291 1.58519 12.147C1.81635 13.0098 2.49022 13.6836 3.35295 13.9148C3.67087 14 4.05836 14 4.83333 14Z"
stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/> stroke="currentColor"
stroke-width="1.67"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</template> </template>
<text-input name="submit_button_text" class="mt-4" <text-input
:form="form" name="submit_button_text"
label="Text of Submit Button" class="mt-4"
:required="true" :form="form"
label="Text of Submit Button"
:required="true"
/> />
<toggle-switch-input name="editable_submissions" :form="form" class="mt-4" <toggle-switch-input
help="Gives user a unique url to update their submission" name="editable_submissions"
:form="form"
class="mt-4"
help="Gives user a unique url to update their submission"
> >
<template #label> <template #label>
Editable submissions Editable submissions
<pro-tag class="ml-1" /> <pro-tag class="ml-1" />
</template> </template>
</toggle-switch-input> </toggle-switch-input>
<text-input v-if="form.editable_submissions" name="editable_submissions_button_text" <text-input
:form="form" v-if="form.editable_submissions"
label="Text of editable submissions button" name="editable_submissions_button_text"
:required="true" :form="form"
label="Text of editable submissions button"
:required="true"
/> />
<flat-select-input :form="submissionOptions" name="databaseAction" label="Database Submission Action" <flat-select-input
:options="[ :form="submissionOptions"
{name:'Create new record (default)', value:'create'}, name="databaseAction"
{name:'Update Record (or create if no match)', value:'update'} label="Database Submission Action"
]" :required="true" help="Create a new record or update an existing one" :options="[
{ name: 'Create new record (default)', value: 'create' },
{ name: 'Update Record (or create if no match)', value: 'update' },
]"
:required="true"
help="Create a new record or update an existing one"
> >
<template #selected="{option,optionName}"> <template #selected="{ option, optionName }">
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
{{ optionName }} {{ optionName }}
<pro-tag v-if="option === 'update'" class="ml-2" /> <pro-tag
v-if="option === 'update'"
class="ml-2"
/>
</div> </div>
</template> </template>
<template #option="{option, selected}"> <template #option="{ option, selected }">
<span class="flex hover:text-white"> <span class="flex hover:text-white">
<p class="flex-grow hover:text-white"> <p class="flex-grow hover:text-white">
{{ option.name }} <template v-if="option.value === 'update'"><pro-tag /></template> {{ option.name }}
<template v-if="option.value === 'update'"><pro-tag /></template>
</p> </p>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white"> <span
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> v-if="selected"
<path fill-rule="evenodd" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white"
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
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> </svg>
</span> </span>
@ -58,45 +94,77 @@
</flat-select-input> </flat-select-input>
<v-transition> <v-transition>
<div v-if="submissionOptions.databaseAction == 'update' && filterableFields.length"> <div
<select-input v-if="filterableFields.length" :form="form" name="database_fields_update" v-if="
label="Properties to check on update" :options="filterableFields" :required="true" submissionOptions.databaseAction == 'update' &&
:multiple="true" 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"> <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 <small>If the submission has the same value(s) as a previous one for the
column(s), we will update it, instead of creating a new one. selected column(s), we will update it, instead of creating a new
<a href="#" one.
@click.prevent="crisp.openHelpdeskArticle('how-to-update-a-page-on-form-submission-1t1jwmn')" <a
>More href="#"
info here.</a> @click.prevent="
crisp.openHelpdeskArticle(
'how-to-update-a-page-on-form-submission-1t1jwmn',
)
"
>More info here.</a>
</small> </small>
</div> </div>
</div> </div>
</v-transition> </v-transition>
<select-input :form="submissionOptions" name="submissionMode" label="Post Submission Action" <select-input
:options="[ :form="submissionOptions"
{name:'Show Success page', value:'default'}, name="submissionMode"
{name:'Redirect', value:'redirect'} label="Post Submission Action"
]" :required="true" help="Show a message, or redirect to a URL" :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}"> <template #selected="{ option, optionName }">
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
{{ optionName }} {{ optionName }}
<pro-tag v-if="option === 'redirect'" class="ml-2" /> <pro-tag
v-if="option === 'redirect'"
class="ml-2"
/>
</div> </div>
</template> </template>
<template #option="{option, selected}"> <template #option="{ option, selected }">
<span class="flex hover:text-white"> <span class="flex hover:text-white">
<p class="flex-grow hover:text-white"> <p class="flex-grow hover:text-white">
{{ option.name }} <template v-if="option.value === 'redirect'"><pro-tag /></template> {{ option.name }}
<template v-if="option.value === 'redirect'"><pro-tag /></template>
</p> </p>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4"> <span
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> v-if="selected"
<path fill-rule="evenodd" class="absolute inset-y-0 right-0 flex items-center pr-4"
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
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> </svg>
</span> </span>
@ -104,105 +172,111 @@
</template> </template>
</select-input> </select-input>
<template v-if="submissionOptions.submissionMode === 'redirect'"> <template v-if="submissionOptions.submissionMode === 'redirect'">
<text-input name="redirect_url" <text-input
:form="form" name="redirect_url"
label="Redirect URL" :form="form"
:required="true" help="On submit, redirects to that URL" label="Redirect URL"
:required="true"
help="On submit, redirects to that URL"
/> />
</template> </template>
<template v-else> <template v-else>
<toggle-switch-input name="re_fillable" :form="form" class="mt-4" <toggle-switch-input
label="Allow users to fill the form again" 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" <text-input
:form="form" v-if="form.re_fillable"
label="Text of re-start button" name="re_fill_button_text"
:required="true" :form="form"
label="Text of re-start button"
:required="true"
/> />
<rich-text-area-input name="submitted_text" <rich-text-area-input
:form="form" name="submitted_text"
label="Text After Submission" :form="form"
:required="false" label="Text After Submission"
:required="false"
/> />
</template> </template>
</editor-options-panel> </editor-options-panel>
</template> </template>
<script> <script>
import { useWorkingFormStore } from '../../../../../stores/working_form' import { useWorkingFormStore } from "../../../../../stores/working_form"
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue' import EditorOptionsPanel from "../../../editors/EditorOptionsPanel.vue"
import ProTag from '~/components/global/ProTag.vue' import ProTag from "~/components/global/ProTag.vue"
import VTransition from '~/components/global/transitions/VTransition.vue' import VTransition from "~/components/global/transitions/VTransition.vue"
export default { export default {
components: {EditorOptionsPanel, ProTag, VTransition}, components: { EditorOptionsPanel, ProTag, VTransition },
props: {}, props: {},
setup () { setup() {
const workingFormStore = useWorkingFormStore() const workingFormStore = useWorkingFormStore()
const {content: form} = storeToRefs(workingFormStore) const { content: form } = storeToRefs(workingFormStore)
return { return {
form, form,
workingFormStore, workingFormStore,
crisp: useCrisp() crisp: useCrisp(),
} }
}, },
data () { data() {
return { return {
submissionOptions: {} submissionOptions: {},
} }
}, },
computed: { computed: {
form: {
get () {
return this.workingFormStore.content
},
/* We add a setter */
set (value) {
this.workingFormStore.set(value)
}
},
/** /**
* Used for the update record on submission. Lists all visible fields on which you can filter records to update * Used for the update record on submission. Lists all visible fields on which you can filter records to update
* on submission instead of creating * on submission instead of creating
*/ */
filterableFields () { filterableFields() {
if (this.submissionOptions.databaseAction !== 'update') return [] if (this.submissionOptions.databaseAction !== "update") return []
return this.form.properties.filter((field) => { return this.form.properties
return !field.hidden && !['files', 'signature', 'multi_select'].includes(field.type) .filter((field) => {
}).map((field) => { return (
return { !field.hidden &&
name: field.name, !["files", "signature", "multi_select"].includes(field.type)
value: field.id )
} })
}) .map((field) => {
} return {
name: field.name,
value: field.id,
}
})
},
}, },
watch: { watch: {
form: { form: {
handler () { handler() {
if (this.form) { if (this.form) {
this.submissionOptions = { this.submissionOptions = {
submissionMode: this.form.redirect_url ? 'redirect' : 'default', submissionMode: this.form.redirect_url ? "redirect" : "default",
databaseAction: this.form.database_fields_update ? 'update' : 'create' databaseAction: this.form.database_fields_update
? "update"
: "create",
} }
} }
}, },
deep: true deep: true,
}, },
submissionOptions: { submissionOptions: {
deep: true, deep: true,
handler: function (val) { handler: function (val) {
if (val.submissionMode === 'default') { if (val.submissionMode === "default") {
this.form.redirect_url = null this.form.redirect_url = null
} }
if (val.databaseAction === 'create') { if (val.databaseAction === "create") {
this.form.database_fields_update = null this.form.database_fields_update = null
} }
} },
} },
} },
} }
</script> </script>

View File

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

View File

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

View File

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

View File

@ -1,93 +1,166 @@
<template> <template>
<editor-options-panel name="Customization" :already-opened="true"> <editor-options-panel
name="Customization"
:already-opened="true"
>
<template #icon> <template #icon>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M1.66667 9.99984C1.66667 14.6022 5.39763 18.3332 10 18.3332C11.3807 18.3332 12.5 17.2139 12.5 15.8332V15.4165C12.5 15.0295 12.5 14.836 12.5214 14.6735C12.6691 13.5517 13.5519 12.6689 14.6737 12.5212C14.8361 12.4998 15.0297 12.4998 15.4167 12.4998H15.8333C17.214 12.4998 18.3333 11.3805 18.3333 9.99984C18.3333 5.39746 14.6024 1.6665 10 1.6665C5.39763 1.6665 1.66667 5.39746 1.66667 9.99984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/> class="h-5 w-5"
<path d="M5.83333 10.8332C6.29357 10.8332 6.66667 10.4601 6.66667 9.99984C6.66667 9.5396 6.29357 9.1665 5.83333 9.1665C5.3731 9.1665 5 9.5396 5 9.99984C5 10.4601 5.3731 10.8332 5.83333 10.8332Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/> viewBox="0 0 20 20"
<path d="M13.3333 7.49984C13.7936 7.49984 14.1667 7.12674 14.1667 6.6665C14.1667 6.20627 13.7936 5.83317 13.3333 5.83317C12.8731 5.83317 12.5 6.20627 12.5 6.6665C12.5 7.12674 12.8731 7.49984 13.3333 7.49984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/> fill="none"
<path d="M8.33333 6.6665C8.79357 6.6665 9.16667 6.29341 9.16667 5.83317C9.16667 5.37293 8.79357 4.99984 8.33333 4.99984C7.8731 4.99984 7.5 5.37293 7.5 5.83317C7.5 6.29341 7.8731 6.6665 8.33333 6.6665Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/> xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.66667 9.99984C1.66667 14.6022 5.39763 18.3332 10 18.3332C11.3807 18.3332 12.5 17.2139 12.5 15.8332V15.4165C12.5 15.0295 12.5 14.836 12.5214 14.6735C12.6691 13.5517 13.5519 12.6689 14.6737 12.5212C14.8361 12.4998 15.0297 12.4998 15.4167 12.4998H15.8333C17.214 12.4998 18.3333 11.3805 18.3333 9.99984C18.3333 5.39746 14.6024 1.6665 10 1.6665C5.39763 1.6665 1.66667 5.39746 1.66667 9.99984Z"
stroke="currentColor"
stroke-width="1.67"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.83333 10.8332C6.29357 10.8332 6.66667 10.4601 6.66667 9.99984C6.66667 9.5396 6.29357 9.1665 5.83333 9.1665C5.3731 9.1665 5 9.5396 5 9.99984C5 10.4601 5.3731 10.8332 5.83333 10.8332Z"
stroke="currentColor"
stroke-width="1.67"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M13.3333 7.49984C13.7936 7.49984 14.1667 7.12674 14.1667 6.6665C14.1667 6.20627 13.7936 5.83317 13.3333 5.83317C12.8731 5.83317 12.5 6.20627 12.5 6.6665C12.5 7.12674 12.8731 7.49984 13.3333 7.49984Z"
stroke="currentColor"
stroke-width="1.67"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.33333 6.6665C8.79357 6.6665 9.16667 6.29341 9.16667 5.83317C9.16667 5.37293 8.79357 4.99984 8.33333 4.99984C7.8731 4.99984 7.5 5.37293 7.5 5.83317C7.5 6.29341 7.8731 6.6665 8.33333 6.6665Z"
stroke="currentColor"
stroke-width="1.67"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</template> </template>
<select-input name="theme" class="mt-4" <select-input
:options="[ name="theme"
{name:'Default',value:'default'}, class="mt-4"
{name:'Simple',value:'simple'}, :options="[
{name:'Notion',value:'notion'}, { name: 'Default', value: 'default' },
]" { name: 'Simple', value: 'simple' },
:form="form" label="Form Theme" { name: 'Notion', value: 'notion' },
]"
:form="form"
label="Form Theme"
/> />
<select-input name="dark_mode" class="mt-4" <select-input
help="To see changes, save your form and open it" name="dark_mode"
:options="[ class="mt-4"
{name:'Auto - use Device System Preferences',value:'auto'}, help="To see changes, save your form and open it"
{name:'Light Mode',value:'light'}, :options="[
{name:'Dark Mode',value:'dark'} { name: 'Auto - use Device System Preferences', value: 'auto' },
]" { name: 'Light Mode', value: 'light' },
:form="form" label="Dark Mode" { name: 'Dark Mode', value: 'dark' },
]"
:form="form"
label="Dark Mode"
/> />
<select-input name="width" class="mt-4" <select-input
:options="[ name="width"
{name:'Centered',value:'centered'}, class="mt-4"
{name:'Full Width',value:'full'}, :options="[
]" { name: 'Centered', value: 'centered' },
:form="form" label="Form Width" help="Useful when embedding your form" { name: 'Full Width', value: 'full' },
]"
:form="form"
label="Form Width"
help="Useful when embedding your form"
/> />
<image-input name="cover_picture" class="mt-4" <image-input
:form="form" label="Cover Picture" help="Not visible when form is embedded" name="cover_picture"
:required="false" 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" <image-input
:form="form" label="Logo" help="Not visible when form is embedded" name="logo_picture"
:required="false" class="mt-4"
:form="form"
label="Logo"
help="Not visible when form is embedded"
:required="false"
/> />
<color-input name="color" class="mt-4" <color-input
:form="form" name="color"
label="Color (for buttons & inputs border)" class="mt-4"
:form="form"
label="Color (for buttons & inputs border)"
/> />
<toggle-switch-input name="hide_title" :form="form" class="mt-4" <toggle-switch-input
label="Hide Title" name="hide_title"
:form="form"
class="mt-4"
label="Hide Title"
/> />
<toggle-switch-input name="no_branding" :form="form" class="mt-4"> <toggle-switch-input
name="no_branding"
:form="form"
class="mt-4"
>
<template #label> <template #label>
Remove OpnForm Branding Remove OpnForm Branding
<pro-tag class="ml-1" /> <pro-tag class="ml-1" />
</template> </template>
</toggle-switch-input> </toggle-switch-input>
<toggle-switch-input name="show_progress_bar" :form="form" class="mt-4" <toggle-switch-input
label="Show progress bar" name="show_progress_bar"
:form="form"
class="mt-4"
label="Show progress bar"
/> />
<toggle-switch-input name="uppercase_labels" :form="form" class="mt-4" <toggle-switch-input
label="Uppercase Input Labels" name="uppercase_labels"
:form="form"
class="mt-4"
label="Uppercase Input Labels"
/> />
<toggle-switch-input name="transparent_background" :form="form" class="mt-4" <toggle-switch-input
label="Transparent Background" help="Only applies when form is embedded" name="transparent_background"
:form="form"
class="mt-4"
label="Transparent Background"
help="Only applies when form is embedded"
/> />
<toggle-switch-input name="confetti_on_submission" :form="form" class="mt-4" <toggle-switch-input
label="Confetti on successful submisison" name="confetti_on_submission"
@update:model-value="onChangeConfettiOnSubmission" :form="form"
class="mt-4"
label="Confetti on successful submisison"
@update:model-value="onChangeConfettiOnSubmission"
/> />
<toggle-switch-input name="auto_save" :form="form" <toggle-switch-input
label="Auto save form response" name="auto_save"
help="Will save data in browser, if user not submit the form then next time will auto prefill last entered data" :form="form"
label="Auto save form response"
help="Will save data in browser, if user not submit the form then next time will auto prefill last entered data"
/> />
</editor-options-panel> </editor-options-panel>
</template> </template>
<script setup> <script setup>
import { useWorkingFormStore } from '../../../../../stores/working_form' import { useWorkingFormStore } from "../../../../../stores/working_form"
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue' import EditorOptionsPanel from "../../../editors/EditorOptionsPanel.vue"
import ProTag from '~/components/global/ProTag.vue' import ProTag from "~/components/global/ProTag.vue"
const workingFormStore = useWorkingFormStore() const workingFormStore = useWorkingFormStore()
const form = storeToRefs(workingFormStore).content const form = storeToRefs(workingFormStore).content
const isMounted = ref(false) const isMounted = ref(false)
const crisp = useCrisp()
const confetti = useConfetti() const confetti = useConfetti()
onMounted(() => { onMounted(() => {

View File

@ -1,56 +1,78 @@
<template> <template>
<!-- Form Preview (desktop only) --> <!-- Form Preview (desktop only) -->
<div ref="parent" <div
ref="parent"
class="bg-gray-50 dark:bg-notion-dark-light hidden md:flex flex-grow p-5 flex-col items-center overflow-y-scroll" class="bg-gray-50 dark:bg-notion-dark-light hidden md:flex flex-grow p-5 flex-col items-center overflow-y-scroll"
> >
<div class="border rounded-lg bg-white dark:bg-notion-dark w-full block transition-all max-w-5xl"> <div class="border rounded-lg bg-white dark:bg-notion-dark w-full block transition-all max-w-5xl">
<transition enter-active-class="linear duration-100 overflow-hidden" <transition
enter-from-class="max-h-0" enter-active-class="linear duration-100 overflow-hidden"
enter-to-class="max-h-56" enter-from-class="max-h-0"
leave-active-class="linear duration-100 overflow-hidden" enter-to-class="max-h-56"
leave-from-class="max-h-56" leave-active-class="linear duration-100 overflow-hidden"
leave-to-class="max-h-0" leave-from-class="max-h-56"
leave-to-class="max-h-0"
> >
<div v-if="(form.logo_picture || form.cover_picture)"> <div v-if="(form.logo_picture || form.cover_picture)">
<div v-if="form.cover_picture"> <div v-if="form.cover_picture">
<div id="cover-picture" <div
class="max-h-56 rounded-t-lg w-full overflow-hidden flex items-center justify-center" 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"/> <img
alt="Cover Picture"
:src="coverPictureSrc(form.cover_picture)"
class="w-full"
>
</div> </div>
</div> </div>
<div v-if="form.logo_picture" class="w-full mx-auto p-5 relative" <div
:class="{'pt-20':!form.cover_picture, 'max-w-lg': form && (form.width === 'centered')}" 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)" <img
:class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}" alt="Logo Picture"
class="max-w-60 h-20 object-contain absolute left-5 transition-all" :src="coverPictureSrc(form.logo_picture)"
/> :class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}"
class="max-w-60 h-20 object-contain absolute left-5 transition-all"
>
</div> </div>
</div> </div>
</transition> </transition>
<open-complete-form ref="form-preview" class="w-full mx-auto py-5 px-3" :class="{'max-w-lg': form && (form.width === 'centered')}" <open-complete-form
:creating="creating" ref="form-preview"
:form="form" class="w-full mx-auto py-5 px-3"
:dark-mode="darkMode" :class="{'max-w-lg': form && (form.width === 'centered')}"
:admin-preview="true" :creating="creating"
@restarted="previewFormSubmitted=false" :form="form"
@submitted="previewFormSubmitted=true" :dark-mode="darkMode"
:admin-preview="true"
@restarted="previewFormSubmitted=false"
@submitted="previewFormSubmitted=true"
/> />
</div> </div>
<p class="text-center text-xs text-gray-400 dark:text-gray-600 mt-1"> <p class="text-center text-xs text-gray-400 dark:text-gray-600 mt-1">
Form Preview <span v-if="creating" Form Preview <span
class="font-normal text-gray-400 dark:text-gray-600 text-xs" v-if="creating"
class="font-normal text-gray-400 dark:text-gray-600 text-xs"
>- Answers won't be saved</span> >- Answers won't be saved</span>
<br> <br>
<span v-if="previewFormSubmitted && !form.re_fillable"> <span v-if="previewFormSubmitted && !form.re_fillable">
<a href="#" @click.prevent="$refs['form-preview'].restart()">Restart Form <a
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-nt-blue inline" viewBox="0 0 20 20" href="#"
fill="currentColor" @click.prevent="$refs['form-preview'].restart()"
>Restart Form
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-nt-blue inline"
viewBox="0 0 20 20"
fill="currentColor"
> >
<path fill-rule="evenodd" <path
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" fill-rule="evenodd"
clip-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> </svg>
</a> </a>
@ -60,13 +82,12 @@
</template> </template>
<script> <script>
import VSwitch from '../../../../forms/components/VSwitch.vue'
import OpenCompleteForm from '../../OpenCompleteForm.vue' import OpenCompleteForm from '../../OpenCompleteForm.vue'
import {handleDarkMode, useDarkMode} from "~/lib/forms/public-page.js" import {handleDarkMode, useDarkMode} from "~/lib/forms/public-page.js"
import { default as _has } from 'lodash/has' import { default as _has } from 'lodash/has'
export default { export default {
components: { OpenCompleteForm, VSwitch }, components: { OpenCompleteForm },
props: {}, props: {},
setup () { setup () {
const workingFormStore = useWorkingFormStore() const workingFormStore = useWorkingFormStore()

View File

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

View File

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

View File

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

View File

@ -1,48 +1,67 @@
<template> <template>
<editor-options-panel name="Security & Privacy" :already-opened="false"> <editor-options-panel
name="Security & Privacy"
:already-opened="false"
>
<template #icon> <template #icon>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg
<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" /> class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg> </svg>
</template> </template>
<toggle-switch-input name="can_be_indexed" :form="form" class="mt-4" <toggle-switch-input
label="Indexable by Google" name="can_be_indexed"
help="If enabled, your form can appear in the search results of Google" :form="form"
class="mt-4"
label="Indexable by Google"
help="If enabled, your form can appear in the search results of Google"
/> />
<toggle-switch-input name="use_captcha" :form="form" class="mt-4" <toggle-switch-input
label="Protect your form with a Captcha" name="use_captcha"
help="If enabled we will make sure respondant is a human" :form="form"
class="mt-4"
label="Protect your form with a Captcha"
help="If enabled we will make sure respondant is a human"
/> />
</editor-options-panel> </editor-options-panel>
</template> </template>
<script> <script>
import { useWorkingFormStore } from '../../../../../stores/working_form' import { useWorkingFormStore } from "../../../../../stores/working_form"
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue' import EditorOptionsPanel from "../../../editors/EditorOptionsPanel.vue"
export default { export default {
components: { EditorOptionsPanel }, components: { EditorOptionsPanel },
props: {}, props: {},
setup () { setup() {
const workingFormStore = useWorkingFormStore() const workingFormStore = useWorkingFormStore()
return { return {
workingFormStore workingFormStore,
} }
}, },
data () { data() {
return { return {}
}
}, },
computed: { computed: {
form: { form: {
get () { get() {
return this.workingFormStore.content return this.workingFormStore.content
}, },
/* We add a setter */ /* We add a setter */
set (value) { set(value) {
this.workingFormStore.set(value) this.workingFormStore.set(value)
} },
} },
}, },
} }
</script> </script>

View File

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

View File

@ -1,47 +1,64 @@
<template> <template>
<div v-if="content" class="flex flex-wrap"> <div
v-if="content"
class="flex flex-wrap"
>
<div class="w-full font-semibold text-gray-700 dark:text-gray-300 mb-2"> <div class="w-full font-semibold text-gray-700 dark:text-gray-300 mb-2">
{{ property.name }} {{ property.name }}
</div> </div>
<SelectInput v-model="content.operator" class="w-full" :options="operators" :name="'operator_' + property.id" <SelectInput
placeholder="Comparison operator" @update:model-value="operatorChanged()" /> v-model="content.operator"
class="w-full"
:options="operators"
:name="'operator_' + property.id"
placeholder="Comparison operator"
@update:model-value="operatorChanged()"
/>
<template v-if="needsInput"> <template v-if="needsInput">
<component v-bind="inputComponentData" :is="inputComponentData.component" v-model="content.value" class="w-full" <component
:name="'value_' + property.id" placeholder="Filter Value" @update:model-value="emitInput()" /> v-bind="inputComponentData"
:is="inputComponentData.component"
v-model="content.value"
class="w-full"
:name="'value_' + property.id"
placeholder="Filter Value"
@update:model-value="emitInput()"
/>
</template> </template>
</div> </div>
</template> </template>
<script> <script>
import OpenFilters from '../../../../../data/open_filters.json' import OpenFilters from "../../../../../data/open_filters.json"
export default { export default {
components: {}, components: {},
props: { props: {
modelValue: { required: true } modelValue: { type: Object, required: true },
}, },
emits: ['update:modelValue'],
data() { data() {
return { return {
content: { ...this.modelValue }, content: { ...this.modelValue },
available_filters: OpenFilters, available_filters: OpenFilters,
hasInput: false, hasInput: false,
inputComponent: { inputComponent: {
text: 'TextInput', text: "TextInput",
number: 'TextInput', number: "TextInput",
rating: 'TextInput', rating: "TextInput",
scale: 'TextInput', scale: "TextInput",
slider: 'TextInput', slider: "TextInput",
select: 'SelectInput', select: "SelectInput",
multi_select: 'SelectInput', multi_select: "SelectInput",
date: 'DateInput', date: "DateInput",
files: 'FileInput', files: "FileInput",
checkbox: 'CheckboxInput', checkbox: "CheckboxInput",
url: 'TextInput', url: "TextInput",
email: 'TextInput', email: "TextInput",
phone_number: 'TextInput' phone_number: "TextInput",
} },
} }
}, },
@ -51,34 +68,41 @@ export default {
const componentData = { const componentData = {
component: this.inputComponent[this.property.type], component: this.inputComponent[this.property.type],
name: this.property.id, name: this.property.id,
required: true required: true,
} }
if (this.property.type === 'phone_number' && !this.property.use_simple_text_input) { if (
componentData.component = 'PhoneInput' this.property.type === "phone_number" &&
!this.property.use_simple_text_input
) {
componentData.component = "PhoneInput"
} }
if (['select', 'multi_select'].includes(this.property.type)) { if (["select", "multi_select"].includes(this.property.type)) {
componentData.multiple = false componentData.multiple = false
componentData.options = this.property[this.property.type].options.map(option => { componentData.options = this.property[this.property.type].options.map(
return { (option) => {
name: option.name, return {
value: option.name name: option.name,
} value: option.name,
}) }
} else if (this.property.type === 'date') { },
)
} else if (this.property.type === "date") {
// componentData.withTime = true // componentData.withTime = true
} else if (this.property.type === 'checkbox') { } else if (this.property.type === "checkbox") {
componentData.label = this.property.name componentData.label = this.property.name
} }
return componentData return componentData
}, },
operators() { operators() {
return Object.keys(this.available_filters[this.property.type].comparators).map(key => { return Object.keys(
this.available_filters[this.property.type].comparators,
).map((key) => {
return { return {
value: key, value: key,
name: this.optionFilterNames(key, this.property.type) name: this.optionFilterNames(key, this.property.type),
} }
}) })
}, },
@ -90,9 +114,17 @@ export default {
const operatorFormat = operator.format const operatorFormat = operator.format
if (!operatorFormat) return true if (!operatorFormat) return true
if (operator.expected_type === 'boolean' && operatorFormat.type === 'enum' && operatorFormat.values.length === 1) { if (
operator.expected_type === "boolean" &&
operatorFormat.type === "enum" &&
operatorFormat.values.length === 1
) {
return false return false
} else if (operator.expected_type === 'object' && operatorFormat.type === 'empty' && operatorFormat.values === '{}') { } else if (
operator.expected_type === "object" &&
operatorFormat.type === "empty" &&
operatorFormat.values === "{}"
) {
return false return false
} }
@ -104,21 +136,28 @@ export default {
modelValue() { modelValue() {
this.refreshContent() this.refreshContent()
}, },
'content.operator': function (val) { "content.operator": function (val) {
if (val) { if (val) {
this.operatorChanged() this.operatorChanged()
} }
} },
},
mounted() {
this.refreshContent()
}, },
methods: { methods: {
castContent(content) { castContent(content) {
if (['number', 'rating', 'scale', 'slider'].includes(this.property.type) && content.value) { if (
["number", "rating", "scale", "slider"].includes(this.property.type) &&
content.value
) {
content.value = Number(content.value) content.value = Number(content.value)
} }
const operator = this.selectedOperator() const operator = this.selectedOperator()
if (operator.expected_type === 'boolean') { if (operator.expected_type === "boolean") {
content.value = Boolean(content.value) content.value = Boolean(content.value)
} }
@ -132,11 +171,22 @@ export default {
const operator = this.selectedOperator() const operator = this.selectedOperator()
const operatorFormat = operator.format const operatorFormat = operator.format
if (operator.expected_type === 'boolean' && operatorFormat.type === 'enum' && operatorFormat.values.length === 1) { if (
operator.expected_type === "boolean" &&
operatorFormat.type === "enum" &&
operatorFormat.values.length === 1
) {
this.content.value = operator.format.values[0] this.content.value = operator.format.values[0]
} else if (operator.expected_type === 'object' && operatorFormat.type === 'empty' && operatorFormat.values === '{}') { } else if (
operator.expected_type === "object" &&
operatorFormat.type === "empty" &&
operatorFormat.values === "{}"
) {
this.content.value = {} this.content.value = {}
} else if (typeof this.content.value === 'boolean' || typeof this.content.value === 'object') { } else if (
typeof this.content.value === "boolean" ||
typeof this.content.value === "object"
) {
this.content.value = null this.content.value = null
} }
this.emitInput() this.emitInput()
@ -145,21 +195,26 @@ export default {
if (!this.content.operator) { if (!this.content.operator) {
return null return null
} }
return this.available_filters[this.property.type].comparators[this.content.operator] return this.available_filters[this.property.type].comparators[
this.content.operator
]
}, },
optionFilterNames(key, propertyType) { optionFilterNames(key, propertyType) {
if (propertyType === 'checkbox') { if (propertyType === "checkbox") {
return { return {
equals: 'Is checked', equals: "Is checked",
does_not_equal: 'Is not checked' does_not_equal: "Is not checked",
}[key] }[key]
} }
return key.split('_').map(function (item) { return key
return item.charAt(0).toUpperCase() + item.substring(1) .split("_")
}).join(' ') .map(function (item) {
return item.charAt(0).toUpperCase() + item.substring(1)
})
.join(" ")
}, },
emitInput() { emitInput() {
this.$emit('update:modelValue', this.castContent(this.content)) this.$emit("update:modelValue", this.castContent(this.content))
}, },
refreshContent() { refreshContent() {
this.content = { this.content = {
@ -167,14 +222,10 @@ export default {
...this.modelValue, ...this.modelValue,
property_meta: { property_meta: {
id: this.property.id, id: this.property.id,
type: this.property.type type: this.property.type,
} },
} }
} },
}, },
mounted() {
this.refreshContent()
}
} }
</script> </script>

View File

@ -1,7 +1,14 @@
<template> <template>
<query-builder v-model="query" :rules="rules" :config="config" @update:model-value="onChange"> <query-builder
v-model="query"
:rules="rules"
:config="config"
@update:model-value="onChange"
>
<template #groupOperator="props"> <template #groupOperator="props">
<div class="query-builder-group-slot__group-selection flex items-center px-5 border-b py-1 mb-1 flex"> <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"> <p class="mr-2 font-semibold">
Operator Operator
</p> </p>
@ -30,91 +37,100 @@
</query-builder> </query-builder>
</template> </template>
<style src="query-builder-vue-3/dist/style.css" />
<script> <script>
import { defineComponent } from 'vue' /* eslint-disable vue/one-component-per-file */
import QueryBuilder from 'query-builder-vue-3' import { defineComponent } from "vue"
import ColumnCondition from './ColumnCondition.vue' import QueryBuilder from "query-builder-vue-3"
import GroupControlSlot from './GroupControlSlot.vue' import ColumnCondition from "./ColumnCondition.vue"
import GroupControlSlot from "./GroupControlSlot.vue"
export default { export default {
components: { components: {
GroupControlSlot, GroupControlSlot,
QueryBuilder, QueryBuilder,
ColumnCondition ColumnCondition,
}, },
props: { props: {
form: { type: Object, required: true }, form: { type: Object, required: true },
modelValue: { required: false } modelValue: { type: Object, required: false },
}, },
emits: ['update:modelValue'],
data () { data() {
return { return {
query: this.modelValue query: this.modelValue,
} }
}, },
computed: { computed: {
rules () { rules() {
return this.form.properties.filter((property) => { return this.form.properties
return !property.type.startsWith('nf-') .filter((property) => {
}).map((property) => { return !property.type.startsWith("nf-")
const workspaceId = this.form.workspace_id })
const formSlug = this.form.slug .map((property) => {
return { const workspaceId = this.form.workspace_id
identifier: property.id, const formSlug = this.form.slug
name: property.name, return {
component: (function () { identifier: property.id,
return defineComponent({ name: property.name,
extends: ColumnCondition, component: (function () {
computed: { return defineComponent({
property () { extends: ColumnCondition,
return property computed: {
property() {
return property
},
viewContext() {
return {
form_slug: formSlug,
workspace_id: workspaceId,
}
},
}, },
viewContext () { })
return { })(),
form_slug: formSlug, }
workspace_id: workspaceId })
}
}
}
})
})()
}
})
}, },
config () { config() {
return { return {
operators: [ operators: [
{ {
name: 'And', name: "And",
identifier: 'and' identifier: "and",
}, },
{ {
name: 'Or', name: "Or",
identifier: 'or' identifier: "or",
} },
], ],
rules: this.rules, rules: this.rules,
colors: ['#ef4444', '#22c55e', '#f97316', '#0ea5e9', '#8b5cf6', '#ec4899'] colors: [
"#ef4444",
"#22c55e",
"#f97316",
"#0ea5e9",
"#8b5cf6",
"#ec4899",
],
} }
} },
}, },
watch: { watch: {
modelValue () { modelValue() {
this.query = this.modelValue this.query = this.modelValue
} },
}, },
methods: { methods: {
onChange () { onChange() {
this.$emit('update:modelValue', this.query) this.$emit("update:modelValue", this.query)
} },
} },
} }
</script> </script>
<style src="query-builder-vue-3/dist/style.css" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,153 +8,194 @@
<p class="text-gray-400 mb-3 text-xs"> <p class="text-gray-400 mb-3 text-xs">
Exclude this field or make it required. Exclude this field or make it required.
</p> </p>
<v-checkbox v-model="field.hidden" class="mb-3" <toggle-switch-input
:name="field.id+'_hidden'" :form="field"
@update:model-value="onFieldHiddenChange" name="hidden"
> label="Hidden"
Hidden @update:model-value="onFieldHiddenChange"
</v-checkbox>
<select-input name="width" class="mt-3"
:options="[
{name:'Full',value:'full'},
{name:'1/2 (half width)',value:'1/2'},
{name:'1/3 (a third of the width)',value:'1/3'},
{name:'2/3 (two thirds of the width)',value:'2/3'},
{name:'1/4 (a quarter of the width)',value:'1/4'},
{name:'3/4 (three quarters of the width)',value:'3/4'}
]"
:form="field" label="Field Width"
/> />
<select-input v-if="['nf-text','nf-image'].includes(field.type)" name="align" class="mt-3" <select-input
:options="[ name="width"
{name:'Left',value:'left'}, class="mt-3"
{name:'Center',value:'center'}, :options="[
{name:'Right',value:'right'}, { name: 'Full', value: 'full' },
{name:'Justify',value:'justify'} { name: '1/2 (half width)', value: '1/2' },
]" { name: '1/3 (a third of the width)', value: '1/3' },
:form="field" label="Field Alignment" { name: '2/3 (two thirds of the width)', value: '2/3' },
{ name: '1/4 (a quarter of the width)', value: '1/4' },
{ name: '3/4 (three quarters of the width)', value: '3/4' },
]"
:form="field"
label="Field Width"
/>
<select-input
v-if="['nf-text', 'nf-image'].includes(field.type)"
name="align"
class="mt-3"
:options="[
{ name: 'Left', value: 'left' },
{ name: 'Center', value: 'center' },
{ name: 'Right', value: 'right' },
{ name: 'Justify', value: 'justify' },
]"
:form="field"
label="Field Alignment"
/> />
</div> </div>
<div v-if="field.type == 'nf-text'" class="border-b py-2 px-4"> <div
<rich-text-area-input name="content" v-if="field.type == 'nf-text'"
:form="field" class="border-b py-2 px-4"
label="Content" >
:required="false" <rich-text-area-input
name="content"
:form="field"
label="Content"
:required="false"
/> />
</div> </div>
<div v-else-if="field.type == 'nf-page-break'" class="border-b py-2 px-4"> <div
<text-input name="next_btn_text" v-else-if="field.type == 'nf-page-break'"
:form="field" class="border-b py-2 px-4"
label="Text of next button" >
:required="true" <text-input
name="next_btn_text"
:form="field"
label="Text of next button"
:required="true"
/> />
<text-input name="previous_btn_text" <text-input
:form="field" name="previous_btn_text"
label="Text of previous button" :form="field"
help="Shown on the next page" label="Text of previous button"
:required="true" help="Shown on the next page"
:required="true"
/> />
</div> </div>
<div v-else-if="field.type == 'nf-divider'" class="border-b py-2 px-4"> <div
<text-input name="name" v-else-if="field.type == 'nf-divider'"
:form="field" :required="true" class="border-b py-2 px-4"
label="Field Name" >
<text-input
name="name"
:form="field"
:required="true"
label="Field Name"
/> />
</div> </div>
<div v-else-if="field.type == 'nf-image'" class="border-b py-2 px-4"> <div
<text-input name="name" v-else-if="field.type == 'nf-image'"
:form="field" :required="true" class="border-b py-2 px-4"
label="Field Name" >
<text-input
name="name"
:form="field"
:required="true"
label="Field Name"
/> />
<image-input name="image_block" class="mt-3" <image-input
:form="field" label="Upload Image" :required="false" name="image_block"
class="mt-3"
:form="field"
label="Upload Image"
:required="false"
/> />
</div> </div>
<div v-else-if="field.type == 'nf-code'" class="border-b py-2 px-4"> <div
<code-input name="content" :form="field" label="Content" v-else-if="field.type == 'nf-code'"
help="You can add any html code, including iframes" class="border-b py-2 px-4"
>
<code-input
name="content"
:form="field"
label="Content"
help="You can add any html code, including iframes"
/> />
</div> </div>
<div v-else class="border-b py-2 px-4"> <div
v-else
class="border-b py-2 px-4"
>
<p>No settings found.</p> <p>No settings found.</p>
</div> </div>
<!-- Logic Block --> <!-- Logic Block -->
<form-block-logic-editor class="py-2 px-4 border-b" :form="form" :field="field" /> <form-block-logic-editor
class="py-2 px-4 border-b"
:form="form"
:field="field"
/>
</div> </div>
</template> </template>
<script> <script>
import FormBlockLogicEditor from '../../components/form-logic-components/FormBlockLogicEditor.vue' import FormBlockLogicEditor from "../../components/form-logic-components/FormBlockLogicEditor.vue"
export default { export default {
name: 'BlockOptions', name: "BlockOptions",
components: {FormBlockLogicEditor}, components: { FormBlockLogicEditor },
props: { props: {
field: { field: {
type: Object, type: Object,
required: false required: false,
}, },
form: { form: {
type: Object, type: Object,
required: false required: false,
} },
}, },
data () { data() {
return { return {
editorToolbarCustom: [ editorToolbarCustom: [["bold", "italic", "underline", "link"]],
['bold', 'italic', 'underline', 'link']
]
} }
}, },
computed: {}, computed: {},
watch: { watch: {
'field.width': { "field.width": {
handler (val) { handler(val) {
if (val === undefined || val === null) { if (val === undefined || val === null) {
this.field.width = 'full' this.field.width = "full"
} }
}, },
immediate: true immediate: true,
}, },
'field.align': { "field.align": {
handler (val) { handler(val) {
if (val === undefined || val === null) { if (val === undefined || val === null) {
this.field.align = 'left' this.field.align = "left"
} }
}, },
immediate: true immediate: true,
} },
}, },
created () { created() {
if (this.field?.width === undefined || this.field?.width === null) { if (this.field?.width === undefined || this.field?.width === null) {
this.field.width = 'full' this.field.width = "full"
} }
}, },
mounted () {}, mounted() {},
methods: { methods: {
onFieldHiddenChange (val) { onFieldHiddenChange(val) {
this.field.hidden = val this.field.hidden = val
if (this.field.hidden) { if (this.field.hidden) {
this.field.required = false this.field.required = false
} }
}, },
onFieldHelpPositionChange (val) { onFieldHelpPositionChange(val) {
if (!val) { if (!val) {
this.field.help_position = 'below_input' this.field.help_position = "below_input"
} }
} },
} },
} }
</script> </script>

View File

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

View File

@ -1,5 +1,8 @@
<template> <template>
<div v-if="field" class="py-2"> <div
v-if="field"
class="py-2"
>
<!-- General --> <!-- General -->
<div class="border-b px-4"> <div class="border-b px-4">
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
@ -8,108 +11,206 @@
<p class="text-gray-400 mb-2 text-xs"> <p class="text-gray-400 mb-2 text-xs">
Exclude this field or make it required. Exclude this field or make it required.
</p> </p>
<toggle-switch-input :form="field" name="required" label="Required" <toggle-switch-input
@update:model-value="onFieldRequiredChange"/> :form="field"
<toggle-switch-input :form="field" name="hidden" label="Hidden" name="required"
@update:model-value="onFieldHiddenChange"/> label="Required"
<toggle-switch-input :form="field" name="disabled" label="Disabled" @update:model-value="onFieldRequiredChange"
@update:model-value="onFieldDisabledChange"/> />
<toggle-switch-input
:form="field"
name="hidden"
label="Hidden"
@update:model-value="onFieldHiddenChange"
/>
<toggle-switch-input
:form="field"
name="disabled"
label="Disabled"
@update:model-value="onFieldDisabledChange"
/>
</div> </div>
<!-- Checkbox --> <!-- Checkbox -->
<div v-if="field.type === 'checkbox'" class="border-b py-2 px-4"> <div
v-if="field.type === 'checkbox'"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
Checkbox Checkbox
</h3> </h3>
<p class="text-gray-400 mb-3 text-xs"> <p class="text-gray-400 mb-3 text-xs">
Advanced options for checkbox. Advanced options for checkbox.
</p> </p>
<v-checkbox v-model="field.use_toggle_switch" class="mt-3" name="use_toggle_switch" help=""> <toggle-switch-input
Use toggle switch :form="field"
</v-checkbox> name="use_toggle_switch"
<p class="text-gray-400 mb-3 text-xs"> label="Use toggle switch"
If enabled, checkbox will be replaced with a toggle switch help="If enabled, checkbox will be replaced with a toggle switch"
</p> />
</div> </div>
<!-- File Uploads --> <!-- File Uploads -->
<div v-if="field.type === 'files'" class="border-b py-2 px-4"> <div
<h3 class="font-semibold block text-lg"> v-if="field.type === 'files'"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg mb-3">
File uploads File uploads
</h3> </h3>
<v-checkbox v-model="field.multiple" class="mt-3" :name="field.id + '_multiple'"> <toggle-switch-input
Allow multiple files :form="field"
</v-checkbox> name="multiple"
<v-checkbox v-model="field.camera_upload" class="mt-3" label="Allow multiple files"
:name="field.id+'_camera_upload'" />
> <toggle-switch-input
Allow Camera uploads :form="field"
</v-checkbox> name="camera_upload"
<text-input name="allowed_file_types" class="mt-3" :form="field" label="Allowed file types" label="Allow Camera uploads"
placeholder="jpg,jpeg,png,gif" help="Comma separated values, leave blank to allow all file types" /> />
<text-input
name="allowed_file_types"
class="mt-3"
:form="field"
label="Allowed file types"
placeholder="jpg,jpeg,png,gif"
help="Comma separated values, leave blank to allow all file types"
/>
<text-input name="max_file_size" class="mt-3" :form="field" native-type="number" :min="1" :max="mbLimit" <text-input
label="Maximum file size (in MB)" :placeholder="`1MB - ${mbLimit}MB`" name="max_file_size"
help="Set the maximum file size that can be uploaded" /> class="mt-3"
:form="field"
native-type="number"
:min="1"
:max="mbLimit"
label="Maximum file size (in MB)"
:placeholder="`1MB - ${mbLimit}MB`"
help="Set the maximum file size that can be uploaded"
/>
</div> </div>
<div v-if="field.type === 'rating'" class="border-b py-2 px-4"> <div
v-if="field.type === 'rating'"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
Rating Rating
</h3> </h3>
<p class="text-gray-400 mb-3 text-xs"> <p class="text-gray-400 mb-3 text-xs">
Advanced options for rating. Advanced options for rating.
</p> </p>
<text-input name="rating_max_value" native-type="number" :min="1" class="mt-3" :form="field" required <text-input
label="Max rating value" /> name="rating_max_value"
native-type="number"
:min="1"
class="mt-3"
:form="field"
required
label="Max rating value"
/>
</div> </div>
<div v-if="field.type === 'scale'" class="border-b py-2 px-4"> <div
v-if="field.type === 'scale'"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
Scale Scale
</h3> </h3>
<p class="text-gray-400 mb-3 text-xs"> <p class="text-gray-400 mb-3 text-xs">
Advanced options for scale. Advanced options for scale.
</p> </p>
<text-input name="scale_min_value" native-type="number" class="mt-4" :form="field" required <text-input
label="Min scale value" /> name="scale_min_value"
<text-input name="scale_max_value" native-type="number" :min="1" class="mt-4" :form="field" required native-type="number"
label="Max scale value" /> class="mt-4"
<text-input name="scale_step_value" native-type="number" :min="1" class="mt-4" :form="field" required :form="field"
label="Scale steps value" /> required
label="Min scale value"
/>
<text-input
name="scale_max_value"
native-type="number"
:min="1"
class="mt-4"
:form="field"
required
label="Max scale value"
/>
<text-input
name="scale_step_value"
native-type="number"
:min="1"
class="mt-4"
:form="field"
required
label="Scale steps value"
/>
</div> </div>
<div v-if="field.type === 'slider'" class="border-b py-2 px-4"> <div
v-if="field.type === 'slider'"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
Slider Slider
</h3> </h3>
<p class="text-gray-400 mb-3 text-xs"> <p class="text-gray-400 mb-3 text-xs">
Advanced options for slider. Advanced options for slider.
</p> </p>
<text-input name="slider_min_value" native-type="number" class="mt-4" :form="field" required <text-input
label="Min slider value" /> name="slider_min_value"
<text-input name="slider_max_value" native-type="number" :min="1" class="mt-4" :form="field" required native-type="number"
label="Max slider value" /> class="mt-4"
<text-input name="slider_step_value" native-type="number" :min="1" class="mt-4" :form="field" required :form="field"
label="Slider steps value" /> required
label="Min slider value"
/>
<text-input
name="slider_max_value"
native-type="number"
:min="1"
class="mt-4"
:form="field"
required
label="Max slider value"
/>
<text-input
name="slider_step_value"
native-type="number"
:min="1"
class="mt-4"
:form="field"
required
label="Slider steps value"
/>
</div> </div>
<!-- Text Options --> <!-- Text Options -->
<div v-if="field.type === 'text' && displayBasedOnAdvanced" class="border-b py-2 px-4"> <div
v-if="field.type === 'text' && displayBasedOnAdvanced"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
Text Options Text Options
</h3> </h3>
<p class="text-gray-400 mb-3 text-xs"> <p class="text-gray-400 mb-3 text-xs">
Keep it simple or make it a multi-lines input. Keep it simple or make it a multi-lines input.
</p> </p>
<v-checkbox v-model="field.multi_lines" class="mb-2" :name="field.id + '_multi_lines'" <toggle-switch-input
@update:model-value="field.multi_lines = $event"> :form="field"
Multi-lines input name="multi_lines"
</v-checkbox> label="Multi-lines input"
@update:model-value="field.multi_lines = $event"
/>
</div> </div>
<!-- Date Options --> <!-- Date Options -->
<div v-if="field.type === 'date'" class="border-b py-2 px-4"> <div
v-if="field.type === 'date'"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
Date Options Date Options
</h3> </h3>
@ -163,30 +264,44 @@
</div> </div>
<!-- select/multiselect Options --> <!-- select/multiselect Options -->
<div v-if="['select', 'multi_select'].includes(field.type)" class="border-b py-2 px-4"> <div
v-if="['select', 'multi_select'].includes(field.type)"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
Select Options Select Options
</h3> </h3>
<p class="text-gray-400 mb-3 text-xs"> <p class="text-gray-400 mb-3 text-xs">
Advanced options for your select/multiselect fields. Advanced options for your select/multiselect fields.
</p> </p>
<text-area-input v-model="optionsText" :name="field.id + '_options_text'" class="mt-3" <text-area-input
label="Set selection options" help="Add one option per line" @update:model-value="onFieldOptionsChange" /> v-model="optionsText"
<v-checkbox v-model="field.allow_creation" name="allow_creation" help="" :name="field.id + '_options_text'"
@update:model-value="onFieldAllowCreationChange"> class="mt-3"
Allow respondent to create new options label="Set selection options"
</v-checkbox> help="Add one option per line"
<v-checkbox v-model="field.without_dropdown" class="mt-3" name="without_dropdown" help="" @update:model-value="onFieldOptionsChange"
@update:model-value="onFieldWithoutDropdownChange"> />
Always show all select options <toggle-switch-input
</v-checkbox> :form="field"
<p class="text-gray-400 mb-3 text-xs"> name="allow_creation"
Options won't be in a dropdown anymore, but will all be visible label="Allow respondent to create new options"
</p> @update:model-value="onFieldAllowCreationChange"
/>
<toggle-switch-input
:form="field"
name="without_dropdown"
label="Always show all select options"
help="Options won't be in a dropdown anymore, but will all be visible"
@update:model-value="onFieldWithoutDropdownChange"
/>
</div> </div>
<!-- Customization - Placeholder, Prefill, Relabel, Field Help --> <!-- Customization - Placeholder, Prefill, Relabel, Field Help -->
<div v-if="displayBasedOnAdvanced" class="border-b py-2 px-4"> <div
v-if="displayBasedOnAdvanced"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
Customization Customization
</h3> </h3>
@ -195,128 +310,253 @@
Change your form field name, pre-fill a value, add hints, etc. Change your form field name, pre-fill a value, add hints, etc.
</p> </p>
<text-input name="name" class="mt-3" :form="field" :required="true" label="Field Name" /> <text-input
name="name"
class="mt-3"
:form="field"
:required="true"
label="Field Name"
/>
<v-checkbox v-model="field.hide_field_name" class="mt-3" :name="field.id + '_hide_field_name'"> <toggle-switch-input
Hide field name :form="field"
</v-checkbox> name="hide_field_name"
label="Hide field name"
/>
<v-checkbox v-if="field.type === 'phone_number'" v-model="field.use_simple_text_input" class="mt-3" <toggle-switch-input
:name="field.id + '_use_simple_text_input'"> v-if="field.type === 'phone_number'"
Use simple text input :form="field"
</v-checkbox> name="use_simple_text_input"
label="Use simple text input"
/>
<template v-if="field.type === 'phone_number' && !field.use_simple_text_input"> <template v-if="field.type === 'phone_number' && !field.use_simple_text_input">
<select-input v-model="field.unavailable_countries" class="mt-4" wrapper-class="relative" <select-input
:options="allCountries" :multiple="true" :searchable="true" :search-keys="['name']" :option-key="'code'" v-model="field.unavailable_countries"
:emit-key="'code'" label="Disabled countries" :placeholder="'Select a country'" class="mt-4"
help="Remove countries from the phone input"> wrapper-class="relative"
<template #selected="{ option, selected }"> :options="allCountries"
:multiple="true"
:searchable="true"
:search-keys="['name']"
:option-key="'code'"
:emit-key="'code'"
label="Disabled countries"
:placeholder="'Select a country'"
help="Remove countries from the phone input"
>
<template #selected="{ option }">
<div class="flex items-center space-x-2 justify-center overflow-hidden"> <div class="flex items-center space-x-2 justify-center overflow-hidden">
{{ option.length }} selected {{ option.length }} selected
</div> </div>
</template> </template>
<template #option="{ option, selected }"> <template #option="{ option, selected }">
<div class="flex items-center space-x-2 hover:text-white"> <div class="flex items-center space-x-2 hover:text-white">
<country-flag size="normal" class="!-mt-[9px]" :country="option.code" /> <country-flag
size="normal"
class="!-mt-[9px]"
:country="option.code"
/>
<span class="grow">{{ option.name }}</span> <span class="grow">{{ option.name }}</span>
<span>{{ option.dial_code }}</span> <span>{{ option.dial_code }}</span>
</div> </div>
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-2 dark:text-white"> <span
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> v-if="selected"
<path fill-rule="evenodd" class="absolute inset-y-0 right-0 flex items-center pr-2 dark:text-white"
>
<svg
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" 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" /> clip-rule="evenodd"
/>
</svg> </svg>
</span> </span>
</template> </template>
</select-input> </select-input>
<small class="flex -mt-2"> <small class="flex -mt-2">
<a href="#" class="grow" @click.prevent="selectAllCountries">Select All</a> <a
<a href="#" @click.prevent="field.unavailable_countries = null">Un-select All</a> href="#"
class="grow"
@click.prevent="selectAllCountries"
>Select All</a>
<a
href="#"
@click.prevent="field.unavailable_countries = null"
>Un-select All</a>
</small> </small>
</template> </template>
<!-- Pre-fill depends on type --> <!-- Pre-fill depends on type -->
<v-checkbox v-if="field.type == 'checkbox'" v-model="field.prefill" class="mt-3" :name="field.id + '_prefill'" <toggle-switch-input
@update:model-value="field.prefill = $event"> v-if="field.type == 'checkbox'"
Pre-filled value :form="field"
</v-checkbox> name="prefill"
<select-input v-else-if="['select', 'multi_select'].includes(field.type)" name="prefill" class="mt-3" label="Pre-filled value"
:form="field" :options="prefillSelectsOptions" label="Pre-filled value" @update:model-value="field.prefill = $event"
:multiple="field.type === 'multi_select'" /> />
<date-input v-else-if="field.type === 'date' && field.prefill_today !== true" name="prefill" class="mt-3" <select-input
:form="field" :with-time="field.with_time === true" :date-range="field.date_range === true" v-else-if="['select', 'multi_select'].includes(field.type)"
label="Pre-filled value" /> name="prefill"
<phone-input v-else-if="field.type === 'phone_number' && !field.use_simple_text_input" name="prefill" class="mt-3" class="mt-3"
:form="field" :can-only-country="true" :unavailable-countries="field.unavailable_countries ?? []" :form="field"
label="Pre-filled value" /> :options="prefillSelectsOptions"
<text-area-input v-else-if="field.type === 'text' && field.multi_lines" name="prefill" class="mt-3" :form="field" label="Pre-filled value"
label="Pre-filled value" /> :multiple="field.type === 'multi_select'"
<file-input v-else-if="field.type === 'files'" name="prefill" class="mt-4" :form="field" label="Pre-filled file" />
:multiple="field.multiple === true" :move-to-form-assets="true" /> <date-input
<text-input v-else-if="!['files', 'signature'].includes(field.type)" name="prefill" class="mt-3" :form="field" v-else-if="field.type === 'date' && field.prefill_today !== true"
label="Pre-filled value" /> name="prefill"
<div v-if="['select', 'multi_select'].includes(field.type)" class="-mt-3 mb-3 text-gray-400 dark:text-gray-500"> class="mt-3"
:form="field"
:with-time="field.with_time === true"
:date-range="field.date_range === true"
label="Pre-filled value"
/>
<phone-input
v-else-if="field.type === 'phone_number' && !field.use_simple_text_input"
name="prefill"
class="mt-3"
:form="field"
:can-only-country="true"
:unavailable-countries="field.unavailable_countries ?? []"
label="Pre-filled value"
/>
<text-area-input
v-else-if="field.type === 'text' && field.multi_lines"
name="prefill"
class="mt-3"
:form="field"
label="Pre-filled value"
/>
<file-input
v-else-if="field.type === 'files'"
name="prefill"
class="mt-4"
:form="field"
label="Pre-filled file"
:multiple="field.multiple === true"
:move-to-form-assets="true"
/>
<text-input
v-else-if="!['files', 'signature'].includes(field.type)"
name="prefill"
class="mt-3"
:form="field"
label="Pre-filled value"
/>
<div
v-if="['select', 'multi_select'].includes(field.type)"
class="-mt-3 mb-3 text-gray-400 dark:text-gray-500"
>
<small> <small>
A problem? <a href="#" @click.prevent="field.prefill = null">Click here to clear your pre-fill</a> A problem? <a
href="#"
@click.prevent="field.prefill = null"
>Click here to clear your pre-fill</a>
</small> </small>
</div> </div>
<!-- Placeholder --> <!-- Placeholder -->
<text-input v-if="hasPlaceholder" name="placeholder" class="mt-3" :form="field" <text-input
label="Empty Input Text (Placeholder)" /> v-if="hasPlaceholder"
name="placeholder"
class="mt-3"
:form="field"
label="Empty Input Text (Placeholder)"
/>
<select-input name="width" class="mt-3" :options="[ <select-input
{ name: 'Full', value: 'full' }, name="width"
{ name: '1/2 (half width)', value: '1/2' }, class="mt-3"
{ name: '1/3 (a third of the width)', value: '1/3' }, :options="[
{ name: '2/3 (two thirds of the width)', value: '2/3' }, { name: 'Full', value: 'full' },
{ name: '1/4 (a quarter of the width)', value: '1/4' }, { name: '1/2 (half width)', value: '1/2' },
{ name: '3/4 (three quarters of the width)', value: '3/4' }, { name: '1/3 (a third of the width)', value: '1/3' },
]" :form="field" label="Field Width" /> { name: '2/3 (two thirds of the width)', value: '2/3' },
{ name: '1/4 (a quarter of the width)', value: '1/4' },
{ name: '3/4 (three quarters of the width)', value: '3/4' },
]"
:form="field"
label="Field Width"
/>
<!-- Help --> <!-- Help -->
<rich-text-area-input name="help" class="mt-3" :form="field" :editor-toolbar="editorToolbarCustom" <rich-text-area-input
label="Field Help" help="Your field help will be shown below/above the field, just like this text." name="help"
:help-position="field.help_position" /> class="mt-3"
<select-input name="help_position" class="mt-3" :options="[ :form="field"
{ name: 'Below input', value: 'below_input' }, :editor-toolbar="editorToolbarCustom"
{ name: 'Above input', value: 'above_input' }, label="Field Help"
]" :form="field" label="Field Help Position" @update:model-value="onFieldHelpPositionChange" /> help="Your field help will be shown below/above the field, just like this text."
:help-position="field.help_position"
/>
<select-input
name="help_position"
class="mt-3"
:options="[
{ name: 'Below input', value: 'below_input' },
{ name: 'Above input', value: 'above_input' },
]"
:form="field"
label="Field Help Position"
@update:model-value="onFieldHelpPositionChange"
/>
<template v-if="['text', 'number', 'url', 'email'].includes(field.type)"> <template v-if="['text', 'number', 'url', 'email'].includes(field.type)">
<text-input name="max_char_limit" native-type="number" :min="1" :max="2000" :form="field" <text-input
label="Max character limit" help="Maximum character limit of 2000" :required="false" /> name="max_char_limit"
<checkbox-input name="show_char_limit" :form="field" class="mt-3" label="Always show character limit" /> native-type="number"
:min="1"
:max="2000"
:form="field"
label="Max character limit"
help="Maximum character limit of 2000"
:required="false"
/>
<checkbox-input
name="show_char_limit"
:form="field"
class="mt-3"
label="Always show character limit"
/>
</template> </template>
</div> </div>
<!-- Advanced Options --> <!-- Advanced Options -->
<div v-if="field.type === 'text'" class="border-b py-2 px-4"> <div
v-if="field.type === 'text'"
class="border-b py-2 px-4"
>
<h3 class="font-semibold block text-lg mb-3"> <h3 class="font-semibold block text-lg mb-3">
Advanced Options Advanced Options
</h3> </h3>
<toggle-switch-input
<v-checkbox v-model="field.generates_uuid" :name="field.id + '_generates_uuid'" :form="field"
@update:model-value="onFieldGenUIdChange"> name="generates_uuid"
Generates a unique id label="Generates a unique id"
</v-checkbox> help="If you enable this, we will hide this field and fill it with a unique id (UUID format) on each new form submission"
<p class="text-gray-400 mb-3 text-xs"> @update:model-value="onFieldGenUIdChange"
If you enable this, we will hide this field and fill it with a unique id (UUID format) on each new form />
submission <toggle-switch-input
</p> :form="field"
name="generates_auto_increment_id"
<v-checkbox v-model="field.generates_auto_increment_id" :name="field.id + '_generates_auto_increment_id'" label="Generates an auto-incremented id"
@update:model-value="onFieldGenAutoIdChange"> help="If you enable this, we will hide this field and fill it a unique incrementing number on each new form submission"
Generates an auto-incremented id @update:model-value="onFieldGenAutoIdChange"
</v-checkbox> />
<p class="text-gray-400 mb-3 text-xs">
If you enable this, we will hide this field and fill it a unique incrementing number on each new form submission
</p>
</div> </div>
<!-- Logic Block --> <!-- Logic Block -->
<form-block-logic-editor class="py-2 px-4 border-b" :form="form" :field="field" /> <form-block-logic-editor
class="py-2 px-4 border-b"
:form="form"
:field="field"
/>
</div> </div>
</template> </template>

View File

@ -1,21 +1,37 @@
<template> <template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData"> <IntegrationWrapper
<text-input :form="integrationData" name="settings.discord_webhook_url" v-model="props.integrationData"
label="Discord webhook url" help="help" required> :integration="props.integration"
:form="form"
>
<text-input
:form="integrationData"
name="settings.discord_webhook_url"
label="Discord webhook url"
help="help"
required
>
<template #help> <template #help>
<InputHelp> <InputHelp>
<template #help> <template #help>
<span> <span>
Receive a discord message on each form submission. Receive a discord message on each form submission.
<a href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" target="_blank"> Click <a
here </a> to learn how to get a discord webhook url. href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
target="_blank"
>
Click here
</a>
to learn how to get a discord webhook url.
</span> </span>
</template> </template>
</InputHelp> </InputHelp>
</template> </template>
</text-input> </text-input>
<h4 class="font-bold mt-4">Discord message options</h4> <h4 class="font-bold mt-4">
<notifications-message-actions v-model="integrationData.settings"/> Discord message options
</h4>
<notifications-message-actions v-model="integrationData.settings" />
</IntegrationWrapper> </IntegrationWrapper>
</template> </template>
@ -24,9 +40,9 @@ import IntegrationWrapper from "./components/IntegrationWrapper.vue"
import NotificationsMessageActions from "./components/NotificationsMessageActions.vue" import NotificationsMessageActions from "./components/NotificationsMessageActions.vue"
const props = defineProps({ const props = defineProps({
integration: {type: Object, required: true}, integration: { type: Object, required: true },
form: {type: Object, required: true}, form: { type: Object, required: true },
integrationData: {type: Object, required: true}, integrationData: { type: Object, required: true },
formIntegrationId: {type: Number, required: false, default: null} formIntegrationId: { type: Number, required: false, default: null },
}) })
</script> </script>

View File

@ -1,25 +1,38 @@
<template> <template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData"> <IntegrationWrapper
<text-area-input :form="integrationData" name="settings.notification_emails" required v-model="props.integrationData"
label="Notification Emails" help="Add one email per line" /> :integration="props.integration"
<text-input :form="integrationData" name="settings.notification_reply_to" :form="form"
label="Notification Reply To" :help="notifiesHelp" /> >
<text-area-input
:form="integrationData"
name="settings.notification_emails"
required
label="Notification Emails"
help="Add one email per line"
/>
<text-input
:form="integrationData"
name="settings.notification_reply_to"
label="Notification Reply To"
:help="notifiesHelp"
/>
</IntegrationWrapper> </IntegrationWrapper>
</template> </template>
<script setup> <script setup>
import IntegrationWrapper from './components/IntegrationWrapper.vue' import IntegrationWrapper from "./components/IntegrationWrapper.vue"
const props = defineProps({ const props = defineProps({
integration: { type: Object, required: true }, integration: { type: Object, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },
integrationData: { type: Object, required: true }, integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null } formIntegrationId: { type: Number, required: false, default: null },
}) })
const replayToEmailField = computed(() => { const replayToEmailField = computed(() => {
const emailFields = props.form.properties.filter((field) => { const emailFields = props.form.properties.filter((field) => {
return field.type === 'email' && !field.hidden return field.type === "email" && !field.hidden
}) })
if (emailFields.length === 1) return emailFields[0] if (emailFields.length === 1) return emailFields[0]
return null return null
@ -27,8 +40,12 @@ const replayToEmailField = computed(() => {
const notifiesHelp = computed(() => { const notifiesHelp = computed(() => {
if (replayToEmailField.value) { if (replayToEmailField.value) {
return 'If empty, Reply-to for this notification will be the email filled in the field "' + replayToEmailField.value.name + '".' return (
'If empty, Reply-to for this notification will be the email filled in the field "' +
replayToEmailField.value.name +
'".'
)
} }
return 'If empty, Reply-to for this notification will be your own email. Add a single email field to your form, and it will automatically become the reply to value.' return "If empty, Reply-to for this notification will be your own email. Add a single email field to your form, and it will automatically become the reply to value."
}) })
</script> </script>

View File

@ -1,5 +1,9 @@
<template> <template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData"> <IntegrationWrapper
v-model="props.integrationData"
:integration="props.integration"
:form="form"
>
<div class="my-5"> <div class="my-5">
Coming Soon... Coming Soon...
</div> </div>
@ -7,12 +11,12 @@
</template> </template>
<script setup> <script setup>
import IntegrationWrapper from './components/IntegrationWrapper.vue' import IntegrationWrapper from "./components/IntegrationWrapper.vue"
const props = defineProps({ const props = defineProps({
integration: { type: Object, required: true }, integration: { type: Object, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },
integrationData: { type: Object, required: true }, integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null } formIntegrationId: { type: Number, required: false, default: null },
}) })
</script> </script>

View File

@ -1,20 +1,37 @@
<template> <template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData"> <IntegrationWrapper
<text-input :form="integrationData" name="settings.slack_webhook_url" v-model="props.integrationData"
label="Slack webhook url" help="help" required> :integration="props.integration"
:form="form"
>
<text-input
:form="integrationData"
name="settings.slack_webhook_url"
label="Slack webhook url"
help="help"
required
>
<template #help> <template #help>
<InputHelp> <InputHelp>
<template #help> <template #help>
<span> <span>
Receive slack message on each form submission. <a href="https://api.slack.com/messaging/webhooks" target="_blank"> Click here </a> Receive slack message on each form submission.
to learn how to get a slack webhook url <a
</span> href="https://api.slack.com/messaging/webhooks"
target="_blank"
>
Click here
</a>
to learn how to get a slack webhook url
</span>
</template> </template>
</InputHelp> </InputHelp>
</template> </template>
</text-input> </text-input>
<h4 class="font-bold mt-4">Slack message actions</h4> <h4 class="font-bold mt-4">
<notifications-message-actions v-model="integrationData.settings"/> Slack message actions
</h4>
<notifications-message-actions v-model="integrationData.settings" />
</IntegrationWrapper> </IntegrationWrapper>
</template> </template>
@ -23,9 +40,9 @@ import IntegrationWrapper from "./components/IntegrationWrapper.vue"
import NotificationsMessageActions from "./components/NotificationsMessageActions.vue" import NotificationsMessageActions from "./components/NotificationsMessageActions.vue"
const props = defineProps({ const props = defineProps({
integration: {type: Object, required: true}, integration: { type: Object, required: true },
form: {type: Object, required: true}, form: { type: Object, required: true },
integrationData: {type: Object, required: true}, integrationData: { type: Object, required: true },
formIntegrationId: {type: Number, required: false, default: null} formIntegrationId: { type: Number, required: false, default: null },
}) })
</script> </script>

View File

@ -1,19 +1,50 @@
<template> <template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData"> <IntegrationWrapper
v-model="props.integrationData"
:integration="props.integration"
:form="form"
>
<div>{{ emailSubmissionConfirmationHelp }}</div> <div>{{ emailSubmissionConfirmationHelp }}</div>
<div v-if="emailSubmissionConfirmationField"> <div v-if="emailSubmissionConfirmationField">
<text-input :form="integrationData" name="settings.notification_sender" class="mt-4" required <text-input
:form="integrationData"
name="settings.notification_sender"
class="mt-4"
required
label="Confirmation Email Sender Name" label="Confirmation Email Sender Name"
help="Emails will be sent from our email address but you can customize the name of the Sender" /> help="Emails will be sent from our email address but you can customize the name of the Sender"
<text-input :form="integrationData" name="settings.notification_subject" class="mt-4" required />
label="Confirmation email subject" help="Subject of the confirmation email that will be sent" /> <text-input
<rich-text-area-input :form="integrationData" name="settings.notification_body" class="mt-4" required :form="integrationData"
label="Confirmation email content" help="Content of the confirmation email that will be sent" /> name="settings.notification_subject"
<text-input :form="integrationData" name="settings.confirmation_reply_to" class="mt-4" class="mt-4"
label="Confirmation Reply To" help="If empty, Reply-to will be your own email."/> required
<toggle-switch-input :form="integrationData" name="settings.notifications_include_submission" class="mt-4" label="Confirmation email subject"
label="Include submission data" help="If enabled the confirmation email will contain form submission answers" /> help="Subject of the confirmation email that will be sent"
/>
<rich-text-area-input
:form="integrationData"
name="settings.notification_body"
class="mt-4"
required
label="Confirmation email content"
help="Content of the confirmation email that will be sent"
/>
<text-input
:form="integrationData"
name="settings.confirmation_reply_to"
class="mt-4"
label="Confirmation Reply To"
help="If empty, Reply-to will be your own email."
/>
<toggle-switch-input
:form="integrationData"
name="settings.notifications_include_submission"
class="mt-4"
label="Include submission data"
help="If enabled the confirmation email will contain form submission answers"
/>
</div> </div>
</IntegrationWrapper> </IntegrationWrapper>
</template> </template>
@ -25,30 +56,36 @@ const props = defineProps({
integration: { type: Object, required: true }, integration: { type: Object, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },
integrationData: { type: Object, required: true }, integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null } formIntegrationId: { type: Number, required: false, default: null },
}) })
const emailSubmissionConfirmationField = computed(() => { const emailSubmissionConfirmationField = computed(() => {
if (!props.form.properties || !Array.isArray(props.form.properties)) return null if (!props.form.properties || !Array.isArray(props.form.properties))
return null
const emailFields = props.form.properties.filter((field) => { const emailFields = props.form.properties.filter((field) => {
return field.type === 'email' && !field.hidden return field.type === "email" && !field.hidden
}) })
if (emailFields.length === 1) return emailFields[0] if (emailFields.length === 1) return emailFields[0]
return null return null
}) })
const emailSubmissionConfirmationHelp = computed(() => { const emailSubmissionConfirmationHelp = computed(() => {
if (emailSubmissionConfirmationField.value) { if (emailSubmissionConfirmationField.value) {
return 'Confirmation will be sent to the email in the "' + emailSubmissionConfirmationField.value.name + '" field.' return (
'Confirmation will be sent to the email in the "' +
emailSubmissionConfirmationField.value.name +
'" field.'
)
} }
return 'Only available if your form contains 1 email field.' return "Only available if your form contains 1 email field."
}) })
onBeforeMount(() => { onBeforeMount(() => {
for (const [keyname, defaultValue] of Object.entries({ for (const [keyname, defaultValue] of Object.entries({
'notification_sender': 'OpnForm', notification_sender: "OpnForm",
'notification_subject': 'We saved your answers', notification_subject: "We saved your answers",
'notification_body': 'Hello there 👋 <br>This is a confirmation that your submission was successfully saved.', notification_body:
'notifications_include_submission': true, "Hello there 👋 <br>This is a confirmation that your submission was successfully saved.",
notifications_include_submission: true,
})) { })) {
if (props.integrationData.settings[keyname] === undefined) { if (props.integrationData.settings[keyname] === undefined) {
props.integrationData.settings[keyname] = defaultValue props.integrationData.settings[keyname] = defaultValue

View File

@ -1,7 +1,17 @@
<template> <template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData"> <IntegrationWrapper
<text-input :form="integrationData" name="settings.webhook_url" class="mt-4" label="Webhook url" v-model="props.integrationData"
help="We will post form submissions to this endpoint" required /> :integration="props.integration"
:form="form"
>
<text-input
:form="integrationData"
name="settings.webhook_url"
class="mt-4"
label="Webhook url"
help="We will post form submissions to this endpoint"
required
/>
</IntegrationWrapper> </IntegrationWrapper>
</template> </template>
@ -12,6 +22,6 @@ const props = defineProps({
integration: { type: Object, required: true }, integration: { type: Object, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },
integrationData: { type: Object, required: true }, integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null } formIntegrationId: { type: Number, required: false, default: null },
}) })
</script> </script>

View File

@ -1,5 +1,9 @@
<template> <template>
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData"> <IntegrationWrapper
v-model="props.integrationData"
:integration="props.integration"
:form="form"
>
<div class="my-5"> <div class="my-5">
Coming Soon... Coming Soon...
</div> </div>
@ -7,12 +11,12 @@
</template> </template>
<script setup> <script setup>
import IntegrationWrapper from './components/IntegrationWrapper.vue' import IntegrationWrapper from "./components/IntegrationWrapper.vue"
const props = defineProps({ const props = defineProps({
integration: { type: Object, required: true }, integration: { type: Object, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },
integrationData: { type: Object, required: true }, integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null } formIntegrationId: { type: Number, required: false, default: null },
}) })
</script> </script>

View File

@ -1,106 +1,194 @@
<template> <template>
<div class="text-gray-500 border shadow rounded-md p-5 mt-4 relative flex items-center"> <div
class="text-gray-500 border shadow rounded-md p-5 mt-4 relative flex items-center"
>
<div class="flex-grow flex items-center"> <div class="flex-grow flex items-center">
<div class="mr-4" <div
:class="{ 'text-blue-500': integration.status === 'active', 'text-gray-400': integration.status !== 'active' }"> class="mr-4"
<Icon :name="integrationTypeInfo.icon" size="32px"/> :class="{
'text-blue-500': integration.status === 'active',
'text-gray-400': integration.status !== 'active',
}"
>
<Icon
:name="integrationTypeInfo.icon"
size="32px"
/>
</div> </div>
<div> <div>
<div class="flex space-x-3 font-semibold mr-2">{{ integrationTypeInfo.name }}</div> <div class="flex space-x-3 font-semibold mr-2">
<Badge :color="integration.status === 'active' ? 'green' : 'gray'" {{ integrationTypeInfo.name }}
:before-icon="integration.status === 'active' ? 'solar:play-bold' : 'solar:pause-bold'" </div>
<Badge
:color="integration.status === 'active' ? 'green' : 'gray'"
:before-icon="
integration.status === 'active'
? 'solar:play-bold'
: 'solar:pause-bold'
"
> >
{{ integration.status === 'active' ? 'Active' : 'Paused' }} {{ integration.status === "active" ? "Active" : "Paused" }}
</Badge> </Badge>
</div> </div>
</div> </div>
<div v-if="loadingDelete" class="pr-4 pt-2"> <div
<Loader class="h-6 w-6 mx-auto"/> v-if="loadingDelete"
class="pr-4 pt-2"
>
<Loader class="h-6 w-6 mx-auto" />
</div> </div>
<dropdown v-else class="inline"> <dropdown
v-else
class="inline"
>
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<v-button color="white" @click="toggle"> <v-button
<svg class="w-4 h-4 inline -mt-1" viewBox="0 0 16 4" fill="none" xmlns="http://www.w3.org/2000/svg"> color="white"
@click="toggle"
>
<svg
class="w-4 h-4 inline -mt-1"
viewBox="0 0 16 4"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M8.00016 2.83366C8.4604 2.83366 8.8335 2.46056 8.8335 2.00033C8.8335 1.54009 8.4604 1.16699 8.00016 1.16699C7.53993 1.16699 7.16683 1.54009 7.16683 2.00033C7.16683 2.46056 7.53993 2.83366 8.00016 2.83366Z" d="M8.00016 2.83366C8.4604 2.83366 8.8335 2.46056 8.8335 2.00033C8.8335 1.54009 8.4604 1.16699 8.00016 1.16699C7.53993 1.16699 7.16683 1.54009 7.16683 2.00033C7.16683 2.46056 7.53993 2.83366 8.00016 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/> stroke="#344054"
stroke-width="1.66667"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path <path
d="M13.8335 2.83366C14.2937 2.83366 14.6668 2.46056 14.6668 2.00033C14.6668 1.54009 14.2937 1.16699 13.8335 1.16699C13.3733 1.16699 13.0002 1.54009 13.0002 2.00033C13.0002 2.46056 13.3733 2.83366 13.8335 2.83366Z" d="M13.8335 2.83366C14.2937 2.83366 14.6668 2.46056 14.6668 2.00033C14.6668 1.54009 14.2937 1.16699 13.8335 1.16699C13.3733 1.16699 13.0002 1.54009 13.0002 2.00033C13.0002 2.46056 13.3733 2.83366 13.8335 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/> stroke="#344054"
stroke-width="1.66667"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path <path
d="M2.16683 2.83366C2.62707 2.83366 3.00016 2.46056 3.00016 2.00033C3.00016 1.54009 2.62707 1.16699 2.16683 1.16699C1.70659 1.16699 1.3335 1.54009 1.3335 2.00033C1.3335 2.46056 1.70659 2.83366 2.16683 2.83366Z" d="M2.16683 2.83366C2.62707 2.83366 3.00016 2.46056 3.00016 2.00033C3.00016 1.54009 2.62707 1.16699 2.16683 1.16699C1.70659 1.16699 1.3335 1.54009 1.3335 2.00033C1.3335 2.46056 1.70659 2.83366 2.16683 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/> stroke="#344054"
stroke-width="1.66667"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</v-button> </v-button>
</template> </template>
<a v-track.edit_form_integration_click="{ form_slug: form.slug, form_integration_id: integration.id }" href="#" <a
@click.prevent="showIntegrationModal = true" v-track.edit_form_integration_click="{
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center"> form_slug: form.slug,
<Icon name="heroicons:pencil" class="w-5 h-5 mr-2"/> form_integration_id: integration.id,
}"
href="#"
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center"
@click.prevent="showIntegrationModal = true"
>
<Icon
name="heroicons:pencil"
class="w-5 h-5 mr-2"
/>
Edit Edit
</a> </a>
<a v-track.past_events_form_integration_click="{ form_slug: form.slug, form_integration_id: integration.id }" <a
href="#" v-track.past_events_form_integration_click="{
@click.prevent="showIntegrationEventsModal = true" form_slug: form.slug,
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center"> form_integration_id: integration.id,
<Icon name="heroicons:clock" class="w-5 h-5 mr-2"/> }"
href="#"
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center"
@click.prevent="showIntegrationEventsModal = true"
>
<Icon
name="heroicons:clock"
class="w-5 h-5 mr-2"
/>
Past Events Past Events
</a> </a>
<a v-track.delete_form_integration_click="{ form_integration_id: integration.id }" href="#" <a
class="flex px-4 py-2 text-md text-red-600 hover:bg-red-50 hover:no-underline items-center" v-track.delete_form_integration_click="{
@click.prevent="deleteFormIntegration(integration.id)"> form_integration_id: integration.id,
<Icon name="heroicons:trash" class="w-5 h-5 mr-2"/> }"
href="#"
class="flex px-4 py-2 text-md text-red-600 hover:bg-red-50 hover:no-underline items-center"
@click.prevent="deleteFormIntegration(integration.id)"
>
<Icon
name="heroicons:trash"
class="w-5 h-5 mr-2"
/>
Delete Integration Delete Integration
</a> </a>
</dropdown> </dropdown>
<IntegrationModal v-if="form && integration && integrationTypeInfo" :form="form" :integration="integrationTypeInfo" <IntegrationModal
:integrationKey="integration.integration_id" :formIntegrationId="integration.id" v-if="form && integration && integrationTypeInfo"
:show="showIntegrationModal" :form="form"
@close="showIntegrationModal = false"/> :integration="integrationTypeInfo"
:integration-key="integration.integration_id"
:form-integration-id="integration.id"
:show="showIntegrationModal"
@close="showIntegrationModal = false"
/>
<IntegrationEventsModal v-if="form && integration" :form="form" :formIntegrationId="integration.id" <IntegrationEventsModal
:show="showIntegrationEventsModal" v-if="form && integration"
@close="showIntegrationEventsModal = false"/> :form="form"
:form-integration-id="integration.id"
:show="showIntegrationEventsModal"
@close="showIntegrationEventsModal = false"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import {computed} from "vue"; import { computed } from "vue"
const props = defineProps({ const props = defineProps({
integration: { integration: {
type: Object, type: Object,
required: true required: true,
}, },
form: { form: {
type: Object, type: Object,
required: true required: true,
} },
}) })
const alert = useAlert() const alert = useAlert()
const formIntegrationsStore = useFormIntegrationsStore() const formIntegrationsStore = useFormIntegrationsStore()
const integrations = computed(() => formIntegrationsStore.availableIntegrations) const integrations = computed(
const integrationTypeInfo = computed(() => integrations.value.get(props.integration.integration_id)) () => formIntegrationsStore.availableIntegrations,
)
const integrationTypeInfo = computed(() =>
integrations.value.get(props.integration.integration_id),
)
let showIntegrationModal = ref(false) const showIntegrationModal = ref(false)
let showIntegrationEventsModal = ref(false) const showIntegrationEventsModal = ref(false)
let loadingDelete = ref(false) const loadingDelete = ref(false)
const deleteFormIntegration = (integrationid) => { const deleteFormIntegration = (integrationid) => {
alert.confirm('Do you really want to delete this form integration?', () => { alert.confirm("Do you really want to delete this form integration?", () => {
opnFetch('/open/forms/{formid}/integration/{integrationid}'.replace('{formid}', props.form.id).replace('{integrationid}', integrationid), {method: 'DELETE'}).then((data) => { opnFetch(
if (data.type === 'success') { "/open/forms/{formid}/integration/{integrationid}"
alert.success(data.message) .replace("{formid}", props.form.id)
formIntegrationsStore.remove(integrationid) .replace("{integrationid}", integrationid),
} else { { method: "DELETE" },
alert.error('Something went wrong!') )
} .then((data) => {
}).catch((error) => { if (data.type === "success") {
alert.error(error.data.message) alert.success(data.message)
}) formIntegrationsStore.remove(integrationid)
} else {
alert.error("Something went wrong!")
}
})
.catch((error) => {
alert.error(error.data.message)
})
}) })
} }
</script> </script>

View File

@ -1,27 +1,48 @@
<template> <template>
<modal :show="show" @close="emit('close')" compact-header inner-padding=""> <modal
:show="show"
compact-header
inner-padding=""
@close="emit('close')"
>
<template #icon> <template #icon>
<Icon name="heroicons:clock" size="40px"/> <Icon
name="heroicons:clock"
size="40px"
/>
</template> </template>
<template #title> <template #title>
Past Events Past Events
</template> </template>
<UTable :loading="integrationEventsLoading" :columns="columns" :rows="integrationEvents"> <UTable
:loading="integrationEventsLoading"
:columns="columns"
:rows="integrationEvents"
>
<template #status-data="{ row }"> <template #status-data="{ row }">
<Badge :color="(row.status==='Success') ? 'green' : 'red'"> <Badge :color="row.status === 'Success' ? 'green' : 'red'">
{{row.status}} {{ row.status }}
</Badge> </Badge>
</template> </template>
<template #data-data="{ row }"> <template #data-data="{ row }">
<vue-json-pretty v-if="row.data && Object.keys(row.data).length > 0" :data="row.data" :collapsedNodeLength="0" :showLength="true" :showIcon="true" /> <vue-json-pretty
v-if="row.data && Object.keys(row.data).length > 0"
:data="row.data"
:collapsed-node-length="0"
:show-length="true"
:show-icon="true"
/>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</UTable> </UTable>
<template #footer> <template #footer>
<div class="flex justify-center gap-x-2"> <div class="flex justify-center gap-x-2">
<v-button color="white" @click.prevent="emit('close')"> <v-button
color="white"
@click.prevent="emit('close')"
>
Close Close
</v-button> </v-button>
</div> </div>
@ -30,35 +51,43 @@
</template> </template>
<script setup> <script setup>
import VueJsonPretty from 'vue-json-pretty' import VueJsonPretty from "vue-json-pretty"
import 'vue-json-pretty/lib/styles.css' import "vue-json-pretty/lib/styles.css"
const props = defineProps({ const props = defineProps({
show: { type: Boolean, required: true }, show: { type: Boolean, required: true },
form: {type: Object, required: true}, form: { type: Object, required: true },
formIntegrationId: {type: Number, required: true} formIntegrationId: { type: Number, required: true },
}) })
const emit = defineEmits(['close']) const emit = defineEmits(["close"])
const formIntegrationEventEndpoint = '/open/forms/{formid}/integration/{integrationid}/events' const formIntegrationEventEndpoint =
"/open/forms/{formid}/integration/{integrationid}/events"
const columns = [ const columns = [
{ key: 'date', label: 'Date', sortable: true }, { key: "date", label: "Date", sortable: true },
{ key: 'status', label: 'Status', sortable: true }, { key: "status", label: "Status", sortable: true },
{ key: 'data', label: 'Info'} { key: "data", label: "Info" },
] ]
let integrationEvents = ref([]) const integrationEvents = ref([])
let integrationEventsLoading = ref(false) const integrationEventsLoading = ref(false)
watch(() => props.show, () => { watch(
fetchEvents() () => props.show,
}) () => {
fetchEvents()
},
)
const fetchEvents = () => { const fetchEvents = () => {
if (props.show) { if (props.show) {
nextTick(() => { nextTick(() => {
integrationEventsLoading.value = true integrationEventsLoading.value = true
integrationEvents.value = [] integrationEvents.value = []
opnFetch(formIntegrationEventEndpoint.replace('{formid}', props.form.id).replace('{integrationid}', props.formIntegrationId)).then((data) => { opnFetch(
formIntegrationEventEndpoint
.replace("{formid}", props.form.id)
.replace("{integrationid}", props.formIntegrationId),
).then((data) => {
integrationEvents.value = data integrationEvents.value = data
integrationEventsLoading.value = false integrationEventsLoading.value = false
}) })

View File

@ -1,49 +1,72 @@
<template> <template>
<UTooltip :text="tooltipText" :prevent="!unavailable"> <UTooltip
<div role="button" @click="onClick" :text="tooltipText"
v-track.new_integration_click="{ name: integration.id }" :prevent="!unavailable"
:class="{'hover:bg-blue-50 group cursor-pointer': !unavailable, 'cursor-not-allowed': unavailable}" >
class="bg-gray-50 border border-gray-200 rounded-md transition-colors p-4 pb-2 items-center justify-center w-[170px] h-[110px] flex flex-col relative"> <div
v-track.new_integration_click="{ name: integration.id }"
role="button"
:class="{
'hover:bg-blue-50 group cursor-pointer': !unavailable,
'cursor-not-allowed': unavailable,
}"
class="bg-gray-50 border border-gray-200 rounded-md transition-colors p-4 pb-2 items-center justify-center w-[170px] h-[110px] flex flex-col relative"
@click="onClick"
>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="h-10 w-10 text-gray-500 group-hover:text-blue-500 transition-colors flex items-center"> <div
<Icon :name="integration.icon" size="40px"/> class="h-10 w-10 text-gray-500 group-hover:text-blue-500 transition-colors flex items-center"
>
<Icon
:name="integration.icon"
size="40px"
/>
</div> </div>
</div> </div>
<div class="flex-grow flex items-center"> <div class="flex-grow flex items-center">
<div class="text-gray-400 font-medium text-sm text-center"> <div class="text-gray-400 font-medium text-sm text-center">
{{ integration.name }}<span class="text-xs" v-if="integration.coming_soon"> (coming soon)</span> {{ integration.name
}}<span
v-if="integration.coming_soon"
class="text-xs"
>
(coming soon)</span>
</div> </div>
</div> </div>
<pro-tag v-if="integration?.is_pro === true" class="absolute top-0 right-1"/> <pro-tag
v-if="integration?.is_pro === true"
class="absolute top-0 right-1"
/>
</div> </div>
</UTooltip> </UTooltip>
</template> </template>
<script setup> <script setup>
const emit = defineEmits(["select"])
const emit = defineEmits(['select'])
const props = defineProps({ const props = defineProps({
integration: { integration: {
type: Object, type: Object,
required: true required: true,
} },
}) })
const unavailable = computed(() => { const unavailable = computed(() => {
return props.integration.coming_soon || props.integration.requires_subscription return (
props.integration.coming_soon || props.integration.requires_subscription
)
}) })
const tooltipText = computed(() => { const tooltipText = computed(() => {
if (props.integration.coming_soon) return 'This integration is coming soon' if (props.integration.coming_soon) return "This integration is coming soon"
if (props.integration.requires_subscription) return 'You need a subscription to use this integration.' if (props.integration.requires_subscription)
return '' return "You need a subscription to use this integration."
return ""
}) })
const onClick = () => { const onClick = () => {
if (props.integration.coming_soon || props.integration.requires_subscription) return if (props.integration.coming_soon || props.integration.requires_subscription)
emit('select', props.integration.id) return
emit("select", props.integration.id)
} }
</script> </script>

View File

@ -1,22 +1,40 @@
<template> <template>
<modal :show="show" @close="emit('close')" compact-header> <modal
:show="show"
compact-header
@close="emit('close')"
>
<template #icon> <template #icon>
<Icon :name="integration?.icon" size="40px"/> <Icon
:name="integration?.icon"
size="40px"
/>
</template> </template>
<template #title> <template #title>
{{ integration?.name }} {{ integration?.name }}
<pro-tag v-if="integration?.is_pro === true"/> <pro-tag v-if="integration?.is_pro === true" />
</template> </template>
<component v-if="integration && component" :is="component" :form="form" :integration="integration" <component
:integrationData="integrationData"/> :is="component"
v-if="integration && component"
:form="form"
:integration="integration"
:integration-data="integrationData"
/>
<template #footer> <template #footer>
<div class="flex justify-center gap-x-2"> <div class="flex justify-center gap-x-2">
<v-button class="px-8" @click.prevent="save"> <v-button
class="px-8"
@click.prevent="save"
>
Save Save
</v-button> </v-button>
<v-button color="white" @click.prevent="emit('close')"> <v-button
color="white"
@click.prevent="emit('close')"
>
Close Close
</v-button> </v-button>
</div> </div>
@ -25,21 +43,25 @@
</template> </template>
<script setup> <script setup>
import {computed} from 'vue' import { computed } from "vue"
const props = defineProps({ const props = defineProps({
show: {type: Boolean, required: true}, show: { type: Boolean, required: true },
form: {type: Object, required: true}, form: { type: Object, required: true },
integrationKey: {type: String, required: true}, integrationKey: { type: String, required: true },
integration: {type: Object, required: true}, integration: { type: Object, required: true },
formIntegrationId: {type: Number, required: false, default: null} formIntegrationId: { type: Number, required: false, default: null },
}) })
const alert = useAlert() const alert = useAlert()
const emit = defineEmits(['close']) const emit = defineEmits(["close"])
const formIntegrationsStore = useFormIntegrationsStore() const formIntegrationsStore = useFormIntegrationsStore()
const formIntegration = computed(() => (props.formIntegrationId) ? formIntegrationsStore.getByKey(props.formIntegrationId) : null) const formIntegration = computed(() =>
props.formIntegrationId
? formIntegrationsStore.getByKey(props.formIntegrationId)
: null,
)
const component = computed(() => { const component = computed(() => {
if (!props.integration) return null if (!props.integration) return null
@ -48,35 +70,51 @@ const component = computed(() => {
const integrationData = ref(null) const integrationData = ref(null)
watch(() => props.integrationKey, () => { watch(
initIntegrationData() () => props.integrationKey,
}) () => {
initIntegrationData()
},
)
const initIntegrationData = () => { const initIntegrationData = () => {
integrationData.value = useForm({ integrationData.value = useForm({
integration_id: (props.formIntegrationId) ? formIntegration.value.integration_id : props.integrationKey, integration_id: props.formIntegrationId
status: (props.formIntegrationId) ? formIntegration.value.status === 'active' : true, ? formIntegration.value.integration_id
settings: (props.formIntegrationId) ? formIntegration.value.data ?? {} : {}, : props.integrationKey,
logic: (props.formIntegrationId) ? (!Array.isArray(formIntegration.value.logic) && formIntegration.value.logic) ? formIntegration.value.logic : null : null status: props.formIntegrationId
? formIntegration.value.status === "active"
: true,
settings: props.formIntegrationId ? formIntegration.value.data ?? {} : {},
logic: props.formIntegrationId
? !Array.isArray(formIntegration.value.logic) &&
formIntegration.value.logic
? formIntegration.value.logic
: null
: null,
}) })
} }
initIntegrationData() initIntegrationData()
const save = () => { const save = () => {
if (!integrationData.value) return if (!integrationData.value) return
integrationData.value.submit( integrationData.value
(props.formIntegrationId) ? 'PUT' : 'POST', .submit(
'/open/forms/{formid}/integration'.replace('{formid}', props.form.id) + ((props.formIntegrationId) ? '/' + props.formIntegrationId : '') props.formIntegrationId ? "PUT" : "POST",
).then(data => { "/open/forms/{formid}/integration".replace("{formid}", props.form.id) +
alert.success(data.message) (props.formIntegrationId ? "/" + props.formIntegrationId : ""),
formIntegrationsStore.save(data.form_integration) )
emit('close') .then((data) => {
}).catch((error) => { alert.success(data.message)
try { formIntegrationsStore.save(data.form_integration)
alert.error(error.data.message) emit("close")
} catch (e) { })
alert.error('An error occurred while saving the integration') .catch((error) => {
} try {
}) alert.error(error.data.message)
} catch (e) {
alert.error("An error occurred while saving the integration")
}
})
} }
</script> </script>

View File

@ -1,28 +1,53 @@
<template> <template>
<div :class="wrapperClass" :style="inputStyle"> <div
:class="wrapperClass"
:style="inputStyle"
>
<div class="flex justify-between"> <div class="flex justify-between">
<slot name="status"> <slot name="status">
<toggle-switch-input name="status" v-model="modelValue.status" label="Enabled"/> <toggle-switch-input
v-model="modelValue.status"
name="status"
label="Enabled"
/>
</slot> </slot>
<slot name="help"> <slot name="help">
<v-button class="flex" color="white" size="small" @click="openHelp"> <v-button
<Icon name="heroicons:question-mark-circle-16-solid" class="w-4 h-4 text-gray-500 -mt-[3px]"/> class="flex"
<span class="text-gray-500"> color="white"
Help size="small"
</span> @click="openHelp"
>
<Icon
name="heroicons:question-mark-circle-16-solid"
class="w-4 h-4 text-gray-500 -mt-[3px]"
/>
<span class="text-gray-500"> Help </span>
</v-button> </v-button>
</slot> </slot>
</div> </div>
<slot/> <slot />
<slot name="logic"> <slot name="logic">
<div class="-mx-6 px-6 border-t pt-6"> <div class="-mx-6 px-6 border-t pt-6">
<collapse class="w-full" v-model="showLogic"> <collapse
v-model="showLogic"
class="w-full"
>
<template #title> <template #title>
<div class="flex gap-x-3 items-start pr-8"> <div class="flex gap-x-3 items-start pr-8">
<div class="transition-colors" :class="{ 'text-blue-600': showLogic, 'text-gray-300': !showLogic }"> <div
<Icon name="material-symbols:settings" size="30px"/> class="transition-colors"
:class="{
'text-blue-600': showLogic,
'text-gray-300': !showLogic,
}"
>
<Icon
name="material-symbols:settings"
size="30px"
/>
</div> </div>
<div class="flex-grow"> <div class="flex-grow">
<h3 class="font-semibold"> <h3 class="font-semibold">
@ -34,8 +59,12 @@
</div> </div>
</div> </div>
</template> </template>
<condition-editor ref="filter-editor" v-model="modelValue.logic" class="mt-4 border-t border rounded-md integration-logic" <condition-editor
:form="form"/> ref="filter-editor"
v-model="modelValue.logic"
class="mt-4 border-t border rounded-md integration-logic"
:form="form"
/>
</collapse> </collapse>
</div> </div>
</slot> </slot>
@ -43,18 +72,18 @@
</template> </template>
<script setup> <script setup>
import ConditionEditor from '~/components/open/forms/components/form-logic-components/ConditionEditor.client.vue' import ConditionEditor from "~/components/open/forms/components/form-logic-components/ConditionEditor.client.vue"
const props = defineProps({ const props = defineProps({
integration: {type: Object, required: true}, integration: { type: Object, required: true },
modelValue: {required: false}, modelValue: { type: Object, required: false },
wrapperClass: {type: String, required: false}, wrapperClass: { type: String, required: false },
inputStyle: {type: Object, required: false}, inputStyle: { type: Object, required: false },
form: {type: Object, required: false} form: { type: Object, required: false },
}) })
const crisp = useCrisp() const crisp = useCrisp()
const emit = defineEmits(['close']) defineEmits(["close"])
const showLogic = ref(!!props.modelValue.logic) const showLogic = ref(!!props.modelValue.logic)
const openHelp = () => { const openHelp = () => {

View File

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

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