Notification & Integrations refactoring (#346)
* Integrations Refactoring - WIP * integrations list & edit - WIP * Fix integration store binding issue * integrations refactor - WIP * Form integration - WIP * Form integration Edit - WIP * Integration Refactor - Slack - WIP * Integration Refactor - Discord - WIP * Integration Refactor - Webhook - WIP * Integration Refactor - Send Submission Confirmation - WIP * Integration Refactor - Backend handler - WIP * Form Integration Status field * Integration Refactor - Backend SubmissionConfirmation - WIP * IntegrationMigration Command * skip confirmation email test case * Small refactoring * FormIntegration status active/inactive * formIntegrationData to integrationData * Rename file name with Integration suffix for integration realted files * Loader on form integrations * WIP * form integration test case * WIP * Added Integration card - working on refactoring * change location for IntegrationCard and update package file * Form Integration Create/Edit in single Modal * Remove integration extra pages * crisp_help_page_slug for integration json * integration logic as collapse * UI improvements * WIP * Trying to debug vue devtools * WIP for integrations * getIntegrationHandler change namespace name * useForm for integration fields + validation structure * Integration Test case & apply validation rules * Apply useform changes to integration other files * validation rules for FormNotificationsMessageActions fields * Zapier integration as coming soon * Update FormCleaner * set default settings for confirmation integration * WIP * Finish validation for all integrations * Updated purify, added integration formatData * Fix testcase * Ran pint; working on integration errors * Handle integration events * command for Delete Old Integration Events * Display Past Events in Modal * on Integration event create with status error send email to form creator * Polish styling * Minor improvements * Finish badge and integration status * Fix tests and add an integration event test * Lint --------- Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
6
client/app.config.ts
Normal file
6
client/app.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
primary: 'blue',
|
||||
gray: 'slate'
|
||||
}
|
||||
})
|
||||
@@ -16,7 +16,14 @@
|
||||
@change="onChange" @keydown.enter.prevent="onEnterPress"
|
||||
>
|
||||
|
||||
<template v-if="maxCharLimit && showCharLimit" #bottom_after_help>
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="maxCharLimit && showCharLimit"
|
||||
#bottom_after_help
|
||||
>
|
||||
<small :class="theme.default.help">
|
||||
{{ charCount }}/{{ maxCharLimit }}
|
||||
</small>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default {
|
||||
this.cameraPermissionStatus = 'allowed';
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err)
|
||||
console.error(err)
|
||||
if(err.toString() === 'NotAllowedError: Permission denied'){
|
||||
this.cameraPermissionStatus = 'blocked';
|
||||
return;
|
||||
@@ -140,4 +140,4 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
<template>
|
||||
<div class="flex mb-1 input-help">
|
||||
<small :class="theme.default.help" class="grow flex">
|
||||
<slot name="help"><span v-if="help" class="field-help" v-html="help" /></slot>
|
||||
<small
|
||||
:class="helpClasses"
|
||||
class="grow flex"
|
||||
>
|
||||
<slot name="help">
|
||||
<span
|
||||
v-if="help"
|
||||
class="field-help"
|
||||
v-html="help"/>
|
||||
</slot>
|
||||
</small>
|
||||
<slot name="after-help">
|
||||
<small class="flex-grow" />
|
||||
<small class="flex-grow"/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'InputHelp',
|
||||
<script setup>
|
||||
|
||||
props: {
|
||||
theme: { type: Object, required: true },
|
||||
help: { type: String, required: false }
|
||||
}
|
||||
}
|
||||
defineProps({
|
||||
helpClasses: {type: String, default: 'text-gray-400 dark:text-gray-500'},
|
||||
help: {type: String, required: false}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<label :for="nativeFor"
|
||||
class="input-label"
|
||||
:class="[theme.default.label,{'uppercase text-xs': uppercaseLabels, 'text-sm': !uppercaseLabels}]"
|
||||
<label
|
||||
:for="nativeFor"
|
||||
class="input-label"
|
||||
:class="[
|
||||
theme.default.label,
|
||||
{ 'uppercase text-xs': uppercaseLabels, 'text-sm': !uppercaseLabels },
|
||||
]"
|
||||
>
|
||||
<slot>
|
||||
{{ label }}
|
||||
<span v-if="required" class="text-red-500 required-dot">*</span>
|
||||
<span
|
||||
v-if="required"
|
||||
class="text-red-500 required-dot"
|
||||
>*</span>
|
||||
</slot>
|
||||
</label>
|
||||
</template>
|
||||
@@ -19,7 +26,7 @@ export default {
|
||||
theme: { type: Object, required: true },
|
||||
uppercaseLabels: { type: Boolean, default: false },
|
||||
required: { type: Boolean, default: false },
|
||||
label: { type: String, required: true }
|
||||
}
|
||||
label: { type: String, required: true },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,54 +1,71 @@
|
||||
<template>
|
||||
<div :class="wrapperClass" :style="inputStyle">
|
||||
<div
|
||||
:class="wrapperClass"
|
||||
:style="inputStyle"
|
||||
>
|
||||
<slot name="label">
|
||||
<input-label v-if="label && !hideFieldName"
|
||||
:label="label"
|
||||
:theme="theme"
|
||||
:required="required"
|
||||
:native-for="id?id:name"
|
||||
:uppercase-labels="uppercaseLabels"
|
||||
<InputLabel
|
||||
v-if="label && !hideFieldName"
|
||||
:label="label"
|
||||
:theme="theme"
|
||||
:required="required"
|
||||
:native-for="id ? id : name"
|
||||
:uppercase-labels="uppercaseLabels"
|
||||
/>
|
||||
</slot>
|
||||
<slot v-if="help && helpPosition==='above_input'" name="help">
|
||||
<input-help :help="help" :theme="theme" />
|
||||
<slot
|
||||
v-if="helpPosition === 'above_input'"
|
||||
name="help"
|
||||
>
|
||||
<InputHelp
|
||||
v-if="help"
|
||||
:help="help"
|
||||
:help-classes="theme.default.help"
|
||||
/>
|
||||
</slot>
|
||||
<slot />
|
||||
|
||||
<slot v-if="(help && helpPosition==='below_input') || $slots.bottom_after_help" name="help">
|
||||
<input-help :help="help" :theme="theme">
|
||||
<slot
|
||||
v-if="helpPosition === 'below_input'"
|
||||
name="help"
|
||||
>
|
||||
<InputHelp
|
||||
v-if="help"
|
||||
:help="help"
|
||||
:help-classes="theme.default.help"
|
||||
>
|
||||
<template #after-help>
|
||||
<slot name="bottom_after_help" />
|
||||
</template>
|
||||
</input-help>
|
||||
</InputHelp>
|
||||
</slot>
|
||||
<slot name="error">
|
||||
<has-error v-if="hasValidation && form" :form="form" :field="name" />
|
||||
<has-error
|
||||
v-if="hasValidation && form"
|
||||
:form="form"
|
||||
:field="name"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import InputLabel from './InputLabel.vue'
|
||||
import InputHelp from './InputHelp.vue'
|
||||
|
||||
export default {
|
||||
name: 'InputWrapper',
|
||||
components: { InputLabel, InputHelp },
|
||||
|
||||
props: {
|
||||
id: { type: String, required: false },
|
||||
name: { type: String, required: false },
|
||||
label: { type: String, required: false },
|
||||
form: { type: Object, required: false },
|
||||
theme: { type: Object, required: true },
|
||||
wrapperClass: { type: String, required: false },
|
||||
inputStyle: { type: Object, required: false },
|
||||
help: { type: String, required: false },
|
||||
helpPosition: { type: String, default: 'below_input' },
|
||||
uppercaseLabels: { type: Boolean, default: true },
|
||||
hideFieldName: { type: Boolean, default: true },
|
||||
required: { type: Boolean, default: false },
|
||||
hasValidation: { type: Boolean, default: true }
|
||||
}
|
||||
}
|
||||
defineProps({
|
||||
id: { type: String, required: false },
|
||||
name: { type: String, required: false },
|
||||
label: { type: String, required: false },
|
||||
form: { type: Object, required: false },
|
||||
theme: { type: Object, required: true },
|
||||
wrapperClass: { type: String, required: false },
|
||||
inputStyle: { type: Object, required: false },
|
||||
help: { type: String, required: false },
|
||||
helpPosition: { type: String, default: 'below_input' },
|
||||
uppercaseLabels: { type: Boolean, default: true },
|
||||
hideFieldName: { type: Boolean, default: true },
|
||||
required: { type: Boolean, default: false },
|
||||
hasValidation: { type: Boolean, default: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,24 +2,35 @@
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
:id="id || name"
|
||||
:name="name"
|
||||
v-model="internalValue"
|
||||
:name="name"
|
||||
type="checkbox"
|
||||
:class="sizeClasses"
|
||||
class="rounded border-gray-500 cursor-pointer"
|
||||
:disabled="disabled?true:null"
|
||||
:disabled="disabled ? true : null"
|
||||
>
|
||||
<label
|
||||
:for="id || name"
|
||||
class="text-gray-700 dark:text-gray-300 ml-2"
|
||||
:class="{ '!cursor-not-allowed': disabled }"
|
||||
>
|
||||
<label :for="id || name" class="text-gray-700 dark:text-gray-300 ml-2" :class="{'!cursor-not-allowed':disabled}">
|
||||
<slot />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, defineProps, defineEmits, defineOptions } from 'vue'
|
||||
import {
|
||||
defineEmits,
|
||||
defineOptions,
|
||||
defineProps,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'VCheckbox'
|
||||
name: 'VCheckbox',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
@@ -27,33 +38,42 @@ const props = defineProps({
|
||||
name: { type: String, default: 'checkbox' },
|
||||
modelValue: { type: [Boolean, String], 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 internalValue = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, val => {
|
||||
internalValue.value = val
|
||||
})
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
internalValue.value = val
|
||||
},
|
||||
)
|
||||
|
||||
watch(() => props.checked, val => {
|
||||
internalValue.value = val
|
||||
})
|
||||
watch(
|
||||
() => props.checked,
|
||||
(val) => {
|
||||
internalValue.value = val
|
||||
},
|
||||
)
|
||||
|
||||
watch(() => internalValue.value, (val, oldVal) => {
|
||||
if (val === 0 || val === '0') val = false
|
||||
if (val === 1 || val === '1') val = true
|
||||
watch(
|
||||
() => internalValue.value,
|
||||
(val, oldVal) => {
|
||||
if (val === 0 || val === '0')
|
||||
val = false
|
||||
if (val === 1 || val === '1')
|
||||
val = true
|
||||
|
||||
if (val !== oldVal) {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
if (val !== oldVal)
|
||||
emit('update:modelValue', val)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (internalValue.value === null) {
|
||||
if (internalValue.value === null)
|
||||
internalValue.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
<template>
|
||||
<div class="v-select relative" :class="[{ 'w-0': multiple, 'min-w-full': multiple }]" ref="select">
|
||||
<div
|
||||
ref="select"
|
||||
class="v-select relative"
|
||||
:class="[{ 'w-0': multiple, 'min-w-full': multiple }]"
|
||||
>
|
||||
<span class="inline-block w-full rounded-md">
|
||||
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
|
||||
class="cursor-pointer" :style="inputStyle"
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
class="cursor-pointer"
|
||||
:style="inputStyle"
|
||||
:class="[theme.SelectInput.input, { 'py-2': !multiple || loading, 'py-1': multiple, '!ring-red-500 !ring-2 !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 }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<Loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
<div v-else-if="modelValue" key="value" class="flex" :class="{ 'min-h-8': multiple }">
|
||||
<slot name="selected" :option="modelValue" />
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
>
|
||||
<Loader
|
||||
v-if="loading"
|
||||
key="loader"
|
||||
class="h-6 w-6 text-nt-blue mx-auto"
|
||||
/>
|
||||
<div
|
||||
v-else-if="modelValue"
|
||||
key="value"
|
||||
class="flex"
|
||||
:class="{ 'min-h-8': multiple }"
|
||||
>
|
||||
<slot
|
||||
name="selected"
|
||||
:option="modelValue"
|
||||
/>
|
||||
</div>
|
||||
<div v-else key="placeholder">
|
||||
<div
|
||||
v-else
|
||||
key="placeholder"
|
||||
>
|
||||
<slot name="placeholder">
|
||||
<div class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
|
||||
<div
|
||||
class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
|
||||
:class="{ 'py-1': multiple && !loading }">
|
||||
{{ placeholder }}
|
||||
</div>
|
||||
@@ -22,8 +51,18 @@
|
||||
</transition>
|
||||
</div>
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
|
||||
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
@@ -36,23 +75,40 @@
|
||||
<div v-if="isSearchable" class="px-2 pt-2 sticky top-0 bg-white dark-bg-notion-dark-light z-10">
|
||||
<text-input v-model="searchTerm" name="search" :color="color" :theme="theme" placeholder="Search..." />
|
||||
</div>
|
||||
<div v-if="loading" class="w-full py-2 flex justify-center">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="w-full py-2 flex justify-center"
|
||||
>
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
<template v-if="filteredOptions.length > 0">
|
||||
<li v-for="item in filteredOptions" :key="item[optionKey]" role="option" :style="optionStyle"
|
||||
<li
|
||||
v-for="item in filteredOptions"
|
||||
:key="item[optionKey]"
|
||||
role="option"
|
||||
:style="optionStyle"
|
||||
:class="{ 'px-3 pr-9': multiple, 'px-3': !multiple }"
|
||||
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
|
||||
@click="select(item)">
|
||||
<slot name="option" :option="item" :selected="isSelected(item)" />
|
||||
<slot
|
||||
name="option"
|
||||
:option="item"
|
||||
:selected="isSelected(item)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
<p v-else-if="!loading && !(allowCreation && searchTerm)" class="w-full text-gray-500 text-center py-2">
|
||||
<p
|
||||
v-else-if="!loading && !(allowCreation && searchTerm)"
|
||||
class="w-full text-gray-500 text-center py-2"
|
||||
>
|
||||
{{ (allowCreation ? 'Type something to add an option' : 'No option available') }}.
|
||||
</p>
|
||||
<li v-if="allowCreation && searchTerm" role="option" :style="optionStyle"
|
||||
<li
|
||||
v-if="allowCreation && searchTerm"
|
||||
role="option"
|
||||
:style="optionStyle"
|
||||
:class="{ 'px-3 pr-9': multiple, 'px-3': !multiple }"
|
||||
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
|
||||
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)">
|
||||
Create <b class="px-1 bg-gray-300 rounded group-hover-text-black">{{ searchTerm }}</b>
|
||||
</li>
|
||||
@@ -63,9 +119,9 @@
|
||||
|
||||
<script>
|
||||
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 debounce from 'debounce'
|
||||
import debounce from 'lodash/debounce'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
export default {
|
||||
@@ -74,7 +130,7 @@ export default {
|
||||
directives: {},
|
||||
props: {
|
||||
data: Array,
|
||||
modelValue: { default: null },
|
||||
modelValue: { default: null, type: [String, Number, Array, Object] },
|
||||
inputClass: { type: String, default: null },
|
||||
dropdownClass: { type: String, default: 'w-full' },
|
||||
loading: { type: Boolean, default: false },
|
||||
@@ -93,6 +149,7 @@ export default {
|
||||
allowCreation: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['update:modelValue', 'update-options'],
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
@@ -210,10 +267,12 @@ export default {
|
||||
if (newOption) {
|
||||
const newItem = {
|
||||
name: newOption,
|
||||
value: newOption
|
||||
value: newOption,
|
||||
id: newOption
|
||||
}
|
||||
this.$emit('update-options', newItem)
|
||||
this.select(newItem)
|
||||
this.searchTerm = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
<template>
|
||||
<div role="button" @click.stop="onClick">
|
||||
<div class="inline-flex items-center h-6 w-12 p-1 bg-gray-300 border rounded-full cursor-pointer focus:outline-none transition-all transform ease-in-out duration-100" :class="{'bg-nt-blue': props.modelValue}">
|
||||
<div class="inline-block h-4 w-4 rounded-full bg-white shadow transition-all transform ease-in-out duration-150 rounded-2xl scale-100" :class="{'translate-x-5.5': props.modelValue}" />
|
||||
<div
|
||||
role="button"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center h-6 w-12 p-1 bg-gray-300 border rounded-full cursor-pointer focus:outline-none transition-all transform ease-in-out duration-100"
|
||||
:class="{ 'bg-nt-blue': props.modelValue }"
|
||||
>
|
||||
<div
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-all transform ease-in-out duration-150 rounded-2xl scale-100"
|
||||
:class="{ 'translate-x-5.5': props.modelValue }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { defineEmits, defineProps } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const onClick = () => {
|
||||
if (props.disabled) return
|
||||
function onClick() {
|
||||
if (props.disabled)
|
||||
return
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
6
client/components/forms/useFormInput.js
vendored
6
client/components/forms/useFormInput.js
vendored
@@ -1,5 +1,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { themes } from '~/lib/forms/form-themes.js'
|
||||
import {default as _get} from 'lodash/get'
|
||||
import {default as _set} from 'lodash/set'
|
||||
|
||||
export const inputProps = {
|
||||
id: { type: String, default: null },
|
||||
@@ -39,13 +41,13 @@ export function useFormInput (props, context, formPrefixKey = null) {
|
||||
const compVal = computed({
|
||||
get: () => {
|
||||
if (props.form) {
|
||||
return props.form[(formPrefixKey || '') + props.name]
|
||||
return _get(props.form, (formPrefixKey || '') + props.name)
|
||||
}
|
||||
return content.value
|
||||
},
|
||||
set: (val) => {
|
||||
if (props.form) {
|
||||
props.form[(formPrefixKey || '') + props.name] = val
|
||||
_set(props.form, (formPrefixKey || '') + props.name, val)
|
||||
} else {
|
||||
content.value = val
|
||||
}
|
||||
|
||||
50
client/components/global/Badge.vue
Normal file
50
client/components/global/Badge.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<Icon v-if="beforeIcon" :name="beforeIcon" :class="iconClasses"/>
|
||||
<slot></slot>
|
||||
<Icon v-if="afterIcon" :name="afterIcon" :class="iconClasses"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const props = defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: 'green'
|
||||
},
|
||||
beforeIcon: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
afterIcon: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const baseClasses = {
|
||||
'green': ['bg-green-100', 'border', 'border-green-300', 'text-green-700'],
|
||||
'red': ['bg-red-100', 'border', 'border-red-300', 'text-red-700'],
|
||||
'gray': ['bg-gray-100', 'border', 'border-gray-300', 'text-gray-700'],
|
||||
}
|
||||
|
||||
const iconBaseClasses = {
|
||||
'green': ['text-green-500'],
|
||||
'red': ['text-red-500'],
|
||||
'gray': ['text-gray-500'],
|
||||
}
|
||||
|
||||
const activeColor = computed(() => {
|
||||
return Object.hasOwn(baseClasses, props.color) ? props.color : 'gray'
|
||||
})
|
||||
|
||||
const classes = computed(() => {
|
||||
const classes = ['border', 'text-xs', 'px-2', 'inline-flex', 'items-center', 'rounded-full'].concat(baseClasses[activeColor.value])
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
return iconBaseClasses[activeColor.value].concat(['w-2 h-2 mr-1']).join(' ')
|
||||
})
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
@click.self="close"
|
||||
>
|
||||
<div ref="content"
|
||||
class="self-start bg-white dark:bg-notion-dark w-full relative p-4 md:p-6 my-6 rounded-xl shadow-xl"
|
||||
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">
|
||||
@@ -19,15 +19,17 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sm:flex sm:flex-col sm:items-start">
|
||||
<div v-if="$slots.hasOwnProperty('icon')" class="flex w-full justify-center mb-4">
|
||||
<div class="flex border-b pb-4"
|
||||
v-if="$slots.hasOwnProperty('icon') || $slots.hasOwnProperty('title')"
|
||||
:class="[{'flex-col sm:items-start':!compactHeader, 'items-center justify-center py-6 gap-x-4':compactHeader},headerInnerPadding]">
|
||||
<div v-if="$slots.hasOwnProperty('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"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 w-full">
|
||||
<div class="mt-3 text-center sm:mt-0" :class="{'w-full':!compactHeader}">
|
||||
<h2 v-if="$slots.hasOwnProperty('title')"
|
||||
class="text-2xl font-semibold text-center text-gray-900"
|
||||
>
|
||||
@@ -36,11 +38,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full" :class="innerPadding">
|
||||
<slot/>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.hasOwnProperty('footer')" class="px-6 py-4 bg-gray-100 text-right">
|
||||
<div v-if="$slots.hasOwnProperty('footer')" class="bg-gray-50 border-t rounded-b-xl text-right" :class="footerInnerPadding">
|
||||
<slot name="footer"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,19 +68,34 @@ const props = defineProps({
|
||||
maxWidth: {
|
||||
default: '2xl'
|
||||
},
|
||||
innerPadding: {
|
||||
default: 'p-6'
|
||||
},
|
||||
headerInnerPadding: {
|
||||
default: 'p-6'
|
||||
},
|
||||
footerInnerPadding: {
|
||||
default: 'p-6'
|
||||
},
|
||||
closeable: {
|
||||
default: true
|
||||
}
|
||||
},
|
||||
compactHeader: {
|
||||
default: false,
|
||||
type: Boolean
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
useHead({
|
||||
bodyAttrs: {
|
||||
class: {
|
||||
'overflow-hidden': props.show
|
||||
bodyAttrs: computed(() => {
|
||||
return {
|
||||
class: {
|
||||
'overflow-hidden': props.show
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const closeOnEscape = (e) => {
|
||||
@@ -146,7 +163,8 @@ const motionSlideBottom = {
|
||||
}
|
||||
|
||||
const onLeave = (el, done) => {
|
||||
contentMotion.value.leave(()=>{})
|
||||
contentMotion.value.leave(() => {
|
||||
})
|
||||
backdropMotion.value.leave(done)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
<template>
|
||||
<div class="inline" v-if="shouldDisplayProTag">
|
||||
<div class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold cursor-pointer"
|
||||
@click.prevent="showPremiumModal=true"
|
||||
>
|
||||
PRO
|
||||
</div>
|
||||
<modal :show="showPremiumModal" @close="showPremiumModal=false">
|
||||
<h2 class="text-nt-blue">
|
||||
OpnForm PRO
|
||||
</h2>
|
||||
<h4 v-if="user && user.is_subscribed" class="text-center mt-5">
|
||||
We're happy to have you as a Pro customer. If you're having any issue with OpnForm, or if you have a
|
||||
feature request, please <a href="mailto:contact@opnform.com">contact us</a>.
|
||||
</h4>
|
||||
<div v-if="!user || !user.is_subscribed" class="mt-4">
|
||||
<p>
|
||||
All the features with a<span
|
||||
class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold mx-1"
|
||||
>
|
||||
<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"
|
||||
@click="showPremiumModal=true">
|
||||
PRO
|
||||
</div>
|
||||
<modal :show="showPremiumModal" @close="showPremiumModal=false">
|
||||
<h2 class="text-nt-blue">
|
||||
OpnForm PRO
|
||||
</h2>
|
||||
<h4 v-if="user && user.is_subscribed" class="text-center mt-5">
|
||||
We're happy to have you as a Pro customer. If you're having any issue with OpnForm, or if you have a
|
||||
feature request, please <a href="mailto:contact@opnform.com">contact us</a>.
|
||||
</h4>
|
||||
<div v-if="!user || !user.is_subscribed" class="mt-4">
|
||||
<p>
|
||||
All the features with a<span
|
||||
class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold mx-1"
|
||||
>
|
||||
PRO
|
||||
</span> tag are available in the Pro plan of OpnForm. <b>You can play around and try all Pro features
|
||||
within
|
||||
the form editor, but you can't use them in your real forms</b>. You can subscribe now to gain unlimited access
|
||||
to
|
||||
all our pro features!
|
||||
</p>
|
||||
</div>
|
||||
within
|
||||
the form editor, but you can't use them in your real forms</b>. You can subscribe now to gain unlimited
|
||||
access
|
||||
to
|
||||
all our pro features!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="my-4 text-center">
|
||||
<v-button color="white" @click="showPremiumModal=false">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</modal>
|
||||
<div class="my-4 text-center">
|
||||
<v-button color="white" @click="showPremiumModal=false">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</modal>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {computed} from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import throttle from 'lodash.throttle'
|
||||
import throttle from 'lodash/throttle'
|
||||
function newResizeObserver (callback) {
|
||||
// Skip this feature for browsers which
|
||||
// do not support ResizeObserver.
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
help="With form submission answers"
|
||||
/>
|
||||
<toggle-switch-input name="link_open_form" v-model="compVal.link_open_form" class="mt-4"
|
||||
label="Open Form"
|
||||
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"
|
||||
label="Edit Form"
|
||||
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"
|
||||
label="Analytics (views & submissions)"
|
||||
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"
|
||||
label="Link to the Edit Submission Record"
|
||||
label="Edit Submission Link"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
33
client/components/open/integrations/DiscordIntegration.vue
Normal file
33
client/components/open/integrations/DiscordIntegration.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
|
||||
<text-input :form="integrationData" name="settings.discord_webhook_url"
|
||||
label="Discord webhook url" help="help" required>
|
||||
<template #help>
|
||||
<InputHelp>
|
||||
<template #help>
|
||||
<span>
|
||||
Receive a discord message on each form submission.
|
||||
<a href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" target="_blank"> Click
|
||||
here </a> to learn how to get a discord webhook url.
|
||||
</span>
|
||||
</template>
|
||||
</InputHelp>
|
||||
</template>
|
||||
</text-input>
|
||||
<h4 class="font-bold mt-4">Discord message options</h4>
|
||||
<form-notifications-message-actions v-model="integrationData.settings"/>
|
||||
</IntegrationWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IntegrationWrapper from "./components/IntegrationWrapper.vue"
|
||||
import FormNotificationsMessageActions
|
||||
from "~/components/open/forms/components/form-components/components/FormNotificationsMessageActions.vue"
|
||||
|
||||
const props = defineProps({
|
||||
integration: {type: Object, required: true},
|
||||
form: {type: Object, required: true},
|
||||
integrationData: {type: Object, required: true},
|
||||
formIntegrationId: {type: Number, required: false, default: null}
|
||||
})
|
||||
</script>
|
||||
34
client/components/open/integrations/EmailIntegration.vue
Normal file
34
client/components/open/integrations/EmailIntegration.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IntegrationWrapper from './components/IntegrationWrapper.vue'
|
||||
|
||||
const props = defineProps({
|
||||
integration: { type: Object, required: true },
|
||||
form: { type: Object, required: true },
|
||||
integrationData: { type: Object, required: true },
|
||||
formIntegrationId: { type: Number, required: false, default: null }
|
||||
})
|
||||
|
||||
const replayToEmailField = computed(() => {
|
||||
const emailFields = props.form.properties.filter((field) => {
|
||||
return field.type === 'email' && !field.hidden
|
||||
})
|
||||
if (emailFields.length === 1) return emailFields[0]
|
||||
return null
|
||||
})
|
||||
|
||||
const notifiesHelp = computed(() => {
|
||||
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 your own email. Add a single email field to your form, and it will automatically become the reply to value.'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
|
||||
<div class="my-5">
|
||||
Coming Soon...
|
||||
</div>
|
||||
</IntegrationWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IntegrationWrapper from './components/IntegrationWrapper.vue'
|
||||
|
||||
const props = defineProps({
|
||||
integration: { type: Object, required: true },
|
||||
form: { type: Object, required: true },
|
||||
integrationData: { type: Object, required: true },
|
||||
formIntegrationId: { type: Number, required: false, default: null }
|
||||
})
|
||||
</script>
|
||||
32
client/components/open/integrations/SlackIntegration.vue
Normal file
32
client/components/open/integrations/SlackIntegration.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
|
||||
<text-input :form="integrationData" name="settings.slack_webhook_url"
|
||||
label="Slack webhook url" help="help" required>
|
||||
<template #help>
|
||||
<InputHelp>
|
||||
<template #help>
|
||||
<span>
|
||||
Receive slack message on each form submission. <a href="https://api.slack.com/messaging/webhooks" target="_blank"> Click here </a>
|
||||
to learn how to get a slack webhook url
|
||||
</span>
|
||||
</template>
|
||||
</InputHelp>
|
||||
</template>
|
||||
</text-input>
|
||||
<h4 class="font-bold mt-4">Slack message actions</h4>
|
||||
<form-notifications-message-actions v-model="integrationData.settings"/>
|
||||
</IntegrationWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IntegrationWrapper from "./components/IntegrationWrapper.vue"
|
||||
import FormNotificationsMessageActions
|
||||
from "~/components/open/forms/components/form-components/components/FormNotificationsMessageActions.vue"
|
||||
|
||||
const props = defineProps({
|
||||
integration: {type: Object, required: true},
|
||||
form: {type: Object, required: true},
|
||||
integrationData: {type: Object, required: true},
|
||||
formIntegrationId: {type: Number, required: false, default: null}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
|
||||
<div>{{ emailSubmissionConfirmationHelp }}</div>
|
||||
|
||||
<div v-if="emailSubmissionConfirmationField">
|
||||
<text-input :form="integrationData" name="settings.notification_sender" class="mt-4" required
|
||||
label="Confirmation Email Sender Name"
|
||||
help="Emails will be sent from our email address but you can customize the name of the Sender" />
|
||||
<text-input :form="integrationData" name="settings.notification_subject" class="mt-4" required
|
||||
label="Confirmation email subject" 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>
|
||||
</IntegrationWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IntegrationWrapper from "./components/IntegrationWrapper.vue"
|
||||
|
||||
const props = defineProps({
|
||||
integration: { type: Object, required: true },
|
||||
form: { type: Object, required: true },
|
||||
integrationData: { type: Object, required: true },
|
||||
formIntegrationId: { type: Number, required: false, default: null }
|
||||
})
|
||||
|
||||
const emailSubmissionConfirmationField = computed(() => {
|
||||
if (!props.form.properties || !Array.isArray(props.form.properties)) return null
|
||||
const emailFields = props.form.properties.filter((field) => {
|
||||
return field.type === 'email' && !field.hidden
|
||||
})
|
||||
if (emailFields.length === 1) return emailFields[0]
|
||||
return null
|
||||
})
|
||||
const emailSubmissionConfirmationHelp = computed(() => {
|
||||
if (emailSubmissionConfirmationField.value) {
|
||||
return 'Confirmation will be sent to the email in the "' + emailSubmissionConfirmationField.value.name + '" field.'
|
||||
}
|
||||
return 'Only available if your form contains 1 email field.'
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
for (const [keyname, defaultValue] of Object.entries({
|
||||
'notification_sender': 'OpnForm',
|
||||
'notification_subject': 'We saved your answers',
|
||||
'notification_body': 'Hello there 👋 <br>This is a confirmation that your submission was successfully saved.',
|
||||
'notifications_include_submission': true,
|
||||
})) {
|
||||
if (props.integrationData.settings[keyname] === undefined) {
|
||||
props.integrationData.settings[keyname] = defaultValue
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
17
client/components/open/integrations/WebhookIntegration.vue
Normal file
17
client/components/open/integrations/WebhookIntegration.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IntegrationWrapper from "./components/IntegrationWrapper.vue"
|
||||
|
||||
const props = defineProps({
|
||||
integration: { type: Object, required: true },
|
||||
form: { type: Object, required: true },
|
||||
integrationData: { type: Object, required: true },
|
||||
formIntegrationId: { type: Number, required: false, default: null }
|
||||
})
|
||||
</script>
|
||||
18
client/components/open/integrations/ZapierIntegration.vue
Normal file
18
client/components/open/integrations/ZapierIntegration.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<IntegrationWrapper :integration="props.integration" :form="form" v-model="props.integrationData">
|
||||
<div class="my-5">
|
||||
Coming Soon...
|
||||
</div>
|
||||
</IntegrationWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IntegrationWrapper from './components/IntegrationWrapper.vue'
|
||||
|
||||
const props = defineProps({
|
||||
integration: { type: Object, required: true },
|
||||
form: { type: Object, required: true },
|
||||
integrationData: { type: Object, required: true },
|
||||
formIntegrationId: { type: Number, required: false, default: null }
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<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="mr-4"
|
||||
:class="{ 'text-blue-500': integration.status === 'active', 'text-gray-400': integration.status !== 'active' }">
|
||||
<Icon :name="integrationTypeInfo.icon" size="32px"/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex space-x-3 font-semibold mr-2">{{ integrationTypeInfo.name }}</div>
|
||||
<Badge :color="integration.status === 'active' ? 'green' : 'gray'"
|
||||
:before-icon="integration.status === 'active' ? 'solar:play-bold' : 'solar:pause-bold'"
|
||||
>
|
||||
{{ integration.status === 'active' ? 'Active' : 'Paused' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingDelete" class="pr-4 pt-2">
|
||||
<Loader class="h-6 w-6 mx-auto"/>
|
||||
</div>
|
||||
<dropdown v-else class="inline">
|
||||
<template #trigger="{ toggle }">
|
||||
<v-button 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
|
||||
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"/>
|
||||
<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"
|
||||
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<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"
|
||||
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</v-button>
|
||||
</template>
|
||||
<a v-track.edit_form_integration_click="{ form_slug: form.slug, form_integration_id: integration.id }" href="#"
|
||||
@click.prevent="showIntegrationModal = true"
|
||||
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center">
|
||||
<Icon name="heroicons:pencil" class="w-5 h-5 mr-2"/>
|
||||
Edit
|
||||
</a>
|
||||
<a v-track.past_events_form_integration_click="{ form_slug: form.slug, form_integration_id: integration.id }"
|
||||
href="#"
|
||||
@click.prevent="showIntegrationEventsModal = true"
|
||||
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center">
|
||||
<Icon name="heroicons:clock" class="w-5 h-5 mr-2"/>
|
||||
Past Events
|
||||
</a>
|
||||
<a v-track.delete_form_integration_click="{ form_integration_id: integration.id }" 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
|
||||
</a>
|
||||
</dropdown>
|
||||
<IntegrationModal v-if="form && integration && integrationTypeInfo" :form="form" :integration="integrationTypeInfo"
|
||||
:integrationKey="integration.integration_id" :formIntegrationId="integration.id"
|
||||
:show="showIntegrationModal"
|
||||
@close="showIntegrationModal = false"/>
|
||||
|
||||
<IntegrationEventsModal v-if="form && integration" :form="form" :formIntegrationId="integration.id"
|
||||
:show="showIntegrationEventsModal"
|
||||
@close="showIntegrationEventsModal = false"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
integration: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const alert = useAlert()
|
||||
const formIntegrationsStore = useFormIntegrationsStore()
|
||||
const integrations = computed(() => formIntegrationsStore.availableIntegrations)
|
||||
const integrationTypeInfo = computed(() => integrations.value.get(props.integration.integration_id))
|
||||
|
||||
let showIntegrationModal = ref(false)
|
||||
let showIntegrationEventsModal = ref(false)
|
||||
let loadingDelete = ref(false)
|
||||
|
||||
const deleteFormIntegration = (integrationid) => {
|
||||
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) => {
|
||||
if (data.type === 'success') {
|
||||
alert.success(data.message)
|
||||
formIntegrationsStore.remove(integrationid)
|
||||
} else {
|
||||
alert.error('Something went wrong!')
|
||||
}
|
||||
}).catch((error) => {
|
||||
alert.error(error.data.message)
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<modal :show="show" @close="emit('close')" compact-header inner-padding="">
|
||||
<template #icon>
|
||||
<Icon name="heroicons:clock" size="40px"/>
|
||||
</template>
|
||||
<template #title>
|
||||
Past Events
|
||||
</template>
|
||||
|
||||
<UTable :loading="integrationEventsLoading" :columns="columns" :rows="integrationEvents">
|
||||
<template #status-data="{ row }">
|
||||
<Badge :color="(row.status==='Success') ? 'green' : 'red'">
|
||||
{{row.status}}
|
||||
</Badge>
|
||||
</template>
|
||||
<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" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-center gap-x-2">
|
||||
<v-button color="white" @click.prevent="emit('close')">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import VueJsonPretty from 'vue-json-pretty'
|
||||
import 'vue-json-pretty/lib/styles.css'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, required: true },
|
||||
form: {type: Object, required: true},
|
||||
formIntegrationId: {type: Number, required: true}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const formIntegrationEventEndpoint = '/open/forms/{formid}/integration/{integrationid}/events'
|
||||
const columns = [
|
||||
{ key: 'date', label: 'Date', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: true },
|
||||
{ key: 'data', label: 'Info'}
|
||||
]
|
||||
let integrationEvents = ref([])
|
||||
let integrationEventsLoading = ref(false)
|
||||
|
||||
watch(() => props.show, () => {
|
||||
fetchEvents()
|
||||
})
|
||||
|
||||
const fetchEvents = () => {
|
||||
if (props.show) {
|
||||
nextTick(() => {
|
||||
integrationEventsLoading.value = true
|
||||
integrationEvents.value = []
|
||||
opnFetch(formIntegrationEventEndpoint.replace('{formid}', props.form.id).replace('{integrationid}', props.formIntegrationId)).then((data) => {
|
||||
integrationEvents.value = data
|
||||
integrationEventsLoading.value = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<UTooltip :text="tooltipText" :prevent="!unavailable">
|
||||
<div role="button" @click="onClick"
|
||||
v-track.new_integration_click="{ name: integration.id }"
|
||||
: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 class="flex justify-center">
|
||||
<div 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 class="flex-grow flex items-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>
|
||||
</div>
|
||||
</div>
|
||||
<pro-tag v-if="integration?.is_pro === true" class="absolute top-0 right-1"/>
|
||||
</div>
|
||||
</UTooltip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const props = defineProps({
|
||||
integration: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const unavailable = computed(() => {
|
||||
return props.integration.coming_soon || props.integration.requires_subscription
|
||||
})
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
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.'
|
||||
return ''
|
||||
})
|
||||
|
||||
const onClick = () => {
|
||||
if (props.integration.coming_soon || props.integration.requires_subscription) return
|
||||
emit('select', props.integration.id)
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<modal :show="show" @close="emit('close')" compact-header>
|
||||
<template #icon>
|
||||
<Icon :name="integration?.icon" size="40px"/>
|
||||
</template>
|
||||
<template #title>
|
||||
{{ integration?.name }}
|
||||
<pro-tag v-if="integration?.is_pro === true"/>
|
||||
</template>
|
||||
|
||||
<component v-if="integration && component" :is="component" :form="form" :integration="integration"
|
||||
:integrationData="integrationData"/>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-center gap-x-2">
|
||||
<v-button class="px-8" @click.prevent="save">
|
||||
Save
|
||||
</v-button>
|
||||
<v-button color="white" @click.prevent="emit('close')">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {type: Boolean, required: true},
|
||||
form: {type: Object, required: true},
|
||||
integrationKey: {type: String, required: true},
|
||||
integration: {type: Object, required: true},
|
||||
formIntegrationId: {type: Number, required: false, default: null}
|
||||
})
|
||||
|
||||
const alert = useAlert()
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const formIntegrationsStore = useFormIntegrationsStore()
|
||||
const formIntegration = computed(() => (props.formIntegrationId) ? formIntegrationsStore.getByKey(props.formIntegrationId) : null)
|
||||
|
||||
const component = computed(() => {
|
||||
if (!props.integration) return null
|
||||
return resolveComponent(props.integration.file_name)
|
||||
})
|
||||
|
||||
const integrationData = ref(null)
|
||||
|
||||
watch(() => props.integrationKey, () => {
|
||||
initIntegrationData()
|
||||
})
|
||||
|
||||
const initIntegrationData = () => {
|
||||
integrationData.value = useForm({
|
||||
integration_id: (props.formIntegrationId) ? formIntegration.value.integration_id : props.integrationKey,
|
||||
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()
|
||||
|
||||
const save = () => {
|
||||
if (!integrationData.value) return
|
||||
integrationData.value.submit(
|
||||
(props.formIntegrationId) ? 'PUT' : 'POST',
|
||||
'/open/forms/{formid}/integration'.replace('{formid}', props.form.id) + ((props.formIntegrationId) ? '/' + props.formIntegrationId : '')
|
||||
).then(data => {
|
||||
alert.success(data.message)
|
||||
formIntegrationsStore.save(data.form_integration)
|
||||
emit('close')
|
||||
}).catch((error) => {
|
||||
try {
|
||||
alert.error(error.data.message)
|
||||
} catch (e) {
|
||||
alert.error('An error occurred while saving the integration')
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div :class="wrapperClass" :style="inputStyle">
|
||||
<div class="flex justify-between">
|
||||
<slot name="status">
|
||||
<toggle-switch-input name="status" v-model="modelValue.status" label="Enabled"/>
|
||||
</slot>
|
||||
<slot name="help">
|
||||
<v-button class="flex" color="white" size="small" @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>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<slot/>
|
||||
|
||||
<slot name="logic">
|
||||
<div class="-mx-6 px-6 border-t pt-6">
|
||||
<collapse class="w-full" v-model="showLogic">
|
||||
<template #title>
|
||||
<div class="flex gap-x-3 items-start pr-8">
|
||||
<div class="transition-colors" :class="{ 'text-blue-600': showLogic, 'text-gray-300': !showLogic }">
|
||||
<Icon name="material-symbols:settings" size="30px"/>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold">
|
||||
Logic
|
||||
</h3>
|
||||
<p class="text-gray-400 text-xs">
|
||||
Only run integration when a condition is met
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<condition-editor ref="filter-editor" v-model="modelValue.logic" class="mt-4 border-t border rounded-md integration-logic"
|
||||
:form="form"/>
|
||||
</collapse>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ConditionEditor from '~/components/open/forms/components/form-logic-components/ConditionEditor.client.vue'
|
||||
|
||||
const props = defineProps({
|
||||
integration: {type: Object, required: true},
|
||||
modelValue: {required: false},
|
||||
wrapperClass: {type: String, required: false},
|
||||
inputStyle: {type: Object, required: false},
|
||||
form: {type: Object, required: false}
|
||||
})
|
||||
|
||||
const crisp = useCrisp()
|
||||
const emit = defineEmits(['close'])
|
||||
const showLogic = ref(!!props.modelValue.logic)
|
||||
|
||||
const openHelp = () => {
|
||||
if (props.integration && props.integration?.crisp_help_page_slug) {
|
||||
crisp.openHelpdeskArticle(props.integration?.crisp_help_page_slug)
|
||||
return
|
||||
}
|
||||
crisp.openHelpdesk()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.integration-logic {
|
||||
.query-builder-group__group-children {
|
||||
margin: 4px 0px 0px 0px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +1,32 @@
|
||||
<template>
|
||||
<Modal :show="show" :closeable="!aiForm.busy" @close="$emit('close')">
|
||||
<template #icon>
|
||||
<template v-if="state=='default'">
|
||||
<template v-if="state == 'default'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-10 h-10 text-blue">
|
||||
<path fill-rule="evenodd"
|
||||
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="state=='ai'">
|
||||
<template v-else-if="state == 'ai'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
|
||||
<path fill-rule="evenodd"
|
||||
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</template>
|
||||
</template>
|
||||
<template #title>
|
||||
<template v-if="state=='default'">
|
||||
<template v-if="state == 'default'">
|
||||
Choose a base for your form
|
||||
</template>
|
||||
<template v-else-if="state=='ai'">
|
||||
<template v-else-if="state == 'ai'">
|
||||
AI-powered form generator
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="state=='default'" class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8">
|
||||
<div v-track.select_form_base="{base:'contact-form'}" role="button"
|
||||
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" @click="$emit('close')"
|
||||
>
|
||||
<div v-if="state == 'default'" class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8">
|
||||
<div v-track.select_form_base="{ base: 'contact-form' }" role="button"
|
||||
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" @click="$emit('close')">
|
||||
<div class="p-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
|
||||
<path d="M1.5 8.67v8.58a3 3 0 003 3h15a3 3 0 003-3V8.67l-8.928 5.493a3 3 0 01-3.144 0L1.5 8.67z" />
|
||||
@@ -40,15 +37,14 @@
|
||||
Start from a simple contact form
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="aiFeaturesEnabled" v-track.select_form_base="{base:'ai'}"
|
||||
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" role="button" @click="state='ai'"
|
||||
>
|
||||
<div v-if="aiFeaturesEnabled" v-track.select_form_base="{ base: 'ai' }"
|
||||
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" role="button"
|
||||
@click="state = 'ai'">
|
||||
<div class="p-4 relative">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
|
||||
<path fill-rule="evenodd"
|
||||
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="font-medium text-blue-700">
|
||||
@@ -60,26 +56,27 @@
|
||||
<div class="p-4 relative">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
|
||||
<path
|
||||
d="M11.25 5.337c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.036 1.007-1.875 2.25-1.875S15 2.34 15 3.375c0 .369-.128.713-.349 1.003-.215.283-.401.604-.401.959 0 .332.278.598.61.578 1.91-.114 3.79-.342 5.632-.676a.75.75 0 01.878.645 49.17 49.17 0 01.376 5.452.657.657 0 01-.66.664c-.354 0-.675-.186-.958-.401a1.647 1.647 0 00-1.003-.349c-1.035 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401.31 0 .557.262.534.571a48.774 48.774 0 01-.595 4.845.75.75 0 01-.61.61c-1.82.317-3.673.533-5.555.642a.58.58 0 01-.611-.581c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.035-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959a.641.641 0 01-.658.643 49.118 49.118 0 01-4.708-.36.75.75 0 01-.645-.878c.293-1.614.504-3.257.629-4.924A.53.53 0 005.337 15c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.036 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.369 0 .713.128 1.003.349.283.215.604.401.959.401a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.75.75 0 01.83-.832c1.343.155 2.703.254 4.077.294a.64.64 0 00.657-.642z"
|
||||
/>
|
||||
d="M11.25 5.337c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.036 1.007-1.875 2.25-1.875S15 2.34 15 3.375c0 .369-.128.713-.349 1.003-.215.283-.401.604-.401.959 0 .332.278.598.61.578 1.91-.114 3.79-.342 5.632-.676a.75.75 0 01.878.645 49.17 49.17 0 01.376 5.452.657.657 0 01-.66.664c-.354 0-.675-.186-.958-.401a1.647 1.647 0 00-1.003-.349c-1.035 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401.31 0 .557.262.534.571a48.774 48.774 0 01-.595 4.845.75.75 0 01-.61.61c-1.82.317-3.673.533-5.555.642a.58.58 0 01-.611-.581c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.035-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959a.641.641 0 01-.658.643 49.118 49.118 0 01-4.708-.36.75.75 0 01-.645-.878c.293-1.614.504-3.257.629-4.924A.53.53 0 005.337 15c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.036 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.369 0 .713.128 1.003.349.283.215.604.401.959.401a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.75.75 0 01.83-.832c1.343.155 2.703.254 4.077.294a.64.64 0 00.657-.642z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="font-medium">
|
||||
Start from a template
|
||||
</p>
|
||||
<NuxtLink v-track.select_form_base="{base:'template'}" :to="{name:'templates'}" class="absolute inset-0" />
|
||||
<NuxtLink v-track.select_form_base="{ base: 'template' }" :to="{ name: 'templates' }" class="absolute inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="state=='ai'">
|
||||
<a class="absolute top-4 left-4" href="#" role="button" @click.prevent="state='default'">
|
||||
<div v-else-if="state == 'ai'">
|
||||
<a class="absolute top-4 left-4" href="#" role="button" @click.prevent="state = 'default'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 inline -mt-1">
|
||||
<path fill-rule="evenodd" d="M7.72 12.53a.75.75 0 010-1.06l7.5-7.5a.75.75 0 111.06 1.06L9.31 12l6.97 6.97a.75.75 0 11-1.06 1.06l-7.5-7.5z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd"
|
||||
d="M7.72 12.53a.75.75 0 010-1.06l7.5-7.5a.75.75 0 111.06 1.06L9.31 12l6.97 6.97a.75.75 0 11-1.06 1.06l-7.5-7.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<text-area-input label="Form Description" :disabled="loading?true:null" :form="aiForm" name="form_prompt" help="Give us a description of the form you want to build (the more details the better)"
|
||||
placeholder="A simple contact form, with a name, email and message field"
|
||||
/>
|
||||
<text-area-input label="Form Description" :disabled="loading ? true : null" :form="aiForm" name="form_prompt"
|
||||
help="Give us a description of the form you want to build (the more details the better)"
|
||||
placeholder="A simple contact form, with a name, email and message field" />
|
||||
<v-button class="w-full" :loading="loading" @click.prevent="generateForm">
|
||||
Generate a form
|
||||
</v-button>
|
||||
@@ -92,10 +89,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['close', 'form-generated'],
|
||||
props: {
|
||||
show: { type: Boolean, required: true }
|
||||
},
|
||||
setup () {
|
||||
setup() {
|
||||
return {
|
||||
useAlert: useAlert(),
|
||||
runtimeConfig: useRuntimeConfig()
|
||||
@@ -110,13 +108,13 @@ export default {
|
||||
}),
|
||||
|
||||
computed: {
|
||||
aiFeaturesEnabled () {
|
||||
aiFeaturesEnabled() {
|
||||
return this.runtimeConfig.public.aiFeaturesEnabled
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateForm () {
|
||||
generateForm() {
|
||||
if (this.loading) return
|
||||
|
||||
this.loading = true
|
||||
@@ -126,10 +124,10 @@ export default {
|
||||
}).catch(error => {
|
||||
console.error(error)
|
||||
this.loading = false
|
||||
this.state = 'default'
|
||||
this.state = 'ai'
|
||||
})
|
||||
},
|
||||
fetchGeneratedForm (generationId) {
|
||||
fetchGeneratedForm(generationId) {
|
||||
// check every 4 seconds if form is generated
|
||||
setTimeout(() => {
|
||||
opnFetch('/forms/ai/' + generationId).then(data => {
|
||||
@@ -145,7 +143,7 @@ export default {
|
||||
this.fetchGeneratedForm(generationId)
|
||||
}
|
||||
}).catch(error => {
|
||||
if (error?.data?.message){
|
||||
if (error?.data?.message) {
|
||||
this.useAlert.error(error.data.message)
|
||||
}
|
||||
this.state = 'default'
|
||||
|
||||
54
client/data/forms/integrations.json
Normal file
54
client/data/forms/integrations.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"email": {
|
||||
"name": "Email Notification",
|
||||
"icon": "heroicons:envelope-20-solid",
|
||||
"section_name": "Notifications",
|
||||
"file_name": "EmailIntegration",
|
||||
"is_pro": false,
|
||||
"crisp_help_page_slug": "can-i-receive-notifications-on-form-submissions-134svqv"
|
||||
},
|
||||
"submission_confirmation": {
|
||||
"name": "Submission Confirmation",
|
||||
"icon": "heroicons:paper-airplane-20-solid",
|
||||
"section_name": "Notifications",
|
||||
"file_name": "SubmissionConfirmationIntegration",
|
||||
"is_pro": true
|
||||
},
|
||||
"slack": {
|
||||
"name": "Slack Notification",
|
||||
"icon": "mdi:slack",
|
||||
"section_name": "Notifications",
|
||||
"file_name": "SlackIntegration",
|
||||
"is_pro": false
|
||||
},
|
||||
"discord": {
|
||||
"name": "Discord Notification",
|
||||
"icon": "ic:baseline-discord",
|
||||
"section_name": "Notifications",
|
||||
"file_name": "DiscordIntegration",
|
||||
"is_pro": true
|
||||
},
|
||||
"webhook": {
|
||||
"name": "Webhook Notification",
|
||||
"icon": "material-symbols:webhook",
|
||||
"section_name": "Notifications",
|
||||
"file_name": "WebhookIntegration",
|
||||
"is_pro": false
|
||||
},
|
||||
"zapier": {
|
||||
"name": "Zapier Integration",
|
||||
"icon": "cib:zapier",
|
||||
"section_name": "Notifications",
|
||||
"file_name": "ZapierIntegration",
|
||||
"is_pro": true,
|
||||
"coming_soon": true
|
||||
},
|
||||
"google_sheets": {
|
||||
"name": "Google Sheets",
|
||||
"icon": "mdi:google-spreadsheet",
|
||||
"section_name": "Databases",
|
||||
"file_name": "GoogleSheetsIntegration",
|
||||
"is_pro": true,
|
||||
"coming_soon": true
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
import runtimeConfig from "./runtimeConfig";
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
||||
import {sentryVitePlugin} from "@sentry/vite-plugin";
|
||||
import sitemap from "./sitemap";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
@@ -13,18 +13,11 @@ export default defineNuxtConfig({
|
||||
'@vueuse/motion/nuxt',
|
||||
'nuxt3-notifications',
|
||||
'nuxt-simple-sitemap',
|
||||
... process.env.NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE ? ['nuxt-gtag'] : [],
|
||||
'@nuxt/ui',
|
||||
...process.env.NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE ? ['nuxt-gtag'] : [],
|
||||
],
|
||||
build: {
|
||||
transpile: ["vue-notion", "query-builder-vue-3","vue-signature-pad"],
|
||||
},
|
||||
postcss: {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
transpile: process.env.NODE_ENV === "development" ? [] : ["vue-notion", "query-builder-vue-3", "vue-signature-pad"],
|
||||
},
|
||||
experimental: {
|
||||
inlineRouteRules: true
|
||||
@@ -50,6 +43,11 @@ export default defineNuxtConfig({
|
||||
path: '~/components/pages',
|
||||
pathPrefix: false,
|
||||
},
|
||||
{
|
||||
path: '~/components/open/integrations',
|
||||
pathPrefix: false,
|
||||
global: true,
|
||||
},
|
||||
'~/components',
|
||||
],
|
||||
sourcemap: true,
|
||||
|
||||
3187
client/package-lock.json
generated
3187
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,24 +10,24 @@
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "latest",
|
||||
"@nuxt/devtools": "~1.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"nuxt": "^3.9.1",
|
||||
"nuxt-gtag": "^1.1.2",
|
||||
"nuxt-icon": "^0.6.10",
|
||||
"nuxt-simple-sitemap": "^4.2.3",
|
||||
"postcss": "^8.4.32",
|
||||
"sass": "^1.69.6",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vue": "^3.3.10",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@nuxt/ui": "^2.14.2",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@sentry/vite-plugin": "^2.10.2",
|
||||
"@sentry/vue": "^7.93.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@vueuse/components": "^10.5.0",
|
||||
"@vueuse/core": "^10.5.0",
|
||||
"@vueuse/motion": "^2.0.0",
|
||||
@@ -38,11 +38,9 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"crisp-sdk-web": "^1.0.21",
|
||||
"date-fns": "^2.28.0",
|
||||
"debounce": "^1.2.1",
|
||||
"fuse.js": "^6.4.6",
|
||||
"js-sha256": "^0.10.0",
|
||||
"libphonenumber-js": "^1.10.44",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"nuxt3-notifications": "^1.1.9",
|
||||
"object-to-formdata": "^4.5.1",
|
||||
"pinia": "^2.1.7",
|
||||
@@ -55,6 +53,7 @@
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-confetti": "^2.3.0",
|
||||
"vue-country-flag-next": "^2.3.2",
|
||||
"vue-json-pretty": "^2.4.0",
|
||||
"vue-notion": "^3.0.0-beta.1",
|
||||
"vue-signature-pad": "^3.0.2",
|
||||
"vue3-editor": "^0.1.1",
|
||||
|
||||
@@ -76,7 +76,6 @@ const passwordEntered = function (password) {
|
||||
})
|
||||
cookie.value = sha256(password)
|
||||
nextTick(() => {
|
||||
console.log('cookie value:',cookie.value)
|
||||
loadForm().then(() => {
|
||||
if (form.value?.is_password_protected) {
|
||||
openCompleteForm.value.addPasswordError('Invalid password.')
|
||||
|
||||
@@ -5,12 +5,9 @@
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="pt-4 pb-0">
|
||||
<a href="#" class="flex text-blue mb-2 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 class="w-3 h-3 text-blue mt-1 mr-1" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 9L1 5L5 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
Go back
|
||||
</a>
|
||||
@@ -20,47 +17,34 @@
|
||||
{{ form.title }}
|
||||
</h2>
|
||||
<div class="flex">
|
||||
<extra-menu class="mr-2" :form="form"/>
|
||||
<extra-menu class="mr-2" :form="form" />
|
||||
|
||||
<v-button v-if="form.visibility === 'draft'" color="white"
|
||||
class="mr-2 text-blue-600 hidden sm:block" @click="showDraftFormWarningNotification"
|
||||
>
|
||||
<svg class="w-6 h-6 inline -mt-1" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
<v-button v-if="form.visibility === 'draft'" color="white" class="mr-2 text-blue-600 hidden sm:block"
|
||||
@click="showDraftFormWarningNotification">
|
||||
<svg class="w-6 h-6 inline -mt-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path
|
||||
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</v-button>
|
||||
<v-button v-else v-track.view_form_click="{form_id:form.id, form_slug:form.slug}" target="_blank"
|
||||
:href="form.share_url" color="white"
|
||||
class="mr-2 text-blue-600 hidden sm:block"
|
||||
>
|
||||
<svg class="w-6 h-6 inline -mt-1" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
<v-button v-else v-track.view_form_click="{ form_id: form.id, form_slug: form.slug }" target="_blank"
|
||||
:href="form.share_url" color="white" class="mr-2 text-blue-600 hidden sm:block">
|
||||
<svg class="w-6 h-6 inline -mt-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path
|
||||
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</v-button>
|
||||
<v-button class="text-white" :to="{name: 'forms-slug-edit', params: {slug: slug}}">
|
||||
<v-button class="text-white" :to="{ name: 'forms-slug-edit', params: { slug: slug } }">
|
||||
<svg class="inline mr-1 -mt-1" width="18" height="17" viewBox="0 0 18 17" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8.99998 15.6662H16.5M1.5 15.6662H2.89545C3.3031 15.6662 3.50693 15.6662 3.69874 15.6202C3.8688 15.5793 4.03138 15.512 4.1805 15.4206C4.34869 15.3175 4.49282 15.1734 4.78107 14.8852L15.25 4.4162C15.9404 3.72585 15.9404 2.60656 15.25 1.9162C14.5597 1.22585 13.4404 1.22585 12.75 1.9162L2.28105 12.3852C1.9928 12.6734 1.84867 12.8175 1.7456 12.9857C1.65422 13.1348 1.58688 13.2974 1.54605 13.4675C1.5 13.6593 1.5 13.8631 1.5 14.2708V15.6662Z"
|
||||
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>
|
||||
Edit form
|
||||
</v-button>
|
||||
@@ -74,48 +58,45 @@
|
||||
</span>
|
||||
<span>- Edited {{ form.last_edited_human }}</span>
|
||||
</p>
|
||||
<div v-if="['draft','closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)"
|
||||
class="mt-2 flex items-center flex-wrap gap-3"
|
||||
>
|
||||
<span v-if="form.visibility=='draft'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
<div v-if="['draft', 'closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)"
|
||||
class="mt-2 flex items-center flex-wrap gap-3">
|
||||
<span v-if="form.visibility == 'draft'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
|
||||
Draft - not publicly accessible
|
||||
</span>
|
||||
<span v-else-if="form.visibility=='closed'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
<span v-else-if="form.visibility == 'closed'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
|
||||
Closed - won't accept new submissions
|
||||
</span>
|
||||
<span v-for="(tag,i) in form.tags" :key="tag"
|
||||
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
<span v-for="(tag, i) in form.tags" :key="tag"
|
||||
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="form.closes_at" class="text-yellow-500">
|
||||
<span v-if="form.is_closed"> This form stopped accepting submissions on the {{
|
||||
displayClosesDate
|
||||
}} </span>
|
||||
<span v-if="form.is_closed"> This form stopped accepting submissions on the {{
|
||||
displayClosesDate
|
||||
}} </span>
|
||||
<span v-else> This form will stop accepting submissions on the {{ displayClosesDate }} </span>
|
||||
</p>
|
||||
<p v-if="form.max_submissions_count > 0" class="text-yellow-500">
|
||||
<span v-if="form.max_number_of_submissions_reached"> The form is now closed because it reached its limit of {{
|
||||
<span v-if="form.max_number_of_submissions_reached"> The form is now closed because it reached its limit of
|
||||
{{
|
||||
form.max_submissions_count
|
||||
}} submissions. </span>
|
||||
<span v-else> This form will stop accepting submissions after {{ form.max_submissions_count }} submissions. </span>
|
||||
<span v-else> This form will stop accepting submissions after {{ form.max_submissions_count }} submissions.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<form-cleanings class="mt-4" :form="form"/>
|
||||
<form-cleanings class="mt-4" :form="form" />
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center">
|
||||
<li v-for="(tab, i) in tabsList" :key="i+1" class="mr-6">
|
||||
<li v-for="(tab, i) in tabsList" :key="i + 1" class="mr-6">
|
||||
<nuxt-link :to="{ name: tab.route }"
|
||||
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 text-gray-500 hover:text-gray-600"
|
||||
active-class="text-blue-600 hover:text-blue-900 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500"
|
||||
>
|
||||
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 text-gray-500 hover:text-gray-600"
|
||||
active-class="text-blue-600 hover:text-blue-900 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500">
|
||||
{{ tab.name }}
|
||||
</nuxt-link>
|
||||
</li>
|
||||
@@ -125,11 +106,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col bg-white">
|
||||
<NuxtPage :form="form"/>
|
||||
<NuxtPage :form="form" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="loading" class="text-center w-full p-5">
|
||||
<Loader class="h-6 w-6 mx-auto"/>
|
||||
<Loader class="h-6 w-6 mx-auto" />
|
||||
</div>
|
||||
<div v-else class="text-center w-full p-5">
|
||||
Form not found.
|
||||
@@ -138,7 +119,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import ProTag from '~/components/global/ProTag.vue'
|
||||
import VButton from '~/components/global/VButton.vue'
|
||||
import ExtraMenu from '../../../components/pages/forms/show/ExtraMenu.vue'
|
||||
@@ -181,6 +162,10 @@ const tabsList = [
|
||||
name: 'Submissions',
|
||||
route: 'forms-slug-show-submissions'
|
||||
},
|
||||
{
|
||||
name: 'Integrations',
|
||||
route: 'forms-slug-show-integrations'
|
||||
},
|
||||
{
|
||||
name: 'Analytics',
|
||||
route: 'forms-slug-show-stats'
|
||||
@@ -207,7 +192,7 @@ watch(() => form?.value?.id, (id) => {
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
useRouter().push({name: 'home'})
|
||||
useRouter().push({ name: 'home' })
|
||||
}
|
||||
|
||||
const showDraftFormWarningNotification = () => {
|
||||
|
||||
91
client/pages/forms/[slug]/show/integrations/index.vue
Normal file
91
client/pages/forms/[slug]/show/integrations/index.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl p-4">
|
||||
<div class="mb-20">
|
||||
<h1 class="font-semibold mt-4 text-xl">
|
||||
Your integrations
|
||||
</h1>
|
||||
|
||||
<div class="text-sm text-gray-500">
|
||||
Read, update and create data with dozens of 3rd-party integrations
|
||||
</div>
|
||||
|
||||
<div v-if="integrationsLoading" class="my-6">
|
||||
<Loader class="h-6 w-6 mx-auto"/>
|
||||
</div>
|
||||
<div v-else-if="formIntegrationsList.length" class="my-6">
|
||||
<IntegrationCard v-for="(row) in formIntegrationsList" :key="row.id" :integration="row" :form="form"/>
|
||||
</div>
|
||||
<div class="text-gray-500 border shadow rounded-md p-5 mt-4" v-else>
|
||||
No integration yet form this form.
|
||||
</div>
|
||||
|
||||
|
||||
<h1 class="font-semibold mt-8 text-xl">
|
||||
Add a new integration
|
||||
</h1>
|
||||
<div v-for="(section, sectionName) in sectionsList" :key="sectionName" class="mb-8">
|
||||
<h3 class="text-gray-500">
|
||||
{{ sectionName }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap mt-2 gap-4">
|
||||
<IntegrationListOption v-for="(sectionItem, sectionItemKey) in section"
|
||||
@select="openIntegrationModal"
|
||||
:key="sectionItemKey" :integration="sectionItem"/>
|
||||
</div>
|
||||
</div>
|
||||
<IntegrationModal v-if="form && selectedIntegrationKey && selectedIntegration" :form="form"
|
||||
:integration="selectedIntegration" :integrationKey="selectedIntegrationKey"
|
||||
:show="showIntegrationModal"
|
||||
@close="closeIntegrationModal"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
import IntegrationModal from '~/components/open/integrations/components/IntegrationModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
form: {type: Object, required: true}
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
useOpnSeoMeta({
|
||||
title: (props.form) ? 'Form Integrations - ' + props.form.title : 'Form Integrations'
|
||||
})
|
||||
|
||||
const alert = useAlert()
|
||||
const crisp = useCrisp()
|
||||
const route = useRoute()
|
||||
|
||||
const formIntegrationsStore = useFormIntegrationsStore()
|
||||
const integrationsLoading = computed(() => formIntegrationsStore.loading)
|
||||
const integrations = computed(() => formIntegrationsStore.availableIntegrations)
|
||||
const sectionsList = computed(() => formIntegrationsStore.integrationsBySection)
|
||||
const formIntegrationsList = computed(() => formIntegrationsStore.getAllByFormId(props.form.id))
|
||||
|
||||
let showIntegrationModal = ref(false)
|
||||
let selectedIntegrationKey = ref(null)
|
||||
let selectedIntegration = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
formIntegrationsStore.fetchFormIntegrations(props.form.id)
|
||||
})
|
||||
|
||||
const openIntegrationModal = (itemKey) => {
|
||||
if (!itemKey || !integrations.value.has(itemKey)) return alert.error('Integration not found')
|
||||
if (integrations.value.get(itemKey).coming_soon) return alert.warning('This integration is not available yet')
|
||||
selectedIntegrationKey.value = itemKey
|
||||
selectedIntegration.value = integrations.value.get(selectedIntegrationKey.value)
|
||||
showIntegrationModal.value = true
|
||||
}
|
||||
const closeIntegrationModal = () => {
|
||||
showIntegrationModal.value = false
|
||||
nextTick(() => {
|
||||
selectedIntegrationKey.value = null
|
||||
selectedIntegration.value = null
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -17,7 +17,6 @@ useOpnSeoMeta({
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
console.log('Clearing store state')
|
||||
useRecordsStore().resetState()
|
||||
})
|
||||
|
||||
|
||||
71
client/stores/form_integrations.js
vendored
Normal file
71
client/stores/form_integrations.js
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {useContentStore} from "~/composables/stores/useContentStore.js";
|
||||
import integrationsList from '~/data/forms/integrations.json'
|
||||
|
||||
export const formIntegrationsEndpoint = '/open/forms/{formid}/integrations'
|
||||
|
||||
export const useFormIntegrationsStore = defineStore('form_integrations', () => {
|
||||
|
||||
const contentStore = useContentStore()
|
||||
const integrations = ref(new Map)
|
||||
|
||||
const availableIntegrations = computed(() => {
|
||||
const user = useAuthStore().user
|
||||
if (!user) return integrations.value
|
||||
|
||||
const enrichedIntegrations = new Map()
|
||||
for (const [key, integration] of integrations.value.entries()) {
|
||||
enrichedIntegrations.set(key, {
|
||||
...integration,
|
||||
id: key,
|
||||
requires_subscription: !user.is_subscribed && integration.is_pro
|
||||
})
|
||||
}
|
||||
|
||||
return enrichedIntegrations
|
||||
})
|
||||
|
||||
const integrationsBySection = computed(() => {
|
||||
const groupedObject = {};
|
||||
for (const [key, integration] of availableIntegrations.value.entries()) {
|
||||
const sectionName = integration.section_name;
|
||||
if (!groupedObject[sectionName]) {
|
||||
groupedObject[sectionName] = {};
|
||||
}
|
||||
groupedObject[sectionName][key] = integration
|
||||
}
|
||||
return groupedObject;
|
||||
})
|
||||
|
||||
const fetchFormIntegrations = (formId) => {
|
||||
contentStore.resetState()
|
||||
contentStore.startLoading()
|
||||
return useOpnApi(formIntegrationsEndpoint.replace('{formid}', formId)).then((response) => {
|
||||
contentStore.save(response.data.value)
|
||||
contentStore.stopLoading()
|
||||
})
|
||||
}
|
||||
|
||||
const getAllByFormId = (formId) => {
|
||||
return contentStore.getAll.value.filter((item) => {
|
||||
return (item.form_id) ? item.form_id === formId : false
|
||||
})
|
||||
}
|
||||
|
||||
const initIntegrations = () => {
|
||||
if (integrations.value.size === 0) {
|
||||
integrations.value = new Map(Object.entries(integrationsList))
|
||||
}
|
||||
}
|
||||
|
||||
initIntegrations()
|
||||
|
||||
return {
|
||||
...contentStore,
|
||||
initIntegrations,
|
||||
availableIntegrations,
|
||||
integrationsBySection,
|
||||
fetchFormIntegrations,
|
||||
getAllByFormId,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user