Browse Source

Merge pull request #5349 from nocodb/refactor/webhooks

refactor: webhooks
pull/5404/head
Pranav C 2 years ago committed by GitHub
parent
commit
6ec81082ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      packages/nc-gui/components.d.ts
  2. 2
      packages/nc-gui/components/api-client/Headers.vue
  3. 2
      packages/nc-gui/components/api-client/Params.vue
  4. 183
      packages/nc-gui/components/webhook/CallLog.vue
  5. 49
      packages/nc-gui/components/webhook/ChannelMultiSelect.vue
  6. 593
      packages/nc-gui/components/webhook/Editor.vue
  7. 11
      packages/nc-gui/components/webhook/List.vue
  8. 20
      packages/nc-gui/components/webhook/Test.vue
  9. 1
      packages/nc-gui/composables/useGlobal/state.ts
  10. 1
      packages/nc-gui/composables/useGlobal/types.ts
  11. 6
      packages/nc-gui/lib/enums.ts
  12. 27
      packages/noco-docs/content/en/developer-resources/webhooks.md
  13. 1
      packages/noco-docs/content/en/getting-started/environment-variables.md
  14. 183
      packages/nocodb-sdk/src/lib/Api.ts
  15. 2
      packages/nocodb/src/lib/Noco.ts
  16. 58
      packages/nocodb/src/lib/controllers/hook.ctl.ts
  17. 181
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  18. 3
      packages/nocodb/src/lib/meta/NcMetaIOImpl.ts
  19. 10
      packages/nocodb/src/lib/meta/helpers/NcPluginMgrv2.ts
  20. 65
      packages/nocodb/src/lib/meta/helpers/populateSamplePayload.ts
  21. 224
      packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts
  22. 4
      packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts
  23. 35
      packages/nocodb/src/lib/migrations/v2/nc_029_webhook.ts
  24. 41
      packages/nocodb/src/lib/models/Hook.ts
  25. 74
      packages/nocodb/src/lib/models/HookLog.ts
  26. 3
      packages/nocodb/src/lib/plugins/discord/Discord.ts
  27. 3
      packages/nocodb/src/lib/plugins/mattermost/Mattermost.ts
  28. 3
      packages/nocodb/src/lib/plugins/slack/Slack.ts
  29. 3
      packages/nocodb/src/lib/plugins/teams/Teams.ts
  30. 1
      packages/nocodb/src/lib/plugins/twilio/Twilio.ts
  31. 1
      packages/nocodb/src/lib/plugins/twilioWhatsapp/TwilioWhatsapp.ts
  32. 51
      packages/nocodb/src/lib/services/hook.svc.ts
  33. 1
      packages/nocodb/src/lib/services/util.svc.ts
  34. 3
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/discord/Discord.ts
  35. 3
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/mattermost/Mattermost.ts
  36. 3
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/slack/Slack.ts
  37. 1
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/twilio/Twilio.ts
  38. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  39. 13
      packages/nocodb/src/lib/version-upgrader/ncHookUpgrader.ts
  40. 692
      packages/nocodb/src/schema/swagger.json
  41. 2
      packages/nocodb/tests/unit/model/tests/baseModelSql.test.ts
  42. 2
      tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts
  43. 520
      tests/playwright/tests/01-webhook.spec.ts
  44. 21
      tests/playwright/tests/utils/general.ts

9
packages/nc-gui/components.d.ts vendored

@ -121,9 +121,18 @@ declare module '@vue/runtime-core' {
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiEditOutline: typeof import('~icons/mdi/edit-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiHistory: typeof import('~icons/mdi/history')['default']
MdiHook: typeof import('~icons/mdi/hook')['default']
MdiInformation: typeof import('~icons/mdi/information')['default']
MdiJson: typeof import('~icons/mdi/json')['default']
MdiKey: typeof import('~icons/mdi/key')['default']
MdiKeyboard: typeof import('~icons/mdi/keyboard')['default']
MdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
MdiKeyChange: typeof import('~icons/mdi/key-change')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']

2
packages/nc-gui/components/api-client/Headers.vue

@ -123,7 +123,7 @@ const filterOption = (input: string, option: Option) => {
<tr>
<td :colspan="12" class="text-center">
<a-button type="default" class="!bg-gray-100 rounded-md border-none mr-1" @click="addHeaderRow">
<a-button type="default" class="!bg-gray-100 rounded-md border-none mr-1 mb-3" @click="addHeaderRow">
<template #icon>
<component :is="iconMap.plus" class="flex mx-auto" />
</template>

2
packages/nc-gui/components/api-client/Params.vue

@ -66,7 +66,7 @@ const deleteParamRow = (i: number) => vModel.value.splice(i, 1)
<tr>
<td :colspan="12" class="text-center">
<a-button type="default" class="!bg-gray-100 rounded-md border-none mr-1" @click="addParamRow">
<a-button type="default" class="!bg-gray-100 rounded-md border-none mr-1 mb-3" @click="addParamRow">
<template #icon>
<component :is="iconMap.plus" class="flex mx-auto" />
</template>

183
packages/nc-gui/components/webhook/CallLog.vue

@ -0,0 +1,183 @@
<script setup lang="ts">
import type { HookLogType, HookType } from 'nocodb-sdk'
import { AutomationLogLevel, extractSdkResponseErrorMsg, onBeforeMount, parseProp, timeAgo, useApi, useGlobal } from '#imports'
interface Props {
hook: HookType
}
const props = defineProps<Props>()
const { api, isLoading } = useApi()
const hookLogs = ref<HookLogType[]>([])
const activeKey = ref()
const { appInfo } = useGlobal()
let totalRows = $ref(0)
const currentPage = $ref(1)
const currentLimit = $ref(10)
const showLogs = computed(
() =>
!(
appInfo.value.automationLogLevel === AutomationLogLevel.OFF ||
(appInfo.value.automationLogLevel === AutomationLogLevel.ALL && !appInfo.value.ee)
),
)
async function loadHookLogs(page = currentPage, limit = currentLimit) {
try {
// cater empty records
page = page || 1
const { list, pageInfo } = await api.dbTableWebhookLogs.list(props.hook.id!, {
offset: limit * (page - 1),
limit,
})
hookLogs.value = parseHookLog(list)
totalRows = pageInfo.totalRows ?? 0
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
function parseHookLog(hookLogs: any) {
for (const hookLog of hookLogs) {
if (hookLog?.response) {
hookLog.response = parseProp(hookLog.response)
}
if (hookLog?.response?.config?.data) {
hookLog.response.config.data = parseProp(hookLog.response.config.data)
}
if (hookLog?.payload) {
hookLog.payload = parseProp(hookLog.payload)
}
if (hookLog?.notification) {
hookLog.notification = parseProp(hookLog.notification)
}
}
return hookLogs
}
onBeforeMount(async () => {
if (showLogs.value) {
await loadHookLogs(currentPage, currentLimit)
}
})
</script>
<template>
<a-skeleton v-if="isLoading" />
<div v-else>
<a-card class="!mb-[20px]" :body-style="{ padding: '10px' }">
<span v-if="appInfo.automationLogLevel === AutomationLogLevel.OFF">
The NC_AUTOMATION_LOG_LEVEL is set to OFF, no logs will be displayed.
</span>
<span v-if="appInfo.automationLogLevel === AutomationLogLevel.ERROR">
The NC_AUTOMATION_LOG_LEVEL is set to ERROR, only error logs will be displayed.
</span>
<span v-if="appInfo.automationLogLevel === AutomationLogLevel.ALL">
<span v-if="appInfo.ee">
The NC_AUTOMATION_LOG_LEVEL is set to ALL, both error and success logs will be displayed.
</span>
<span v-else> Upgrade to Enterprise Edition to show all the logs. </span>
</span>
<span>
For additional configuration options, please refer the documentation
<a href="https://docs.nocodb.com/developer-resources/webhooks#call-log" target="_blank">here</a>.
</span>
</a-card>
<div v-if="showLogs">
<a-empty v-if="!hookLogs.length" />
<a-layout v-else>
<a-layout-content>
<a-collapse v-model:activeKey="activeKey" class="nc-hook-log-collapse">
<a-collapse-panel v-for="(hookLog, idx) of hookLogs" :key="idx">
<template #header>
<div class="w-full cursor-pointer">
<div class="font-weight-medium flex">
<div class="flex-1">
{{ hookLog.type }}: records.{{ hookLog.event }}.{{ hookLog.operation }} ({{ timeAgo(hookLog.created_at) }})
</div>
<div v-if="hookLog.type === 'Email'">
<div v-if="hookLog.error_message" class="mx-1 px-2 py-1 text-white rounded text-xs bg-red-500">ERROR</div>
<div v-else class="mx-1 px-2 py-1 text-white rounded text-xs bg-green-500">OK</div>
</div>
<div
v-else
class="mx-1 px-2 py-1 text-white rounded bg-red-500 text-xs"
:class="{ '!bg-green-500': hookLog.response?.status === 200 }"
>
{{ hookLog.response?.status }}
{{ hookLog.response?.statusText || (hookLog.response?.status === 200 ? 'OK' : 'ERROR') }}
</div>
</div>
<div v-if="hookLog.type === 'URL'">
<span class="font-weight-medium text-primary">
{{ hookLog.payload.method }}
</span>
{{ hookLog.payload.path }}
</div>
</div>
</template>
<div v-if="hookLog.error_message" class="mb-4">
{{ hookLog.error_message }}
</div>
<div v-if="hookLog.type !== 'Email'">
<div v-if="hookLog?.response?.config?.headers" class="nc-hook-log-request">
<div class="nc-hook-pre-title">Request</div>
<pre class="nc-hook-pre">{{ hookLog.response.config.headers }}</pre>
</div>
<div v-if="hookLog?.response?.headers" class="nc-hook-log-response">
<div class="nc-hook-pre-title">Response</div>
<pre class="nc-hook-pre">{{ hookLog.response.headers }}</pre>
</div>
<div v-if="hookLog?.response?.config?.data" class="nc-hook-log-payload">
<div class="nc-hook-pre-title">Payload</div>
<pre class="nc-hook-pre">{{ hookLog.response.config.data }}</pre>
</div>
</div>
<div v-else>
<div v-if="hookLog?.payload" class="nc-hook-log-payload">
<div class="nc-hook-pre-title">Payload</div>
<pre class="nc-hook-pre">{{ hookLog.payload }}</pre>
</div>
</div>
</a-collapse-panel>
</a-collapse>
</a-layout-content>
<a-layout-footer class="!bg-white text-center">
<a-pagination
v-model:current="currentPage"
:page-size="currentLimit"
:total="totalRows"
show-less-items
@change="loadHookLogs"
/>
</a-layout-footer>
</a-layout>
</div>
</div>
</template>
<style scoped lang="scss">
.nc-hook-log-collapse {
.nc-hook-pre-title {
@apply font-bold mb-2;
}
.nc-hook-pre {
@apply bg-gray-100;
padding: 10px;
}
}
</style>

49
packages/nc-gui/components/webhook/ChannelMultiSelect.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, useVModel, watch } from '#imports'
import { onBeforeMount, useVModel, watch } from '#imports'
interface Props {
modelValue: Record<string, any>[]
@ -17,24 +17,9 @@ const vModel = useVModel(rest, 'modelValue', emit)
const localChannelValues = $ref<number[]>([])
// availableChannelList with idx enriched
let availableChannelWithIdxList = $ref<Record<string, any>[]>()
let availableChannelWithIdxList = $ref<Record<string, any>[]>([])
watch(
() => localChannelValues,
(v) => {
const res = []
for (const channelIdx of v) {
const target = availableChannelWithIdxList.find((availableChannel) => availableChannel.idx === channelIdx)
if (target) {
// push without target.idx
res.push({ webhook_url: target.webhook_url, channel: target.channel })
}
}
vModel.value = res
},
)
onMounted(() => {
function setAvailableChannelWithIdxList(availableChannelList: Record<string, any>[]) {
if (availableChannelList.length) {
// enrich idx
let idx = 0
@ -54,7 +39,33 @@ onMounted(() => {
}
}
}
})
}
watch(
() => availableChannelList,
(n, o) => {
if (n !== o) {
setAvailableChannelWithIdxList(n)
}
},
)
watch(
() => localChannelValues,
(v) => {
const res = []
for (const channelIdx of v) {
const target = availableChannelWithIdxList.find((availableChannel) => availableChannel.idx === channelIdx)
if (target) {
// push without target.idx
res.push({ webhook_url: target.webhook_url, channel: target.channel })
}
}
vModel.value = res
},
)
onBeforeMount(() => setAvailableChannelWithIdxList(availableChannelList))
</script>
<template>

593
packages/nc-gui/components/webhook/Editor.vue

@ -39,16 +39,18 @@ const { appInfo } = $(useGlobal())
const meta = inject(MetaInj, ref())
const hookTabKey = ref('hook-edit')
const useForm = Form.useForm
const hook = reactive<
Omit<HookType, 'notification'> & { notification: Record<string, any>; eventOperation: string; condition: boolean }
Omit<HookType, 'notification'> & { notification: Record<string, any>; eventOperation?: string; condition: boolean }
>({
id: '',
title: '',
event: undefined,
operation: undefined,
eventOperation: '',
eventOperation: undefined,
notification: {
type: 'URL',
payload: {
@ -56,13 +58,15 @@ const hook = reactive<
body: '{{ json data }}',
headers: [{}],
parameters: [{}],
path: '',
},
},
condition: false,
active: true,
version: 'v2',
})
const urlTabKey = ref('body')
const urlTabKey = ref('params')
const apps: Record<string, any> = ref()
@ -172,11 +176,14 @@ const formInput = ref({
],
})
const eventList = [
const eventList = ref<Record<string, any>[]>([
{ text: ['After', 'Insert'], value: ['after', 'insert'] },
{ text: ['After', 'Update'], value: ['after', 'update'] },
{ text: ['After', 'Delete'], value: ['after', 'delete'] },
]
{ text: ['After', 'Bulk Insert'], value: ['after', 'bulkInsert'] },
{ text: ['After', 'Bulk Update'], value: ['after', 'bulkUpdate'] },
{ text: ['After', 'Bulk Delete'], value: ['after', 'bulkDelete'] },
])
const notificationList = computed(() => {
return appInfo.isCloud
@ -216,10 +223,7 @@ const validators = computed(() => {
'notification.payload.subject': [fieldRequiredValidator()],
'notification.payload.body': [fieldRequiredValidator()],
}),
...((hook.notification.type === 'Slack' ||
hook.notification.type === 'Microsoft Teams' ||
hook.notification.type === 'Discord' ||
hook.notification.type === 'Mattermost') && {
...(['Slack', 'Microsoft Teams', 'Discord', 'Mattermost'].includes(hook.notification.type) && {
'notification.payload.channels': [fieldRequiredValidator()],
'notification.payload.body': [fieldRequiredValidator()],
}),
@ -231,9 +235,13 @@ const validators = computed(() => {
})
const { validate, validateInfos } = useForm(hook, validators)
function onNotTypeChange(reset = false) {
function onNotificationTypeChange(reset = false) {
if (reset) {
hook.notification.payload = {} as Record<string, any>
if (['Slack', 'Microsoft Teams', 'Discord', 'Mattermost'].includes(hook.notification.type)) {
hook.notification.payload.channels = []
hook.notification.payload.body = ''
}
}
if (hook.notification.type === 'Slack') {
@ -270,9 +278,17 @@ function setHook(newHook: HookType) {
payload: notification.payload,
},
})
if (hook.version === 'v1') {
urlTabKey.value = 'body'
eventList.value = [
{ text: ['After', 'Insert'], value: ['after', 'insert'] },
{ text: ['After', 'Update'], value: ['after', 'update'] },
{ text: ['After', 'Delete'], value: ['after', 'delete'] },
]
}
}
async function onEventChange() {
function onEventChange() {
const { notification: { payload = {}, type = {} } = {} } = hook
Object.assign(hook, {
@ -285,20 +301,20 @@ async function onEventChange() {
hook.notification.payload = payload
let channels: Ref<Record<string, any>[] | null> = ref(null)
const channels: Ref<Record<string, any>[] | null> = ref(null)
switch (hook.notification.type) {
case 'Slack':
channels = slackChannels
channels.value = slackChannels.value
break
case 'Microsoft Teams':
channels = teamsChannels
channels.value = teamsChannels.value
break
case 'Discord':
channels = discordChannels
channels.value = discordChannels.value
break
case 'Mattermost':
channels = mattermostChannels
channels.value = mattermostChannels.value
break
}
@ -429,7 +445,7 @@ onMounted(async () => {
hook.eventOperation = `${hook.event} ${hook.operation}`
}
onNotTypeChange()
onNotificationTypeChange()
})
</script>
@ -462,271 +478,288 @@ onMounted(async () => {
<a-divider />
<a-form :model="hook" name="create-or-edit-webhook">
<a-form-item>
<a-row type="flex">
<a-col :span="24">
<a-card>
<a-checkbox
:checked="Boolean(hook.active)"
class="nc-check-box-enable-webhook"
@update:checked="hook.active = $event"
>
{{ $t('activity.enableWebhook') }}
</a-checkbox>
</a-card>
</a-col>
</a-row>
</a-form-item>
<a-form-item>
<a-row type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos.title">
<a-input
v-model:value="hook.title"
size="large"
:placeholder="$t('general.title')"
class="nc-text-field-hook-title"
/>
</a-form-item>
</a-col>
</a-row>
<a-row type="flex" :gutter="[16, 16]">
<a-col :span="12">
<a-form-item v-bind="validateInfos.eventOperation">
<a-select
v-model:value="hook.eventOperation"
size="large"
:placeholder="$t('general.event')"
class="nc-text-field-hook-event"
dropdown-class-name="nc-dropdown-webhook-event"
>
<a-select-option v-for="(event, i) in eventList" :key="i" :value="event.value.join(' ')">
{{ event.text.join(' ') }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-bind="validateInfos['notification.type']">
<a-select
v-model:value="hook.notification.type"
size="large"
class="nc-select-hook-notification-type"
:placeholder="$t('general.notification')"
dropdown-class-name="nc-dropdown-webhook-notification"
@change="onNotTypeChange(true)"
>
<a-select-option v-for="(notificationOption, i) in notificationList" :key="i" :value="notificationOption.type">
<a-tabs v-model:activeKey="hookTabKey" type="card" closeable="false" class="shadow-sm">
<a-tab-pane key="hook-edit" class="nc-hook-edit" force-render>
<template #tab>
<span>
<MdiEditOutline />
Edit
</span>
</template>
<a-form :model="hook" name="create-or-edit-webhook">
<a-form-item>
<a-row type="flex">
<a-col :span="24">
<a-card>
<a-checkbox
:checked="Boolean(hook.active)"
class="nc-check-box-enable-webhook"
@update:checked="hook.active = $event"
>
{{ $t('activity.enableWebhook') }}
</a-checkbox>
</a-card>
</a-col>
</a-row>
</a-form-item>
<a-form-item>
<a-row type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos.title">
<a-input
v-model:value="hook.title"
size="large"
:placeholder="$t('general.title')"
class="nc-text-field-hook-title"
/>
</a-form-item>
</a-col>
</a-row>
<a-row type="flex" :gutter="[16, 16]">
<a-col :span="12">
<a-form-item v-bind="validateInfos.eventOperation">
<a-select
v-model:value="hook.eventOperation"
size="large"
:placeholder="$t('general.event')"
class="nc-text-field-hook-event"
dropdown-class-name="nc-dropdown-webhook-event"
>
<a-select-option v-for="(event, i) in eventList" :key="i" :value="event.value.join(' ')">
{{ event.text.join(' ') }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-bind="validateInfos['notification.type']">
<a-select
v-model:value="hook.notification.type"
size="large"
class="nc-select-hook-notification-type"
:placeholder="$t('general.notification')"
dropdown-class-name="nc-dropdown-webhook-notification"
@change="onNotificationTypeChange(true)"
>
<a-select-option v-for="(notificationOption, i) in notificationList" :key="i" :value="notificationOption.type">
<div class="flex items-center">
<component :is="iconMap.link" v-if="notificationOption.type === 'URL'" class="mr-2" />
<component :is="iconMap.email" v-if="notificationOption.type === 'Email'" class="mr-2" />
<MdiSlack v-if="notificationOption.type === 'Slack'" class="mr-2" />
<MdiMicrosoftTeams v-if="notificationOption.type === 'Microsoft Teams'" class="mr-2" />
<MdiDiscord v-if="notificationOption.type === 'Discord'" class="mr-2" />
<MdiChat v-if="notificationOption.type === 'Mattermost'" class="mr-2" />
<MdiWhatsapp v-if="notificationOption.type === 'Whatsapp Twilio'" class="mr-2" />
<MdiCellphoneMessage v-if="notificationOption.type === 'Twilio'" class="mr-2" />
{{ notificationOption.type }}
</div>
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'URL'" class="mb-5" type="flex" :gutter="[16, 0]">
<a-col :span="6">
<a-select
v-model:value="hook.notification.payload.method"
size="large"
class="nc-select-hook-url-method"
dropdown-class-name="nc-dropdown-hook-notification-url-method"
>
<a-select-option v-for="(method, i) in methodList" :key="i" :value="method.title">
{{ method.title }}
</a-select-option>
</a-select>
</a-col>
<a-col :span="18">
<a-form-item v-bind="validateInfos['notification.payload.path']">
<a-input
v-model:value="hook.notification.payload.path"
size="large"
placeholder="http://example.com"
class="nc-text-field-hook-url-path"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-tabs v-model:activeKey="urlTabKey" type="card" closeable="false" class="shadow-sm">
<a-tab-pane v-if="hook.version === 'v1'" key="body" tab="Body">
<LazyMonacoEditor
v-model="hook.notification.payload.body"
disable-deep-compare
:validate="false"
class="min-h-60 max-h-80"
/>
</a-tab-pane>
<a-tab-pane key="params" tab="Params" force-render>
<LazyApiClientParams v-model="hook.notification.payload.parameters" />
</a-tab-pane>
<a-tab-pane key="headers" tab="Headers" class="nc-tab-headers">
<LazyApiClientHeaders v-model="hook.notification.payload.headers" />
</a-tab-pane>
<!-- No in use at this moment -->
<!-- <a-tab-pane key="auth" tab="Auth"> -->
<!-- <LazyMonacoEditor v-model="hook.notification.payload.auth" class="min-h-60 max-h-80" /> -->
<!-- <span class="text-gray-500 prose-sm p-2"> -->
<!-- For more about auth option refer -->
<!-- <a class="prose-sm" href ="https://github.com/axios/axios#request-config" target="_blank">axios docs</a>. -->
<!-- </span> -->
<!-- </a-tab-pane> -->
</a-tabs>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'Slack'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect
v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels"
:available-channel-list="slackChannels"
placeholder="Select Slack channels"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'Microsoft Teams'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect
v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels"
:available-channel-list="teamsChannels"
placeholder="Select Microsoft Teams channels"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'Discord'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect
v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels"
:available-channel-list="discordChannels"
placeholder="Select Discord channels"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'Mattermost'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect
v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels"
:available-channel-list="mattermostChannels"
placeholder="Select Mattermost channels"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="formInput[hook.notification.type] && hook.notification.payload" type="flex">
<a-col v-for="(input, i) in formInput[hook.notification.type]" :key="i" :span="24">
<a-form-item v-if="input.type === 'LongText'" v-bind="validateInfos[`notification.payload.${input.key}`]">
<a-textarea v-model:value="hook.notification.payload[input.key]" :placeholder="input.label" size="large" />
</a-form-item>
<a-form-item v-else v-bind="validateInfos[`notification.payload.${input.key}`]">
<a-input v-model:value="hook.notification.payload[input.key]" :placeholder="input.label" size="large" />
</a-form-item>
</a-col>
</a-row>
<a-row class="mb-5" type="flex">
<a-col :span="24">
<a-card>
<a-checkbox
:checked="Boolean(hook.condition)"
class="nc-check-box-hook-condition"
@update:checked="hook.condition = $event"
>
On Condition
</a-checkbox>
<LazySmartsheetToolbarColumnFilter
v-if="hook.condition"
ref="filterRef"
class="mt-4"
:auto-save="false"
:show-loading="false"
:hook-id="hook.id"
web-hook
/>
</a-card>
</a-col>
</a-row>
<a-row>
<a-col :span="24">
<div v-if="!(hook.version === 'v2' && hook.notification.type === 'URL')" class="text-gray-600">
<div class="flex items-center">
<component :is="iconMap.link" v-if="notificationOption.type === 'URL'" class="mr-2" />
<component :is="iconMap.email" v-if="notificationOption.type === 'Email'" class="mr-2" />
<MdiSlack v-if="notificationOption.type === 'Slack'" class="mr-2" />
<MdiMicrosoftTeams v-if="notificationOption.type === 'Microsoft Teams'" class="mr-2" />
<MdiDiscord v-if="notificationOption.type === 'Discord'" class="mr-2" />
<MdiChat v-if="notificationOption.type === 'Mattermost'" class="mr-2" />
<MdiWhatsapp v-if="notificationOption.type === 'Whatsapp Twilio'" class="mr-2" />
<MdiCellphoneMessage v-if="notificationOption.type === 'Twilio'" class="mr-2" />
<em>Use context variable <strong>data</strong> to refer the record under consideration</em>
<a-tooltip bottom>
<template #title>
<span> <strong>data</strong> : Row data <br /> </span>
</template>
<component :is="iconMap.info" class="ml-2" />
</a-tooltip>
</div>
{{ notificationOption.type }}
<div class="my-3">
<a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank">
<!-- Document Reference -->
{{ $t('labels.docReference') }}
</a>
</div>
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'URL'" class="mb-5" type="flex" :gutter="[16, 0]">
<a-col :span="6">
<a-select
v-model:value="hook.notification.payload.method"
size="large"
class="nc-select-hook-url-method"
dropdown-class-name="nc-dropdown-hook-notification-url-method"
>
<a-select-option v-for="(method, i) in methodList" :key="i" :value="method.title">
{{ method.title }}
</a-select-option>
</a-select>
</a-col>
<a-col :span="18">
<a-form-item v-bind="validateInfos['notification.payload.path']">
<a-input
v-model:value="hook.notification.payload.path"
size="large"
placeholder="http://example.com"
class="nc-text-field-hook-url-path"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-tabs v-model:activeKey="urlTabKey" type="card" closeable="false" class="shadow-sm">
<a-tab-pane key="body" tab="Body">
<LazyMonacoEditor
v-model="hook.notification.payload.body"
disable-deep-compare
:validate="false"
class="min-h-60 max-h-80"
</div>
<LazyWebhookTest
ref="webhookTestRef"
:hook="{
...hook,
notification: {
...hook.notification,
payload: hook.notification.payload,
},
}"
/>
</a-tab-pane>
<a-tab-pane key="params" tab="Params" force-render>
<LazyApiClientParams v-model="hook.notification.payload.parameters" />
</a-tab-pane>
<a-tab-pane key="headers" tab="Headers" class="nc-tab-headers">
<LazyApiClientHeaders v-model="hook.notification.payload.headers" />
</a-tab-pane>
<!-- No in use at this moment -->
<!-- <a-tab-pane key="auth" tab="Auth"> -->
<!-- <LazyMonacoEditor v-model="hook.notification.payload.auth" class="min-h-60 max-h-80" /> -->
<!-- <span class="text-gray-500 prose-sm p-2"> -->
<!-- For more about auth option refer -->
<!-- <a class="prose-sm" href ="https://github.com/axios/axios#request-config" target="_blank">axios docs</a>. -->
<!-- </span> -->
<!-- </a-tab-pane> -->
</a-tabs>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'Slack'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.channels']">
<LazyWebhookChannelMultiSelect
v-if="slackChannels.length > 0"
v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels"
:available-channel-list="slackChannels"
placeholder="Select Slack channels"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'Microsoft Teams'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.channels']">
<LazyWebhookChannelMultiSelect
v-if="teamsChannels.length > 0"
v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels"
:available-channel-list="teamsChannels"
placeholder="Select Microsoft Teams channels"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'Discord'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.channels']">
<LazyWebhookChannelMultiSelect
v-if="discordChannels.length > 0"
v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels"
:available-channel-list="discordChannels"
placeholder="Select Discord channels"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hook.notification.type === 'Mattermost'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.channels']">
<LazyWebhookChannelMultiSelect
v-if="mattermostChannels.length > 0"
v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels"
:available-channel-list="mattermostChannels"
placeholder="Select Mattermost channels"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="formInput[hook.notification.type] && hook.notification.payload" type="flex">
<a-col v-for="(input, i) in formInput[hook.notification.type]" :key="i" :span="24">
<a-form-item v-if="input.type === 'LongText'" v-bind="validateInfos[`notification.payload.${input.key}`]">
<a-textarea v-model:value="hook.notification.payload[input.key]" :placeholder="input.label" size="large" />
</a-form-item>
<a-form-item v-else v-bind="validateInfos[`notification.payload.${input.key}`]">
<a-input v-model:value="hook.notification.payload[input.key]" :placeholder="input.label" size="large" />
</a-form-item>
</a-col>
</a-row>
<a-row class="mb-5" type="flex">
<a-col :span="24">
<a-card>
<a-checkbox
:checked="Boolean(hook.condition)"
class="nc-check-box-hook-condition"
@update:checked="hook.condition = $event"
>
On Condition
</a-checkbox>
<LazySmartsheetToolbarColumnFilter
v-if="hook.condition"
ref="filterRef"
class="mt-4"
:auto-save="false"
:show-loading="false"
:hook-id="hook.id"
web-hook
/>
</a-card>
</a-col>
</a-row>
<a-row>
<a-col :span="24">
<div class="text-gray-600">
<div class="flex items-center">
<em>Use context variable <strong>data</strong> to refer the record under consideration</em>
<a-tooltip bottom>
<template #title>
<span> <strong>data</strong> : Row data <br /> </span>
</template>
<component :is="iconMap.info" class="ml-2" />
</a-tooltip>
</div>
<div class="mt-3">
<a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank">
<!-- Document Reference -->
{{ $t('labels.docReference') }}
</a>
</div>
</div>
<LazyWebhookTest
ref="webhookTestRef"
:hook="{
...hook,
notification: {
...hook.notification,
payload: hook.notification.payload,
},
}"
/>
</a-col>
</a-row>
</a-form-item>
</a-form>
</a-col>
</a-row>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="hook-log" class="nc-hook-log" :disabled="!props.hook">
<template #tab>
<span>
<MdiHistory />
Call Log
</span>
</template>
<LazyWebhookCallLog :hook="hook" />
</a-tab-pane>
</a-tabs>
</template>

11
packages/nc-gui/components/webhook/List.vue

@ -125,17 +125,18 @@ onMounted(() => {
<a-list-item class="p-2 nc-hook" @click="emit('edit', item)">
<a-list-item-meta>
<template #description>
<span class="uppercase"> {{ item.event }} {{ item.operation }}</span>
<span class="uppercase"> {{ item.event }} {{ item.operation.replace(/[A-Z]/g, ' $&') }}</span>
</template>
<template #title>
<span class="text-xl normal-case">
<div class="text-xl normal-case">
<span class="text-gray-400 text-sm"> ({{ item.version }}) </span>
{{ item.title }}
</span>
</div>
</template>
<template #avatar>
<div class="my-1 px-2">
<div class="px-2">
<component :is="iconMap.hook" class="text-xl" />
</div>
<div class="px-2 text-white rounded" :class="{ 'bg-green-500': item.active, 'bg-gray-500': !item.active }">
@ -150,7 +151,7 @@ onMounted(() => {
<div class="mr-2">{{ $t('labels.notifyVia') }} : {{ item?.notification?.type }}</div>
<div class="float-right pt-2 pr-1">
<a-tooltip placement="left">
<a-tooltip v-if="item.version === 'v2'" placement="left">
<template #title>
{{ $t('activity.copyWebhook') }}
</template>

20
packages/nc-gui/components/webhook/Test.vue

@ -14,10 +14,7 @@ const { $api } = useNuxtApp()
const meta = inject(MetaInj, ref())
const sampleData = ref({
data: {},
})
const activeKey = ref(0)
const sampleData = ref()
watch(
() => hook?.operation,
@ -27,9 +24,11 @@ watch(
)
async function loadSampleData() {
sampleData.value = {
data: await $api.dbTableWebhook.samplePayloadGet(meta?.value?.id as string, hook?.operation || 'insert'),
}
sampleData.value = await $api.dbTableWebhook.samplePayloadGet(
meta?.value?.id as string,
hook?.operation || 'insert',
hook.version!,
)
}
async function testWebhook() {
@ -59,9 +58,6 @@ onMounted(async () => {
</script>
<template>
<a-collapse v-model:activeKey="activeKey" ghost>
<a-collapse-panel key="1" header="Sample Payload">
<LazyMonacoEditor v-model="sampleData" class="min-h-60 max-h-80" />
</a-collapse-panel>
</a-collapse>
<div class="mb-4 font-weight-medium">Sample Payload</div>
<LazyMonacoEditor v-model="sampleData" class="min-h-60 max-h-80" />
</template>

1
packages/nc-gui/composables/useGlobal/state.ts

@ -101,6 +101,7 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
ncAttachmentFieldSize: 20,
ncMaxAttachmentsAllowed: 10,
isCloud: false,
automationLogLevel: 'OFF',
})
/** reactive token payload */

1
packages/nc-gui/composables/useGlobal/types.ts

@ -23,6 +23,7 @@ export interface AppInfo {
ncAttachmentFieldSize: number
ncMaxAttachmentsAllowed: number
isCloud: boolean
automationLogLevel: 'OFF' | 'ERROR' | 'ALL'
}
export interface StoredState {

6
packages/nc-gui/lib/enums.ts

@ -99,3 +99,9 @@ export enum DataSourcesSubTab {
Misc = 'Misc',
Edit = 'Edit',
}
export enum AutomationLogLevel {
OFF = 'OFF',
ERROR = 'ERROR',
ALL = 'ALL',
}

27
packages/noco-docs/content/en/developer-resources/webhooks.md

@ -19,6 +19,7 @@ Some types of notifications can be triggered by a webhook after a particular eve
<img width="686" alt="image" src="https://user-images.githubusercontent.com/35857179/194849248-1d0b80c6-f65b-4075-8ebd-af7dc735c2c3.png">
### Configure Webhook
- General configurations
- Webhook Name
- Webhook Trigger
@ -32,6 +33,16 @@ Some types of notifications can be triggered by a webhook after a particular eve
<!-- ![image](https://user-images.githubusercontent.com/35857179/166660248-a3c81a34-4334-48c2-846a-65759d761559.png) -->
## Call Log
Call Log allows user to check the call history of the hook. By default, it has been disabled. However, it can be configured by using environment variable `NC_AUTOMATION_LOG_LEVEL`.
- `NC_AUTOMATION_LOG_LEVEL=OFF`: No logs will be displayed and no history will be inserted to meta database.
- `NC_AUTOMATION_LOG_LEVEL=ERROR`: only error logs will be displayed and history of error logs will be inserted to meta database.
- `NC_AUTOMATION_LOG_LEVEL=ALL`: Both error and success logs will be displayed and history of both types of logs will be inserted to meta database. **This option is only available for Enterprise Edition.**
![image](https://user-images.githubusercontent.com/35857179/228790148-1e3f21c7-9385-413a-843f-b93073ca6bea.png)
## Triggers
@ -59,6 +70,11 @@ The triggers will trigger asynchronously without blocking the actual operation.
## Accessing Data: Handlebars
<alert type="warning">
You can accessing data using handlebars for v1 webhooks only.
</alert>
The current row data and other details will be available in the hooks payload so the user can use [handlebar syntax](https://handlebarsjs.com/guide/#simple-expressions) to use data.
> We are using [Handlebars](https://handlebarsjs.com/) library to parse the payload internally.
@ -136,6 +152,7 @@ Detailed procedure for discord webhook described [here](https://support.discord.
## Slack
### 1. Create WebHook
- Details to create slack webhook are captured [here](https://api.slack.com/messaging/webhooks)
@ -196,3 +213,13 @@ Detailed procedure for discord webhook described [here](https://support.discord.
- **Body**: Message to be posted over Teams channel, via webhooks on trigger of configured event.
- Body can contain plain text &
- Handlebars {{ }}
## Webhook V2
Webhook v2 is available after v0.106.0. Here's the differences.
- Response Payload has been predefined and cannot configure in Body using Handlebars. The payload can be referenced under `Sample Payload` in Hook detail page.
- Support the following bulk operations:
- AFTER BULK INSERT
- AFTER BULK UPDATE
- AFTER BULK DELETE

1
packages/noco-docs/content/en/getting-started/environment-variables.md

@ -62,3 +62,4 @@ For production usecases, it is **recommended** to configure
| NODE_OPTIONS | For passing Node.js [options](https://nodejs.org/api/cli.html#node_optionsoptions) to instance | | |
| NC_MINIMAL_DBS | Create a new SQLite file for each project. All the db files are stored in `nc_minimal_dbs` folder in current working directory. (This option restricts project creation on external sources) | | |
| NC_DISABLE_AUDIT | Disable Audit Log | `false` | |
| NC_AUTOMATION_LOG_LEVEL | Possible Values: `OFF`, `ERROR`, `ALL`. See [Webhooks](/developer-resources/webhooks#call-log) for details. | `OFF` | |

183
packages/nocodb-sdk/src/lib/Api.ts

@ -622,6 +622,16 @@ export interface FilterListType {
pageInfo: PaginatedType;
}
/**
* Model for Filter Log List
*/
export interface FilterLogListType {
/** List of filter objects */
list: FilterType[];
/** Model for Paginated */
pageInfo: PaginatedType;
}
/**
* Model for Filter Request
*/
@ -983,6 +993,29 @@ export interface GridType {
columns?: GridColumnType[];
}
/**
* Model for Grid
*/
export interface GridCopyType {
/** Unique ID */
id?: IdType;
/** Project ID */
project_id?: IdType;
/** Base ID */
base_id?: IdType;
/** Foreign Key to View */
fk_view_id?: IdType;
/**
* Row Height
* @example 1
*/
row_height?: number;
/** Meta info for Grid Model */
meta?: MetaType;
/** Grid View Columns */
columns?: GridColumnType[];
}
/**
* Model for Grid Column
*/
@ -1082,7 +1115,13 @@ export interface HookType {
* Hook Operation
* @example insert
*/
operation?: 'delete' | 'insert' | 'update';
operation?:
| 'insert'
| 'update'
| 'delete'
| 'bulkInsert'
| 'bulkUpdate'
| 'bulkDelete';
/**
* Retry Count
* @example 10
@ -1105,6 +1144,11 @@ export interface HookType {
title?: string;
/** Hook Type */
type?: string;
/**
* Hook Version
* @example v2
*/
version?: 'v1' | 'v2';
}
/**
@ -1140,7 +1184,13 @@ export interface HookReqType {
* Hook Operation
* @example insert
*/
operation: 'delete' | 'insert' | 'update';
operation:
| 'insert'
| 'update'
| 'delete'
| 'bulkInsert'
| 'bulkUpdate'
| 'bulkDelete';
/**
* Retry Count
* @example 10
@ -1181,28 +1231,79 @@ export interface HookListType {
* Model for Hook Log
*/
export interface HookLogType {
/**
* Unique Base ID
* @example ds_jxuewivwbxeum2
*/
base_id?: string;
/** Hook Conditions */
conditions?: string;
error?: string;
error_code?: string;
error_message?: string;
event?: string;
/** Error */
error?: StringOrNullType;
/** Error Code */
error_code?: StringOrNullType;
/** Error Message */
error_message?: StringOrNullType;
/**
* Hook Event
* @example after
*/
event?: 'after' | 'before';
/**
* Execution Time in milliseconds
* @example 98
*/
execution_time?: string;
/** Model for StringOrNull */
/** Foreign Key to Hook */
fk_hook_id?: StringOrNullType;
/** Unique ID */
id?: IdType;
id?: StringOrNullType;
/** Hook Notification */
notifications?: string;
operation?: string;
payload?: any;
/**
* Hook Operation
* @example insert
*/
operation?:
| 'insert'
| 'update'
| 'delete'
| 'bulkInsert'
| 'bulkUpdate'
| 'bulkDelete';
/**
* Hook Payload
* @example {"method":"POST","body":"{{ json data }}","headers":[{}],"parameters":[{}],"auth":"","path":"https://webhook.site/6eb45ce5-b611-4be1-8b96-c2965755662b"}
*/
payload?: string;
/**
* Project ID
* @example p_tbhl1hnycvhe5l
*/
project_id?: string;
response?: string;
/** Model for Bool */
/** Hook Response */
response?: StringOrNullType;
/** Is this testing hook call? */
test_call?: BoolType;
triggered_by?: string;
/** Who triggered the hook? */
triggered_by?: StringOrNullType;
/**
* Hook Type
* @example URL
*/
type?: string;
}
/**
* Model for Hook Log List
*/
export interface HookLogListType {
/** List of hook objects */
list: HookLogType[];
/** Model for Paginated */
pageInfo: PaginatedType;
}
/**
* Model for Hook Test Request
*/
@ -6399,6 +6500,45 @@ export class Api<
...params,
}),
};
dbTableWebhookLogs = {
/**
* @description List the log data in a given Hook
*
* @tags DB Table Webhook Logs
* @name List
* @summary List Hook Logs
* @request GET:/api/v1/db/meta/hooks/{hookId}/logs
* @response `200` `HookLogListType` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
list: (
hookId: IdType,
query?: {
/** @min 1 */
limit?: number;
/** @min 0 */
offset?: number;
},
params: RequestParams = {}
) =>
this.request<
HookLogListType,
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/hooks/${hookId}/logs`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
};
dbTableRow = {
/**
* @description List all table rows in a given table and project
@ -8409,6 +8549,8 @@ export class Api<
ncAttachmentFieldSize?: number,
ncMaxAttachmentsAllowed?: number,
isCloud?: boolean,
\** @example OFF *\
automationLogLevel?: "OFF" | "ERROR" | "ALL",
}` OK
* @response `400` `{
@ -8438,6 +8580,8 @@ export class Api<
ncAttachmentFieldSize?: number;
ncMaxAttachmentsAllowed?: number;
isCloud?: boolean;
/** @example OFF */
automationLogLevel?: 'OFF' | 'ERROR' | 'ALL';
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
@ -8860,7 +9004,7 @@ export class Api<
* @tags DB Table Webhook
* @name SamplePayloadGet
* @summary Get Sample Hook Payload
* @request GET:/api/v1/db/meta/tables/{tableId}/hooks/samplePayload/{operation}
* @request GET:/api/v1/db/meta/tables/{tableId}/hooks/samplePayload/{operation}/{version}
* @response `200` `{
\** Sample Payload Data *\
data?: object,
@ -8874,7 +9018,14 @@ export class Api<
*/
samplePayloadGet: (
tableId: IdType,
operation: 'update' | 'delete' | 'insert',
operation:
| 'insert'
| 'update'
| 'delete'
| 'bulkInsert'
| 'bulkUpdate'
| 'bulkDelete',
version: 'v1' | 'v2',
params: RequestParams = {}
) =>
this.request<
@ -8887,7 +9038,7 @@ export class Api<
msg: string;
}
>({
path: `/api/v1/db/meta/tables/${tableId}/hooks/samplePayload/${operation}`,
path: `/api/v1/db/meta/tables/${tableId}/hooks/samplePayload/${operation}/${version}`,
method: 'GET',
format: 'json',
...params,

2
packages/nocodb/src/lib/Noco.ts

@ -102,7 +102,7 @@ export default class Noco {
constructor() {
process.env.PORT = process.env.PORT || '8080';
// todo: move
process.env.NC_VERSION = '0105003';
process.env.NC_VERSION = '0105004';
// if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources
if (process.env.NC_MINIMAL_DBS) {

58
packages/nocodb/src/lib/controllers/hook.ctl.ts

@ -4,7 +4,7 @@ import { PagedResponseImpl } from '../meta/helpers/PagedResponse';
import ncMetaAclMw from '../meta/helpers/ncMetaAclMw';
import { metaApiMetrics } from '../meta/helpers/apiMetrics';
import { hookService } from '../services';
import type { HookListType, HookType } from 'nocodb-sdk';
import type { HookListType, HookLogListType, HookType } from 'nocodb-sdk';
import type { Request, Response } from 'express';
export async function hookList(
@ -46,29 +46,62 @@ export async function hookUpdate(
}
export async function hookTest(req: Request<any, any>, res: Response) {
await hookService.hookTest({
hookTest: req.body,
tableId: req.params.tableId,
});
res.json({ msg: 'The hook has been tested successfully' });
try {
await hookService.hookTest({
hookTest: {
...req.body,
payload: {
...req.body.payload,
user: (req as any)?.user,
},
},
tableId: req.params.tableId,
});
res.json({ msg: 'The hook has been tested successfully' });
} catch (e) {
console.error(e);
throw e;
}
}
export async function tableSampleData(req: Request, res: Response) {
res.json(
await hookService.tableSampleData({
tableId: req.params.tableId,
// todo: replace any with type
operation: req.params.operation as any,
operation: req.params.operation as HookType['operation'],
version: req.params.version as HookType['version'],
})
);
}
export async function hookLogList(
req: Request<any, any, any>,
res: Response<HookLogListType>
) {
res.json(
new PagedResponseImpl(
await hookService.hookLogList({
query: req.query,
hookId: req.params.hookId,
}),
{
...req.query,
count: await hookService.hookLogCount({
hookId: req.params.hookId,
}),
}
)
);
}
const router = Router({ mergeParams: true });
router.get(
'/api/v1/db/meta/tables/:tableId/hooks',
metaApiMetrics,
ncMetaAclMw(hookList, 'hookList')
);
router.post(
'/api/v1/db/meta/tables/:tableId/hooks/test',
metaApiMetrics,
@ -90,8 +123,15 @@ router.patch(
ncMetaAclMw(hookUpdate, 'hookUpdate')
);
router.get(
'/api/v1/db/meta/tables/:tableId/hooks/samplePayload/:operation',
'/api/v1/db/meta/tables/:tableId/hooks/samplePayload/:operation/:version',
metaApiMetrics,
catchError(tableSampleData)
);
router.get(
'/api/v1/db/meta/hooks/:hookId/logs',
metaApiMetrics,
ncMetaAclMw(hookLogList, 'hookLogList')
);
export default router;

181
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts

@ -1,6 +1,7 @@
import autoBind from 'auto-bind';
import groupBy from 'lodash/groupBy';
import DataLoader from 'dataloader';
import { nocoExecute } from 'nc-help';
import {
AuditOperationSubTypes,
AuditOperationTypes,
@ -14,8 +15,10 @@ import ejs from 'ejs';
import Validator from 'validator';
import { customAlphabet } from 'nanoid';
import DOMPurify from 'isomorphic-dompurify';
import { getViewAndModelByAliasOrId } from '../../../../services/dbData/helpers';
import Model from '../../../../models/Model';
import Column from '../../../../models/Column';
import Project from '../../../../models/Project';
import Filter, {
COMPARISON_OPS,
COMPARISON_SUB_OPS,
@ -33,6 +36,7 @@ import {
invokeWebhook,
} from '../../../../meta/helpers/webhookHelpers';
import { NcError } from '../../../../meta/helpers/catchError';
import getAst from './helpers/getAst';
import { customValidators } from './customValidators';
import formulaQueryBuilderv2 from './formulav2/formulaQueryBuilderv2';
import genRollupSelectv2 from './genRollupSelectv2';
@ -136,6 +140,15 @@ class BaseModelSqlv2 {
const proto = await this.getProto();
data.__proto__ = proto;
}
// retrieve virtual column data as well
const project = await Project.get(this.model.project_id);
const { model, view } = await getViewAndModelByAliasOrId({
projectName: project.title,
tableName: this.model.title,
});
const { ast } = await getAst({ model, view });
data = await nocoExecute(ast, data, {});
return data;
}
@ -1700,9 +1713,6 @@ class BaseModelSqlv2 {
await this.beforeInsert(insertObj, trx, cookie);
}
// if ('beforeInsert' in this) {
// await this.beforeInsert(insertObj, trx, cookie);
// }
await this.model.getColumns();
let response;
// const driver = trx ? trx : this.dbDriver;
@ -1774,7 +1784,7 @@ class BaseModelSqlv2 {
async delByPk(id, trx?, cookie?) {
try {
// retrieve data for handling paramas in hook
// retrieve data for handling params in hook
const data = await this.readByPk(id);
await this.beforeDelete(id, trx, cookie);
const response = await this.dbDriver(this.tnPath)
@ -1839,15 +1849,17 @@ class BaseModelSqlv2 {
await this.beforeUpdate(data, trx, cookie);
const prevData = await this.readByPk(id);
const query = this.dbDriver(this.tnPath)
.update(updateObj)
.where(await this._wherePk(id));
await this.execAndParse(query);
const response = await this.readByPk(id);
await this.afterUpdate(response, trx, cookie);
return response;
const newData = await this.readByPk(id);
await this.afterUpdate(prevData, newData, trx, cookie);
return newData;
} catch (e) {
console.log(e);
await this.errorUpdate(e, data, trx, cookie);
@ -2031,21 +2043,14 @@ class BaseModelSqlv2 {
rowId =
response[this.model.primaryKey.title] ||
response[this.model.primaryKey.column_name];
await Promise.all(postInsertOps.map((f) => f()));
// if (!trx) {
// await driver.commit();
// }
await Promise.all(postInsertOps.map((f) => f()));
await this.afterInsert(response, this.dbDriver, cookie);
return response;
} catch (e) {
console.log(e);
// await this.errorInsert(e, data, trx, cookie);
// if (!trx) {
// await driver.rollback(e);
// }
throw e;
}
}
@ -2073,11 +2078,6 @@ class BaseModelSqlv2 {
for (const data of datas) {
await this.validate(data);
}
// let chunkSize = 50;
//
// if (this.isSqlite && datas[0]) {
// chunkSize = Math.max(1, Math.floor(999 / Object.keys(datas[0]).length));
// }
// fallbacks to `10` if database client is sqlite
// to avoid `too many SQL variables` error
@ -2114,6 +2114,9 @@ class BaseModelSqlv2 {
transaction = await this.dbDriver.transaction();
// await this.beforeUpdateb(updateDatas, transaction);
const prevData = [];
const newData = [];
const updatePkValues = [];
const res = [];
for (const d of updateDatas) {
await this.validate(d);
@ -2122,21 +2125,24 @@ class BaseModelSqlv2 {
// pk not specified - bypass
continue;
}
prevData.push(await this.readByPk(pkValues));
const wherePk = await this._wherePk(pkValues);
const response = await transaction(this.tnPath)
.update(d)
.where(wherePk);
res.push(response);
await transaction(this.tnPath).update(d).where(wherePk);
res.push(wherePk);
updatePkValues.push(pkValues);
}
await transaction.commit();
for (const pkValues of updatePkValues) {
newData.push(await this.readByPk(pkValues));
}
await this.afterBulkUpdate(updateDatas.length, this.dbDriver, cookie);
transaction.commit();
await this.afterBulkUpdate(prevData, newData, this.dbDriver, cookie);
return res;
} catch (e) {
if (transaction) transaction.rollback();
// console.log(e);
// await this.errorUpdateb(e, data, null);
if (transaction) await transaction.rollback();
throw e;
}
}
@ -2146,8 +2152,8 @@ class BaseModelSqlv2 {
data,
{ cookie }: { cookie?: any } = {}
) {
let queryResponse;
try {
let count = 0;
const updateData = await this.model.mapAliasToColumn(data);
await this.validate(updateData);
const pkValues = await this._extractPksValues(updateData);
@ -2178,11 +2184,11 @@ class BaseModelSqlv2 {
);
qb.update(updateData);
queryResponse = (await qb) as any;
count = (await qb) as any;
}
const count = queryResponse ?? 0;
await this.afterBulkUpdate(count, this.dbDriver, cookie);
await this.afterBulkUpdate(null, count, this.dbDriver, cookie, true);
return count;
} catch (e) {
@ -2197,27 +2203,32 @@ class BaseModelSqlv2 {
ids.map((d) => this.model.mapAliasToColumn(d))
);
transaction = await this.dbDriver.transaction();
// await this.beforeDeleteb(ids, transaction);
const deleted = [];
const res = [];
for (const d of deleteIds) {
if (Object.keys(d).length) {
const response = await transaction(this.tnPath).del().where(d);
res.push(response);
const pkValues = await this._extractPksValues(d);
if (!pkValues) {
// pk not specified - bypass
continue;
}
deleted.push(await this.readByPk(pkValues));
res.push(d);
}
// await this.afterDeleteb(res, transaction);
transaction.commit();
transaction = await this.dbDriver.transaction();
for (const d of res) {
await transaction(this.tnPath).del().where(d);
}
await transaction.commit();
await this.afterBulkDelete(ids.length, this.dbDriver, cookie);
await this.afterBulkDelete(deleted, this.dbDriver, cookie);
return res;
} catch (e) {
if (transaction) transaction.rollback();
if (transaction) await transaction.rollback();
console.log(e);
// await this.errorDeleteb(e, ids);
throw e;
}
}
@ -2249,10 +2260,12 @@ class BaseModelSqlv2 {
qb,
this.dbDriver
);
qb.del();
const count = (await qb) as any;
await this.afterBulkDelete(count, this.dbDriver, cookie);
await this.afterBulkDelete(count, this.dbDriver, cookie, true);
return count;
} catch (e) {
@ -2265,12 +2278,11 @@ class BaseModelSqlv2 {
* */
public async beforeInsert(data: any, _trx: any, req): Promise<void> {
await this.handleHooks('Before.insert', data, req);
await this.handleHooks('before.insert', null, data, req);
}
public async afterInsert(data: any, _trx: any, req): Promise<void> {
await this.handleHooks('After.insert', data, req);
// if (req?.headers?.['xc-gui']) {
await this.handleHooks('after.insert', null, data, req);
const id = this._extractPksValues(data);
await Audit.insert({
fk_model_id: this.model.id,
@ -2284,16 +2296,27 @@ class BaseModelSqlv2 {
ip: req?.clientIp,
user: req?.user?.email,
});
// }
}
public async afterBulkUpdate(count: number, _trx: any, req): Promise<void> {
public async afterBulkUpdate(
prevData: any,
newData: any,
_trx: any,
req,
isBulkAllOperation = false
): Promise<void> {
let noOfUpdatedRecords = newData;
if (!isBulkAllOperation) {
noOfUpdatedRecords = newData.length;
await this.handleHooks('after.bulkUpdate', prevData, newData, req);
}
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_UPDATE,
description: DOMPurify.sanitize(
`${count} records bulk updated in ${this.model.title}`
`${noOfUpdatedRecords} records bulk updated in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
@ -2301,13 +2324,24 @@ class BaseModelSqlv2 {
});
}
public async afterBulkDelete(count: number, _trx: any, req): Promise<void> {
public async afterBulkDelete(
data: any,
_trx: any,
req,
isBulkAllOperation = false
): Promise<void> {
let noOfDeletedRecords = data;
if (!isBulkAllOperation) {
noOfDeletedRecords = data.length;
await this.handleHooks('after.bulkDelete', null, data, req);
}
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_DELETE,
description: DOMPurify.sanitize(
`${count} records bulk deleted in ${this.model.title}`
`${noOfDeletedRecords} records bulk deleted in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
@ -2316,6 +2350,8 @@ class BaseModelSqlv2 {
}
public async afterBulkInsert(data: any[], _trx: any, req): Promise<void> {
await this.handleHooks('after.bulkInsert', null, data, req);
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
@ -2337,12 +2373,18 @@ class BaseModelSqlv2 {
}
}
if (ignoreWebhook === undefined || ignoreWebhook === 'false') {
await this.handleHooks('Before.update', data, req);
await this.handleHooks('before.update', null, data, req);
}
}
public async afterUpdate(data: any, _trx: any, req): Promise<void> {
const id = this._extractPksValues(data);
public async afterUpdate(
prevData: any,
newData: any,
_trx: any,
req
): Promise<void> {
const id = this._extractPksValues(newData);
await Audit.insert({
fk_model_id: this.model.id,
row_id: id,
@ -2361,16 +2403,15 @@ class BaseModelSqlv2 {
}
}
if (ignoreWebhook === undefined || ignoreWebhook === 'false') {
await this.handleHooks('After.update', data, req);
await this.handleHooks('after.update', prevData, newData, req);
}
}
public async beforeDelete(data: any, _trx: any, req): Promise<void> {
await this.handleHooks('Before.delete', data, req);
await this.handleHooks('before.delete', null, data, req);
}
public async afterDelete(data: any, _trx: any, req): Promise<void> {
// if (req?.headers?.['xc-gui']) {
const id = req?.params?.id;
await Audit.insert({
fk_model_id: this.model.id,
@ -2382,15 +2423,17 @@ class BaseModelSqlv2 {
ip: req?.clientIp,
user: req?.user?.email,
});
// }
await this.handleHooks('After.delete', data, req);
await this.handleHooks('after.delete', null, data, req);
}
private async handleHooks(hookName, data, req): Promise<void> {
private async handleHooks(hookName, prevData, newData, req): Promise<void> {
const view = await View.get(this.viewId);
// handle form view data submission
if (hookName === 'After.insert' && view.type === ViewTypes.FORM) {
if (
(hookName === 'after.insert' || hookName === 'after.bulkInsert') &&
view.type === ViewTypes.FORM
) {
try {
const formView = await view.getView<FormView>();
const { columns } = await FormView.getWithInfo(formView.fk_view_id);
@ -2440,11 +2483,11 @@ class BaseModelSqlv2 {
.map((a) => a[0]);
if (emails?.length) {
const transformedData = _transformSubmittedFormDataForEmail(
data,
newData,
formView,
filteredColumns
);
(await NcPluginMgrv2.emailAdapter())?.mailSend({
(await NcPluginMgrv2.emailAdapter(false))?.mailSend({
to: emails.join(','),
subject: 'NocoDB Form',
html: ejs.render(formSubmissionEmailTemplate, {
@ -2468,7 +2511,7 @@ class BaseModelSqlv2 {
});
for (const hook of hooks) {
if (hook.active) {
invokeWebhook(hook, this.model, data, req?.user);
invokeWebhook(hook, this.model, view, prevData, newData, req?.user);
}
}
} catch (e) {
@ -2634,6 +2677,8 @@ class BaseModelSqlv2 {
break;
}
const response = await this.readByPk(rowId);
await this.afterInsert(response, this.dbDriver, cookie);
await this.afterAddChild(rowId, childId, cookie);
}
@ -2681,6 +2726,8 @@ class BaseModelSqlv2 {
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const prevData = await this.readByPk(rowId);
switch (colOptions.type) {
case RelationTypes.MANY_TO_MANY:
{
@ -2732,6 +2779,8 @@ class BaseModelSqlv2 {
break;
}
const newData = await this.readByPk(rowId);
await this.afterUpdate(prevData, newData, this.dbDriver, cookie);
await this.afterRemoveChild(rowId, childId, cookie);
}

3
packages/nocodb/src/lib/meta/NcMetaIOImpl.ts

@ -855,6 +855,9 @@ export default class NcMetaIOImpl extends NcMetaIO {
case MetaTable.HOOKS:
prefix = 'hk_';
break;
case MetaTable.HOOK_LOGS:
prefix = 'hkl_';
break;
case MetaTable.AUDIT:
prefix = 'adt_';
break;

10
packages/nocodb/src/lib/meta/helpers/NcPluginMgrv2.ts

@ -174,6 +174,7 @@ class NcPluginMgrv2 {
}
public static async emailAdapter(
isUserInvite = true,
ncMeta = Noco.ncMeta
): Promise<IEmailAdapter> {
const pluginData = await ncMeta.metaGet2(null, null, MetaTable.PLUGIN, {
@ -181,7 +182,12 @@ class NcPluginMgrv2 {
active: true,
});
if (!pluginData) return null;
if (!pluginData) {
// return null to show the invite link in UI
if (isUserInvite) return null;
// for webhooks, throw the error
throw new Error('Plugin not configured / active');
}
const pluginConfig = defaultPlugins.find(
(c) => c.title === pluginData.title && c.category === PluginCategory.EMAIL
@ -205,7 +211,7 @@ class NcPluginMgrv2 {
active: true,
});
if (!pluginData) throw new Error('Plugin not configured/active');
if (!pluginData) throw new Error('Plugin not configured / active');
const pluginConfig = defaultPlugins.find(
(c) => c.title === pluginData.title

65
packages/nocodb/src/lib/meta/helpers/populateSamplePayload.ts

@ -1,4 +1,5 @@
import { RelationTypes, UITypes } from 'nocodb-sdk';
import { v4 as uuidv4 } from 'uuid';
import View from '../../models/View';
import Column from '../../models/Column';
import Model from '../../models/Model';
@ -6,7 +7,7 @@ import type LinkToAnotherRecordColumn from '../../models/LinkToAnotherRecordColu
import type LookupColumn from '../../models/LookupColumn';
import type SelectOption from '../../models/SelectOption';
export default async function populateSamplePayload(
export async function populateSamplePayload(
viewOrModel: View | Model,
includeNested = false,
operation = 'insert'
@ -42,6 +43,68 @@ export default async function populateSamplePayload(
return out;
}
export async function populateSamplePayloadV2(
viewOrModel: View | Model,
includeNested = false,
operation = 'insert',
scope = 'records'
) {
const rows = {};
let columns: Column[] = [];
let model: Model;
if (viewOrModel instanceof View) {
const viewColumns = await viewOrModel.getColumns();
for (const col of viewColumns) {
if (col.show) columns.push(await Column.get({ colId: col.fk_column_id }));
}
model = await viewOrModel.getModel();
await model.getColumns();
} else if (viewOrModel instanceof Model) {
columns = await viewOrModel.getColumns();
model = viewOrModel;
}
await model.getViews();
const samplePayload = {
type: `${scope}.after.${operation}`,
id: uuidv4(),
data: {
table_id: model.id,
table_name: model.title,
view_id: model.views[0].id,
view_name: model.views[0].title,
},
};
for (const column of columns) {
if (
!includeNested &&
[UITypes.LinkToAnotherRecord, UITypes.Lookup].includes(column.uidt)
)
continue;
rows[column.title] = await getSampleColumnValue(column);
}
let prevRows;
if (['update', 'bulkUpdate'].includes(operation)) {
prevRows = rows;
}
samplePayload.data = {
...samplePayload.data,
...(prevRows && { previous_rows: [prevRows] }),
...(operation !== 'bulkInsert' && rows && { rows: [rows] }),
...(operation === 'bulkInsert' &&
rows && {
row_inserted: 10,
}),
};
return samplePayload;
}
async function getSampleColumnValue(column: Column): Promise<any> {
switch (column.uidt) {
case UITypes.ID:

224
packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts

@ -1,10 +1,12 @@
import Handlebars from 'handlebars';
import { v4 as uuidv4 } from 'uuid';
import Filter from '../../models/Filter';
import HookLog from '../../models/HookLog';
import NcPluginMgrv2 from './NcPluginMgrv2';
import type Model from '../../models/Model';
import type Column from '../../models/Column';
import type View from '../../models/View';
import type Hook from '../../models/Hook';
import type Column from '../../models/Column';
import type { HookLogType } from 'nocodb-sdk';
import type FormView from '../../models/FormView';
@ -133,13 +135,54 @@ export async function validateCondition(filters: Filter[], data: any) {
return isValid;
}
export async function handleHttpWebHook(apiMeta, user, data) {
// try {
const req = axiosRequestMake(apiMeta, user, data);
await require('axios')(req);
// } catch (e) {
// console.log(e);
// }
export function constructWebHookData(hook, model, view, prevData, newData) {
if (hook.version === 'v2') {
// extend in the future - currently only support records
const scope = 'records';
return {
type: `${scope}.${hook.event}.${hook.operation}`,
id: uuidv4(),
data: {
table_id: model.id,
table_name: model.title,
view_id: view?.id,
view_name: view?.title,
...(prevData && {
previous_rows: Array.isArray(prevData) ? prevData : [prevData],
}),
...(hook.operation !== 'bulkInsert' &&
newData && { rows: Array.isArray(newData) ? newData : [newData] }),
...(hook.operation === 'bulkInsert' && {
rows_inserted: Array.isArray(newData)
? newData.length
: newData
? 1
: 0,
}),
},
};
}
// for v1, keep it as it is
return newData;
}
export async function handleHttpWebHook(
hook,
model,
view,
apiMeta,
user,
prevData,
newData
) {
const req = axiosRequestMake(
apiMeta,
user,
constructWebHookData(hook, model, view, prevData, newData)
);
return require('axios')(req);
}
export function axiosRequestMake(_apiMeta, _user, data) {
@ -203,29 +246,57 @@ export function axiosRequestMake(_apiMeta, _user, data) {
export async function invokeWebhook(
hook: Hook,
_model: Model,
data,
model: Model,
view: View,
prevData,
newData,
user,
testFilters = null,
throwErrorOnFailure = false
throwErrorOnFailure = false,
testHook = false
) {
let hookLog: HookLogType;
const startTime = process.hrtime();
let notification;
try {
// for (const hook of hooks) {
const notification =
notification =
typeof hook.notification === 'string'
? JSON.parse(hook.notification)
: hook.notification;
const isBulkOperation = Array.isArray(newData);
if (isBulkOperation && notification?.type !== 'URL') {
// only URL hook is supported for bulk operations
return;
}
if (hook.condition) {
if (
!(await validateCondition(
testFilters || (await hook.getFilters()),
data
))
) {
return;
if (isBulkOperation) {
const filteredData = [];
for (const data of newData) {
if (
await validateCondition(
testFilters || (await hook.getFilters()),
data
)
) {
filteredData.push(data);
}
if (!filteredData.length) {
return;
}
newData = filteredData;
}
} else {
if (
!(await validateCondition(
testFilters || (await hook.getFilters()),
newData
))
) {
return;
}
}
}
@ -233,36 +304,57 @@ export async function invokeWebhook(
case 'Email':
{
const res = await (
await NcPluginMgrv2.emailAdapter()
await NcPluginMgrv2.emailAdapter(false)
)?.mailSend({
to: parseBody(notification?.payload?.to, data),
subject: parseBody(notification?.payload?.subject, data),
html: parseBody(notification?.payload?.body, data),
to: parseBody(notification?.payload?.to, newData),
subject: parseBody(notification?.payload?.subject, newData),
html: parseBody(notification?.payload?.body, newData),
});
hookLog = {
...hook,
type: notification.type,
payload: JSON.stringify(notification?.payload),
response: JSON.stringify(res),
triggered_by: user?.email,
};
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'ALL') {
hookLog = {
...hook,
fk_hook_id: hook.id,
type: notification.type,
payload: JSON.stringify(notification?.payload),
response: JSON.stringify(res),
triggered_by: user?.email,
};
}
}
break;
case 'URL':
{
const res = await handleHttpWebHook(
hook,
model,
view,
notification?.payload,
user,
data
prevData,
newData
);
hookLog = {
...hook,
type: notification.type,
payload: JSON.stringify(notification?.payload),
response: JSON.stringify(res),
triggered_by: user?.email,
};
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'ALL') {
hookLog = {
...hook,
fk_hook_id: hook.id,
type: notification.type,
payload: JSON.stringify(notification?.payload),
response: JSON.stringify({
status: res.status,
statusText: res.statusText,
headers: res.headers,
config: {
url: res.config.url,
method: res.config.method,
data: res.config.data,
headers: res.config.headers,
params: res.config.params,
},
}),
triggered_by: user?.email,
};
}
}
break;
default:
@ -270,37 +362,59 @@ export async function invokeWebhook(
const res = await (
await NcPluginMgrv2.webhookNotificationAdapters(notification.type)
).sendMessage(
parseBody(notification?.payload?.body, data),
parseBody(notification?.payload?.body, newData),
JSON.parse(JSON.stringify(notification?.payload), (_key, value) => {
return typeof value === 'string' ? parseBody(value, data) : value;
return typeof value === 'string'
? parseBody(value, newData)
: value;
})
);
hookLog = {
...hook,
type: notification.type,
payload: JSON.stringify(notification?.payload),
response: JSON.stringify(res),
triggered_by: user?.email,
};
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'ALL') {
hookLog = {
...hook,
fk_hook_id: hook.id,
type: notification.type,
payload: JSON.stringify(notification?.payload),
response: JSON.stringify({
status: res.status,
statusText: res.statusText,
headers: res.headers,
config: {
url: res.config.url,
method: res.config.method,
data: res.config.data,
headers: res.config.headers,
params: res.config.params,
},
}),
triggered_by: user?.email,
};
}
}
break;
}
} catch (e) {
console.log(e);
hookLog = {
...hook,
error_code: e.error_code,
error_message: e.message,
error: JSON.stringify(e),
};
if (['ERROR', 'ALL'].includes(process.env.NC_AUTOMATION_LOG_LEVEL)) {
hookLog = {
...hook,
type: notification.type,
payload: JSON.stringify(notification?.payload),
fk_hook_id: hook.id,
error_code: e.error_code,
error_message: e.message,
error: JSON.stringify(e),
triggered_by: user?.email,
};
}
if (throwErrorOnFailure) throw e;
} finally {
if (hookLog) {
hookLog.execution_time = parseHrtimeToMilliSeconds(
process.hrtime(startTime)
);
HookLog.insert({ ...hookLog, test_call: !!testFilters });
HookLog.insert({ ...hookLog, test_call: testHook });
}
}
}

4
packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts

@ -16,6 +16,7 @@ import * as nc_025_add_row_height from './v2/nc_025_add_row_height';
import * as nc_026_map_view from './v2/nc_026_map_view';
import * as nc_027_add_comparison_sub_op from './v2/nc_027_add_comparison_sub_op';
import * as nc_028_add_enable_scanner_in_form_columns_meta_table from './v2/nc_028_add_enable_scanner_in_form_columns_meta_table';
import * as nc_029_webhook from './v2/nc_029_webhook';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -43,6 +44,7 @@ export default class XcMigrationSourcev2 {
'nc_026_map_view',
'nc_027_add_comparison_sub_op',
'nc_028_add_enable_scanner_in_form_columns_meta_table',
'nc_029_webhook',
]);
}
@ -88,6 +90,8 @@ export default class XcMigrationSourcev2 {
return nc_027_add_comparison_sub_op;
case 'nc_028_add_enable_scanner_in_form_columns_meta_table':
return nc_028_add_enable_scanner_in_form_columns_meta_table;
case 'nc_029_webhook':
return nc_029_webhook;
}
}
}

35
packages/nocodb/src/lib/migrations/v2/nc_029_webhook.ts

@ -0,0 +1,35 @@
import { MetaTable } from '../../utils/globals';
import type { Knex } from 'knex';
const up = async (knex: Knex) => {
if (knex.client.config.client === 'mssql') {
await knex.schema.alterTable(MetaTable.HOOK_LOGS, (table) => {
table.dropColumn('response');
});
await knex.schema.alterTable(MetaTable.HOOK_LOGS, (table) => {
table.text('response');
});
} else if (knex.client.config.client !== 'sqlite3') {
await knex.schema.alterTable(MetaTable.HOOK_LOGS, (table) => {
table.text('response').alter();
});
}
await knex.schema.alterTable(MetaTable.HOOKS, (table) => {
table.string('version');
});
};
const down = async (knex) => {
if (knex.client.config.client !== 'sqlite3') {
await knex.schema.alterTable(MetaTable.HOOK_LOGS, (table) => {
table.boolean('response').alter();
});
}
await knex.schema.alterTable(MetaTable.HOOKS, (table) => {
table.dropColumn('version');
});
};
export { up, down };

41
packages/nocodb/src/lib/models/Hook.ts

@ -7,6 +7,7 @@ import {
import Noco from '../Noco';
import NocoCache from '../cache/NocoCache';
import { extractProps } from '../meta/helpers/extractProps';
import { NcError } from '../meta/helpers/catchError';
import Model from './Model';
import Filter from './Filter';
import HookFilter from './HookFilter';
@ -19,8 +20,8 @@ export default class Hook implements HookType {
description?: string;
env?: string;
type?: string;
event?: 'after' | 'before';
operation?: 'insert' | 'delete' | 'update';
event?: HookType['event'];
operation?: HookType['operation'];
async?: BoolType;
payload?: string;
url?: string;
@ -34,6 +35,7 @@ export default class Hook implements HookType {
project_id?: string;
base_id?: string;
version?: 'v1' | 'v2';
constructor(hook: Partial<Hook | HookReqType>) {
Object.assign(this, hook);
@ -78,8 +80,8 @@ export default class Hook implements HookType {
static async list(
param: {
fk_model_id: string;
event?: 'after' | 'before';
operation?: 'insert' | 'delete' | 'update';
event?: HookType['event'];
operation?: HookType['operation'];
},
ncMeta = Noco.ncMeta
) {
@ -135,17 +137,6 @@ export default class Hook implements HookType {
'base_id',
]);
if (insertObj.event) {
insertObj.event = insertObj.event.toLowerCase() as 'after' | 'before';
}
if (insertObj.operation) {
insertObj.operation = insertObj.operation.toLowerCase() as
| 'insert'
| 'delete'
| 'update';
}
if (insertObj.notification && typeof insertObj.notification === 'object') {
insertObj.notification = JSON.stringify(insertObj.notification);
}
@ -156,6 +147,9 @@ export default class Hook implements HookType {
insertObj.base_id = model.base_id;
}
// new hook will set as version 2
insertObj.version = 'v2';
const { id } = await ncMeta.metaInsert2(
null,
null,
@ -194,17 +188,16 @@ export default class Hook implements HookType {
'retry_interval',
'timeout',
'active',
'version',
]);
if (updateObj.event) {
updateObj.event = updateObj.event.toLowerCase() as 'after' | 'before';
}
if (updateObj.operation) {
updateObj.operation = updateObj.operation.toLowerCase() as
| 'insert'
| 'delete'
| 'update';
if (
updateObj.version &&
updateObj.operation &&
updateObj.version === 'v1' &&
['bulkInsert', 'bulkUpdate', 'bulkDelete'].includes(updateObj.operation)
) {
NcError.badRequest(`${updateObj.operation} not supported in v1 hook`);
}
if (updateObj.notification && typeof updateObj.notification === 'object') {

74
packages/nocodb/src/lib/models/HookLog.ts

@ -6,13 +6,12 @@ import type { HookLogType } from 'nocodb-sdk';
export default class HookLog implements HookLogType {
id?: string;
base_id?: string;
project_id?: string;
fk_hook_id?: string;
type?: string;
event?: string;
operation?: string;
event?: HookLogType['event'];
operation?: HookLogType['operation'];
test_call?: boolean;
payload?: string;
conditions?: string;
@ -24,47 +23,49 @@ export default class HookLog implements HookLogType {
response?: string;
triggered_by?: string;
constructor(hook: Partial<HookLog>) {
Object.assign(this, hook);
constructor(hookLog: Partial<HookLog>) {
Object.assign(this, hookLog);
}
static async list(
param: {
fk_hook_id: string;
event?: 'after' | 'before';
operation?: 'insert' | 'delete' | 'update';
event?: HookLogType['event'];
operation?: HookLogType['operation'];
},
{
limit = 25,
offset = 0,
}: {
limit?: number;
offset?: number;
},
ncMeta = Noco.ncMeta
) {
// todo: redis cache ??
// let hooks = await NocoCache.getList(CacheScope.HOOK, [param.fk_model_id]);
// if (!hooks.length) {
const hookLogs = await ncMeta.metaList(null, null, MetaTable.HOOK_LOGS, {
const hookLogs = await ncMeta.metaList2(null, null, MetaTable.HOOK_LOGS, {
condition: {
fk_hook_id: param.fk_hook_id,
// ...(param.event ? { event: param.event?.toLowerCase?.() } : {}),
// ...(param.operation
// ? { operation: param.operation?.toLowerCase?.() }
// : {})
},
...(process.env.NC_AUTOMATION_LOG_LEVEL === 'ERROR' && {
xcCondition: {
error_message: {
neq: null,
},
},
}),
orderBy: {
created_at: 'desc',
},
limit,
offset,
});
// await NocoCache.setList(CacheScope.HOOK, [param.fk_model_id], hooks);
// }
// // filter event & operation
// if (param.event) {
// hooks = hooks.filter(
// h => h.event?.toLowerCase() === param.event?.toLowerCase()
// );
// }
// if (param.operation) {
// hooks = hooks.filter(
// h => h.operation?.toLowerCase() === param.operation?.toLowerCase()
// );
// }
return hookLogs?.map((h) => new HookLog(h));
}
public static async insert(hookLog: Partial<HookLog>, ncMeta = Noco.ncMeta) {
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'OFF') {
return;
}
const insertObj: any = extractProps(hookLog, [
'base_id',
'project_id',
@ -98,4 +99,21 @@ export default class HookLog implements HookLogType {
return await ncMeta.metaInsert2(null, null, MetaTable.HOOK_LOGS, insertObj);
}
public static async count(
{ hookId }: { hookId?: string },
ncMeta = Noco.ncMeta
) {
const qb = ncMeta.knex(MetaTable.HOOK_LOGS);
if (hookId) {
qb.where(`${MetaTable.HOOK_LOGS}.fk_hook_id`, hookId);
}
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'ERROR') {
qb.whereNotNull(`${MetaTable.HOOK_LOGS}.error_message`);
}
return (await qb.count('id', { as: 'count' }).first())?.count ?? 0;
}
}

3
packages/nocodb/src/lib/plugins/discord/Discord.ts

@ -9,11 +9,12 @@ export default class Discord implements IWebhookNotificationAdapter {
public async sendMessage(content: string, payload: any): Promise<any> {
for (const { webhook_url } of payload?.channels) {
try {
await axios.post(webhook_url, {
return await axios.post(webhook_url, {
content,
});
} catch (e) {
console.log(e);
throw e;
}
}
}

3
packages/nocodb/src/lib/plugins/mattermost/Mattermost.ts

@ -9,11 +9,12 @@ export default class Mattermost implements IWebhookNotificationAdapter {
public async sendMessage(text: string, payload: any): Promise<any> {
for (const { webhook_url } of payload?.channels) {
try {
await axios.post(webhook_url, {
return await axios.post(webhook_url, {
text,
});
} catch (e) {
console.log(e);
throw e;
}
}
}

3
packages/nocodb/src/lib/plugins/slack/Slack.ts

@ -9,11 +9,12 @@ export default class Slack implements IWebhookNotificationAdapter {
public async sendMessage(text: string, payload: any): Promise<any> {
for (const { webhook_url } of payload?.channels) {
try {
await axios.post(webhook_url, {
return await axios.post(webhook_url, {
text,
});
} catch (e) {
console.log(e);
throw e;
}
}
}

3
packages/nocodb/src/lib/plugins/teams/Teams.ts

@ -9,11 +9,12 @@ export default class Teams implements IWebhookNotificationAdapter {
public async sendMessage(Text: string, payload: any): Promise<any> {
for (const { webhook_url } of payload?.channels) {
try {
await axios.post(webhook_url, {
return await axios.post(webhook_url, {
Text,
});
} catch (e) {
console.log(e);
throw e;
}
}
}

1
packages/nocodb/src/lib/plugins/twilio/Twilio.ts

@ -23,6 +23,7 @@ export default class Twilio implements IWebhookNotificationAdapter {
});
} catch (e) {
console.log(e);
throw e;
}
}
}

1
packages/nocodb/src/lib/plugins/twilioWhatsapp/TwilioWhatsapp.ts

@ -23,6 +23,7 @@ export default class TwilioWhatsapp implements IWebhookNotificationAdapter {
});
} catch (e) {
console.log(e);
throw e;
}
}
}

51
packages/nocodb/src/lib/services/hook.svc.ts

@ -1,9 +1,13 @@
import { T } from 'nc-help';
import { validatePayload } from '../meta/api/helpers';
import { NcError } from '../meta/helpers/catchError';
import { Hook, Model } from '../models';
import { Hook, HookLog, Model } from '../models';
import { invokeWebhook } from '../meta/helpers/webhookHelpers';
import populateSamplePayload from '../meta/helpers/populateSamplePayload';
import {
populateSamplePayload,
populateSamplePayloadV2,
} from '../meta/helpers/populateSamplePayload';
import type { HookType } from 'nocodb-sdk';
import type { HookReqType, HookTestReqType } from 'nocodb-sdk';
function validateHookPayload(
@ -26,6 +30,10 @@ export async function hookList(param: { tableId: string }) {
return await Hook.list({ fk_model_id: param.tableId });
}
export async function hookLogList(param: { query: any; hookId: string }) {
return await HookLog.list({ fk_hook_id: param.hookId }, param.query);
}
export async function hookCreate(param: {
tableId: string;
hook: HookReqType;
@ -73,29 +81,44 @@ export async function hookTest(param: {
const model = await Model.getByIdOrName({ id: param.tableId });
T.emit('evt', { evt_type: 'webhooks:tested' });
const {
hook,
payload: { data, user },
} = param.hookTest;
await invokeWebhook(
new Hook(hook),
model,
data,
user,
(hook as any)?.filters,
true
);
T.emit('evt', { evt_type: 'webhooks:tested' });
try {
await invokeWebhook(
new Hook(hook),
model,
null,
null,
data,
user,
(hook as any)?.filters,
true,
true
);
} catch (e) {
throw e;
}
return true;
}
export async function tableSampleData(param: {
tableId: string;
operation: 'insert' | 'update';
operation: HookType['operation'];
version: HookType['version'];
}) {
const model = await Model.getByIdOrName({ id: param.tableId });
return await populateSamplePayload(model, false, param.operation);
if (param.version === 'v1') {
return await populateSamplePayload(model, false, param.operation);
}
return await populateSamplePayloadV2(model, false, param.operation);
}
export async function hookLogCount(param: { hookId: string }) {
return await HookLog.count({ hookId: param.hookId });
}

1
packages/nocodb/src/lib/services/util.svc.ts

@ -56,6 +56,7 @@ export async function appInfo(param: { req: { ncSiteUrl: string } }) {
ncAttachmentFieldSize: NC_ATTACHMENT_FIELD_SIZE,
ncMaxAttachmentsAllowed: +(process.env.NC_MAX_ATTACHMENTS_ALLOWED || 10),
isCloud: process.env.NC_CLOUD === 'true',
automationLogLevel: process.env.NC_AUTOMATION_LOG_LEVEL || 'OFF',
};
return result;

3
packages/nocodb/src/lib/v1-legacy/plugins/adapters/discord/Discord.ts

@ -9,11 +9,12 @@ export default class Discord {
): Promise<any> {
for (const { webhook_url } of webhooks) {
try {
await axios.post(webhook_url, {
return await axios.post(webhook_url, {
content,
});
} catch (e) {
console.log(e);
throw e;
}
}
}

3
packages/nocodb/src/lib/v1-legacy/plugins/adapters/mattermost/Mattermost.ts

@ -9,11 +9,12 @@ export default class Mattermost {
): Promise<any> {
for (const { webhook_url } of webhooks) {
try {
await axios.post(webhook_url, {
return await axios.post(webhook_url, {
text,
});
} catch (e) {
console.log(e);
throw e;
}
}
}

3
packages/nocodb/src/lib/v1-legacy/plugins/adapters/slack/Slack.ts

@ -9,11 +9,12 @@ export default class Slack {
): Promise<any> {
for (const { webhook_url } of webhooks) {
try {
await axios.post(webhook_url, {
return await axios.post(webhook_url, {
text,
});
} catch (e) {
console.log(e);
throw e;
}
}
}

1
packages/nocodb/src/lib/v1-legacy/plugins/adapters/twilio/Twilio.ts

@ -37,6 +37,7 @@ export default class Twilio {
});
} catch (e) {
console.log(e);
throw e;
}
}
}

2
packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts

@ -12,6 +12,7 @@ import ncDataTypesUpgrader from './ncDataTypesUpgrader';
import ncProjectUpgraderV2_0090000 from './ncProjectUpgraderV2_0090000';
import ncProjectEnvUpgrader0011045 from './ncProjectEnvUpgrader0011045';
import ncProjectEnvUpgrader from './ncProjectEnvUpgrader';
import ncHookUpgrader from './ncHookUpgrader';
import type { NcConfig } from '../../interface/config';
import type NcMetaIO from '../meta/NcMetaIO';
@ -46,6 +47,7 @@ export default class NcUpgrader {
{ name: '0104004', handler: ncFilterUpgrader_0104004 },
{ name: '0105002', handler: ncStickyColumnUpgrader },
{ name: '0105003', handler: ncFilterUpgrader_0105003 },
{ name: '0105004', handler: ncHookUpgrader },
];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
return;

13
packages/nocodb/src/lib/version-upgrader/ncHookUpgrader.ts

@ -0,0 +1,13 @@
import { MetaTable } from '../utils/globals';
import type { NcUpgraderCtx } from './NcUpgrader';
export default async function ({ ncMeta }: NcUpgraderCtx) {
const actions = [];
const hooks = await ncMeta.metaList2(null, null, MetaTable.HOOKS);
for (const hook of hooks) {
actions.push(
ncMeta.metaUpdate(null, null, MetaTable.HOOKS, { version: 'v1' }, hook.id)
);
}
await Promise.all(actions);
}

692
packages/nocodb/src/schema/swagger.json

File diff suppressed because it is too large Load Diff

2
packages/nocodb/tests/unit/model/tests/baseModelSql.test.ts

@ -273,7 +273,7 @@ function baseModelSqlTests() {
const deletedRow = await baseModelSql.readByPk(rowIdToDeleted);
expect(deletedRow).to.be.undefined;
expect(deletedRow).to.be.an('object').that.is.empty;
console.log('Delete record', await Audit.projectAuditList(project.id, {}));
const rowDeletedAudit = (await Audit.projectAuditList(project.id, {})).find(

2
tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts

@ -97,7 +97,7 @@ export class ToolbarFieldsPage extends BasePage {
}
async getFieldsTitles() {
let fields: string[] = await this.rootPage.locator(`.nc-grid-header .name`).allTextContents();
const fields: string[] = await this.rootPage.locator(`.nc-grid-header .name`).allTextContents();
return fields;
}

520
tests/playwright/tests/01-webhook.spec.ts

@ -3,8 +3,12 @@ import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup';
import makeServer from '../setup/server';
import { WebhookFormPage } from '../pages/Dashboard/WebhookForm';
import { isSubset } from './utils/general';
import { Api, UITypes } from 'nocodb-sdk';
import { isMysql, isPg, isSqlite } from '../setup/db';
const hookPath = 'http://localhost:9090/hook';
let api: Api<any>;
// clear server data
async function clearServerData({ request }) {
@ -16,9 +20,28 @@ async function clearServerData({ request }) {
await expect(await response.json()).toBe(0);
}
async function verifyHookTrigger(count: number, value: string, request) {
async function getWebhookResponses({ request, count = 1 }) {
let response;
// retry since there can be lag between the time the hook is triggered and the time the server receives the request
for (let i = 0; i < 20; i++) {
response = await request.get(hookPath + '/count');
if ((await response.json()) === count) {
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
await expect(await response.json()).toBe(count);
response = await request.get(hookPath + '/all');
return await response.json();
}
async function verifyHookTrigger(count: number, value: string, request, expectedData?: any) {
// Retry since there can be lag between the time the hook is triggered and the time the server receives the request
let response;
// retry since there can be lag between the time the hook is triggered and the time the server receives the request
for (let i = 0; i < 20; i++) {
response = await request.get(hookPath + '/count');
if ((await response.json()) === count) {
@ -30,17 +53,51 @@ async function verifyHookTrigger(count: number, value: string, request) {
if (count) {
let response;
// retry since there can be lag between the time the hook is triggered and the time the server receives the request
for (let i = 0; i < 20; i++) {
response = await request.get(hookPath + '/last');
if ((await response.json()).Title === value) {
const rspJson = await response.json();
if (rspJson.data.rows[0].Title === value) {
break;
}
await new Promise(resolve => setTimeout(resolve, 150));
}
await expect((await response.json()).Title).toBe(value);
const rspJson = await response.json();
await expect(rspJson?.data?.rows[0]?.Title).toBe(value);
if (expectedData) {
await expect(isSubset(rspJson, expectedData)).toBe(true);
}
}
}
async function buildExpectedResponseData(type, value, oldValue?) {
const expectedData = {
type: 'records.after.insert',
data: {
table_name: 'Test',
view_name: 'Test',
rows: [
{
Title: 'Poole',
},
],
},
};
expectedData.type = type;
expectedData.data.rows[0].Title = value;
if (oldValue) {
expectedData.data['previous_rows'] = [];
expectedData.data['previous_rows'][0] = {
Title: oldValue,
};
}
return expectedData;
}
test.describe.serial('Webhook', () => {
// start a server locally for webhook tests
@ -52,13 +109,20 @@ test.describe.serial('Webhook', () => {
});
test.beforeEach(async ({ page }) => {
context = await setup({ page });
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
webhook = dashboard.webhookForm;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
});
test('CRUD', async ({ request, page }) => {
// todo: Waiting for the server to start
// Waiting for the server to start
await page.waitForTimeout(1000);
// close 'Team & Auth' tab
@ -66,6 +130,14 @@ test.describe.serial('Webhook', () => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.createTable({ title: 'Test' });
// create
//
// hook order
// hook-1: after insert
// - verify trigger after insert
// - verify no trigger after edit
// - verify no trigger after delete
// after insert hook
await webhook.create({
title: 'hook-1',
@ -77,12 +149,26 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Poole',
});
await verifyHookTrigger(1, 'Poole', request);
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.insert', 'Poole'));
// trigger edit row & delete row
// verify that the hook is not triggered (count doesn't change in this case)
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(1, 'Poole', request);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(1, 'Poole', request);
///////////////////////////////////////////////////////////////////////////
// update
//
// hook order
// hook-1: after insert
// hook-2: after update
// - verify trigger after insert
// - verify trigger after edit
// - verify no trigger after delete
// after update hook
await webhook.create({
title: 'hook-2',
@ -95,12 +181,27 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Poole',
});
await verifyHookTrigger(1, 'Poole', request);
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.insert', 'Poole'));
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(2, 'Delaware', request);
await verifyHookTrigger(
2,
'Delaware',
request,
buildExpectedResponseData('records.after.update', 'Delaware', 'Poole')
);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(2, 'Delaware', request);
///////////////////////////////////////////////////////////////////////////
// hook order
// hook-1: after insert
// hook-2: after update
// hook-3: after delete
// - verify trigger after insert
// - verify trigger after edit
// - verify trigger after delete
// after delete hook
await webhook.create({
title: 'hook-3',
@ -112,13 +213,29 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Poole',
});
await verifyHookTrigger(1, 'Poole', request);
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.insert', 'Poole'));
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(2, 'Delaware', request);
await verifyHookTrigger(
2,
'Delaware',
request,
buildExpectedResponseData('records.after.update', 'Delaware', 'Poole')
);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(3, 'Delaware', request);
await verifyHookTrigger(3, 'Delaware', request, buildExpectedResponseData('records.after.delete', 'Delaware'));
///////////////////////////////////////////////////////////////////////////
// modify webhook
// hook order
// hook-1: after delete
// hook-2: after delete
// hook-3: after delete
// - verify no trigger after insert
// - verify no trigger after edit
// - verify trigger after delete
await webhook.open({ index: 0 });
await webhook.configureWebhook({
title: 'hook-1-modified',
@ -140,13 +257,28 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Poole',
});
// for insert & edit, the hook should not be triggered (count doesn't change in this case)
await verifyHookTrigger(0, 'Poole', request);
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(0, 'Delaware', request);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(3, 'Delaware', request);
// for delete, the hook should be triggered (thrice in this case)
await verifyHookTrigger(3, 'Delaware', request, buildExpectedResponseData('records.after.delete', 'Delaware'));
///////////////////////////////////////////////////////////////////////////
// delete webhook
// hook order
// hook-1: -
// hook-2: -
// hook-3: -
// - verify no trigger after insert
// - verify no trigger after edit
// - verify no trigger after delete
await webhook.delete({ index: 0 });
await webhook.delete({ index: 0 });
await webhook.delete({ index: 0 });
@ -212,7 +344,18 @@ test.describe.serial('Webhook', () => {
save: true,
});
// verify
///////////////////////////////////////////////////////////////////////////
// webhook with condition
// hook order
// hook-1: after insert where Title is like 'Poole'
// hook-2: after update where Title is like 'Poole'
// hook-3: after delete where Title is like 'Poole'
// - verify trigger after insert gets triggered only when Title is like 'Poole'
// - verify trigger after edit gets triggered only when Title is like 'Poole'
// - verify trigger after delete gets triggered only when Title is like 'Poole'
await clearServerData({ request });
await dashboard.grid.addNewRow({
index: 0,
@ -224,15 +367,30 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Delaware',
});
await verifyHookTrigger(1, 'Poole', request);
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.insert', 'Poole'));
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await dashboard.grid.editRow({ index: 1, value: 'Poole' });
await verifyHookTrigger(2, 'Poole', request);
await verifyHookTrigger(
2,
'Poole',
request,
buildExpectedResponseData('records.after.update', 'Poole', 'Delaware')
);
await dashboard.grid.deleteRow(1);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(3, 'Poole', request);
await verifyHookTrigger(3, 'Poole', request, buildExpectedResponseData('records.after.delete', 'Poole'));
// Delete condition
///////////////////////////////////////////////////////////////////////////
// webhook after conditions are removed
// hook order
// hook-1: after insert
// hook-2: after update
// hook-3: after delete
// - verify trigger after insert gets triggered when Title is like 'Poole' or not
// - verify trigger after edit gets triggered when Title is like 'Poole' or not
// - verify trigger after delete gets triggered when Title is like 'Poole' or not
await webhook.open({ index: 2 });
await webhook.deleteCondition({ save: true });
await webhook.open({ index: 1 });
@ -251,12 +409,334 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Delaware',
});
await verifyHookTrigger(2, 'Delaware', request);
await verifyHookTrigger(2, 'Delaware', request, buildExpectedResponseData('records.after.insert', 'Delaware'));
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await dashboard.grid.editRow({ index: 1, value: 'Poole' });
await verifyHookTrigger(4, 'Poole', request);
await verifyHookTrigger(
4,
'Poole',
request,
buildExpectedResponseData('records.after.update', 'Poole', 'Delaware')
);
await dashboard.grid.deleteRow(1);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(6, 'Delaware', request);
await verifyHookTrigger(6, 'Delaware', request, buildExpectedResponseData('records.after.delete', 'Delaware'));
});
test('Bulk operations', async ({ request, page }) => {
async function verifyBulkOperationTrigger(rsp, type) {
for (let i = 0; i < rsp.length; i++) {
expect(rsp[i].type).toBe(type);
expect(rsp[i].data.table_name).toBe('numberBased');
expect(rsp[i].data.view_name).toBe('numberBased');
// only for insert, rows inserted will not be returned in response. just count
if (type === 'records.after.bulkInsert') {
expect(rsp[i].data.rows_inserted).toBe(50);
} else if (type === 'records.after.bulkUpdate') {
expect(rsp[i].data.rows.length).toBe(50);
expect(rsp[i].data.previous_rows.length).toBe(50);
// verify records
for (let j = 0; j < rsp[i].data.rows.length; j++) {
expect(+rsp[i].data.rows[j].Number).toBe(111 * (j + 1));
expect(+rsp[i].data.previous_rows[j].Number).toBe(100 * (j + 1));
}
} else if (type === 'records.after.bulkDelete') {
expect(rsp[i].data.rows.length).toBe(50);
// verify records
for (let j = 0; j < rsp[i].data.rows.length; j++) {
expect(+rsp[i].data.rows[j].Number).toBe(111 * (j + 1));
}
}
}
}
// Waiting for the server to start
await page.waitForTimeout(1000);
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Number',
title: 'Number',
uidt: UITypes.Number,
},
];
let project, table;
try {
project = await api.project.read(context.project.id);
table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'numberBased',
title: 'numberBased',
columns: columns,
});
} catch (e) {
console.error(e);
}
await page.reload();
await dashboard.treeView.openTable({ title: 'numberBased' });
// create after insert webhook
await webhook.create({
title: 'hook-1',
event: 'After Bulk Insert',
});
await webhook.create({
title: 'hook-1',
event: 'After Bulk Update',
});
await webhook.create({
title: 'hook-1',
event: 'After Bulk Delete',
});
await clearServerData({ request });
const rowAttributesForInsert = Array.from({ length: 50 }, (_, i) => ({
Id: i + 1,
Number: (i + 1) * 100,
}));
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributesForInsert);
await page.reload();
// 50 records inserted, we expect 2 webhook responses
let rsp = await getWebhookResponses({ request, count: 1 });
await verifyBulkOperationTrigger(rsp, 'records.after.bulkInsert');
// bulk update all rows
await clearServerData({ request });
// build rowAttributes for update to contain all the ids & their value set to 100
const rowAttributesForUpdate = Array.from({ length: 50 }, (_, i) => ({
Id: i + 1,
Number: (i + 1) * 111,
}));
await api.dbTableRow.bulkUpdate('noco', context.project.id, table.id, rowAttributesForUpdate);
await page.reload();
// 50 records updated, we expect 2 webhook responses
rsp = await getWebhookResponses({ request, count: 1 });
await verifyBulkOperationTrigger(rsp, 'records.after.bulkUpdate');
// bulk delete all rows
await clearServerData({ request });
const rowAttributesForDelete = Array.from({ length: 50 }, (_, i) => ({ Id: i + 1 }));
await api.dbTableRow.bulkDelete('noco', context.project.id, table.id, rowAttributesForDelete);
await page.reload();
rsp = await getWebhookResponses({ request, count: 1 });
await verifyBulkOperationTrigger(rsp, 'records.after.bulkDelete');
});
test('Virtual columns', async ({ request, page }) => {
let cityTable, countryTable;
const cityColumns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'City',
title: 'City',
uidt: UITypes.SingleLineText,
pv: true,
},
{
column_name: 'CityCode',
title: 'CityCode',
uidt: UITypes.Number,
},
];
const countryColumns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Country',
title: 'Country',
uidt: UITypes.SingleLineText,
pv: true,
},
{
column_name: 'CountryCode',
title: 'CountryCode',
uidt: UITypes.Number,
},
];
try {
const project = await api.project.read(context.project.id);
cityTable = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'City',
title: 'City',
columns: cityColumns,
});
countryTable = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'Country',
title: 'Country',
columns: countryColumns,
});
const cityRowAttributes = [
{ City: 'Mumbai', CityCode: 23 },
{ City: 'Pune', CityCode: 33 },
{ City: 'Delhi', CityCode: 43 },
{ City: 'Bangalore', CityCode: 53 },
];
await api.dbTableRow.bulkCreate('noco', context.project.id, cityTable.id, cityRowAttributes);
const countryRowAttributes = [
{ Country: 'India', CountryCode: 1 },
{ Country: 'USA', CountryCode: 2 },
{ Country: 'UK', CountryCode: 3 },
{ Country: 'Australia', CountryCode: 4 },
];
await api.dbTableRow.bulkCreate('noco', context.project.id, countryTable.id, countryRowAttributes);
// create LTAR Country has-many City
countryTable = await api.dbTableColumn.create(countryTable.id, {
column_name: 'CityList',
title: 'CityList',
uidt: UITypes.LinkToAnotherRecord,
parentId: countryTable.id,
childId: cityTable.id,
type: 'hm',
});
// Create Lookup column in Country table
countryTable = await api.dbTableColumn.create(countryTable.id, {
column_name: 'CityCodeLookup',
title: 'CityCodeLookup',
uidt: UITypes.Lookup,
fk_relation_column_id: countryTable.columns.filter(c => c.title === 'CityList')[0].id,
fk_lookup_column_id: cityTable.columns.filter(c => c.title === 'CityCode')[0].id,
});
// Create Rollup column in Country table
countryTable = await api.dbTableColumn.create(countryTable.id, {
column_name: 'CityCodeRollup',
title: 'CityCodeRollup',
uidt: UITypes.Rollup,
fk_relation_column_id: countryTable.columns.filter(c => c.title === 'CityList')[0].id,
fk_rollup_column_id: cityTable.columns.filter(c => c.title === 'CityCode')[0].id,
rollup_function: 'count',
});
// Create links
await api.dbTableRow.nestedAdd('noco', context.project.title, countryTable.title, 1, 'hm', 'CityList', '1');
await api.dbTableRow.nestedAdd('noco', context.project.title, countryTable.title, 1, 'hm', 'CityList', '2');
await api.dbTableRow.nestedAdd('noco', context.project.title, countryTable.title, 2, 'hm', 'CityList', '3');
await api.dbTableRow.nestedAdd('noco', context.project.title, countryTable.title, 3, 'hm', 'CityList', '4');
// create formula column
countryTable = await api.dbTableColumn.create(countryTable.id, {
column_name: 'CityCodeFormula',
title: 'CityCodeFormula',
uidt: UITypes.Formula,
formula_raw: '({Id} * 100)',
});
} catch (e) {
console.log(e);
}
await page.reload();
await dashboard.treeView.openTable({ title: 'Country' });
// create after update webhook
// after update hook
await webhook.create({
title: 'hook-2',
event: 'After Update',
});
// clear server data
await clearServerData({ request });
// edit first record
await dashboard.grid.editRow({ index: 0, columnHeader: 'Country', value: 'INDIA', networkValidation: false });
const rsp = await getWebhookResponses({ request, count: 1 });
const expectedData = {
type: 'records.after.update',
data: {
table_name: 'Country',
view_name: 'Country',
previous_rows: [
{
Id: 1,
Country: 'India',
CountryCode: '1',
CityCodeRollup: '2',
CityCodeFormula: 100,
CityList: [
{
Id: 1,
City: 'Mumbai',
},
{
Id: 2,
City: 'Pune',
},
],
CityCodeLookup: ['23', '33'],
},
],
rows: [
{
Id: 1,
Country: 'INDIA',
CountryCode: '1',
CityCodeRollup: '2',
CityCodeFormula: 100,
CityList: [
{
Id: 1,
City: 'Mumbai',
},
{
Id: 2,
City: 'Pune',
},
],
CityCodeLookup: ['23', '33'],
},
],
},
};
if (isSqlite(context) || isMysql(context)) {
// @ts-ignore
expectedData.data.previous_rows[0].CountryCode = 1;
// @ts-ignore
expectedData.data.rows[0].CountryCode = 1;
// @ts-ignore
expectedData.data.previous_rows[0].CityCodeRollup = 2;
// @ts-ignore
expectedData.data.rows[0].CityCodeRollup = 2;
// @ts-ignore
expectedData.data.previous_rows[0].CityCodeLookup = [23, 33];
// @ts-ignore
expectedData.data.rows[0].CityCodeLookup = [23, 33];
if (isMysql(context)) {
// @ts-ignore
expectedData.data.previous_rows[0].CityCodeFormula = '100';
// @ts-ignore
expectedData.data.rows[0].CityCodeFormula = '100';
}
}
await expect(isSubset(rsp[0], expectedData)).toBe(true);
});
});

21
tests/playwright/tests/utils/general.ts

@ -19,4 +19,23 @@ async function getTextExcludeIconText(selector) {
return text.trim();
}
export { getTextExcludeIconText };
function isSubset(obj, potentialSubset) {
for (const prop in potentialSubset) {
// eslint-disable-next-line no-prototype-builtins
if (potentialSubset.hasOwnProperty(prop)) {
const potentialValue = potentialSubset[prop];
const objValue = obj[prop];
if (typeof potentialValue === 'object' && typeof objValue === 'object') {
if (!isSubset(objValue, potentialValue)) {
return false;
}
// eslint-disable-next-line no-prototype-builtins
} else if (!obj.hasOwnProperty(prop) || objValue !== potentialValue) {
return false;
}
}
}
return true;
}
export { getTextExcludeIconText, isSubset };

Loading…
Cancel
Save