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:
formsdev
2024-03-28 22:44:30 +05:30
committed by GitHub
parent d9996e0d9d
commit 6f61faa9ef
84 changed files with 6121 additions and 2205 deletions

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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