Browse Source

Merge pull request #5427 from nocodb/develop

pull/5428/head 0.106.0-beta.1
github-actions[bot] 2 years ago committed by GitHub
parent
commit
7a5fa393ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      markdown/readme/languages/README.md
  2. 9
      packages/nc-gui/components.d.ts
  3. 2
      packages/nc-gui/components/api-client/Headers.vue
  4. 2
      packages/nc-gui/components/api-client/Params.vue
  5. 37
      packages/nc-gui/components/cell/ClampedText.vue
  6. 29
      packages/nc-gui/components/cell/MultiSelect.vue
  7. 21
      packages/nc-gui/components/cell/SingleSelect.vue
  8. 40
      packages/nc-gui/components/cell/attachment/index.vue
  9. 2
      packages/nc-gui/components/dashboard/settings/AuditTab.vue
  10. 35
      packages/nc-gui/components/smartsheet/Cell.vue
  11. 2
      packages/nc-gui/components/smartsheet/Pagination.vue
  12. 15
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  13. 35
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  14. 1
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  15. 1
      packages/nc-gui/components/smartsheet/toolbar/AddRow.vue
  16. 2
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  17. 183
      packages/nc-gui/components/webhook/CallLog.vue
  18. 49
      packages/nc-gui/components/webhook/ChannelMultiSelect.vue
  19. 91
      packages/nc-gui/components/webhook/Editor.vue
  20. 11
      packages/nc-gui/components/webhook/List.vue
  21. 18
      packages/nc-gui/components/webhook/Test.vue
  22. 1
      packages/nc-gui/composables/useGlobal/state.ts
  23. 1
      packages/nc-gui/composables/useGlobal/types.ts
  24. 3
      packages/nc-gui/composables/useSmartsheetStore.ts
  25. 2
      packages/nc-gui/composables/useViewData.ts
  26. 1
      packages/nc-gui/context/index.ts
  27. 16
      packages/nc-gui/lang/fr.json
  28. 6
      packages/nc-gui/lang/pt_BR.json
  29. 184
      packages/nc-gui/lang/uk.json
  30. 8
      packages/nc-gui/lang/zh-Hans.json
  31. 6
      packages/nc-gui/lib/enums.ts
  32. 52
      packages/nc-gui/package-lock.json
  33. 2
      packages/nc-gui/package.json
  34. 30
      packages/noco-docs/content/en/developer-resources/webhooks.md
  35. 1
      packages/noco-docs/content/en/getting-started/environment-variables.md
  36. 4
      packages/nocodb-sdk/package-lock.json
  37. 183
      packages/nocodb-sdk/src/lib/Api.ts
  38. 55
      packages/nocodb/Dockerfile.local
  39. 14
      packages/nocodb/docker/start-local.sh
  40. 30
      packages/nocodb/package-lock.json
  41. 2
      packages/nocodb/package.json
  42. 2
      packages/nocodb/src/lib/Noco.ts
  43. 50
      packages/nocodb/src/lib/controllers/hook.ctl.ts
  44. 15
      packages/nocodb/src/lib/db/sql-client/lib/SqlClientFactory.ts
  45. 23
      packages/nocodb/src/lib/db/sql-client/lib/pg/PgClient.ts
  46. 199
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  47. 92
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts
  48. 86
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/commonFns.ts
  49. 180
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts
  50. 120
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts
  51. 140
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts
  52. 188
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts
  53. 9
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/mapFunctionName.ts
  54. 3
      packages/nocodb/src/lib/meta/NcMetaIOImpl.ts
  55. 10
      packages/nocodb/src/lib/meta/helpers/NcPluginMgrv2.ts
  56. 65
      packages/nocodb/src/lib/meta/helpers/populateSamplePayload.ts
  57. 162
      packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts
  58. 4
      packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts
  59. 35
      packages/nocodb/src/lib/migrations/v2/nc_029_webhook.ts
  60. 41
      packages/nocodb/src/lib/models/Hook.ts
  61. 74
      packages/nocodb/src/lib/models/HookLog.ts
  62. 3
      packages/nocodb/src/lib/plugins/discord/Discord.ts
  63. 3
      packages/nocodb/src/lib/plugins/mattermost/Mattermost.ts
  64. 3
      packages/nocodb/src/lib/plugins/slack/Slack.ts
  65. 3
      packages/nocodb/src/lib/plugins/teams/Teams.ts
  66. 1
      packages/nocodb/src/lib/plugins/twilio/Twilio.ts
  67. 1
      packages/nocodb/src/lib/plugins/twilioWhatsapp/TwilioWhatsapp.ts
  68. 33
      packages/nocodb/src/lib/services/hook.svc.ts
  69. 1
      packages/nocodb/src/lib/services/util.svc.ts
  70. 15
      packages/nocodb/src/lib/utils/NcConfigFactory.ts
  71. 3
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/discord/Discord.ts
  72. 3
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/mattermost/Mattermost.ts
  73. 3
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/slack/Slack.ts
  74. 1
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/twilio/Twilio.ts
  75. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  76. 13
      packages/nocodb/src/lib/version-upgrader/ncHookUpgrader.ts
  77. 18
      packages/nocodb/src/run/local.ts
  78. 686
      packages/nocodb/src/schema/swagger.json
  79. 2
      packages/nocodb/tests/unit/model/tests/baseModelSql.test.ts
  80. 53
      packages/nocodb/webpack.local.config.js
  81. 4
      tests/playwright/pages/Dashboard/Kanban/index.ts
  82. 3
      tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts
  83. 1
      tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts
  84. 5
      tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts
  85. 9
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  86. 2
      tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts
  87. 520
      tests/playwright/tests/01-webhook.spec.ts
  88. 4
      tests/playwright/tests/columnAttachments.spec.ts
  89. 160
      tests/playwright/tests/megaTable.spec.ts
  90. 21
      tests/playwright/tests/utils/general.ts

2
markdown/readme/languages/README.md

@ -9,7 +9,7 @@ Supported Translations:
<li><a href="french.md">French</a></li> <li><a href="french.md">French</a></li>
<li><a href="german.md">German</a></li> <li><a href="german.md">German</a></li>
<li><a href="spanish.md">Spanish</a></li> <li><a href="spanish.md">Spanish</a></li>
<li><a href="portugese.md">Portugese</a></li> <li><a href="portuguese.md">Portugese</a></li>
<li><a href="italian.md">Italian</a></li> <li><a href="italian.md">Italian</a></li>
<li><a href="japanese.md">Japanese</a></li> <li><a href="japanese.md">Japanese</a></li>
<li><a href="korean.md">Korean</a></li> <li><a href="korean.md">Korean</a></li>

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

@ -121,9 +121,18 @@ declare module '@vue/runtime-core' {
MdiClose: typeof import('~icons/mdi/close')['default'] MdiClose: typeof import('~icons/mdi/close')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default'] MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDiscord: typeof import('~icons/mdi/discord')['default'] MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiEditOutline: typeof import('~icons/mdi/edit-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default'] MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default'] MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default']
MdiHeart: typeof import('~icons/mdi/heart')['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'] MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default'] MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['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> <tr>
<td :colspan="12" class="text-center"> <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> <template #icon>
<component :is="iconMap.plus" class="flex mx-auto" /> <component :is="iconMap.plus" class="flex mx-auto" />
</template> </template>

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

@ -66,7 +66,7 @@ const deleteParamRow = (i: number) => vModel.value.splice(i, 1)
<tr> <tr>
<td :colspan="12" class="text-center"> <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> <template #icon>
<component :is="iconMap.plus" class="flex mx-auto" /> <component :is="iconMap.plus" class="flex mx-auto" />
</template> </template>

37
packages/nc-gui/components/cell/ClampedText.vue

@ -3,35 +3,18 @@ const props = defineProps<{
value?: string | number | null value?: string | number | null
lines?: number lines?: number
}>() }>()
const wrapper = ref()
const key = ref(0)
const debouncedRefresh = useDebounceFn(() => {
key.value++
}, 500)
onMounted(() => {
const observer = new ResizeObserver(() => {
debouncedRefresh()
})
observer.observe(wrapper.value)
})
</script> </script>
<template> <template>
<div ref="wrapper"> <div
<!-- :style="{
using '' for :text in text-clamp would keep the previous cell value after changing a filter 'display': '-webkit-box',
use ' ' instead of '' to trigger update 'max-width': '100%',
--> '-webkit-line-clamp': props.lines || 1,
<text-clamp '-webkit-box-orient': 'vertical',
:key="`clamp-${key}-${props.value?.toString().length || 0}`" 'overflow': 'hidden',
class="w-full h-full break-word" }"
:text="`${props.value || ' '}`" >
:max-lines="props.lines || 1" {{ props.value || '' }}
/>
</div> </div>
</template> </template>

29
packages/nc-gui/components/cell/MultiSelect.vue

@ -313,11 +313,40 @@ const handleClose = (e: MouseEvent) => {
} }
useEventListener(document, 'click', handleClose, true) useEventListener(document, 'click', handleClose, true)
const selectedOpts = computed(() => {
return options.value.reduce<(SelectOptionType & { index: number })[]>((selectedOptions, option) => {
const index = vModel.value.indexOf(option.value!)
if (index !== -1) {
selectedOptions.push({ ...option, index })
}
return selectedOptions
}, [])
})
</script> </script>
<template> <template>
<div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu"> <div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div v-if="!editable && !active" class="flex flex-nowrap">
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<a-tag class="rounded-tag" :color="selectedOpt.color" :style="{ order: selectedOpt.index }">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.title }}
</span>
</a-tag>
</template>
</div>
<a-select <a-select
v-else
ref="aselect" ref="aselect"
v-model:value="vModel" v-model:value="vModel"
mode="multiple" mode="multiple"

21
packages/nc-gui/components/cell/SingleSelect.vue

@ -242,11 +242,32 @@ const handleClose = (e: MouseEvent) => {
} }
useEventListener(document, 'click', handleClose, true) useEventListener(document, 'click', handleClose, true)
const selectedOpt = computed(() => {
return options.value.find((o) => o.value === vModel.value)
})
</script> </script>
<template> <template>
<div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu"> <div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div v-if="!editable && !active">
<a-tag v-if="selectedOpt" class="rounded-tag" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.title }}
</span>
</a-tag>
</div>
<a-select <a-select
v-else
ref="aselect" ref="aselect"
v-model:value="vModel" v-model:value="vModel"
class="w-full overflow-hidden" class="w-full overflow-hidden"

40
packages/nc-gui/components/cell/attachment/index.vue

@ -4,13 +4,11 @@ import { useProvideAttachmentCell } from './utils'
import { useSortable } from './sort' import { useSortable } from './sort'
import { import {
ActiveCellInj, ActiveCellInj,
CurrentCellInj,
DropZoneRef, DropZoneRef,
IsGalleryInj,
IsKanbanInj,
iconMap, iconMap,
inject, inject,
isImage, isImage,
nextTick,
ref, ref,
useAttachment, useAttachment,
useDropZone, useDropZone,
@ -29,23 +27,19 @@ interface Emits {
(event: 'update:modelValue', value: string | Record<string, any>[]): void (event: 'update:modelValue', value: string | Record<string, any>[]): void
} }
const { modelValue, rowIndex } = defineProps<Props>() const { modelValue } = defineProps<Props>()
const emits = defineEmits<Emits>() const emits = defineEmits<Emits>()
const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const dropZoneInjection = inject(DropZoneRef, ref()) const dropZoneInjection = inject(DropZoneRef, ref())
const attachmentCellRef = ref<HTMLDivElement>() const attachmentCellRef = ref<HTMLDivElement>()
const sortableRef = ref<HTMLDivElement>() const sortableRef = ref<HTMLDivElement>()
const currentCellRef = ref<Element | undefined>(dropZoneInjection.value) const currentCellRef = inject(CurrentCellInj, dropZoneInjection.value)
const { cellRefs, isSharedForm } = useSmartsheetStoreOrThrow()! const { isSharedForm } = useSmartsheetStoreOrThrow()!
const { getPossibleAttachmentSrc, openAttachment } = useAttachment() const { getPossibleAttachmentSrc, openAttachment } = useAttachment()
@ -65,32 +59,6 @@ const {
storedFiles, storedFiles,
} = useProvideAttachmentCell(updateModelValue) } = useProvideAttachmentCell(updateModelValue)
watch(
[() => rowIndex, isForm, attachmentCellRef],
() => {
if (dropZoneInjection?.value) return
if (!rowIndex && (isForm.value || isGallery.value || isKanban.value)) {
currentCellRef.value = attachmentCellRef.value
} else {
nextTick(() => {
const nextCell = cellRefs.value.reduceRight((cell, curr) => {
if (!cell && curr.dataset.key === `${rowIndex}${column.value!.id}`) cell = curr
return cell
}, undefined as HTMLTableDataCellElement | undefined)
if (!nextCell) {
currentCellRef.value = attachmentCellRef.value
} else {
currentCellRef.value = nextCell
}
})
}
},
{ immediate: true, flush: 'post' },
)
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly) const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly)
const { state: rowState } = useSmartsheetRowStoreOrThrow() const { state: rowState } = useSmartsheetRowStoreOrThrow()

2
packages/nc-gui/components/dashboard/settings/AuditTab.vue

@ -102,7 +102,7 @@ const columns = [
<a-pagination <a-pagination
v-model:current="currentPage" v-model:current="currentPage"
:page-size="currentLimit" v-model:page-size="currentLimit"
:total="totalRows" :total="totalRows"
show-less-items show-less-items
@change="loadAudits" @change="loadAudits"

35
packages/nc-gui/components/smartsheet/Cell.vue

@ -151,10 +151,43 @@ const onContextmenu = (e: MouseEvent) => {
e.stopPropagation() e.stopPropagation()
} }
} }
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
let intersectionObserver = $ref<IntersectionObserver>()
const elementToObserve = $ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver?.disconnect()
intersectionObserver = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver?.observe(elementToObserve!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver?.disconnect()
})
</script> </script>
<template> <template>
<div <div
ref="elementToObserve"
class="nc-cell w-full h-full relative" class="nc-cell w-full h-full relative"
:class="[ :class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, `nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
@ -167,6 +200,7 @@ const onContextmenu = (e: MouseEvent) => {
@contextmenu="onContextmenu" @contextmenu="onContextmenu"
> >
<template v-if="column"> <template v-if="column">
<template v-if="intersected">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" /> <LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" /> <LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" /> <LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
@ -197,6 +231,7 @@ const onContextmenu = (e: MouseEvent) => {
@dblclick.stop.prevent @dblclick.stop.prevent
/> />
</template> </template>
</template>
</div> </div>
</template> </template>

2
packages/nc-gui/components/smartsheet/Pagination.vue

@ -28,10 +28,10 @@ const page = computed({
<a-pagination <a-pagination
v-if="count !== Infinity" v-if="count !== Infinity"
v-model:current="page" v-model:current="page"
v-model:page-size="size"
size="small" size="small"
class="!text-xs !m-1 nc-pagination" class="!text-xs !m-1 nc-pagination"
:total="count" :total="count"
:page-size="size"
show-less-items show-less-items
:show-size-changer="false" :show-size-changer="false"
/> />

15
packages/nc-gui/components/smartsheet/TableDataCell.vue

@ -1,7 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { CellClickHookInj, createEventHook, onBeforeUnmount, onMounted, ref, useSmartsheetStoreOrThrow } from '#imports' import { CellClickHookInj, CurrentCellInj, createEventHook, ref } from '#imports'
const { cellRefs } = useSmartsheetStoreOrThrow()
const el = ref<HTMLTableDataCellElement>() const el = ref<HTMLTableDataCellElement>()
@ -9,16 +7,7 @@ const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook) provide(CellClickHookInj, cellClickHook)
onMounted(() => { provide(CurrentCellInj, el)
cellRefs.value.push(el.value!)
})
onBeforeUnmount(() => {
const index = cellRefs.value.indexOf(el.value!)
if (index > -1) {
cellRefs.value.splice(index, 1)
}
})
</script> </script>
<template> <template>

35
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -52,15 +52,49 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
if (!isForm.value) e.stopImmediatePropagation() if (!isForm.value) e.stopImmediatePropagation()
} }
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
let intersectionObserver = $ref<IntersectionObserver>()
const elementToObserve = $ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver?.disconnect()
intersectionObserver = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver?.observe(elementToObserve!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver?.disconnect()
})
</script> </script>
<template> <template>
<div <div
ref="elementToObserve"
class="nc-virtual-cell w-full flex items-center" class="nc-virtual-cell w-full flex items-center"
:class="{ 'text-right justify-end': isGrid && !isForm && isRollup(column) }" :class="{ 'text-right justify-end': isGrid && !isForm && isRollup(column) }"
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
> >
<template v-if="intersected">
<LazyVirtualCellHasMany v-if="isHm(column)" /> <LazyVirtualCellHasMany v-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" /> <LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" /> <LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
@ -70,5 +104,6 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
<LazyVirtualCellBarcode v-else-if="isBarcode(column)" /> <LazyVirtualCellBarcode v-else-if="isBarcode(column)" />
<LazyVirtualCellCount v-else-if="isCount(column)" /> <LazyVirtualCellCount v-else-if="isCount(column)" />
<LazyVirtualCellLookup v-else-if="isLookup(column)" /> <LazyVirtualCellLookup v-else-if="isLookup(column)" />
</template>
</div> </div>
</template> </template>

1
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -11,7 +11,6 @@ import {
ReloadRowDataHookInj, ReloadRowDataHookInj,
computedInject, computedInject,
createEventHook, createEventHook,
iconMap,
inject, inject,
message, message,
provide, provide,

1
packages/nc-gui/components/smartsheet/toolbar/AddRow.vue

@ -13,7 +13,6 @@ const onClick = () => {
<template> <template>
<a-tooltip placement="bottom"> <a-tooltip placement="bottom">
<template #title> {{ $t('activity.addRow') }} </template> <template #title> {{ $t('activity.addRow') }} </template>
<IonImageOutline />
<div <div
v-e="['c:row:add:grid-top']" v-e="['c:row:add:grid-top']"
:class="{ 'group': !isLocked, 'disabled-ring': isLocked }" :class="{ 'group': !isLocked, 'disabled-ring': isLocked }"

2
packages/nc-gui/components/tabs/auth/UserManagement.vue

@ -369,9 +369,9 @@ const isSuperAdmin = (user: { main_roles?: string }) => {
<a-pagination <a-pagination
v-model:current="currentPage" v-model:current="currentPage"
v-model:page-size="currentLimit"
hide-on-single-page hide-on-single-page
class="mt-4" class="mt-4"
:page-size="currentLimit"
:total="totalRows" :total="totalRows"
show-less-items show-less-items
@change="loadUsers" @change="loadUsers"

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"
v-model: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"> <script setup lang="ts">
import { onMounted, useVModel, watch } from '#imports' import { onBeforeMount, useVModel, watch } from '#imports'
interface Props { interface Props {
modelValue: Record<string, any>[] modelValue: Record<string, any>[]
@ -17,24 +17,9 @@ const vModel = useVModel(rest, 'modelValue', emit)
const localChannelValues = $ref<number[]>([]) const localChannelValues = $ref<number[]>([])
// availableChannelList with idx enriched // availableChannelList with idx enriched
let availableChannelWithIdxList = $ref<Record<string, any>[]>() let availableChannelWithIdxList = $ref<Record<string, any>[]>([])
watch( function setAvailableChannelWithIdxList(availableChannelList: Record<string, any>[]) {
() => 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(() => {
if (availableChannelList.length) { if (availableChannelList.length) {
// enrich idx // enrich idx
let idx = 0 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> </script>
<template> <template>

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

@ -39,16 +39,18 @@ const { appInfo } = $(useGlobal())
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const hookTabKey = ref('hook-edit')
const useForm = Form.useForm const useForm = Form.useForm
const hook = reactive< 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: '', id: '',
title: '', title: '',
event: undefined, event: undefined,
operation: undefined, operation: undefined,
eventOperation: '', eventOperation: undefined,
notification: { notification: {
type: 'URL', type: 'URL',
payload: { payload: {
@ -56,13 +58,15 @@ const hook = reactive<
body: '{{ json data }}', body: '{{ json data }}',
headers: [{}], headers: [{}],
parameters: [{}], parameters: [{}],
path: '',
}, },
}, },
condition: false, condition: false,
active: true, active: true,
version: 'v2',
}) })
const urlTabKey = ref('body') const urlTabKey = ref('params')
const apps: Record<string, any> = ref() 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', 'Insert'], value: ['after', 'insert'] },
{ text: ['After', 'Update'], value: ['after', 'update'] }, { text: ['After', 'Update'], value: ['after', 'update'] },
{ text: ['After', 'Delete'], value: ['after', 'delete'] }, { 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(() => { const notificationList = computed(() => {
return appInfo.isCloud return appInfo.isCloud
@ -216,10 +223,7 @@ const validators = computed(() => {
'notification.payload.subject': [fieldRequiredValidator()], 'notification.payload.subject': [fieldRequiredValidator()],
'notification.payload.body': [fieldRequiredValidator()], 'notification.payload.body': [fieldRequiredValidator()],
}), }),
...((hook.notification.type === 'Slack' || ...(['Slack', 'Microsoft Teams', 'Discord', 'Mattermost'].includes(hook.notification.type) && {
hook.notification.type === 'Microsoft Teams' ||
hook.notification.type === 'Discord' ||
hook.notification.type === 'Mattermost') && {
'notification.payload.channels': [fieldRequiredValidator()], 'notification.payload.channels': [fieldRequiredValidator()],
'notification.payload.body': [fieldRequiredValidator()], 'notification.payload.body': [fieldRequiredValidator()],
}), }),
@ -231,9 +235,13 @@ const validators = computed(() => {
}) })
const { validate, validateInfos } = useForm(hook, validators) const { validate, validateInfos } = useForm(hook, validators)
function onNotTypeChange(reset = false) { function onNotificationTypeChange(reset = false) {
if (reset) { if (reset) {
hook.notification.payload = {} as Record<string, any> 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') { if (hook.notification.type === 'Slack') {
@ -270,9 +278,17 @@ function setHook(newHook: HookType) {
payload: notification.payload, 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 const { notification: { payload = {}, type = {} } = {} } = hook
Object.assign(hook, { Object.assign(hook, {
@ -285,20 +301,20 @@ async function onEventChange() {
hook.notification.payload = payload 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) { switch (hook.notification.type) {
case 'Slack': case 'Slack':
channels = slackChannels channels.value = slackChannels.value
break break
case 'Microsoft Teams': case 'Microsoft Teams':
channels = teamsChannels channels.value = teamsChannels.value
break break
case 'Discord': case 'Discord':
channels = discordChannels channels.value = discordChannels.value
break break
case 'Mattermost': case 'Mattermost':
channels = mattermostChannels channels.value = mattermostChannels.value
break break
} }
@ -429,7 +445,7 @@ onMounted(async () => {
hook.eventOperation = `${hook.event} ${hook.operation}` hook.eventOperation = `${hook.event} ${hook.operation}`
} }
onNotTypeChange() onNotificationTypeChange()
}) })
</script> </script>
@ -462,6 +478,14 @@ onMounted(async () => {
<a-divider /> <a-divider />
<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 :model="hook" name="create-or-edit-webhook">
<a-form-item> <a-form-item>
<a-row type="flex"> <a-row type="flex">
@ -517,7 +541,7 @@ onMounted(async () => {
class="nc-select-hook-notification-type" class="nc-select-hook-notification-type"
:placeholder="$t('general.notification')" :placeholder="$t('general.notification')"
dropdown-class-name="nc-dropdown-webhook-notification" dropdown-class-name="nc-dropdown-webhook-notification"
@change="onNotTypeChange(true)" @change="onNotificationTypeChange(true)"
> >
<a-select-option v-for="(notificationOption, i) in notificationList" :key="i" :value="notificationOption.type"> <a-select-option v-for="(notificationOption, i) in notificationList" :key="i" :value="notificationOption.type">
<div class="flex items-center"> <div class="flex items-center">
@ -572,7 +596,7 @@ onMounted(async () => {
<a-col :span="24"> <a-col :span="24">
<a-tabs v-model:activeKey="urlTabKey" type="card" closeable="false" class="shadow-sm"> <a-tabs v-model:activeKey="urlTabKey" type="card" closeable="false" class="shadow-sm">
<a-tab-pane key="body" tab="Body"> <a-tab-pane v-if="hook.version === 'v1'" key="body" tab="Body">
<LazyMonacoEditor <LazyMonacoEditor
v-model="hook.notification.payload.body" v-model="hook.notification.payload.body"
disable-deep-compare disable-deep-compare
@ -604,9 +628,8 @@ onMounted(async () => {
<a-row v-if="hook.notification.type === 'Slack'" type="flex"> <a-row v-if="hook.notification.type === 'Slack'" type="flex">
<a-col :span="24"> <a-col :span="24">
<a-form-item v-bind="validateInfos['notification.channels']"> <a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect <LazyWebhookChannelMultiSelect
v-if="slackChannels.length > 0"
v-model="hook.notification.payload.channels" v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels" :selected-channel-list="hook.notification.payload.channels"
:available-channel-list="slackChannels" :available-channel-list="slackChannels"
@ -618,9 +641,8 @@ onMounted(async () => {
<a-row v-if="hook.notification.type === 'Microsoft Teams'" type="flex"> <a-row v-if="hook.notification.type === 'Microsoft Teams'" type="flex">
<a-col :span="24"> <a-col :span="24">
<a-form-item v-bind="validateInfos['notification.channels']"> <a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect <LazyWebhookChannelMultiSelect
v-if="teamsChannels.length > 0"
v-model="hook.notification.payload.channels" v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels" :selected-channel-list="hook.notification.payload.channels"
:available-channel-list="teamsChannels" :available-channel-list="teamsChannels"
@ -632,9 +654,8 @@ onMounted(async () => {
<a-row v-if="hook.notification.type === 'Discord'" type="flex"> <a-row v-if="hook.notification.type === 'Discord'" type="flex">
<a-col :span="24"> <a-col :span="24">
<a-form-item v-bind="validateInfos['notification.channels']"> <a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect <LazyWebhookChannelMultiSelect
v-if="discordChannels.length > 0"
v-model="hook.notification.payload.channels" v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels" :selected-channel-list="hook.notification.payload.channels"
:available-channel-list="discordChannels" :available-channel-list="discordChannels"
@ -646,9 +667,8 @@ onMounted(async () => {
<a-row v-if="hook.notification.type === 'Mattermost'" type="flex"> <a-row v-if="hook.notification.type === 'Mattermost'" type="flex">
<a-col :span="24"> <a-col :span="24">
<a-form-item v-bind="validateInfos['notification.channels']"> <a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect <LazyWebhookChannelMultiSelect
v-if="mattermostChannels.length > 0"
v-model="hook.notification.payload.channels" v-model="hook.notification.payload.channels"
:selected-channel-list="hook.notification.payload.channels" :selected-channel-list="hook.notification.payload.channels"
:available-channel-list="mattermostChannels" :available-channel-list="mattermostChannels"
@ -696,7 +716,7 @@ onMounted(async () => {
<a-row> <a-row>
<a-col :span="24"> <a-col :span="24">
<div class="text-gray-600"> <div v-if="!(hook.version === 'v2' && hook.notification.type === 'URL')" class="text-gray-600">
<div class="flex items-center"> <div class="flex items-center">
<em>Use context variable <strong>data</strong> to refer the record under consideration</em> <em>Use context variable <strong>data</strong> to refer the record under consideration</em>
@ -707,7 +727,8 @@ onMounted(async () => {
<component :is="iconMap.info" class="ml-2" /> <component :is="iconMap.info" class="ml-2" />
</a-tooltip> </a-tooltip>
</div> </div>
<div class="mt-3">
<div class="my-3">
<a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank"> <a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank">
<!-- Document Reference --> <!-- Document Reference -->
{{ $t('labels.docReference') }} {{ $t('labels.docReference') }}
@ -729,4 +750,16 @@ onMounted(async () => {
</a-row> </a-row>
</a-form-item> </a-form-item>
</a-form> </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> </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 class="p-2 nc-hook" @click="emit('edit', item)">
<a-list-item-meta> <a-list-item-meta>
<template #description> <template #description>
<span class="uppercase"> {{ item.event }} {{ item.operation }}</span> <span class="uppercase"> {{ item.event }} {{ item.operation.replace(/[A-Z]/g, ' $&') }}</span>
</template> </template>
<template #title> <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 }} {{ item.title }}
</span> </div>
</template> </template>
<template #avatar> <template #avatar>
<div class="my-1 px-2"> <div class="px-2">
<component :is="iconMap.hook" class="text-xl" /> <component :is="iconMap.hook" class="text-xl" />
</div> </div>
<div class="px-2 text-white rounded" :class="{ 'bg-green-500': item.active, 'bg-gray-500': !item.active }"> <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="mr-2">{{ $t('labels.notifyVia') }} : {{ item?.notification?.type }}</div>
<div class="float-right pt-2 pr-1"> <div class="float-right pt-2 pr-1">
<a-tooltip placement="left"> <a-tooltip v-if="item.version === 'v2'" placement="left">
<template #title> <template #title>
{{ $t('activity.copyWebhook') }} {{ $t('activity.copyWebhook') }}
</template> </template>

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

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

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

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

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

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

3
packages/nc-gui/composables/useSmartsheetStore.ts

@ -32,8 +32,6 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
(meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0], (meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0],
) )
const cellRefs = ref<HTMLTableDataCellElement[]>([])
const { search } = useFieldQuery() const { search } = useFieldQuery()
const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore')) const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore'))
@ -78,7 +76,6 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
isGallery, isGallery,
isKanban, isKanban,
isMap, isMap,
cellRefs,
isSharedForm, isSharedForm,
sorts, sorts,
nestedFilters, nestedFilters,

2
packages/nc-gui/composables/useViewData.ts

@ -213,7 +213,7 @@ export function useViewData(
} }
if (viewMeta.value?.type === ViewTypes.GRID) { if (viewMeta.value?.type === ViewTypes.GRID) {
await loadAggCommentsCount() loadAggCommentsCount()
} }
} }

1
packages/nc-gui/context/index.ts

@ -36,3 +36,4 @@ export const DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-
export const ToggleDialogInj: InjectionKey<Function> = Symbol('toggle-dialog-injection') export const ToggleDialogInj: InjectionKey<Function> = Symbol('toggle-dialog-injection')
export const CellClickHookInj: InjectionKey<EventHook<MouseEvent> | undefined> = Symbol('cell-click-injection') export const CellClickHookInj: InjectionKey<EventHook<MouseEvent> | undefined> = Symbol('cell-click-injection')
export const SaveRowInj: InjectionKey<(() => void) | undefined> = Symbol('save-row-injection') export const SaveRowInj: InjectionKey<(() => void) | undefined> = Symbol('save-row-injection')
export const CurrentCellInj: InjectionKey<Ref<Element | undefined>> = Symbol('current-cell-injection')

16
packages/nc-gui/lang/fr.json

@ -101,7 +101,7 @@
"form": "Formulaire", "form": "Formulaire",
"kanban": "Kanban", "kanban": "Kanban",
"calendar": "Calendrier", "calendar": "Calendrier",
"map": "Map" "map": "Carte"
}, },
"user": "Utilisateur", "user": "Utilisateur",
"users": "Utilisateurs", "users": "Utilisateurs",
@ -210,7 +210,7 @@
"advancedSettings": "Paramètres avancés", "advancedSettings": "Paramètres avancés",
"codeSnippet": "Extrait de code", "codeSnippet": "Extrait de code",
"keyboardShortcut": "Raccourcis clavier", "keyboardShortcut": "Raccourcis clavier",
"generateRandomName": "Generate Random Name", "generateRandomName": "Générer un nom aléatoire",
"findRowByScanningCode": "Find row by scanning a QR or Barcode" "findRowByScanningCode": "Find row by scanning a QR or Barcode"
}, },
"labels": { "labels": {
@ -221,7 +221,7 @@
"viewName": "Vue", "viewName": "Vue",
"viewLink": "Lien de vue", "viewLink": "Lien de vue",
"columnName": "Nom de la colonne", "columnName": "Nom de la colonne",
"columnToScanFor": "Column to scan", "columnToScanFor": "Colonne à scanner",
"columnType": "Type de colonne", "columnType": "Type de colonne",
"roleName": "Nom de rôle", "roleName": "Nom de rôle",
"roleDescription": "Description du rôle", "roleDescription": "Description du rôle",
@ -396,7 +396,7 @@
"saveAndExit": "Enregistrer et quitter", "saveAndExit": "Enregistrer et quitter",
"saveAndStay": "Enregistrer et rester", "saveAndStay": "Enregistrer et rester",
"insertRow": "Insérer une nouvelle ligne", "insertRow": "Insérer une nouvelle ligne",
"duplicateRow": "Duplicate Row", "duplicateRow": "Dupliquer la ligne",
"deleteRow": "Supprimer la ligne", "deleteRow": "Supprimer la ligne",
"deleteSelectedRow": "Supprimer les lignes sélectionnées", "deleteSelectedRow": "Supprimer les lignes sélectionnées",
"importExcel": "Importer depuis Excel", "importExcel": "Importer depuis Excel",
@ -548,7 +548,7 @@
"noRowFoundForCode": "No row found for this code for the selected column" "noRowFoundForCode": "No row found for this code for the selected column"
}, },
"map": { "map": {
"overLimit": "You're over the limit.", "overLimit": "Vous avez dépassé la limite.",
"closeLimit": "You're getting close to the limit.", "closeLimit": "You're getting close to the limit.",
"limitNumber": "The limit of markers shown in a Map View is 1000 records." "limitNumber": "The limit of markers shown in a Map View is 1000 records."
}, },
@ -634,7 +634,7 @@
"gallery": "Ajouter une vue Galerie", "gallery": "Ajouter une vue Galerie",
"form": "Ajouter une vue Formulaire", "form": "Ajouter une vue Formulaire",
"kanban": "Ajouter une vue Kanban", "kanban": "Ajouter une vue Kanban",
"map": "Add Map View", "map": "Ajouter la vue Carte",
"calendar": "Ajouter une vue Calendrier" "calendar": "Ajouter une vue Calendrier"
}, },
"tablesMetadataInSync": "Les métadonnées de tables sont en synchronisation", "tablesMetadataInSync": "Les métadonnées de tables sont en synchronisation",
@ -698,7 +698,7 @@
"allowedSpecialCharList": "Liste des caractères spéciaux autorisés" "allowedSpecialCharList": "Liste des caractères spéciaux autorisés"
}, },
"invalidURL": "URL invalide", "invalidURL": "URL invalide",
"invalidEmail": "Invalid Email", "invalidEmail": "Email invalide",
"internalError": "Une erreur interne est survenue", "internalError": "Une erreur interne est survenue",
"templateGeneratorNotFound": "Le générateur de modèles est introuvable !", "templateGeneratorNotFound": "Le générateur de modèles est introuvable !",
"fileUploadFailed": "Échec du téléversement du fichier", "fileUploadFailed": "Échec du téléversement du fichier",
@ -779,7 +779,7 @@
"userDeletedFromProject": "Suppression réussie de l'utilisateur du projet", "userDeletedFromProject": "Suppression réussie de l'utilisateur du projet",
"inviteEmailSent": "Email d'invitation envoyé avec succès", "inviteEmailSent": "Email d'invitation envoyé avec succès",
"inviteURLCopied": "URL de l'invitation copiée dans le presse-papiers", "inviteURLCopied": "URL de l'invitation copiée dans le presse-papiers",
"commentCopied": "Comment copied to clipboard", "commentCopied": "Commentaire copié dans le presse-papier",
"passwordResetURLCopied": "URL de réinitialisation du mot de passe copiée dans le presse-papiers", "passwordResetURLCopied": "URL de réinitialisation du mot de passe copiée dans le presse-papiers",
"shareableURLCopied": "Copie de l'URL de la base partageable dans le presse-papiers !", "shareableURLCopied": "Copie de l'URL de la base partageable dans le presse-papiers !",
"embeddableHTMLCodeCopied": "Copie du code HTML intégrable !", "embeddableHTMLCodeCopied": "Copie du code HTML intégrable !",

6
packages/nc-gui/lang/pt_BR.json

@ -101,7 +101,7 @@
"form": "Formulário", "form": "Formulário",
"kanban": "Kanban", "kanban": "Kanban",
"calendar": "Calendário", "calendar": "Calendário",
"map": "Map" "map": "Mapa"
}, },
"user": "Do utilizador", "user": "Do utilizador",
"users": "Comercial", "users": "Comercial",
@ -210,7 +210,7 @@
"advancedSettings": "Configurações avançadas", "advancedSettings": "Configurações avançadas",
"codeSnippet": "Código Snippet", "codeSnippet": "Código Snippet",
"keyboardShortcut": "Atalhos de teclado", "keyboardShortcut": "Atalhos de teclado",
"generateRandomName": "Generate Random Name", "generateRandomName": "Gerar um código aleatório",
"findRowByScanningCode": "Find row by scanning a QR or Barcode" "findRowByScanningCode": "Find row by scanning a QR or Barcode"
}, },
"labels": { "labels": {
@ -472,7 +472,7 @@
"map": { "map": {
"mappedBy": "Mapped By", "mappedBy": "Mapped By",
"chooseMappingField": "Choose a Mapping Field", "chooseMappingField": "Choose a Mapping Field",
"openInGoogleMaps": "Google Maps", "openInGoogleMaps": "Google Mapas",
"openInOpenStreetMap": "OSM" "openInOpenStreetMap": "OSM"
}, },
"toggleMobileMode": "Toggle Mobile Mode" "toggleMobileMode": "Toggle Mobile Mode"

184
packages/nc-gui/lang/uk.json

@ -22,12 +22,12 @@
"update": "Оновити", "update": "Оновити",
"rename": "Перейменувати", "rename": "Перейменувати",
"reload": "Перезавантажити", "reload": "Перезавантажити",
"reset": "Скинути", "reset": "Відновити",
"install": "Встановити", "install": "Встановити",
"show": "Показати", "show": "Показати",
"hide": "Приховати", "hide": "Сховати",
"showAll": "Показати все", "showAll": "Показати все",
"hideAll": "Приховати все", "hideAll": "Сховати все",
"showMore": "Показати більше", "showMore": "Показати більше",
"showOptions": "Показати опції", "showOptions": "Показати опції",
"hideOptions": "Сховати опції", "hideOptions": "Сховати опції",
@ -52,7 +52,7 @@
"event": "Подія", "event": "Подія",
"condition": "Стан", "condition": "Стан",
"after": "Після", "after": "Після",
"before": "Раніше", "before": "До",
"search": "Пошук", "search": "Пошук",
"notification": "Сповіщення", "notification": "Сповіщення",
"reference": "Посилання", "reference": "Посилання",
@ -133,7 +133,7 @@
"Time": "Час", "Time": "Час",
"PhoneNumber": "Номер телефону", "PhoneNumber": "Номер телефону",
"Email": "Пошта", "Email": "Пошта",
"URL": "URL", "URL": "URL-посилання",
"Number": "Число", "Number": "Число",
"Decimal": "Дробове", "Decimal": "Дробове",
"Currency": "Валюта", "Currency": "Валюта",
@ -148,7 +148,7 @@
"DateTime": "Дата і час", "DateTime": "Дата і час",
"CreateTime": "Час створення", "CreateTime": "Час створення",
"LastModifiedTime": "Час останньої зміни", "LastModifiedTime": "Час останньої зміни",
"AutoNumber": "Автоматичне число", "AutoNumber": "Автоматичне прирощування",
"Barcode": "Штрих-код", "Barcode": "Штрих-код",
"Button": "Кнопка", "Button": "Кнопка",
"Password": "Пароль", "Password": "Пароль",
@ -182,10 +182,10 @@
"teamAndAuth": "Команда та автор", "teamAndAuth": "Команда та автор",
"rolesUserMgmt": "Ролі та управління користувачами", "rolesUserMgmt": "Ролі та управління користувачами",
"userMgmt": "Керування користувачами", "userMgmt": "Керування користувачами",
"apiTokenMgmt": "Управління токенами API", "apiTokenMgmt": "Керування токенами API",
"rolesMgmt": "Керування ролями", "rolesMgmt": "Керування ролями",
"projMeta": "Метадані проєкту", "projMeta": "Метадані проєкту",
"metaMgmt": "Метаменеджмент", "metaMgmt": "Керування метаданими",
"metadata": "Метадані", "metadata": "Метадані",
"exportImportMeta": "Експорт/Імпорт метаданих", "exportImportMeta": "Експорт/Імпорт метаданих",
"uiACL": "Контроль доступу до інтерфейсу", "uiACL": "Контроль доступу до інтерфейсу",
@ -240,7 +240,7 @@
"operation": "Операція", "operation": "Операція",
"operationSub": "Субоперація", "operationSub": "Субоперація",
"operationType": "Тип операції", "operationType": "Тип операції",
"operationSubType": "Підтип операції", "operationSubType": "Тип субоперації",
"description": "Опис", "description": "Опис",
"authentication": "Автентифікація", "authentication": "Автентифікація",
"token": "Токен", "token": "Токен",
@ -272,11 +272,11 @@
"requriedIdentity": "Потрібна ідентифікація", "requriedIdentity": "Потрібна ідентифікація",
"inflection": { "inflection": {
"tableName": "Перехрестя - Назва таблиці", "tableName": "Перехрестя - Назва таблиці",
"columnName": "Перехрестя - Назва стовпця" "columnName": "Перехрестя - назва стовпця"
}, },
"community": { "community": {
"starUs1": "Оцінити", "starUs1": "Оцінити",
"starUs2": "ми на GitHub", "starUs2": "нас на GitHub",
"bookDemo": "Забронюйте безкоштовну демонстрацію", "bookDemo": "Забронюйте безкоштовну демонстрацію",
"getAnswered": "Отримайте відповіді на запитання", "getAnswered": "Отримайте відповіді на запитання",
"joinDiscord": "Ми є в Discord", "joinDiscord": "Ми є в Discord",
@ -338,30 +338,30 @@
"deleteProject": "Видалити проєкт", "deleteProject": "Видалити проєкт",
"refreshProject": "Оновити проєкти", "refreshProject": "Оновити проєкти",
"saveProject": "Зберегти проєкт", "saveProject": "Зберегти проєкт",
"deleteKanbanStack": "Видалити стек?", "deleteKanbanStack": "Видалити табличку?",
"createProjectExtended": { "createProjectExtended": {
"extDB": "Створити підключення <br>до зовнішньої бази даних", "extDB": "Створити підключення<br>до зовнішньої бази даних",
"excel": "Створити проєкт з Excel", "excel": "Створити проєкт з Excel",
"template": "Створити проєкт з шаблону" "template": "Створити проєкт з шаблону"
}, },
"OkSaveProject": "Підтвердити & Зберегти", "OkSaveProject": "Зберегти",
"upgrade": { "upgrade": {
"available": "Доступне оновлення", "available": "Доступне оновлення",
"releaseNote": "Список змін", "releaseNote": "Список змін",
"howTo": "Як оновитися?" "howTo": "Як оновитись?"
}, },
"translate": "Допоможіть з перекладом", "translate": "Допомогти з перекладом",
"account": { "account": {
"authToken": "Копіювати токен авторизації", "authToken": "Скопіювати токен",
"swagger": "Swagger: REST API", "swagger": "Swagger: REST API",
"projInfo": "Скопіювати інформацію про проект", "projInfo": "Скопіювати інформацію про проєкт",
"themes": "Теми" "themes": "Теми"
}, },
"sort": "Сортувати", "sort": "Сортувати",
"addSort": "Додати параметри сортування", "addSort": "Додати параметри сортування",
"filter": "Фільтр", "filter": "Фільтр",
"addFilter": "Додати фільтр", "addFilter": "Додати фільтр",
"share": "Поділитися", "share": "Поділитись",
"shareBase": { "shareBase": {
"disable": "Вимкнути спільну базу", "disable": "Вимкнути спільну базу",
"enable": "Будь-хто, хто має посилання", "enable": "Будь-хто, хто має посилання",
@ -374,10 +374,10 @@
"inviteToken": "Токен запрошення", "inviteToken": "Токен запрошення",
"newUser": "Новий користувач", "newUser": "Новий користувач",
"editUser": "Редагувати користувача", "editUser": "Редагувати користувача",
"deleteUser": "Видалити користувача з проєкту", "deleteUser": "Видалити користувача",
"resendInvite": "Повторно надіслати запрошення на пошту", "resendInvite": "Повторно надіслати запрошення",
"copyInviteURL": "Скопіювати URL-адресу запрошення", "copyInviteURL": "Скопіювати URL-посилання для запрошення",
"copyPasswordResetURL": "Скопіювати URL-адресу для оновлення паролю", "copyPasswordResetURL": "Скопіювати URL-посилання для відновлення паролю",
"newRole": "Нова роль", "newRole": "Нова роль",
"reloadRoles": "Перезавантажити ролі", "reloadRoles": "Перезавантажити ролі",
"nextPage": "Наступна сторінка", "nextPage": "Наступна сторінка",
@ -390,7 +390,7 @@
"renameTable": "Перейменувати таблицю", "renameTable": "Перейменувати таблицю",
"deleteTable": "Видалити таблицю", "deleteTable": "Видалити таблицю",
"addField": "Додати нове поле до цієї таблиці", "addField": "Додати нове поле до цієї таблиці",
"setDisplay": "Встановити як значення для відображення", "setDisplay": "Встановити як значення для показу",
"addRow": "Додати новий рядок", "addRow": "Додати новий рядок",
"saveRow": "Зберегти рядок", "saveRow": "Зберегти рядок",
"saveAndExit": "Зберегти та вийти", "saveAndExit": "Зберегти та вийти",
@ -416,20 +416,20 @@
"fillByCodeScan": "Заповнити за допомогою сканування", "fillByCodeScan": "Заповнити за допомогою сканування",
"listSharedView": "Список спільних виглядів", "listSharedView": "Список спільних виглядів",
"ListView": "Список виглядів", "ListView": "Список виглядів",
"copyView": "Копіювати вигляд", "copyView": "Скопіювати вигляд",
"renameView": "Перейменувати вигляд", "renameView": "Перейменувати вигляд",
"deleteView": "Видалити вигляд", "deleteView": "Видалити вигляд",
"createGrid": "Створіть вигляд сітки", "createGrid": "Створіть вигляд сітки",
"createGallery": "Створити вигляд галереї", "createGallery": "Створити вигляд галереї",
"createCalendar": "Створити вигляд календаря", "createCalendar": "Створити вигляд календаря",
"createKanban": "Створити Kanban вигляд", "createKanban": "Створити табличний вигляд",
"createForm": "Створити вигляд форми", "createForm": "Створити вигляд форми",
"showSystemFields": "Показати системні поля", "showSystemFields": "Показати системні поля",
"copyUrl": "Копіювати URL-адресу", "copyUrl": "Копіювати URL-адресу",
"openTab": "Відкрийте нову вкладку", "openTab": "Відкрийте нову вкладку",
"iFrame": "Копіювати вбудований HTML-код", "iFrame": "Копіювати вбудований HTML-код",
"addWebhook": "Додати новий Webhook", "addWebhook": "Додати новий вебхук",
"enableWebhook": "Увімкнути вебхук", "enableWebhook": "Ввімкнути вебхук",
"testWebhook": "Перевірити вебхук", "testWebhook": "Перевірити вебхук",
"copyWebhook": "Скопіювати вебхук", "copyWebhook": "Скопіювати вебхук",
"deleteWebhook": "Видалити вебхук", "deleteWebhook": "Видалити вебхук",
@ -439,8 +439,8 @@
"metaSync": "Синхронізувати", "metaSync": "Синхронізувати",
"settings": "Налаштування", "settings": "Налаштування",
"previewAs": "Попередній перегляд як", "previewAs": "Попередній перегляд як",
"resetReview": "Скинути попередній перегляд", "resetReview": "Відновити попередній перегляд",
"testDbConn": "Тестове з'єднання з базою даних", "testDbConn": "Тестове зʼєднання з базою даних",
"removeDbFromEnv": "Видалити базу даних з середовища", "removeDbFromEnv": "Видалити базу даних з середовища",
"editConnJson": "Редагувати підключення JSON", "editConnJson": "Редагувати підключення JSON",
"sponsorUs": "Спонсорувати нас", "sponsorUs": "Спонсорувати нас",
@ -460,38 +460,38 @@
"showPkAndFk": "Показати первинні та зовнішні ключі", "showPkAndFk": "Показати первинні та зовнішні ключі",
"showSqlViews": "Показати SQL вигляд", "showSqlViews": "Показати SQL вигляд",
"showMMTables": "Показати таблиці \"багато-до-багатьох\"", "showMMTables": "Показати таблиці \"багато-до-багатьох\"",
"showJunctionTableNames": "Показати ім'я таблиці для з'єднання" "showJunctionTableNames": "Показати імʼя таблиці для зʼєднання"
}, },
"kanban": { "kanban": {
"collapseStack": "Згорнути стек", "collapseStack": "Згорнути стек",
"deleteStack": "Видалити стек", "deleteStack": "Видалити стек",
"stackedBy": "Групувати по", "stackedBy": "Групувати по",
"chooseGroupingField": "Виберіть поле групування", "chooseGroupingField": "Виберіть поле групування",
"addOrEditStack": "Додати / Редагувати стек" "addOrEditStack": "Додати/Редагувати табличку"
}, },
"map": { "map": {
"mappedBy": "Mapped By", "mappedBy": "Мапувати по",
"chooseMappingField": "Виберіть поле для мапування", "chooseMappingField": "Виберіть поле для мапування",
"openInGoogleMaps": "Google Maps", "openInGoogleMaps": "Google Maps",
"openInOpenStreetMap": "OSM" "openInOpenStreetMap": "Відкрити в Google Maps"
}, },
"toggleMobileMode": "Увімкнути мобільний режим" "toggleMobileMode": "Ввімкнути мобільний режим"
}, },
"tooltip": { "tooltip": {
"saveChanges": "Зберегти зміни", "saveChanges": "Зберегти зміни",
"xcDB": "Створити новий проєкт", "xcDB": "Створити новий проєкт",
"extDB": "Підтримує MySQL, PostgreSQL, SQL Server та SQLite", "extDB": "Підтримує MySQL, PostgreSQL, SQL Server та SQLite",
"apiRest": "Доступно через REST API", "apiRest": "Доступно через REST API",
"apiGQL": "Доступно через API GraphQL", "apiGQL": "Доступно через GraphQL API",
"theme": { "theme": {
"dark": "Він доступний у чорному кольорі (^⇧B)", "dark": "Він доступний у чорному кольорі (^⇧B)",
"light": "Чи є він у чорному кольорі? (^⇧B)" "light": "Чи є він у чорному кольорі? (^⇧B)"
}, },
"addTable": "Додати нову таблицю", "addTable": "Додати нову таблицю",
"inviteMore": "Запросіть більше користувачів", "inviteMore": "Запросіть більше користувачів",
"toggleNavDraw": "Ввімвкнути висувне меню", "toggleNavDraw": "Ввімкнути висувне меню",
"reloadApiToken": "Перезавантажити API токен", "reloadApiToken": "Перезавантажити API токен",
"generateNewApiToken": "Створити новий API токен", "generateNewApiToken": "Згенерувати API токен",
"addRole": "Додати нову роль", "addRole": "Додати нову роль",
"reloadList": "Перезавантажити список", "reloadList": "Перезавантажити список",
"metaSync": "Синхронізувати метаданні", "metaSync": "Синхронізувати метаданні",
@ -538,13 +538,13 @@
"info": { "info": {
"pasteNotSupported": "Операція вставки не підтримується в активній комірці", "pasteNotSupported": "Операція вставки не підтримується в активній комірці",
"roles": { "roles": {
"orgCreator": "Розробник може створювати нові проєкти та мати доступ до будь-якого відкритого проєкту.", "orgCreator": "Розробник може створювати нові проєкти та мати доступ до будь-якого публічного проєкту.",
"orgViewer": "Глядач не може створювати нові проєкти, але він може отримати доступ до будь-якого відкритого проєкту." "orgViewer": "Глядач не може створювати нові проєкти, але він може отримати доступ до будь-якого публічного проєкту."
}, },
"codeScanner": { "codeScanner": {
"loadingScanner": "Завантаження сканера...", "loadingScanner": "Завантаження сканера...",
"selectColumn": "Виберіть стовпець, QR-код або штрих-код, який ви хочете використовувати для пошуку рядка за допомогою сканування.", "selectColumn": "Виберіть стовпець, QR-код або штрих-код, який ви хочете використовувати для пошуку рядка за допомогою сканування.",
"moreThanOneRowFoundForCode": "Для цього коду знайдено більше одного рядка. Наразі підтримуються лише унікальні коди.", "moreThanOneRowFoundForCode": "Для цього коду знайдено декілька рядків. Наразі підтримуються лише унікальні коди",
"noRowFoundForCode": "Для цього коду не знайдено жодного рядка у вибраному стовпчику" "noRowFoundForCode": "Для цього коду не знайдено жодного рядка у вибраному стовпчику"
}, },
"map": { "map": {
@ -558,19 +558,19 @@
"excelSupport": "Підтримуються: .xls, .xlsx, .xlsm, .ods, .ots", "excelSupport": "Підтримуються: .xls, .xlsx, .xlsm, .ods, .ots",
"excelURL": "Введіть посилання до файлу Excel", "excelURL": "Введіть посилання до файлу Excel",
"csvURL": "Введіть посилання до файлу CSV", "csvURL": "Введіть посилання до файлу CSV",
"footMsg": "# рядків, які потрібно проаналізувати, щоб вивести тип даних", "footMsg": "К-сть рядків для аналізу, щоб визначити тип даних",
"excelImport": "аркуш(і) які доступні для імпорту", "excelImport": "аркуш(і) які доступні для імпорту",
"exportMetadata": "Ви хочете експортувати метадані з мета-таблиць?", "exportMetadata": "Ви хочете експортувати метадані з мета-таблиць?",
"importMetadata": "Ви хочете імпортувати метадані з мета-таблиць?", "importMetadata": "Ви хочете імпортувати метадані з мета-таблиць?",
"clearMetadata": "Ви хочете очистити метадані з мета-таблиць?", "clearMetadata": "Ви хочете очистити метадані з мета-таблиць?",
"projectEmptyMessage": "Почніть зі створення нового проекту", "projectEmptyMessage": "Почніть зі створення нового проєкту",
"stopProject": "Ви хочете зупинити проєкт?", "stopProject": "Ви хочете зупинити проєкт?",
"startProject": "Ви хочете розпочати проєкт?", "startProject": "Ви хочете розпочати проєкт?",
"restartProject": "Ви хочете перезапустити проєкт?", "restartProject": "Ви хочете перезапустити проєкт?",
"deleteProject": "Ви хочете видалити проєкт?", "deleteProject": "Ви хочете видалити проєкт?",
"shareBasePrivate": "Створіть загальнодоступну базу, доступну лише для читання", "shareBasePrivate": "Створіть загальнодоступну базу, доступну лише для читання",
"shareBasePublic": "Будь-хто в Інтернеті може переглядати за допомогою цього посилання", "shareBasePublic": "Будь-хто в Інтернеті може переглядати за допомогою цього посилання",
"userInviteNoSMTP": "Схоже, ви ще не налаштували SMTP! Будь ласка, скопіюйте посилання на запрошення та надішліть його на адресу", "userInviteNoSMTP": "Схоже, ви ще не налаштували SMTP! Будь ласка, скопіюйте посилання на запрошення та надішліть його на пошту.",
"dragDropHide": "Перетягніть поля сюди, щоб приховати", "dragDropHide": "Перетягніть поля сюди, щоб приховати",
"formInput": "Введіть назву форми для введення", "formInput": "Введіть назву форми для введення",
"formHelpText": "Додайте допоміжний текст", "formHelpText": "Додайте допоміжний текст",
@ -578,7 +578,7 @@
"formDesc": "Додати опис форми", "formDesc": "Додати опис форми",
"beforeEnablePwd": "Обмежити доступ за допомогою пароля", "beforeEnablePwd": "Обмежити доступ за допомогою пароля",
"afterEnablePwd": "Доступ обмежено паролем", "afterEnablePwd": "Доступ обмежено паролем",
"privateLink": им переглядом поділилися через приватне посилання", "privateLink": ей перегляд доступний за приватним посиланням",
"privateLinkAdditionalInfo": "Люди з приватним посиланням можуть бачити лише клітини, видимі в цьому вигляді", "privateLinkAdditionalInfo": "Люди з приватним посиланням можуть бачити лише клітини, видимі в цьому вигляді",
"afterFormSubmitted": "Після надсилання форми", "afterFormSubmitted": "Після надсилання форми",
"apiOptions": "Доступ до проєкту через", "apiOptions": "Доступ до проєкту через",
@ -593,9 +593,9 @@
"collabView": "Учасники з дозволом на редагування або вище можуть змінювати конфігурацію перегляду.", "collabView": "Учасники з дозволом на редагування або вище можуть змінювати конфігурацію перегляду.",
"lockedView": "Ніхто не може відредагувати конфігурацію перегляду, доки вона не розблокована.", "lockedView": "Ніхто не може відредагувати конфігурацію перегляду, доки вона не розблокована.",
"personalView": "Тільки ви можете відредагувати конфігурацію. Іншим учасникам прихована зміна переглядів за замовчуванням.", "personalView": "Тільки ви можете відредагувати конфігурацію. Іншим учасникам прихована зміна переглядів за замовчуванням.",
"ownerDesc": "Може додавати/видаляти редакторів. Також може повністю редагувати структури та поля бази даних.", "ownerDesc": "Може додавати або видаляти редакторів. Також може повністю редагувати структури та поля бази даних.",
"creatorDesc": "Може повністю редагувати структуру бази даних та значення.", "creatorDesc": "Може повністю редагувати структуру бази даних та значення.",
"editorDesc": "Може редагувати записи, але не може змінити структуру бази даних/полів.", "editorDesc": "Може редагувати записи, але не може змінити структуру бази даних або полів.",
"commenterDesc": "Може переглядати та коментувати записи, але нічого не може редагувати", "commenterDesc": "Може переглядати та коментувати записи, але нічого не може редагувати",
"viewerDesc": "Може переглядати записи, але нічого не може редагувати", "viewerDesc": "Може переглядати записи, але нічого не може редагувати",
"addUser": "Додати нового користувача", "addUser": "Додати нового користувача",
@ -606,14 +606,14 @@
"metaNoChange": "Не виявлено жодних змін", "metaNoChange": "Не виявлено жодних змін",
"sqlMigration": "Схема міграції буде створена автоматично. Створіть таблицю та оновіть цю сторінку.", "sqlMigration": "Схема міграції буде створена автоматично. Створіть таблицю та оновіть цю сторінку.",
"dbConnectionStatus": "Середовище перевірено", "dbConnectionStatus": "Середовище перевірено",
"dbConnected": 'єднання успішне", "dbConnected": ʼєднання успішне",
"notifications": { "notifications": {
"no_new": "Немає нових повідомлень", "no_new": "Немає нових повідомлень",
"clear": "Очистити" "clear": "Очистити"
}, },
"sponsor": { "sponsor": {
"header": "Ви можете допомогти нам!", "header": "Ви можете допомогти нам!",
"message": "Ми - невелика команда, яка працює на повну ставку, щоб зробити NocoDB відкритим. Ми віримо, що такий інструмент, як NocoDB, повинен бути доступним безкоштовно для кожного розв'язувача проблем в Інтернеті." "message": "Ми - невелика команда, яка працює на повну ставку, щоб зробити NocoDB відкритим. Ми віримо, що такий інструмент, як NocoDB, повинен бути доступним безкоштовно для кожного розвʼязувача проблем в Інтернеті."
}, },
"loginMsg": "Увійти до NocoDB", "loginMsg": "Увійти до NocoDB",
"passwordRecovery": { "passwordRecovery": {
@ -623,22 +623,22 @@
}, },
"signUp": { "signUp": {
"superAdmin": "Ви будете \"Власником\"", "superAdmin": "Ви будете \"Власником\"",
"alreadyHaveAccount": "Вже є обліковий запис?", "alreadyHaveAccount": "Вже зареєстровані?",
"workEmail": "Введіть робочу адресу електронної пошти", "workEmail": "Введіть робочу адресу електронної пошти",
"enterPassword": "Введіть ваш пароль", "enterPassword": "Введіть ваш пароль",
"forgotPassword": "Забули власний пароль?", "forgotPassword": "Забули пароль?",
"dontHaveAccount": "Не маєте облікового запису?" "dontHaveAccount": "Не маєте облікового запису?"
}, },
"addView": { "addView": {
"grid": "Додати вигляд сітки", "grid": "Додати вигляд сітки",
"gallery": "Додати вигляд галереї", "gallery": "Додати вигляд галереї",
"form": "Додати вигляд форми", "form": "Додати вигляд форми",
"kanban": "Додати вигляд Kanban", "kanban": "Додати вигляд таблички",
"map": "Додати вид мапи", "map": "Додати вид мапи",
"calendar": "Додати вигляд календаря" "calendar": "Додати вигляд календаря"
}, },
"tablesMetadataInSync": "Таблиці метаданих синхронізуються", "tablesMetadataInSync": "Таблиці метаданих синхронізуються",
"addMultipleUsers": "Ви можете додати кілька електронних адрес, розділених комами(,)", "addMultipleUsers": "Ви можете додати кілька електронних адрес через кому",
"enterTableName": "Введіть назву таблиці", "enterTableName": "Введіть назву таблиці",
"addDefaultColumns": "Додайте стовпці за замовчуванням", "addDefaultColumns": "Додайте стовпці за замовчуванням",
"tableNameInDb": "Назва таблиці, збережена в базі даних", "tableNameInDb": "Назва таблиці, збережена в базі даних",
@ -648,7 +648,7 @@
"import": { "import": {
"clickOrDrag": "Клацніть або перетягніть файл у цю область, щоб завантажити його" "clickOrDrag": "Клацніть або перетягніть файл у цю область, щоб завантажити його"
}, },
"metaDataRecreated": "Метадані таблиці успішно відтворено", "metaDataRecreated": "Метадані таблиці відтворено",
"invalidCredentials": "Недійсні облікові дані", "invalidCredentials": "Недійсні облікові дані",
"downloadingMoreFiles": "Завантажте інші файли", "downloadingMoreFiles": "Завантажте інші файли",
"copiedToClipboard": "Скопійовано в буфер обміну", "copiedToClipboard": "Скопійовано в буфер обміну",
@ -658,7 +658,7 @@
"editingPKnotSupported": "Редагування первинного ключа не підтримується", "editingPKnotSupported": "Редагування первинного ключа не підтримується",
"deletedCache": "Кеш успішно видалено", "deletedCache": "Кеш успішно видалено",
"cacheEmpty": "Кеш порожній", "cacheEmpty": "Кеш порожній",
"exportedCache": "Кеш успішно експортовано", "exportedCache": "Кеш експортовано",
"valueAlreadyInList": "Це значення вже є у списку", "valueAlreadyInList": "Це значення вже є у списку",
"noColumnsToUpdate": "Немає стовпців для оновлення", "noColumnsToUpdate": "Немає стовпців для оновлення",
"tableDeleted": "Таблицю успішно видалено", "tableDeleted": "Таблицю успішно видалено",
@ -668,26 +668,26 @@
"showM2mTables": "Показати M2M таблиці", "showM2mTables": "Показати M2M таблиці",
"showM2mTablesDesc": "Звʼязок \"багато-до-багатьох\" підтримується через таблицю зʼєднань і за замовчуванням прихований. Увімкніть цю опцію, щоб перерахувати всі такі таблиці разом з існуючими.", "showM2mTablesDesc": "Звʼязок \"багато-до-багатьох\" підтримується через таблицю зʼєднань і за замовчуванням прихований. Увімкніть цю опцію, щоб перерахувати всі такі таблиці разом з існуючими.",
"showNullInCells": "Показати NULL в комірках", "showNullInCells": "Показати NULL в комірках",
"showNullInCellsDesc": "Відображати тег 'NULL' у клітинках, що містять NULL-значення. Це допомагає відрізнити клітинки, що містять ПУСТИЙ рядок.", "showNullInCellsDesc": "Відображати тег \"NULL\" у клітинках, що містять NULL-значення. Це допомагає відрізнити клітинки, що містять пустий рядок.",
"showNullAndEmptyInFilter": "Показувати NULL та EMPTY у фільтрі", "showNullAndEmptyInFilter": "Показувати NULL та EMPTY у фільтрі",
"showNullAndEmptyInFilterDesc": "Увімкніть \"додаткові\" фільтри для розрізнення полів, що містять NULL та порожні рядки. За замовчуванням підтримка пропусків однаково обробляє як NULL, так і порожні рядки.", "showNullAndEmptyInFilterDesc": "Ввімкніть \"додаткові\" фільтри для розрізнення полів, що містять NULL та порожні рядки. За замовчуванням підтримка пропусків однаково обробляє як NULL, так і порожні рядки.",
"deleteKanbanStackConfirmation": "Видалення цього стека також вилучить опцію вибору `{stackToBeDeleted}` зі стека `{groupingField}`. Записи буде переміщено до не категоризованого стека.", "deleteKanbanStackConfirmation": "Видалення цієї таблички також вилучить опцію вибору `{stackToBeDeleted}` з таблички `{groupingField}`. Записи буде переміщено до не категоризованої таблички.",
"computedFieldEditWarning": "Обчислюване поле: вміст доступний лише для читання. Використовуйте меню редагування стовпця для зміни конфігурації", "computedFieldEditWarning": "Обчислюване поле: вміст доступний лише для читання. Використовуйте меню редагування стовпця для зміни конфігурації",
"computedFieldDeleteWarning": "Обчислюване поле: вміст доступний лише для читання. Не вдалося очистити вміст.", "computedFieldDeleteWarning": "Обчислюване поле: вміст доступний лише для читання. Не вдалося очистити вміст.",
"noMoreRecords": "Більше записів немає" "noMoreRecords": "Більше записів немає"
}, },
"error": { "error": {
"searchProject": "За вашим запитом {search} не знайдено жодного результату", "searchProject": "За вашим запитом {search} не знайдено жодного результату",
"invalidChar": "Недійсний символ у шляху до теки.", "invalidChar": "Недійсний символ у шляху.",
"invalidDbCredentials": "Неправильні облікові дані бази даних.", "invalidDbCredentials": "Неправильні облікові дані бази даних.",
"unableToConnectToDb": "Неможливо під'єднатися до бази даних, будь ласка, перевірте, чи працює ваша база даних.", "unableToConnectToDb": "Неможливо підʼєднатися до бази даних, будь ласка, перевірте, чи працює ваша база даних.",
"userDoesntHaveSufficientPermission": "Користувач не існує або не має достатніх прав для створення конфігурації.", "userDoesntHaveSufficientPermission": "Користувач не існує або не має достатніх прав для створення конфігурації.",
"dbConnectionStatus": "Неправильні параметри бази даних", "dbConnectionStatus": "Неправильні параметри бази даних",
"dbConnectionFailed": "Помилка з’єднання:", "dbConnectionFailed": "Помилка з’єднання:",
"signUpRules": { "signUpRules": {
"emailReqd": "E-mail є обов'язковим полем", "emailReqd": "Пошта є обовʼязковим полем",
"emailInvalid": "E-mail має невірний формат", "emailInvalid": "Пошта має невірний формат",
"passwdRequired": "Пароль є обов'язковим", "passwdRequired": "Пароль є обовʼязковим",
"passwdLength": "Пароль має складатися хоча б з 8 символів", "passwdLength": "Пароль має складатися хоча б з 8 символів",
"passwdMismatch": "Паролі не збігаються", "passwdMismatch": "Паролі не збігаються",
"completeRuleSet": "Щонайменше 8 символів з однією великою літерою, однією цифрою та одним спеціальним символом", "completeRuleSet": "Щонайменше 8 символів з однією великою літерою, однією цифрою та одним спеціальним символом",
@ -704,7 +704,7 @@
"fileUploadFailed": "Не вдалося завантажити файл", "fileUploadFailed": "Не вдалося завантажити файл",
"primaryColumnUpdateFailed": "Не вдалося оновити основний стовпець", "primaryColumnUpdateFailed": "Не вдалося оновити основний стовпець",
"formDescriptionTooLong": "Занадто довгі дані для опису форми", "formDescriptionTooLong": "Занадто довгі дані для опису форми",
"columnsRequired": "Обов'язковими є наступні стовпці", "columnsRequired": "Обовʼязковими є наступні стовпці",
"selectAtleastOneColumn": "Принаймні один стовпець має бути вибраний", "selectAtleastOneColumn": "Принаймні один стовпець має бути вибраний",
"columnDescriptionNotFound": "Не вдається знайти стовпець призначення для", "columnDescriptionNotFound": "Не вдається знайти стовпець призначення для",
"duplicateMappingFound": "Знайдено дублікат схеми, будь ласка, видаліть одну зі схем", "duplicateMappingFound": "Знайдено дублікат схеми, будь ласка, видаліть одну зі схем",
@ -723,7 +723,7 @@
"setFormDataFailed": "Не вдалося встановити дані форми", "setFormDataFailed": "Не вдалося встановити дані форми",
"formViewUpdateFailed": "Не вдалося оновити вигляд форми", "formViewUpdateFailed": "Не вдалося оновити вигляд форми",
"tableNameRequired": "Ім'я таблиці є обов'язковим", "tableNameRequired": "Ім'я таблиці є обов'язковим",
"nameShouldStartWithAnAlphabetOr_": "Ім'я повинно починатися з літери або _", "nameShouldStartWithAnAlphabetOr_": "Імʼя повинно починатися з літери або _",
"followingCharactersAreNotAllowed": "Наступні символи не допускаються", "followingCharactersAreNotAllowed": "Наступні символи не допускаються",
"columnNameRequired": "Ім'я стовпця є обов'язковим", "columnNameRequired": "Ім'я стовпця є обов'язковим",
"columnNameExceedsCharacters": "Довжина назви стовпця перевищує максимальну кількість в {value} символів", "columnNameExceedsCharacters": "Довжина назви стовпця перевищує максимальну кількість в {value} символів",
@ -741,52 +741,52 @@
"copyToClipboardError": "Не вдалося скопіювати в буфер обміну" "copyToClipboardError": "Не вдалося скопіювати в буфер обміну"
}, },
"toast": { "toast": {
"exportMetadata": "Метадані проєкту успішно експортовано", "exportMetadata": "Метадані проєкту експортовано",
"importMetadata": "Метадані проєкту успішно імпортовано", "importMetadata": "Метадані проєкту імпортовано",
"clearMetadata": "Метадані проєкту успішно очищено", "clearMetadata": "Метадані проєкту очищено",
"stopProject": "Проєкт успішно зупинений", "stopProject": "Проєкт зупинений",
"startProject": "Проєкт успішно запущений", "startProject": "Проєкт запущений",
"restartProject": "Проєкт успішно перезапущений", "restartProject": "Проєкт перезапущений",
"deleteProject": "Проєкт успішно видалений", "deleteProject": "Проєкт видалений",
"authToken": "Токен авторизації скопійовано в буфер обміну", "authToken": "Токен авторизації скопійовано в буфер обміну",
"projInfo": "Інформацію про проєкт скопійовано до буфера обміну", "projInfo": "Інформацію про проєкт скопійовано до буфера обміну",
"inviteUrlCopy": "Скопійовано запрошення URL в буфер обміну", "inviteUrlCopy": "Скопійовано запрошення URL в буфер обміну",
"createView": "Вигляд створено успішно", "createView": "Вигляд створено",
"formEmailSMTP": "Будь ласка, активуйте плагін SMTP в Магазині додатків, щоб увімкнути повідомлення електронної пошти", "formEmailSMTP": "Будь ласка, активуйте плагін SMTP в магазині додатків, щоб ввімкнути повідомлення на електронну пошту",
"collabView": "Успішно переведено в режим спільної роботи", "collabView": "Переведено в режим спільної роботи",
"lockedView": "Успішно перемкнуто на заблокований вигляд", "lockedView": "Переведено в заблокований режим",
"futureRelease": "Незабаром!" "futureRelease": "Незабаром!"
}, },
"success": { "success": {
"columnDuplicated": "Стовпець успішно продубльовано", "columnDuplicated": "Стовпець успішно продубльовано",
"rowDuplicatedWithoutSavedYet": "Рядок продубльовано (не збережено)", "rowDuplicatedWithoutSavedYet": "Рядок продубльовано, але не збережено",
"updatedUIACL": "Успішно оновлено UI ACL для таблиць", "updatedUIACL": "Успішно оновлено UI ACL для таблиць",
"pluginUninstalled": "Плагін успішно видалено", "pluginUninstalled": "Плагін успішно видалено",
"pluginSettingsSaved": "Налаштування плагіну успішно збережено", "pluginSettingsSaved": "Налаштування плагіну збережено",
"pluginTested": "Успішно протестовані налаштування плагіну", "pluginTested": "Успішно протестовані налаштування плагіну",
"tableRenamed": "Таблицю успішно перейменовано", "tableRenamed": "Таблицю успішно перейменовано",
"viewDeleted": "Вигляд успішно видалено", "viewDeleted": "Вигляд видалено",
"primaryColumnUpdated": "Успішно оновлено як основний стовпець", "primaryColumnUpdated": "Успішно оновлено як основний стовпець",
"tableDataExported": "Успішно експортовано всі дані таблиці", "tableDataExported": "Успішно експортовано всі дані таблиці",
"updated": "Успішно оновлено", "updated": "Успішно оновлено",
"sharedViewDeleted": "Успішно видалено спільний перегляд", "sharedViewDeleted": "Спільний перегляд видалено",
"userDeleted": "Користувача успішно видалено", "userDeleted": "Користувача успішно видалено",
"viewRenamed": "Вигляд успішно перейменовано", "viewRenamed": "Вигляд перейменовано",
"tokenGenerated": "Токен успішно згенеровано", "tokenGenerated": "Токен успішно згенеровано",
"tokenDeleted": "Токен успішно видалено", "tokenDeleted": "Токен успішно видалено",
"userAddedToProject": "Користувача успішно додано до проєкту", "userAddedToProject": "Користувача додано до проєкту",
"userAdded": "Користувача успішно додано", "userAdded": "Користувача додано",
"userDeletedFromProject": "Користувача успішно видалено з проєкту", "userDeletedFromProject": "Користувача видалено з проєкту",
"inviteEmailSent": "Лист запрошення успішно відправлено на електронну пошту", "inviteEmailSent": "Лист запрошення відправлено на електронну пошту",
"inviteURLCopied": "URL запрошення скопійоване в буфер обміну", "inviteURLCopied": "URL-посилання для запрошення скопійовано",
"commentCopied": "Коментар скопійовано до буфера обміну", "commentCopied": "Коментар скопійовано в буфер обміну",
"passwordResetURLCopied": "URL-адресу скидання пароля скопійовано в буфер обміну", "passwordResetURLCopied": "URL-адресу скидання пароля скопійовано в буфер обміну",
"shareableURLCopied": "URL адресу спільної бази скопійовано в буфер обміну!", "shareableURLCopied": "URL-посилання спільної бази скопійовано!",
"embeddableHTMLCodeCopied": "Скопійовано вбудований HTML-код!", "embeddableHTMLCodeCopied": "Скопійовано вбудований HTML-код!",
"userDetailsUpdated": "Дані користувача успішно оновлено", "userDetailsUpdated": "Дані користувача оновлено",
"tableDataImported": "Дані таблиці успішно імпортовано", "tableDataImported": "Дані таблиці імпортовано",
"webhookUpdated": "Дані Webhook успішно оновлено", "webhookUpdated": "Дані вебхуку оновлено",
"webhookDeleted": "Hook успішно видалено", "webhookDeleted": "Вебхук видалено",
"webhookTested": "Webhook успішно протестовано", "webhookTested": "Webhook успішно протестовано",
"columnUpdated": "Стовпець оновлено", "columnUpdated": "Стовпець оновлено",
"columnCreated": "Стовпець створено", "columnCreated": "Стовпець створено",

8
packages/nc-gui/lang/zh-Hans.json

@ -238,7 +238,7 @@
"action": "操作", "action": "操作",
"actions": "操作", "actions": "操作",
"operation": "操作", "operation": "操作",
"operationSub": "Sub Operation", "operationSub": "子操作",
"operationType": "操作类型", "operationType": "操作类型",
"operationSubType": "子操作类型", "operationSubType": "子操作类型",
"description": "描述", "description": "描述",
@ -471,11 +471,11 @@
}, },
"map": { "map": {
"mappedBy": "Mapped By", "mappedBy": "Mapped By",
"chooseMappingField": "Choose a Mapping Field", "chooseMappingField": "选择映射字段",
"openInGoogleMaps": "谷歌地图", "openInGoogleMaps": "谷歌地图",
"openInOpenStreetMap": "OSM" "openInOpenStreetMap": "OSM"
}, },
"toggleMobileMode": "Toggle Mobile Mode" "toggleMobileMode": "切换移动模式"
}, },
"tooltip": { "tooltip": {
"saveChanges": "保存更改", "saveChanges": "保存更改",
@ -779,7 +779,7 @@
"userDeletedFromProject": "踢出用户成功", "userDeletedFromProject": "踢出用户成功",
"inviteEmailSent": "邀请邮件发送成功", "inviteEmailSent": "邀请邮件发送成功",
"inviteURLCopied": "邀请URL已复制到剪贴板", "inviteURLCopied": "邀请URL已复制到剪贴板",
"commentCopied": "Comment copied to clipboard", "commentCopied": "已复制评论到剪贴板",
"passwordResetURLCopied": "密码重置网址已复制到剪贴板", "passwordResetURLCopied": "密码重置网址已复制到剪贴板",
"shareableURLCopied": "已将可共享的基础URL复制到剪贴板!", "shareableURLCopied": "已将可共享的基础URL复制到剪贴板!",
"embeddableHTMLCodeCopied": "已复制可嵌入的 HTML 代码!", "embeddableHTMLCodeCopied": "已复制可嵌入的 HTML 代码!",

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

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

52
packages/nc-gui/package-lock.json generated

@ -30,7 +30,7 @@
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",
"locale-codes": "^1.3.1", "locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"nocodb-sdk": "0.106.0-beta.0", "nocodb-sdk": "file:../nocodb-sdk",
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
@ -111,7 +111,6 @@
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.106.0-beta.0", "version": "0.106.0-beta.0",
"extraneous": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",
@ -8776,6 +8775,7 @@
"version": "1.15.1", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"devOptional": true,
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -12294,21 +12294,8 @@
} }
}, },
"node_modules/nocodb-sdk": { "node_modules/nocodb-sdk": {
"version": "0.106.0-beta.0", "resolved": "../nocodb-sdk",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.106.0-beta.0.tgz", "link": true
"integrity": "sha512-XoooU7D34nM5Hl9gEbyUmA0UzbWL+7E73PGm9b9Kmt3TCOnGtYL6AVaSqF0ve2xHhyTwNyIU1xB7neEDlZJRwA==",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
}
},
"node_modules/nocodb-sdk/node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
}, },
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "3.23.0", "version": "3.23.0",
@ -24810,7 +24797,8 @@
"follow-redirects": { "follow-redirects": {
"version": "1.15.1", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"devOptional": true
}, },
"form-data": { "form-data": {
"version": "4.0.0", "version": "4.0.0",
@ -27360,22 +27348,22 @@
} }
}, },
"nocodb-sdk": { "nocodb-sdk": {
"version": "0.106.0-beta.0", "version": "file:../nocodb-sdk",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.106.0-beta.0.tgz",
"integrity": "sha512-XoooU7D34nM5Hl9gEbyUmA0UzbWL+7E73PGm9b9Kmt3TCOnGtYL6AVaSqF0ve2xHhyTwNyIU1xB7neEDlZJRwA==",
"requires": { "requires": {
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"jsep": "^1.3.6" "cspell": "^4.1.0",
}, "eslint": "^7.8.0",
"dependencies": { "eslint-config-prettier": "^6.11.0",
"axios": { "eslint-plugin-eslint-comments": "^3.2.0",
"version": "0.21.4", "eslint-plugin-functional": "^3.0.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", "eslint-plugin-import": "^2.22.0",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "eslint-plugin-prettier": "^4.0.0",
"requires": { "jsep": "^1.3.6",
"follow-redirects": "^1.14.0" "npm-run-all": "^4.1.5",
} "prettier": "^2.1.1",
} "typescript": "^4.0.2"
} }
}, },
"node-abi": { "node-abi": {

2
packages/nc-gui/package.json

@ -54,7 +54,7 @@
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",
"locale-codes": "^1.3.1", "locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"nocodb-sdk": "0.106.0-beta.0", "nocodb-sdk": "file:../nocodb-sdk",
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",

30
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"> <img width="686" alt="image" src="https://user-images.githubusercontent.com/35857179/194849248-1d0b80c6-f65b-4075-8ebd-af7dc735c2c3.png">
### Configure Webhook ### Configure Webhook
- General configurations - General configurations
- Webhook Name - Webhook Name
- Webhook Trigger - Webhook Trigger
@ -27,11 +28,22 @@ Some types of notifications can be triggered by a webhook after a particular eve
- Webhook Conditional Trigger - Webhook Conditional Trigger
- Only records meeting the criteria will trigger webhook - Only records meeting the criteria will trigger webhook
![Screenshot 2022-09-14 at 10 35 39 AM](https://user-images.githubusercontent.com/86527202/190064668-37245025-81f6-491c-b639-83c8fd131bc3.png) <!-- ![Screenshot 2022-09-14 at 10 35 39 AM](https://user-images.githubusercontent.com/86527202/190064668-37245025-81f6-491c-b639-83c8fd131bc3.png) -->
![Screenshot 2023-04-06 at 11 39 49 AM](https://user-images.githubusercontent.com/86527202/230288581-c613e591-1c32-4151-a2d1-df2bbf1367fd.png)
<!-- ![image](https://user-images.githubusercontent.com/35857179/166660248-a3c81a34-4334-48c2-846a-65759d761559.png) --> <!-- ![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 ## Triggers
@ -59,6 +71,11 @@ The triggers will trigger asynchronously without blocking the actual operation.
## Accessing Data: Handlebars ## 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. 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. > We are using [Handlebars](https://handlebarsjs.com/) library to parse the payload internally.
@ -136,6 +153,7 @@ Detailed procedure for discord webhook described [here](https://support.discord.
## Slack ## Slack
### 1. Create WebHook ### 1. Create WebHook
- Details to create slack webhook are captured [here](https://api.slack.com/messaging/webhooks) - Details to create slack webhook are captured [here](https://api.slack.com/messaging/webhooks)
@ -196,3 +214,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**: Message to be posted over Teams channel, via webhooks on trigger of configured event.
- Body can contain plain text & - Body can contain plain text &
- Handlebars {{ }} - 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 | | | | 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_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_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` | |

4
packages/nocodb-sdk/package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "nocodb-sdk", "name": "nocodb-sdk",
"version": "0.105.3", "version": "0.106.0-beta.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nocodb-sdk", "name": "nocodb-sdk",
"version": "0.105.3", "version": "0.106.0-beta.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",

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

@ -622,6 +622,16 @@ export interface FilterListType {
pageInfo: PaginatedType; 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 * Model for Filter Request
*/ */
@ -983,6 +993,29 @@ export interface GridType {
columns?: GridColumnType[]; 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 * Model for Grid Column
*/ */
@ -1082,7 +1115,13 @@ export interface HookType {
* Hook Operation * Hook Operation
* @example insert * @example insert
*/ */
operation?: 'delete' | 'insert' | 'update'; operation?:
| 'insert'
| 'update'
| 'delete'
| 'bulkInsert'
| 'bulkUpdate'
| 'bulkDelete';
/** /**
* Retry Count * Retry Count
* @example 10 * @example 10
@ -1105,6 +1144,11 @@ export interface HookType {
title?: string; title?: string;
/** Hook Type */ /** Hook Type */
type?: string; type?: string;
/**
* Hook Version
* @example v2
*/
version?: 'v1' | 'v2';
} }
/** /**
@ -1140,7 +1184,13 @@ export interface HookReqType {
* Hook Operation * Hook Operation
* @example insert * @example insert
*/ */
operation: 'delete' | 'insert' | 'update'; operation:
| 'insert'
| 'update'
| 'delete'
| 'bulkInsert'
| 'bulkUpdate'
| 'bulkDelete';
/** /**
* Retry Count * Retry Count
* @example 10 * @example 10
@ -1181,28 +1231,79 @@ export interface HookListType {
* Model for Hook Log * Model for Hook Log
*/ */
export interface HookLogType { export interface HookLogType {
/**
* Unique Base ID
* @example ds_jxuewivwbxeum2
*/
base_id?: string; base_id?: string;
/** Hook Conditions */
conditions?: string; conditions?: string;
error?: string; /** Error */
error_code?: string; error?: StringOrNullType;
error_message?: string; /** Error Code */
event?: string; error_code?: StringOrNullType;
/** Error Message */
error_message?: StringOrNullType;
/**
* Hook Event
* @example after
*/
event?: 'after' | 'before';
/**
* Execution Time in milliseconds
* @example 98
*/
execution_time?: string; execution_time?: string;
/** Model for StringOrNull */ /** Foreign Key to Hook */
fk_hook_id?: StringOrNullType; fk_hook_id?: StringOrNullType;
/** Unique ID */ /** Unique ID */
id?: IdType; id?: StringOrNullType;
/** Hook Notification */
notifications?: string; 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; project_id?: string;
response?: string; /** Hook Response */
/** Model for Bool */ response?: StringOrNullType;
/** Is this testing hook call? */
test_call?: BoolType; test_call?: BoolType;
triggered_by?: string; /** Who triggered the hook? */
triggered_by?: StringOrNullType;
/**
* Hook Type
* @example URL
*/
type?: string; 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 * Model for Hook Test Request
*/ */
@ -6399,6 +6500,45 @@ export class Api<
...params, ...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 = { dbTableRow = {
/** /**
* @description List all table rows in a given table and project * @description List all table rows in a given table and project
@ -8409,6 +8549,8 @@ export class Api<
ncAttachmentFieldSize?: number, ncAttachmentFieldSize?: number,
ncMaxAttachmentsAllowed?: number, ncMaxAttachmentsAllowed?: number,
isCloud?: boolean, isCloud?: boolean,
\** @example OFF *\
automationLogLevel?: "OFF" | "ERROR" | "ALL",
}` OK }` OK
* @response `400` `{ * @response `400` `{
@ -8438,6 +8580,8 @@ export class Api<
ncAttachmentFieldSize?: number; ncAttachmentFieldSize?: number;
ncMaxAttachmentsAllowed?: number; ncMaxAttachmentsAllowed?: number;
isCloud?: boolean; isCloud?: boolean;
/** @example OFF */
automationLogLevel?: 'OFF' | 'ERROR' | 'ALL';
}, },
{ {
/** @example BadRequest [Error]: <ERROR MESSAGE> */ /** @example BadRequest [Error]: <ERROR MESSAGE> */
@ -8860,7 +9004,7 @@ export class Api<
* @tags DB Table Webhook * @tags DB Table Webhook
* @name SamplePayloadGet * @name SamplePayloadGet
* @summary Get Sample Hook Payload * @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` `{ * @response `200` `{
\** Sample Payload Data *\ \** Sample Payload Data *\
data?: object, data?: object,
@ -8874,7 +9018,14 @@ export class Api<
*/ */
samplePayloadGet: ( samplePayloadGet: (
tableId: IdType, tableId: IdType,
operation: 'update' | 'delete' | 'insert', operation:
| 'insert'
| 'update'
| 'delete'
| 'bulkInsert'
| 'bulkUpdate'
| 'bulkDelete',
version: 'v1' | 'v2',
params: RequestParams = {} params: RequestParams = {}
) => ) =>
this.request< this.request<
@ -8887,7 +9038,7 @@ export class Api<
msg: string; 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', method: 'GET',
format: 'json', format: 'json',
...params, ...params,

55
packages/nocodb/Dockerfile.local

@ -0,0 +1,55 @@
###########
# Builder
###########
FROM node:16.17.0-alpine3.15 as builder
WORKDIR /usr/src/app
# install node-gyp dependencies
RUN apk add --no-cache python3 make g++
# Copy application dependency manifests to the container image.
# A wildcard is used to ensure both package.json AND package-lock.json are copied.
# Copying this separately prevents re-running npm ci on every code change.
COPY ./package*.json ./
COPY ./docker/nc-gui/ ./docker/nc-gui/
COPY ./docker/main.js ./docker/index.js
COPY ./docker/start-local.sh /usr/src/appEntry/start.sh
COPY ./src/lib/public/css/*.css ./docker/public/css/
COPY ./src/lib/public/js/*.js ./docker/public/js/
COPY ./src/lib/public/favicon.ico ./docker/public/
# install production dependencies,
# reduce node_module size with modclean & removing sqlite deps,
# package built code into app.tar.gz & add execute permission to start.sh
RUN npm ci --omit=dev --quiet \
&& npx modclean --patterns="default:*" --ignore="nc-lib-gui/**,dayjs/**,express-status-monitor/**,@azure/msal-node/dist/**" --run \
&& rm -rf ./node_modules/sqlite3/deps \
&& tar -czf ../appEntry/app.tar.gz ./* \
&& chmod +x /usr/src/appEntry/start.sh
##########
# Runner
##########
FROM alpine:3.15
WORKDIR /usr/src/app
ENV NC_DOCKER 0.6
ENV NODE_ENV production
ENV PORT 8080
ENV NC_TOOL_DIR=/usr/app/data/
RUN apk --update --no-cache add \
nodejs \
tar \
dumb-init \
curl \
jq
# Copy packaged production code & main entry file
COPY --from=builder /usr/src/appEntry/ /usr/src/appEntry/
EXPOSE 8080
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
# Start Nocodb
CMD ["/usr/src/appEntry/start.sh"]

14
packages/nocodb/docker/start-local.sh

@ -0,0 +1,14 @@
#!/bin/sh
FILE="/usr/src/app/package.json"
if [ ! -z "${NC_TOOL_DIR}" ]; then
mkdir -p $NC_TOOL_DIR
fi
if [ ! -f "$FILE" ]
then
tar -xzf /usr/src/appEntry/app.tar.gz -C /usr/src/app/
fi
node docker/index.js

30
packages/nocodb/package-lock.json generated

@ -70,7 +70,7 @@
"nc-lib-gui": "0.106.0-beta.0", "nc-lib-gui": "0.106.0-beta.0",
"nc-plugin": "0.1.2", "nc-plugin": "0.1.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nocodb-sdk": "0.106.0-beta.0", "nocodb-sdk": "file:../nocodb-sdk",
"nodemailer": "^6.4.10", "nodemailer": "^6.4.10",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"os-locale": "^5.0.0", "os-locale": "^5.0.0",
@ -157,7 +157,6 @@
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.106.0-beta.0", "version": "0.106.0-beta.0",
"extraneous": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",
@ -11392,13 +11391,8 @@
"dev": true "dev": true
}, },
"node_modules/nocodb-sdk": { "node_modules/nocodb-sdk": {
"version": "0.106.0-beta.0", "resolved": "../nocodb-sdk",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.106.0-beta.0.tgz", "link": true
"integrity": "sha512-XoooU7D34nM5Hl9gEbyUmA0UzbWL+7E73PGm9b9Kmt3TCOnGtYL6AVaSqF0ve2xHhyTwNyIU1xB7neEDlZJRwA==",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
}
}, },
"node_modules/node-abort-controller": { "node_modules/node-abort-controller": {
"version": "3.0.1", "version": "3.0.1",
@ -28097,12 +28091,22 @@
"dev": true "dev": true
}, },
"nocodb-sdk": { "nocodb-sdk": {
"version": "0.106.0-beta.0", "version": "file:../nocodb-sdk",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.106.0-beta.0.tgz",
"integrity": "sha512-XoooU7D34nM5Hl9gEbyUmA0UzbWL+7E73PGm9b9Kmt3TCOnGtYL6AVaSqF0ve2xHhyTwNyIU1xB7neEDlZJRwA==",
"requires": { "requires": {
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"jsep": "^1.3.6" "cspell": "^4.1.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^4.0.0",
"jsep": "^1.3.6",
"npm-run-all": "^4.1.5",
"prettier": "^2.1.1",
"typescript": "^4.0.2"
} }
}, },
"node-abort-controller": { "node-abort-controller": {

2
packages/nocodb/package.json

@ -112,7 +112,7 @@
"nc-lib-gui": "0.106.0-beta.0", "nc-lib-gui": "0.106.0-beta.0",
"nc-plugin": "0.1.2", "nc-plugin": "0.1.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nocodb-sdk": "0.106.0-beta.0", "nocodb-sdk": "file:../nocodb-sdk",
"nodemailer": "^6.4.10", "nodemailer": "^6.4.10",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"os-locale": "^5.0.0", "os-locale": "^5.0.0",

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

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

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

15
packages/nocodb/src/lib/db/sql-client/lib/SqlClientFactory.ts

@ -48,19 +48,28 @@ export default class {
typeof connectionConfig.connection.ssl === 'object' typeof connectionConfig.connection.ssl === 'object'
) { ) {
if (connectionConfig.connection.ssl.caFilePath) { if (connectionConfig.connection.ssl.caFilePath) {
connectionConfig.connection.ssl.ca = await promisify(fs.readFile)( connectionConfig.connection.ssl.ca = (
await promisify(fs.readFile)(
connectionConfig.connection.ssl.caFilePath connectionConfig.connection.ssl.caFilePath
)
).toString(); ).toString();
delete connectionConfig.connection.ssl.caFilePath;
} }
if (connectionConfig.connection.ssl.keyFilePath) { if (connectionConfig.connection.ssl.keyFilePath) {
connectionConfig.connection.ssl.key = await promisify(fs.readFile)( connectionConfig.connection.ssl.key = (
await promisify(fs.readFile)(
connectionConfig.connection.ssl.keyFilePath connectionConfig.connection.ssl.keyFilePath
)
).toString(); ).toString();
delete connectionConfig.connection.ssl.keyFilePath;
} }
if (connectionConfig.connection.ssl.certFilePath) { if (connectionConfig.connection.ssl.certFilePath) {
connectionConfig.connection.ssl.cert = await promisify(fs.readFile)( connectionConfig.connection.ssl.cert = (
await promisify(fs.readFile)(
connectionConfig.connection.ssl.certFilePath connectionConfig.connection.ssl.certFilePath
)
).toString(); ).toString();
delete connectionConfig.connection.ssl.certFilePath;
} }
} }

23
packages/nocodb/src/lib/db/sql-client/lib/pg/PgClient.ts

@ -474,17 +474,22 @@ class PGClient extends KnexClient {
]); ]);
} }
// if (this.connectionConfig.searchPath && this.connectionConfig.searchPath[0]) { const schemaName = this.connectionConfig.searchPath?.[0] || 'public';
// Check schemaExists because `CREATE SCHEMA IF NOT EXISTS` requires permissions of `CREATE ON DATABASE`
const schemaExists = !!(
await this.sqlClient.raw( await this.sqlClient.raw(
` CREATE SCHEMA IF NOT EXISTS ?? AUTHORIZATION ?? `, `SELECT schema_name FROM information_schema.schemata WHERE schema_name = ?`,
[ [schemaName]
(this.connectionConfig.searchPath && )
this.connectionConfig.searchPath[0]) || ).rows?.[0];
'public',
this.connectionConfig.connection.user, if (!schemaExists) {
] await this.sqlClient.raw(
`CREATE SCHEMA IF NOT EXISTS ?? AUTHORIZATION ?? `,
[schemaName, this.connectionConfig.connection.user]
); );
// } }
// this.sqlClient = knex(this.connectionConfig); // this.sqlClient = knex(this.connectionConfig);
} catch (e) { } catch (e) {

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

@ -1,6 +1,7 @@
import autoBind from 'auto-bind'; import autoBind from 'auto-bind';
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import DataLoader from 'dataloader'; import DataLoader from 'dataloader';
import { nocoExecute } from 'nc-help';
import { import {
AuditOperationSubTypes, AuditOperationSubTypes,
AuditOperationTypes, AuditOperationTypes,
@ -14,8 +15,10 @@ import ejs from 'ejs';
import Validator from 'validator'; import Validator from 'validator';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import DOMPurify from 'isomorphic-dompurify'; import DOMPurify from 'isomorphic-dompurify';
import { getViewAndModelByAliasOrId } from '../../../../services/dbData/helpers';
import Model from '../../../../models/Model'; import Model from '../../../../models/Model';
import Column from '../../../../models/Column'; import Column from '../../../../models/Column';
import Project from '../../../../models/Project';
import Filter, { import Filter, {
COMPARISON_OPS, COMPARISON_OPS,
COMPARISON_SUB_OPS, COMPARISON_SUB_OPS,
@ -33,6 +36,7 @@ import {
invokeWebhook, invokeWebhook,
} from '../../../../meta/helpers/webhookHelpers'; } from '../../../../meta/helpers/webhookHelpers';
import { NcError } from '../../../../meta/helpers/catchError'; import { NcError } from '../../../../meta/helpers/catchError';
import getAst from './helpers/getAst';
import { customValidators } from './customValidators'; import { customValidators } from './customValidators';
import formulaQueryBuilderv2 from './formulav2/formulaQueryBuilderv2'; import formulaQueryBuilderv2 from './formulav2/formulaQueryBuilderv2';
import genRollupSelectv2 from './genRollupSelectv2'; import genRollupSelectv2 from './genRollupSelectv2';
@ -136,6 +140,15 @@ class BaseModelSqlv2 {
const proto = await this.getProto(); const proto = await this.getProto();
data.__proto__ = proto; 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; return data;
} }
@ -1283,7 +1296,8 @@ class BaseModelSqlv2 {
private async getSelectQueryBuilderForFormula( private async getSelectQueryBuilderForFormula(
column: Column<any>, column: Column<any>,
tableAlias?: string, tableAlias?: string,
validateFormula = false validateFormula = false,
aliasToColumnBuilder = {}
) { ) {
const formula = await column.getColOptions<FormulaColumn>(); const formula = await column.getColOptions<FormulaColumn>();
if (formula.error) throw new Error(`Formula error: ${formula.error}`); if (formula.error) throw new Error(`Formula error: ${formula.error}`);
@ -1293,7 +1307,7 @@ class BaseModelSqlv2 {
this.dbDriver, this.dbDriver,
this.model, this.model,
column, column,
{}, aliasToColumnBuilder,
tableAlias, tableAlias,
validateFormula validateFormula
); );
@ -1517,7 +1531,7 @@ class BaseModelSqlv2 {
validateFormula, validateFormula,
}: { }: {
fieldsSet?: Set<string>; fieldsSet?: Set<string>;
qb: Knex.QueryBuilder; qb: Knex.QueryBuilder & Knex.QueryInterface;
columns?: Column[]; columns?: Column[];
fields?: string[] | string; fields?: string[] | string;
extractPkAndPv?: boolean; extractPkAndPv?: boolean;
@ -1525,6 +1539,8 @@ class BaseModelSqlv2 {
alias?: string; alias?: string;
validateFormula?: boolean; validateFormula?: boolean;
}): Promise<void> { }): Promise<void> {
// keep a common object for all columns to share across all columns
const aliasToColumnBuilder = {};
let viewOrTableColumns: Column[] | { fk_column_id?: string }[]; let viewOrTableColumns: Column[] | { fk_column_id?: string }[];
const res = {}; const res = {};
@ -1588,7 +1604,8 @@ class BaseModelSqlv2 {
const selectQb = await this.getSelectQueryBuilderForFormula( const selectQb = await this.getSelectQueryBuilderForFormula(
qrValueColumn, qrValueColumn,
alias, alias,
validateFormula validateFormula,
aliasToColumnBuilder
); );
qb.select({ qb.select({
[column.column_name]: selectQb.builder, [column.column_name]: selectQb.builder,
@ -1622,7 +1639,8 @@ class BaseModelSqlv2 {
const selectQb = await this.getSelectQueryBuilderForFormula( const selectQb = await this.getSelectQueryBuilderForFormula(
barcodeValueColumn, barcodeValueColumn,
alias, alias,
validateFormula validateFormula,
aliasToColumnBuilder
); );
qb.select({ qb.select({
[column.column_name]: selectQb.builder, [column.column_name]: selectQb.builder,
@ -1647,7 +1665,8 @@ class BaseModelSqlv2 {
const selectQb = await this.getSelectQueryBuilderForFormula( const selectQb = await this.getSelectQueryBuilderForFormula(
column, column,
alias, alias,
validateFormula validateFormula,
aliasToColumnBuilder
); );
qb.select( qb.select(
this.dbDriver.raw(`?? as ??`, [ this.dbDriver.raw(`?? as ??`, [
@ -1700,9 +1719,6 @@ class BaseModelSqlv2 {
await this.beforeInsert(insertObj, trx, cookie); await this.beforeInsert(insertObj, trx, cookie);
} }
// if ('beforeInsert' in this) {
// await this.beforeInsert(insertObj, trx, cookie);
// }
await this.model.getColumns(); await this.model.getColumns();
let response; let response;
// const driver = trx ? trx : this.dbDriver; // const driver = trx ? trx : this.dbDriver;
@ -1774,7 +1790,7 @@ class BaseModelSqlv2 {
async delByPk(id, trx?, cookie?) { async delByPk(id, trx?, cookie?) {
try { try {
// retrieve data for handling paramas in hook // retrieve data for handling params in hook
const data = await this.readByPk(id); const data = await this.readByPk(id);
await this.beforeDelete(id, trx, cookie); await this.beforeDelete(id, trx, cookie);
const response = await this.dbDriver(this.tnPath) const response = await this.dbDriver(this.tnPath)
@ -1839,15 +1855,17 @@ class BaseModelSqlv2 {
await this.beforeUpdate(data, trx, cookie); await this.beforeUpdate(data, trx, cookie);
const prevData = await this.readByPk(id);
const query = this.dbDriver(this.tnPath) const query = this.dbDriver(this.tnPath)
.update(updateObj) .update(updateObj)
.where(await this._wherePk(id)); .where(await this._wherePk(id));
await this.execAndParse(query); await this.execAndParse(query);
const response = await this.readByPk(id); const newData = await this.readByPk(id);
await this.afterUpdate(response, trx, cookie); await this.afterUpdate(prevData, newData, trx, cookie);
return response; return newData;
} catch (e) { } catch (e) {
console.log(e); console.log(e);
await this.errorUpdate(e, data, trx, cookie); await this.errorUpdate(e, data, trx, cookie);
@ -2031,21 +2049,14 @@ class BaseModelSqlv2 {
rowId = rowId =
response[this.model.primaryKey.title] || response[this.model.primaryKey.title] ||
response[this.model.primaryKey.column_name]; response[this.model.primaryKey.column_name];
await Promise.all(postInsertOps.map((f) => f()));
// if (!trx) { await Promise.all(postInsertOps.map((f) => f()));
// await driver.commit();
// }
await this.afterInsert(response, this.dbDriver, cookie); await this.afterInsert(response, this.dbDriver, cookie);
return response; return response;
} catch (e) { } catch (e) {
console.log(e); console.log(e);
// await this.errorInsert(e, data, trx, cookie);
// if (!trx) {
// await driver.rollback(e);
// }
throw e; throw e;
} }
} }
@ -2073,11 +2084,6 @@ class BaseModelSqlv2 {
for (const data of datas) { for (const data of datas) {
await this.validate(data); 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 // fallbacks to `10` if database client is sqlite
// to avoid `too many SQL variables` error // to avoid `too many SQL variables` error
@ -2114,6 +2120,9 @@ class BaseModelSqlv2 {
transaction = await this.dbDriver.transaction(); transaction = await this.dbDriver.transaction();
// await this.beforeUpdateb(updateDatas, transaction); // await this.beforeUpdateb(updateDatas, transaction);
const prevData = [];
const newData = [];
const updatePkValues = [];
const res = []; const res = [];
for (const d of updateDatas) { for (const d of updateDatas) {
await this.validate(d); await this.validate(d);
@ -2122,21 +2131,24 @@ class BaseModelSqlv2 {
// pk not specified - bypass // pk not specified - bypass
continue; continue;
} }
prevData.push(await this.readByPk(pkValues));
const wherePk = await this._wherePk(pkValues); const wherePk = await this._wherePk(pkValues);
const response = await transaction(this.tnPath) await transaction(this.tnPath).update(d).where(wherePk);
.update(d) res.push(wherePk);
.where(wherePk); updatePkValues.push(pkValues);
res.push(response); }
await transaction.commit();
for (const pkValues of updatePkValues) {
newData.push(await this.readByPk(pkValues));
} }
await this.afterBulkUpdate(updateDatas.length, this.dbDriver, cookie); await this.afterBulkUpdate(prevData, newData, this.dbDriver, cookie);
transaction.commit();
return res; return res;
} catch (e) { } catch (e) {
if (transaction) transaction.rollback(); if (transaction) await transaction.rollback();
// console.log(e);
// await this.errorUpdateb(e, data, null);
throw e; throw e;
} }
} }
@ -2146,8 +2158,8 @@ class BaseModelSqlv2 {
data, data,
{ cookie }: { cookie?: any } = {} { cookie }: { cookie?: any } = {}
) { ) {
let queryResponse;
try { try {
let count = 0;
const updateData = await this.model.mapAliasToColumn(data); const updateData = await this.model.mapAliasToColumn(data);
await this.validate(updateData); await this.validate(updateData);
const pkValues = await this._extractPksValues(updateData); const pkValues = await this._extractPksValues(updateData);
@ -2178,11 +2190,11 @@ class BaseModelSqlv2 {
); );
qb.update(updateData); qb.update(updateData);
queryResponse = (await qb) as any;
count = (await qb) as any;
} }
const count = queryResponse ?? 0; await this.afterBulkUpdate(null, count, this.dbDriver, cookie, true);
await this.afterBulkUpdate(count, this.dbDriver, cookie);
return count; return count;
} catch (e) { } catch (e) {
@ -2197,27 +2209,32 @@ class BaseModelSqlv2 {
ids.map((d) => this.model.mapAliasToColumn(d)) ids.map((d) => this.model.mapAliasToColumn(d))
); );
transaction = await this.dbDriver.transaction(); const deleted = [];
// await this.beforeDeleteb(ids, transaction);
const res = []; const res = [];
for (const d of deleteIds) { for (const d of deleteIds) {
if (Object.keys(d).length) { const pkValues = await this._extractPksValues(d);
const response = await transaction(this.tnPath).del().where(d); if (!pkValues) {
res.push(response); // pk not specified - bypass
continue;
}
deleted.push(await this.readByPk(pkValues));
res.push(d);
} }
transaction = await this.dbDriver.transaction();
for (const d of res) {
await transaction(this.tnPath).del().where(d);
} }
// await this.afterDeleteb(res, transaction);
transaction.commit(); await transaction.commit();
await this.afterBulkDelete(ids.length, this.dbDriver, cookie); await this.afterBulkDelete(deleted, this.dbDriver, cookie);
return res; return res;
} catch (e) { } catch (e) {
if (transaction) transaction.rollback(); if (transaction) await transaction.rollback();
console.log(e); console.log(e);
// await this.errorDeleteb(e, ids);
throw e; throw e;
} }
} }
@ -2249,10 +2266,12 @@ class BaseModelSqlv2 {
qb, qb,
this.dbDriver this.dbDriver
); );
qb.del(); qb.del();
const count = (await qb) as any; const count = (await qb) as any;
await this.afterBulkDelete(count, this.dbDriver, cookie); await this.afterBulkDelete(count, this.dbDriver, cookie, true);
return count; return count;
} catch (e) { } catch (e) {
@ -2265,12 +2284,11 @@ class BaseModelSqlv2 {
* */ * */
public async beforeInsert(data: any, _trx: any, req): Promise<void> { 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> { public async afterInsert(data: any, _trx: any, req): Promise<void> {
await this.handleHooks('After.insert', data, req); await this.handleHooks('after.insert', null, data, req);
// if (req?.headers?.['xc-gui']) {
const id = this._extractPksValues(data); const id = this._extractPksValues(data);
await Audit.insert({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
@ -2284,16 +2302,27 @@ class BaseModelSqlv2 {
ip: req?.clientIp, ip: req?.clientIp,
user: req?.user?.email, 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({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA, op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_UPDATE, op_sub_type: AuditOperationSubTypes.BULK_UPDATE,
description: DOMPurify.sanitize( description: DOMPurify.sanitize(
`${count} records bulk updated in ${this.model.title}` `${noOfUpdatedRecords} records bulk updated in ${this.model.title}`
), ),
// details: JSON.stringify(data), // details: JSON.stringify(data),
ip: req?.clientIp, ip: req?.clientIp,
@ -2301,13 +2330,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({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA, op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_DELETE, op_sub_type: AuditOperationSubTypes.BULK_DELETE,
description: DOMPurify.sanitize( description: DOMPurify.sanitize(
`${count} records bulk deleted in ${this.model.title}` `${noOfDeletedRecords} records bulk deleted in ${this.model.title}`
), ),
// details: JSON.stringify(data), // details: JSON.stringify(data),
ip: req?.clientIp, ip: req?.clientIp,
@ -2316,6 +2356,8 @@ class BaseModelSqlv2 {
} }
public async afterBulkInsert(data: any[], _trx: any, req): Promise<void> { public async afterBulkInsert(data: any[], _trx: any, req): Promise<void> {
await this.handleHooks('after.bulkInsert', null, data, req);
await Audit.insert({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA, op_type: AuditOperationTypes.DATA,
@ -2337,12 +2379,18 @@ class BaseModelSqlv2 {
} }
} }
if (ignoreWebhook === undefined || ignoreWebhook === 'false') { 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> { public async afterUpdate(
const id = this._extractPksValues(data); prevData: any,
newData: any,
_trx: any,
req
): Promise<void> {
const id = this._extractPksValues(newData);
await Audit.insert({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
row_id: id, row_id: id,
@ -2361,16 +2409,15 @@ class BaseModelSqlv2 {
} }
} }
if (ignoreWebhook === undefined || ignoreWebhook === 'false') { 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> { 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> { public async afterDelete(data: any, _trx: any, req): Promise<void> {
// if (req?.headers?.['xc-gui']) {
const id = req?.params?.id; const id = req?.params?.id;
await Audit.insert({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
@ -2382,15 +2429,17 @@ class BaseModelSqlv2 {
ip: req?.clientIp, ip: req?.clientIp,
user: req?.user?.email, user: req?.user?.email,
}); });
// } await this.handleHooks('after.delete', null, data, req);
await this.handleHooks('After.delete', 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); const view = await View.get(this.viewId);
// handle form view data submission // 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 { try {
const formView = await view.getView<FormView>(); const formView = await view.getView<FormView>();
const { columns } = await FormView.getWithInfo(formView.fk_view_id); const { columns } = await FormView.getWithInfo(formView.fk_view_id);
@ -2440,11 +2489,11 @@ class BaseModelSqlv2 {
.map((a) => a[0]); .map((a) => a[0]);
if (emails?.length) { if (emails?.length) {
const transformedData = _transformSubmittedFormDataForEmail( const transformedData = _transformSubmittedFormDataForEmail(
data, newData,
formView, formView,
filteredColumns filteredColumns
); );
(await NcPluginMgrv2.emailAdapter())?.mailSend({ (await NcPluginMgrv2.emailAdapter(false))?.mailSend({
to: emails.join(','), to: emails.join(','),
subject: 'NocoDB Form', subject: 'NocoDB Form',
html: ejs.render(formSubmissionEmailTemplate, { html: ejs.render(formSubmissionEmailTemplate, {
@ -2468,7 +2517,7 @@ class BaseModelSqlv2 {
}); });
for (const hook of hooks) { for (const hook of hooks) {
if (hook.active) { if (hook.active) {
invokeWebhook(hook, this.model, data, req?.user); invokeWebhook(hook, this.model, view, prevData, newData, req?.user);
} }
} }
} catch (e) { } catch (e) {
@ -2634,6 +2683,8 @@ class BaseModelSqlv2 {
break; break;
} }
const response = await this.readByPk(rowId);
await this.afterInsert(response, this.dbDriver, cookie);
await this.afterAddChild(rowId, childId, cookie); await this.afterAddChild(rowId, childId, cookie);
} }
@ -2681,6 +2732,8 @@ class BaseModelSqlv2 {
const childTn = this.getTnPath(childTable); const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable); const parentTn = this.getTnPath(parentTable);
const prevData = await this.readByPk(rowId);
switch (colOptions.type) { switch (colOptions.type) {
case RelationTypes.MANY_TO_MANY: case RelationTypes.MANY_TO_MANY:
{ {
@ -2732,6 +2785,8 @@ class BaseModelSqlv2 {
break; break;
} }
const newData = await this.readByPk(rowId);
await this.afterUpdate(prevData, newData, this.dbDriver, cookie);
await this.afterRemoveChild(rowId, childId, cookie); await this.afterRemoveChild(rowId, childId, cookie);
} }

92
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts

@ -53,7 +53,7 @@ async function _formulaQueryBuilder(
alias, alias,
knex: XKnex, knex: XKnex,
model: Model, model: Model,
aliasToColumn = {}, aliasToColumn: Record<string, () => Promise<{ builder: any }>> = {},
tableAlias?: string tableAlias?: string
) { ) {
// formula may include double curly brackets in previous version // formula may include double curly brackets in previous version
@ -69,6 +69,7 @@ async function _formulaQueryBuilder(
switch (col.uidt) { switch (col.uidt) {
case UITypes.Formula: case UITypes.Formula:
{ {
aliasToColumn[col.id] = async () => {
const formulOption = await col.getColOptions<FormulaColumn>(); const formulOption = await col.getColOptions<FormulaColumn>();
const { builder } = await _formulaQueryBuilder( const { builder } = await _formulaQueryBuilder(
formulOption.formula, formulOption.formula,
@ -79,11 +80,14 @@ async function _formulaQueryBuilder(
tableAlias tableAlias
); );
builder.sql = '(' + builder.sql + ')'; builder.sql = '(' + builder.sql + ')';
aliasToColumn[col.id] = builder; return {
builder,
};
};
} }
break; break;
case UITypes.Lookup: case UITypes.Lookup:
{ aliasToColumn[col.id] = async (): Promise<any> => {
let aliasCount = 0; let aliasCount = 0;
let selectQb; let selectQb;
let isMany = false; let isMany = false;
@ -398,25 +402,27 @@ async function _formulaQueryBuilder(
} }
if (selectQb) if (selectQb)
aliasToColumn[col.id] = return {
builder:
typeof selectQb === 'function' typeof selectQb === 'function'
? selectQb ? selectQb
: knex.raw(selectQb as any).wrap('(', ')'); : knex.raw(selectQb as any).wrap('(', ')'),
} };
} }
};
break; break;
case UITypes.Rollup: case UITypes.Rollup:
{ aliasToColumn[col.id] = async (): Promise<any> => {
const qb = await genRollupSelectv2({ const qb = await genRollupSelectv2({
knex, knex,
columnOptions: (await col.getColOptions()) as RollupColumn, columnOptions: (await col.getColOptions()) as RollupColumn,
alias: tableAlias, alias: tableAlias,
}); });
aliasToColumn[col.id] = knex.raw(qb.builder).wrap('(', ')'); return { builder: knex.raw(qb.builder).wrap('(', ')') };
} };
break; break;
case UITypes.LinkToAnotherRecord: case UITypes.LinkToAnotherRecord:
{ aliasToColumn[col.id] = async (): Promise<any> => {
const alias = `__nc_formula_ll`; const alias = `__nc_formula_ll`;
const relation = await col.getColOptions<LinkToAnotherRecordColumn>(); const relation = await col.getColOptions<LinkToAnotherRecordColumn>();
// if (relation.type !== 'bt') continue; // if (relation.type !== 'bt') continue;
@ -520,19 +526,22 @@ async function _formulaQueryBuilder(
.wrap('(', ')'); .wrap('(', ')');
} }
if (selectQb) if (selectQb)
aliasToColumn[col.id] = return {
builder:
typeof selectQb === 'function' typeof selectQb === 'function'
? selectQb ? selectQb
: knex.raw(selectQb as any).wrap('(', ')'); : knex.raw(selectQb as any).wrap('(', ')'),
} };
};
break; break;
default: default:
aliasToColumn[col.id] = col.column_name; aliasToColumn[col.id] = () =>
Promise.resolve({ builder: col.column_name });
break; break;
} }
} }
const fn = (pt, a?, prevBinaryOp?) => { const fn = async (pt, a?, prevBinaryOp?) => {
const colAlias = a ? ` as ${a}` : ''; const colAlias = a ? ` as ${a}` : '';
pt.arguments?.forEach?.((arg) => { pt.arguments?.forEach?.((arg) => {
if (arg.fnName) return; if (arg.fnName) return;
@ -558,18 +567,6 @@ async function _formulaQueryBuilder(
return fn(pt.arguments[0], a, prevBinaryOp); return fn(pt.arguments[0], a, prevBinaryOp);
} }
break; break;
// case 'AVG':
// if (pt.arguments.length > 1) {
// return fn({
// type: 'BinaryExpression',
// operator: '/',
// left: {...pt, callee: {name: 'SUM'}},
// right: {type: 'Literal', value: pt.arguments.length}
// }, a, prevBinaryOp)
// } else {
// return fn(pt.arguments[0], a, prevBinaryOp)
// }
// break;
case 'CONCAT': case 'CONCAT':
if (knex.clientType() === 'sqlite3') { if (knex.clientType() === 'sqlite3') {
if (pt.arguments.length > 1) { if (pt.arguments.length > 1) {
@ -616,7 +613,7 @@ async function _formulaQueryBuilder(
break; break;
default: default:
{ {
const res = mapFunctionName({ const res = await mapFunctionName({
pt, pt,
knex, knex,
alias, alias,
@ -631,10 +628,12 @@ async function _formulaQueryBuilder(
break; break;
} }
return knex.raw( return {
`${pt.callee.name}(${pt.arguments builder: knex.raw(
.map((arg) => { `${pt.callee.name}(${(
const query = fn(arg).toQuery(); await Promise.all(
pt.arguments.map(async (arg) => {
const query = (await fn(arg)).builder.toQuery();
if (pt.callee.name === 'CONCAT') { if (pt.callee.name === 'CONCAT') {
if (knex.clientType() === 'mysql2') { if (knex.clientType() === 'mysql2') {
// mysql2: CONCAT() returns NULL if any argument is NULL. // mysql2: CONCAT() returns NULL if any argument is NULL.
@ -648,15 +647,18 @@ async function _formulaQueryBuilder(
} }
return query; return query;
}) })
.join()})${colAlias}`.replace(/\?/g, '\\?') )
); ).join()})${colAlias}`.replace(/\?/g, '\\?')
),
};
} else if (pt.type === 'Literal') { } else if (pt.type === 'Literal') {
return knex.raw(`?${colAlias}`, [pt.value]); return { builder: knex.raw(`?${colAlias}`, [pt.value]) };
} else if (pt.type === 'Identifier') { } else if (pt.type === 'Identifier') {
if (typeof aliasToColumn?.[pt.name] === 'function') { const { builder } = await aliasToColumn?.[pt.name]?.();
return knex.raw(`??${colAlias}`, aliasToColumn?.[pt.name](pt.fnName)); if (typeof builder === 'function') {
return { builder: knex.raw(`??${colAlias}`, builder(pt.fnName)) };
} }
return knex.raw(`??${colAlias}`, [aliasToColumn?.[pt.name] || pt.name]); return { builder: knex.raw(`??${colAlias}`, [builder || pt.name]) };
} else if (pt.type === 'BinaryExpression') { } else if (pt.type === 'BinaryExpression') {
if (pt.operator === '==') { if (pt.operator === '==') {
pt.operator = '='; pt.operator = '=';
@ -677,8 +679,8 @@ async function _formulaQueryBuilder(
pt.left.fnName = pt.left.fnName || 'ARITH'; pt.left.fnName = pt.left.fnName || 'ARITH';
pt.right.fnName = pt.right.fnName || 'ARITH'; pt.right.fnName = pt.right.fnName || 'ARITH';
const left = fn(pt.left, null, pt.operator).toQuery(); const left = (await fn(pt.left, null, pt.operator)).builder.toQuery();
const right = fn(pt.right, null, pt.operator).toQuery(); const right = (await fn(pt.right, null, pt.operator)).builder.toQuery();
let sql = `${left} ${pt.operator} ${right}${colAlias}`; let sql = `${left} ${pt.operator} ${right}${colAlias}`;
// comparing a date with empty string would throw // comparing a date with empty string would throw
@ -772,7 +774,7 @@ async function _formulaQueryBuilder(
if (prevBinaryOp && pt.operator !== prevBinaryOp) { if (prevBinaryOp && pt.operator !== prevBinaryOp) {
query.wrap('(', ')'); query.wrap('(', ')');
} }
return query; return { builder: query };
} else if (pt.type === 'UnaryExpression') { } else if (pt.type === 'UnaryExpression') {
const query = knex.raw( const query = knex.raw(
`${pt.operator}${fn( `${pt.operator}${fn(
@ -784,10 +786,12 @@ async function _formulaQueryBuilder(
if (prevBinaryOp && pt.operator !== prevBinaryOp) { if (prevBinaryOp && pt.operator !== prevBinaryOp) {
query.wrap('(', ')'); query.wrap('(', ')');
} }
return query; return { builder: query };
} }
}; };
return { builder: fn(tree, alias) }; const builder = (await fn(tree, alias)).builder;
return { builder };
} }
function getTnPath(tb: Model, knex, tableAlias?: string) { function getTnPath(tb: Model, knex, tableAlias?: string) {
@ -842,7 +846,7 @@ export default async function formulaQueryBuilderv2(
// dry run qb.builder to see if it will break the grid view or not // dry run qb.builder to see if it will break the grid view or not
// if so, set formula error and show empty selectQb instead // if so, set formula error and show empty selectQb instead
await knex(getTnPath(model, knex, tableAlias)) await knex(getTnPath(model, knex, tableAlias))
.select(qb.builder) .select(knex.raw(`?? as ??`, [qb.builder, '__dry_run_alias']))
.as('dry-run-only'); .as('dry-run-only');
// if column is provided, i.e. formula has been created // if column is provided, i.e. formula has been created

86
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/commonFns.ts

@ -2,72 +2,96 @@ import type { MapFnArgs } from '../mapFunctionName';
export default { export default {
// todo: handle default case // todo: handle default case
SWITCH: (args: MapFnArgs) => { SWITCH: async (args: MapFnArgs) => {
const count = Math.floor((args.pt.arguments.length - 1) / 2); const count = Math.floor((args.pt.arguments.length - 1) / 2);
let query = ''; let query = '';
const switchVal = args.fn(args.pt.arguments[0]).toQuery(); const switchVal = (await args.fn(args.pt.arguments[0])).builder.toQuery();
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
query += args.knex query += args.knex
.raw( .raw(
`\n\tWHEN ${args `\n\tWHEN ${(
.fn(args.pt.arguments[i * 2 + 1]) await args.fn(args.pt.arguments[i * 2 + 1])
.toQuery()} THEN ${args.fn(args.pt.arguments[i * 2 + 2]).toQuery()}` ).builder.toQuery()} THEN ${(
await args.fn(args.pt.arguments[i * 2 + 2])
).builder.toQuery()}`
) )
.toQuery(); .toQuery();
} }
if (args.pt.arguments.length % 2 === 0) { if (args.pt.arguments.length % 2 === 0) {
query += args.knex query += args.knex
.raw( .raw(
`\n\tELSE ${args `\n\tELSE ${(
.fn(args.pt.arguments[args.pt.arguments.length - 1]) await args.fn(args.pt.arguments[args.pt.arguments.length - 1])
.toQuery()}` ).builder.toQuery()}`
) )
.toQuery(); .toQuery();
} }
return args.knex.raw(`CASE ${switchVal} ${query}\n END${args.colAlias}`); return {
builder: args.knex.raw(
`CASE ${switchVal} ${query}\n END${args.colAlias}`
),
};
}, },
IF: (args: MapFnArgs) => { IF: async (args: MapFnArgs) => {
let query = args.knex let query = args.knex
.raw( .raw(
`\n\tWHEN ${args.fn(args.pt.arguments[0]).toQuery()} THEN ${args `\n\tWHEN ${(
.fn(args.pt.arguments[1]) await args.fn(args.pt.arguments[0])
.toQuery()}` ).builder.toQuery()} THEN ${(
await args.fn(args.pt.arguments[1])
).builder.toQuery()}`
) )
.toQuery(); .toQuery();
if (args.pt.arguments[2]) { if (args.pt.arguments[2]) {
query += args.knex query += args.knex
.raw(`\n\tELSE ${args.fn(args.pt.arguments[2]).toQuery()}`) .raw(
`\n\tELSE ${(await args.fn(args.pt.arguments[2])).builder.toQuery()}`
)
.toQuery(); .toQuery();
} }
return args.knex.raw(`CASE ${query}\n END${args.colAlias}`); return { builder: args.knex.raw(`CASE ${query}\n END${args.colAlias}`) };
}, },
TRUE: (_args) => 1, TRUE: 1,
FALSE: (_args) => 0, FALSE: 0,
AND: (args: MapFnArgs) => { AND: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`${args.knex `${args.knex
.raw( .raw(
`${args.pt.arguments `${(
.map((ar) => args.fn(ar).toQuery()) await Promise.all(
.join(' AND ')}` args.pt.arguments.map(async (ar) =>
(await args.fn(ar)).builder.toQuery()
)
)
).join(' AND ')}`
) )
.wrap('(', ')') .wrap('(', ')')
.toQuery()}${args.colAlias}` .toQuery()}${args.colAlias}`
); ),
};
}, },
OR: (args: MapFnArgs) => { OR: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`${args.knex `${args.knex
.raw( .raw(
`${args.pt.arguments.map((ar) => args.fn(ar).toQuery()).join(' OR ')}` `${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar)).builder.toQuery()
)
)
).join(' OR ')}`
) )
.wrap('(', ')') .wrap('(', ')')
.toQuery()}${args.colAlias}` .toQuery()}${args.colAlias}`
); ),
};
}, },
AVG: (args: MapFnArgs) => { AVG: async (args: MapFnArgs) => {
if (args.pt.arguments.length > 1) { if (args.pt.arguments.length > 1) {
return args.fn( return args.fn(
{ {
@ -83,7 +107,9 @@ export default {
return args.fn(args.pt.arguments[0], args.a, args.prevBinaryOp); return args.fn(args.pt.arguments[0], args.a, args.prevBinaryOp);
} }
}, },
FLOAT: (args: MapFnArgs) => { FLOAT: async (args: MapFnArgs) => {
return args.fn(args.pt?.arguments?.[0]).wrap('(', ')'); return {
builder: (await args.fn(args.pt?.arguments?.[0])).builder.wrap('(', ')'),
};
}, },
}; };

180
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts

@ -6,62 +6,78 @@ import type { MapFnArgs } from '../mapFunctionName';
const mssql = { const mssql = {
...commonFns, ...commonFns,
MIN: (args: MapFnArgs) => { MIN: async (args: MapFnArgs) => {
if (args.pt.arguments.length === 1) { if (args.pt.arguments.length === 1) {
return args.fn(args.pt.arguments[0]); return args.fn(args.pt.arguments[0]);
} }
let query = ''; let query = '';
for (const [i, arg] of Object.entries(args.pt.arguments)) { for (const [i, arg] of Object.entries(args.pt.arguments)) {
if (+i === args.pt.arguments.length - 1) { if (+i === args.pt.arguments.length - 1) {
query += args.knex.raw(`\n\tElse ${args.fn(arg).toQuery()}`).toQuery(); query += args.knex
.raw(`\n\tElse ${(await args.fn(arg)).builder.toQuery()}`)
.toQuery();
} else { } else {
query += args.knex query += args.knex
.raw( .raw(
`\n\tWhen ${args.pt.arguments `\n\tWhen ${(
await Promise.all(
args.pt.arguments
.filter((_, j) => +i !== j) .filter((_, j) => +i !== j)
.map( .map(
(arg1) => async (arg1) =>
`${args.fn(arg).toQuery()} < ${args.fn(arg1).toQuery()}` `${(await args.fn(arg)).builder.toQuery()} < ${(
await args.fn(arg1)
).builder.toQuery()}`
) )
.join(' And ')} Then ${args.fn(arg).toQuery()}` )
).join(' And ')} Then ${(await args.fn(arg)).builder.toQuery()}`
) )
.toQuery(); .toQuery();
} }
} }
return args.knex.raw(`Case ${query}\n End${args.colAlias}`); return { builder: args.knex.raw(`Case ${query}\n End${args.colAlias}`) };
}, },
MAX: (args: MapFnArgs) => { MAX: async (args: MapFnArgs) => {
if (args.pt.arguments.length === 1) { if (args.pt.arguments.length === 1) {
return args.fn(args.pt.arguments[0]); return args.fn(args.pt.arguments[0]);
} }
let query = ''; let query = '';
for (const [i, arg] of Object.entries(args.pt.arguments)) { for (const [i, arg] of Object.entries(args.pt.arguments)) {
if (+i === args.pt.arguments.length - 1) { if (+i === args.pt.arguments.length - 1) {
query += args.knex.raw(`\nElse ${args.fn(arg).toQuery()}`).toQuery(); query += args.knex
.raw(`\nElse ${(await args.fn(arg)).builder.toQuery()}`)
.toQuery();
} else { } else {
query += args.knex query += args.knex
.raw( .raw(
`\nWhen ${args.pt.arguments `\nWhen ${args.pt.arguments
.filter((_, j) => +i !== j) .filter((_, j) => +i !== j)
.map( .map(
(arg1) => async (arg1) =>
`${args.fn(arg).toQuery()} > ${args.fn(arg1).toQuery()}` `${(await args.fn(arg)).builder.toQuery()} > ${(
await args.fn(arg1)
).builder.toQuery()}`
) )
.join(' And ')} Then ${args.fn(arg).toQuery()}` .join(' And ')} Then ${(await args.fn(arg)).builder.toQuery()}`
) )
.toQuery(); .toQuery();
} }
} }
return args.knex.raw(`Case ${query}\n End${args.colAlias}`); return { builder: args.knex.raw(`Case ${query}\n End${args.colAlias}`) };
}, },
LOG: (args: MapFnArgs) => { LOG: async (args: MapFnArgs) => {
return args.knex.raw( return {
`LOG(${args.pt.arguments builder: args.knex.raw(
`LOG(${(
await Promise.all(
args.pt.arguments
.reverse() .reverse()
.map((ar) => args.fn(ar).toQuery()) .map(async (ar) => (await args.fn(ar)).builder.toQuery())
.join(',')})${args.colAlias}` )
); ).join(',')})${args.colAlias}`
),
};
}, },
MOD: (pt) => { MOD: (pt) => {
Object.assign(pt, { Object.assign(pt, {
@ -73,91 +89,125 @@ const mssql = {
}, },
REPEAT: 'REPLICATE', REPEAT: 'REPLICATE',
NOW: 'getdate', NOW: 'getdate',
SEARCH: (args: MapFnArgs) => { SEARCH: async (args: MapFnArgs) => {
args.pt.callee.name = 'CHARINDEX'; args.pt.callee.name = 'CHARINDEX';
const temp = args.pt.arguments[0]; const temp = args.pt.arguments[0];
args.pt.arguments[0] = args.pt.arguments[1]; args.pt.arguments[0] = args.pt.arguments[1];
args.pt.arguments[1] = temp; args.pt.arguments[1] = temp;
}, },
INT: (args: MapFnArgs) => { INT: async (args: MapFnArgs) => {
return args.knex.raw( return {
`CASE WHEN ISNUMERIC(${args builder: args.knex.raw(
.fn(args.pt.arguments[0]) `CASE WHEN ISNUMERIC(${(
.toQuery()}) = 1 THEN FLOOR(${args await args.fn(args.pt.arguments[0])
.fn(args.pt.arguments[0]) ).builder.toQuery()}) = 1 THEN FLOOR(${(
.toQuery()}) ELSE 0 END${args.colAlias}` await args.fn(args.pt.arguments[0])
); ).builder.toQuery()}) ELSE 0 END${args.colAlias}`
),
};
}, },
MID: 'SUBSTR', MID: 'SUBSTR',
FLOAT: (args: MapFnArgs) => { FLOAT: async (args: MapFnArgs) => {
return args.knex return {
.raw(`CAST(${args.fn(args.pt.arguments[0])} as FLOAT)${args.colAlias}`) builder: args.knex
.wrap('(', ')'); .raw(
`CAST(${(await args.fn(args.pt.arguments[0])).builder} as FLOAT)${
args.colAlias
}`
)
.wrap('(', ')'),
};
}, },
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const dateIN = fn(pt.arguments[1]); const dateIN = (await fn(pt.arguments[1])).builder;
return knex.raw( return {
builder: knex.raw(
`CASE `CASE
WHEN ${fn(pt.arguments[0])} LIKE '%:%' THEN WHEN ${(await fn(pt.arguments[0])).builder} LIKE '%:%' THEN
FORMAT(DATEADD(${String(fn(pt.arguments[2])).replace(/["']/g, '')}, FORMAT(DATEADD(${String((await fn(pt.arguments[2])).builder).replace(
${dateIN > 0 ? '+' : ''}${fn(pt.arguments[1])}, ${fn( /["']/g,
pt.arguments[0] ''
)}), 'yyyy-MM-dd HH:mm') )},
${dateIN > 0 ? '+' : ''}${(await fn(pt.arguments[1])).builder}, ${
(await fn(pt.arguments[0])).builder
}), 'yyyy-MM-dd HH:mm')
ELSE ELSE
FORMAT(DATEADD(${String(fn(pt.arguments[2])).replace(/["']/g, '')}, FORMAT(DATEADD(${String((await fn(pt.arguments[2])).builder).replace(
${dateIN > 0 ? '+' : ''}${fn(pt.arguments[1])}, ${fn( /["']/g,
''
)},
${dateIN > 0 ? '+' : ''}${(await fn(pt.arguments[1])).builder}, ${fn(
pt.arguments[0] pt.arguments[0]
)}), 'yyyy-MM-dd') )}), 'yyyy-MM-dd')
END${colAlias}` END${colAlias}`
); ),
};
}, },
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATETIME_DIFF: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]); const datetime_expr1 = (await fn(pt.arguments[0])).builder;
const datetime_expr2 = fn(pt.arguments[1]); const datetime_expr2 = (await fn(pt.arguments[1])).builder;
const rawUnit = pt.arguments[2] const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0] ? (await fn(pt.arguments[2])).builder.bindings[0]
: 'seconds'; : 'seconds';
const unit = convertUnits(rawUnit, 'mssql'); const unit = convertUnits(rawUnit, 'mssql');
return knex.raw( return {
builder: knex.raw(
`DATEDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}` `DATEDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}`
); ),
};
}, },
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => { WEEKDAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// DATEPART(WEEKDAY, DATE): sunday = 1, monday = 2, ..., saturday = 7 // DATEPART(WEEKDAY, DATE): sunday = 1, monday = 2, ..., saturday = 7
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday // WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw( return {
builder: knex.raw(
`(DATEPART(WEEKDAY, ${ `(DATEPART(WEEKDAY, ${
pt.arguments[0].type === 'Literal' pt.arguments[0].type === 'Literal'
? `'${dayjs(fn(pt.arguments[0])).format('YYYY-MM-DD')}'` ? `'${dayjs((await fn(pt.arguments[0])).builder).format(
'YYYY-MM-DD'
)}'`
: fn(pt.arguments[0]) : fn(pt.arguments[0])
}) - 2 - ${getWeekdayByText( }) - 2 - ${getWeekdayByText(
pt?.arguments[1]?.value pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}` )} % 7 + 7) % 7 ${colAlias}`
); ),
};
}, },
AND: (args: MapFnArgs) => { AND: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`CASE WHEN ${args.knex `CASE WHEN ${args.knex
.raw( .raw(
`${args.pt.arguments `${(
.map((ar) => args.fn(ar, '', 'AND').toQuery()) await Promise.all(
.join(' AND ')}` args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'AND')).builder.toQuery()
)
)
).join(' AND ')}`
) )
.wrap('(', ')') .wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}` .toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
); ),
};
}, },
OR: (args: MapFnArgs) => { OR: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`CASE WHEN ${args.knex `CASE WHEN ${args.knex
.raw( .raw(
`${args.pt.arguments `${(
.map((ar) => args.fn(ar, '', 'OR').toQuery()) await Promise.all(
.join(' OR ')}` args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'OR')).builder.toQuery()
)
)
).join(' OR ')}`
) )
.wrap('(', ')') .wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}` .toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
); ),
};
}, },
}; };

120
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts

@ -9,90 +9,110 @@ const mysql2 = {
LEN: 'CHAR_LENGTH', LEN: 'CHAR_LENGTH',
MIN: 'LEAST', MIN: 'LEAST',
MAX: 'GREATEST', MAX: 'GREATEST',
SEARCH: (args: MapFnArgs) => { SEARCH: async (args: MapFnArgs) => {
args.pt.callee.name = 'LOCATE'; args.pt.callee.name = 'LOCATE';
const temp = args.pt.arguments[0]; const temp = args.pt.arguments[0];
args.pt.arguments[0] = args.pt.arguments[1]; args.pt.arguments[0] = args.pt.arguments[1];
args.pt.arguments[1] = temp; args.pt.arguments[1] = temp;
}, },
INT: (args: MapFnArgs) => { INT: async (args: MapFnArgs) => {
return args.knex.raw( return {
`CAST(${args.fn(args.pt.arguments[0])} as SIGNED)${args.colAlias}` builder: args.knex.raw(
); `CAST(${(await args.fn(args.pt.arguments[0])).builder} as SIGNED)${
args.colAlias
}`
),
};
}, },
LEFT: (args: MapFnArgs) => { LEFT: async (args: MapFnArgs) => {
return args.knex.raw( return {
`SUBSTR(${args.fn(args.pt.arguments[0])},1,${args.fn( builder: args.knex.raw(
args.pt.arguments[1] `SUBSTR(${(await args.fn(args.pt.arguments[0])).builder},1,${
)})${args.colAlias}` (await args.fn(args.pt.arguments[1])).builder
); })${args.colAlias}`
),
};
}, },
RIGHT: (args: MapFnArgs) => { RIGHT: async (args: MapFnArgs) => {
return args.knex.raw( return {
`SUBSTR(${args.fn(args.pt.arguments[0])}, -(${args.fn( builder: args.knex.raw(
args.pt.arguments[1] `SUBSTR(${(await args.fn(args.pt.arguments[0])).builder}, -(${
)}))${args.colAlias}` (await args.fn(args.pt.arguments[1])).builder
); }))${args.colAlias}`
),
};
}, },
MID: 'SUBSTR', MID: 'SUBSTR',
FLOAT: (args: MapFnArgs) => { FLOAT: async (args: MapFnArgs) => {
return args.knex return {
builder: args.knex
.raw( .raw(
`CAST(CAST(${args.fn(args.pt.arguments[0])} as CHAR) AS DOUBLE)${ `CAST(CAST(${
args.colAlias (await args.fn(args.pt.arguments[0])).builder
}` } as CHAR) AS DOUBLE)${args.colAlias}`
) )
.wrap('(', ')'); .wrap('(', ')'),
};
}, },
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return knex.raw( return {
builder: knex.raw(
`CASE `CASE
WHEN ${fn(pt.arguments[0])} LIKE '%:%' THEN WHEN ${(await fn(pt.arguments[0])).builder} LIKE '%:%' THEN
DATE_FORMAT(DATE_ADD(${fn(pt.arguments[0])}, INTERVAL DATE_FORMAT(DATE_ADD(${(await fn(pt.arguments[0])).builder}, INTERVAL
${fn(pt.arguments[1])} ${String(fn(pt.arguments[2])).replace( ${(await fn(pt.arguments[1])).builder} ${String(
/["']/g, (await fn(pt.arguments[2])).builder
'' ).replace(/["']/g, '')}), '%Y-%m-%d %H:%i')
)}), '%Y-%m-%d %H:%i')
ELSE ELSE
DATE(DATE_ADD(${fn(pt.arguments[0])}, INTERVAL DATE(DATE_ADD(${(await fn(pt.arguments[0])).builder}, INTERVAL
${fn(pt.arguments[1])} ${String(fn(pt.arguments[2])).replace( ${(await fn(pt.arguments[1])).builder} ${String(
/["']/g, (await fn(pt.arguments[2])).builder
'' ).replace(/["']/g, '')}))
)}))
END${colAlias}` END${colAlias}`
); ),
};
}, },
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATETIME_DIFF: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]); const datetime_expr1 = (await fn(pt.arguments[0])).builder;
const datetime_expr2 = fn(pt.arguments[1]); const datetime_expr2 = (await fn(pt.arguments[1])).builder;
const unit = convertUnits( const unit = convertUnits(
pt.arguments[2] ? fn(pt.arguments[2]).bindings[0] : 'seconds', pt.arguments[2]
? (await fn(pt.arguments[2])).builder.bindings[0]
: 'seconds',
'mysql' 'mysql'
); );
if (unit === 'MICROSECOND') { if (unit === 'MICROSECOND') {
// MySQL doesn't support millisecond // MySQL doesn't support millisecond
// hence change from MICROSECOND to millisecond manually // hence change from MICROSECOND to millisecond manually
return knex.raw( return {
builder: knex.raw(
`TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) div 1000 ${colAlias}` `TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) div 1000 ${colAlias}`
); ),
};
} }
return knex.raw( return {
builder: knex.raw(
`TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}` `TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}`
); ),
};
}, },
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => { WEEKDAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday // WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw( return {
builder: knex.raw(
`(WEEKDAY(${ `(WEEKDAY(${
pt.arguments[0].type === 'Literal' pt.arguments[0].type === 'Literal'
? `'${dayjs(fn(pt.arguments[0])).format('YYYY-MM-DD')}'` ? `'${dayjs((await fn(pt.arguments[0])).builder).format(
: fn(pt.arguments[0]) 'YYYY-MM-DD'
)}'`
: (await fn(pt.arguments[0])).builder
}) - ${getWeekdayByText( }) - ${getWeekdayByText(
pt?.arguments[1]?.value pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}` )} % 7 + 7) % 7 ${colAlias}`
); ),
};
}, },
}; };

140
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts

@ -12,50 +12,66 @@ const pg = {
CEILING: 'ceil', CEILING: 'ceil',
POWER: 'pow', POWER: 'pow',
SQRT: 'sqrt', SQRT: 'sqrt',
SEARCH: (args: MapFnArgs) => { SEARCH: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`POSITION(${args.knex.raw( `POSITION(${args.knex.raw(
args.fn(args.pt.arguments[1]).toQuery() (await args.fn(args.pt.arguments[1])).builder.toQuery()
)} in ${args.knex.raw(args.fn(args.pt.arguments[0]).toQuery())})${ )} in ${args.knex
args.colAlias .raw((await args.fn(args.pt.arguments[0])).builder)
}` .toQuery()})${args.colAlias}`
); ),
};
}, },
INT(args: MapFnArgs) { INT(args: MapFnArgs) {
// todo: correction // todo: correction
return args.knex.raw( return {
builder: args.knex.raw(
`REGEXP_REPLACE(COALESCE(${args.fn( `REGEXP_REPLACE(COALESCE(${args.fn(
args.pt.arguments[0] args.pt.arguments[0]
)}::character varying, '0'), '[^0-9]+|\\.[0-9]+' ,'')${args.colAlias}` )}::character varying, '0'), '[^0-9]+|\\.[0-9]+' ,'')${args.colAlias}`
); ),
};
}, },
MID: 'SUBSTR', MID: 'SUBSTR',
FLOAT: ({ fn, knex, pt, colAlias }: MapFnArgs) => { FLOAT: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return knex return {
.raw(`CAST(${fn(pt.arguments[0])} as DOUBLE PRECISION)${colAlias}`) builder: knex
.wrap('(', ')'); .raw(
`CAST(${
(await fn(pt.arguments[0])).builder
} as DOUBLE PRECISION)${colAlias}`
)
.wrap('(', ')'),
};
}, },
ROUND: ({ fn, knex, pt, colAlias }: MapFnArgs) => { ROUND: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return knex.raw( return {
`ROUND((${fn(pt.arguments[0])})::numeric, ${ builder: knex.raw(
pt?.arguments[1] ? fn(pt.arguments[1]) : 0 `ROUND((${(await fn(pt.arguments[0])).builder})::numeric, ${
pt?.arguments[1] ? (await fn(pt.arguments[1])).builder : 0
}) ${colAlias}` }) ${colAlias}`
); ),
};
}, },
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return knex.raw( return {
`${fn(pt.arguments[0])} + (${fn(pt.arguments[1])} || builder: knex.raw(
'${String(fn(pt.arguments[2])).replace( `${(await fn(pt.arguments[0])).builder} + (${
(await fn(pt.arguments[1])).builder
} ||
'${String((await fn(pt.arguments[2])).builder).replace(
/["']/g, /["']/g,
'' ''
)}')::interval${colAlias}` )}')::interval${colAlias}`
); ),
};
}, },
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATETIME_DIFF: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]); const datetime_expr1 = (await fn(pt.arguments[0])).builder;
const datetime_expr2 = fn(pt.arguments[1]); const datetime_expr2 = (await fn(pt.arguments[1])).builder;
const rawUnit = pt.arguments[2] const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0] ? (await fn(pt.arguments[2])).builder.bindings[0]
: 'seconds'; : 'seconds';
let sql; let sql;
const unit = convertUnits(rawUnit, 'pg'); const unit = convertUnits(rawUnit, 'pg');
@ -99,59 +115,77 @@ const pg = {
default: default:
sql = ''; sql = '';
} }
return knex.raw(`${sql} ${colAlias}`); return { builder: knex.raw(`${sql} ${colAlias}`) };
}, },
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => { WEEKDAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// isodow: the day of the week as Monday (1) to Sunday (7) // isodow: the day of the week as Monday (1) to Sunday (7)
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday // WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw( return {
builder: knex.raw(
`(EXTRACT(ISODOW FROM ${ `(EXTRACT(ISODOW FROM ${
pt.arguments[0].type === 'Literal' pt.arguments[0].type === 'Literal'
? `date '${dayjs(fn(pt.arguments[0])).format('YYYY-MM-DD')}'` ? `date '${dayjs((await fn(pt.arguments[0])).builder).format(
: fn(pt.arguments[0]) 'YYYY-MM-DD'
)}'`
: (await fn(pt.arguments[0])).builder
}) - 1 - ${getWeekdayByText( }) - 1 - ${getWeekdayByText(
pt?.arguments[1]?.value pt?.arguments[1]?.value
)} % 7 + 7) ::INTEGER % 7 ${colAlias}` )} % 7 + 7) ::INTEGER % 7 ${colAlias}`
); ),
};
}, },
AND: (args: MapFnArgs) => { AND: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`CASE WHEN ${args.knex `CASE WHEN ${args.knex
.raw( .raw(
`${args.pt.arguments `${(
.map((ar) => args.fn(ar, '', 'AND').toQuery()) await Promise.all(
.join(' AND ')}` args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'AND')).builder.toQuery()
)
)
).join(' AND ')}`
) )
.wrap('(', ')') .wrap('(', ')')
.toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}` .toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}`
); ),
};
}, },
OR: (args: MapFnArgs) => { OR: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`CASE WHEN ${args.knex `CASE WHEN ${args.knex
.raw( .raw(
`${args.pt.arguments `${args.pt.arguments
.map((ar) => args.fn(ar, '', 'OR').toQuery()) .map(async (ar) =>
(await args.fn(ar, '', 'OR')).builder.toQuery()
)
.join(' OR ')}` .join(' OR ')}`
) )
.wrap('(', ')') .wrap('(', ')')
.toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}` .toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}`
); ),
};
}, },
SUBSTR: ({ fn, knex, pt, colAlias }: MapFnArgs) => { SUBSTR: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const str = fn(pt.arguments[0]); const str = (await fn(pt.arguments[0])).builder;
const positionFrom = fn(pt.arguments[1] ?? 1); const positionFrom = (await fn(pt.arguments[1] ?? 1)).builder;
const numberOfCharacters = fn(pt.arguments[2] ?? ''); const numberOfCharacters = (await fn(pt.arguments[2] ?? '')).builder;
return knex.raw( return {
builder: knex.raw(
`SUBSTR(${str}::TEXT, ${positionFrom}${ `SUBSTR(${str}::TEXT, ${positionFrom}${
numberOfCharacters ? ', ' + numberOfCharacters : '' numberOfCharacters ? ', ' + numberOfCharacters : ''
})${colAlias}` })${colAlias}`
); ),
};
}, },
MOD: ({ fn, knex, pt, colAlias }: MapFnArgs) => { MOD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const x = fn(pt.arguments[0]); const x = (await fn(pt.arguments[0])).builder;
const y = fn(pt.arguments[1]); const y = (await fn(pt.arguments[1])).builder;
return knex.raw(`MOD((${x})::NUMERIC, (${y})::NUMERIC) ${colAlias}`); return {
builder: knex.raw(`MOD((${x})::NUMERIC, (${y})::NUMERIC) ${colAlias}`),
};
}, },
}; };

188
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts

@ -11,17 +11,25 @@ import type { MapFnArgs } from '../mapFunctionName';
const sqlite3 = { const sqlite3 = {
...commonFns, ...commonFns,
LEN: 'LENGTH', LEN: 'LENGTH',
CEILING(args) { async CEILING(args) {
return args.knex.raw( return {
`round(${args.fn(args.pt.arguments[0])} + 0.5)${args.colAlias}` builder: args.knex.raw(
); `round(${(await args.fn(args.pt.arguments[0])).builder} + 0.5)${
args.colAlias
}`
),
};
}, },
FLOOR(args) { async FLOOR(args) {
return args.knex.raw( return {
`round(${args.fn(args.pt.arguments[0])} - 0.5)${args.colAlias}` builder: args.knex.raw(
); `round(${(await args.fn(args.pt.arguments[0])).builder} - 0.5)${
args.colAlias
}`
),
};
}, },
MOD: (args: MapFnArgs) => { MOD: async (args: MapFnArgs) => {
return args.fn({ return args.fn({
type: 'BinaryExpression', type: 'BinaryExpression',
operator: '%', operator: '%',
@ -29,62 +37,88 @@ const sqlite3 = {
right: args.pt.arguments[1], right: args.pt.arguments[1],
}); });
}, },
REPEAT(args: MapFnArgs) { async REPEAT(args: MapFnArgs) {
return args.knex.raw( return {
`replace(printf('%.' || ${args.fn( builder: args.knex.raw(
args.pt.arguments[1] `replace(printf('%.' || ${
)} || 'c', '/'),'/',${args.fn(args.pt.arguments[0])})${args.colAlias}` (await args.fn(args.pt.arguments[1])).builder
); } || 'c', '/'),'/',${(await args.fn(args.pt.arguments[0])).builder})${
args.colAlias
}`
),
};
}, },
NOW: 'DATE', NOW: 'DATE',
SEARCH: 'INSTR', SEARCH: 'INSTR',
INT(args: MapFnArgs) { async INT(args: MapFnArgs) {
return args.knex.raw( return {
`CAST(${args.fn(args.pt.arguments[0])} as INTEGER)${args.colAlias}` builder: args.knex.raw(
); `CAST(${(await args.fn(args.pt.arguments[0])).builder} as INTEGER)${
args.colAlias
}`
),
};
}, },
LEFT: (args: MapFnArgs) => { LEFT: async (args: MapFnArgs) => {
return args.knex.raw( return {
`SUBSTR(${args.fn(args.pt.arguments[0])},1,${args.fn( builder: args.knex.raw(
args.pt.arguments[1] `SUBSTR(${(await args.fn(args.pt.arguments[0])).builder},1,${
)})${args.colAlias}` (await args.fn(args.pt.arguments[1])).builder
); })${args.colAlias}`
),
};
}, },
RIGHT: (args: MapFnArgs) => { RIGHT: async (args: MapFnArgs) => {
return args.knex.raw( return {
`SUBSTR(${args.fn(args.pt.arguments[0])},-(${args.fn( builder: args.knex.raw(
args.pt.arguments[1] `SUBSTR(${(await args.fn(args.pt.arguments[0])).builder},-(${
)}))${args.colAlias}` (await args.fn(args.pt.arguments[1])).builder
); }))${args.colAlias}`
),
};
}, },
MID: 'SUBSTR', MID: 'SUBSTR',
FLOAT: (args: MapFnArgs) => { FLOAT: async (args: MapFnArgs) => {
return args.knex return {
.raw(`CAST(${args.fn(args.pt.arguments[0])} as FLOAT)${args.colAlias}`) builder: args.knex
.wrap('(', ')'); .raw(
`CAST(${(await args.fn(args.pt.arguments[0])).builder} as FLOAT)${
args.colAlias
}`
)
.wrap('(', ')'),
};
}, },
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const dateIN = fn(pt.arguments[1]); const dateIN = (await fn(pt.arguments[1])).builder;
return knex.raw( return {
builder: knex.raw(
`CASE `CASE
WHEN ${fn(pt.arguments[0])} LIKE '%:%' THEN WHEN ${(await fn(pt.arguments[0])).builder} LIKE '%:%' THEN
STRFTIME('%Y-%m-%d %H:%M', DATETIME(DATETIME(${fn( STRFTIME('%Y-%m-%d %H:%M', DATETIME(DATETIME(${fn(
pt.arguments[0] pt.arguments[0]
)}, 'localtime'), )}, 'localtime'),
${dateIN > 0 ? '+' : ''}${fn(pt.arguments[1])} || ' ${String( ${dateIN > 0 ? '+' : ''}${
fn(pt.arguments[2]) (await fn(pt.arguments[1])).builder
).replace(/["']/g, '')}')) } || ' ${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
''
)}'))
ELSE ELSE
DATE(DATETIME(${fn(pt.arguments[0])}, 'localtime'), DATE(DATETIME(${(await fn(pt.arguments[0])).builder}, 'localtime'),
${dateIN > 0 ? '+' : ''}${fn(pt.arguments[1])} || ' ${String( ${dateIN > 0 ? '+' : ''}${
fn(pt.arguments[2]) (await fn(pt.arguments[1])).builder
).replace(/["']/g, '')}') } || ' ${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
''
)}')
END${colAlias}` END${colAlias}`
); ),
};
}, },
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATETIME_DIFF: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
let datetime_expr1 = fn(pt.arguments[0]); let datetime_expr1 = (await fn(pt.arguments[0])).builder;
let datetime_expr2 = fn(pt.arguments[1]); let datetime_expr2 = (await fn(pt.arguments[1])).builder;
// JULIANDAY takes YYYY-MM-DD // JULIANDAY takes YYYY-MM-DD
if (datetime_expr1.sql === '?' && datetime_expr1.bindings?.[0]) { if (datetime_expr1.sql === '?' && datetime_expr1.bindings?.[0]) {
datetime_expr1 = `'${convertToTargetFormat( datetime_expr1 = `'${convertToTargetFormat(
@ -103,7 +137,7 @@ const sqlite3 = {
} }
const rawUnit = pt.arguments[2] const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0] ? (await fn(pt.arguments[2])).builder.bindings[0]
: 'seconds'; : 'seconds';
let sql; let sql;
const unit = convertUnits(rawUnit, 'sqlite'); const unit = convertUnits(rawUnit, 'sqlite');
@ -154,44 +188,60 @@ const sqlite3 = {
default: default:
sql = ''; sql = '';
} }
return knex.raw(`${sql} ${colAlias}`); return { builder: knex.raw(`${sql} ${colAlias}`) };
}, },
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => { WEEKDAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// strftime('%w', date) - day of week 0 - 6 with Sunday == 0 // strftime('%w', date) - day of week 0 - 6 with Sunday == 0
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday // WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw( return {
builder: knex.raw(
`(strftime('%w', ${ `(strftime('%w', ${
pt.arguments[0].type === 'Literal' pt.arguments[0].type === 'Literal'
? `'${dayjs(fn(pt.arguments[0])).format('YYYY-MM-DD')}'` ? `'${dayjs((await fn(pt.arguments[0])).builder).format(
: fn(pt.arguments[0]) 'YYYY-MM-DD'
)}'`
: (await fn(pt.arguments[0])).builder
}) - 1 - ${getWeekdayByText( }) - 1 - ${getWeekdayByText(
pt?.arguments[1]?.value pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}` )} % 7 + 7) % 7 ${colAlias}`
); ),
};
}, },
AND: (args: MapFnArgs) => { AND: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`CASE WHEN ${args.knex `CASE WHEN ${args.knex
.raw( .raw(
`${args.pt.arguments `${(
.map((ar) => args.fn(ar, '', 'AND').toQuery()) await Promise.all(
.join(' AND ')}` args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'AND')).builder.toQuery()
)
)
).join(' AND ')}`
) )
.wrap('(', ')') .wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}` .toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
); ),
};
}, },
OR: (args: MapFnArgs) => { OR: async (args: MapFnArgs) => {
return args.knex.raw( return {
builder: args.knex.raw(
`CASE WHEN ${args.knex `CASE WHEN ${args.knex
.raw( .raw(
`${args.pt.arguments `${(
.map((ar) => args.fn(ar, '', 'OR').toQuery()) await Promise.all(
.join(' OR ')}` args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'OR')).builder.toQuery()
)
)
).join(' OR ')}`
) )
.wrap('(', ')') .wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}` .toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
); ),
};
}, },
}; };

9
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/mapFunctionName.ts

@ -7,16 +7,19 @@ import type { Knex } from 'knex';
export interface MapFnArgs { export interface MapFnArgs {
pt: any; pt: any;
aliasToCol: { [alias: string]: string }; aliasToCol: Record<
string,
(() => Promise<{ builder: any }>) | string | undefined
>;
knex: XKnex; knex: XKnex;
alias: string; alias: string;
a?: string; a?: string;
fn: (...args: any) => Knex.QueryBuilder | any; fn: (...args: any) => Promise<{ builder: Knex.QueryBuilder | any }>;
colAlias: string; colAlias: string;
prevBinaryOp?: any; prevBinaryOp?: any;
} }
const mapFunctionName = (args: MapFnArgs): any => { const mapFunctionName = async (args: MapFnArgs): Promise<any> => {
const name = args.pt.callee.name; const name = args.pt.callee.name;
let val; let val;

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

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

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

@ -174,6 +174,7 @@ class NcPluginMgrv2 {
} }
public static async emailAdapter( public static async emailAdapter(
isUserInvite = true,
ncMeta = Noco.ncMeta ncMeta = Noco.ncMeta
): Promise<IEmailAdapter> { ): Promise<IEmailAdapter> {
const pluginData = await ncMeta.metaGet2(null, null, MetaTable.PLUGIN, { const pluginData = await ncMeta.metaGet2(null, null, MetaTable.PLUGIN, {
@ -181,7 +182,12 @@ class NcPluginMgrv2 {
active: true, 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( const pluginConfig = defaultPlugins.find(
(c) => c.title === pluginData.title && c.category === PluginCategory.EMAIL (c) => c.title === pluginData.title && c.category === PluginCategory.EMAIL
@ -205,7 +211,7 @@ class NcPluginMgrv2 {
active: true, active: true,
}); });
if (!pluginData) throw new Error('Plugin not configured/active'); if (!pluginData) throw new Error('Plugin not configured / active');
const pluginConfig = defaultPlugins.find( const pluginConfig = defaultPlugins.find(
(c) => c.title === pluginData.title (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 { RelationTypes, UITypes } from 'nocodb-sdk';
import { v4 as uuidv4 } from 'uuid';
import View from '../../models/View'; import View from '../../models/View';
import Column from '../../models/Column'; import Column from '../../models/Column';
import Model from '../../models/Model'; import Model from '../../models/Model';
@ -6,7 +7,7 @@ import type LinkToAnotherRecordColumn from '../../models/LinkToAnotherRecordColu
import type LookupColumn from '../../models/LookupColumn'; import type LookupColumn from '../../models/LookupColumn';
import type SelectOption from '../../models/SelectOption'; import type SelectOption from '../../models/SelectOption';
export default async function populateSamplePayload( export async function populateSamplePayload(
viewOrModel: View | Model, viewOrModel: View | Model,
includeNested = false, includeNested = false,
operation = 'insert' operation = 'insert'
@ -42,6 +43,68 @@ export default async function populateSamplePayload(
return out; 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> { async function getSampleColumnValue(column: Column): Promise<any> {
switch (column.uidt) { switch (column.uidt) {
case UITypes.ID: case UITypes.ID:

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

@ -1,10 +1,12 @@
import Handlebars from 'handlebars'; import Handlebars from 'handlebars';
import { v4 as uuidv4 } from 'uuid';
import Filter from '../../models/Filter'; import Filter from '../../models/Filter';
import HookLog from '../../models/HookLog'; import HookLog from '../../models/HookLog';
import NcPluginMgrv2 from './NcPluginMgrv2'; import NcPluginMgrv2 from './NcPluginMgrv2';
import type Model from '../../models/Model'; 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 Hook from '../../models/Hook';
import type Column from '../../models/Column';
import type { HookLogType } from 'nocodb-sdk'; import type { HookLogType } from 'nocodb-sdk';
import type FormView from '../../models/FormView'; import type FormView from '../../models/FormView';
@ -133,13 +135,54 @@ export async function validateCondition(filters: Filter[], data: any) {
return isValid; return isValid;
} }
export async function handleHttpWebHook(apiMeta, user, data) { export function constructWebHookData(hook, model, view, prevData, newData) {
// try { if (hook.version === 'v2') {
const req = axiosRequestMake(apiMeta, user, data); // extend in the future - currently only support records
await require('axios')(req); const scope = 'records';
// } catch (e) {
// console.log(e); 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) { export function axiosRequestMake(_apiMeta, _user, data) {
@ -203,104 +246,175 @@ export function axiosRequestMake(_apiMeta, _user, data) {
export async function invokeWebhook( export async function invokeWebhook(
hook: Hook, hook: Hook,
_model: Model, model: Model,
data, view: View,
prevData,
newData,
user, user,
testFilters = null, testFilters = null,
throwErrorOnFailure = false throwErrorOnFailure = false,
testHook = false
) { ) {
let hookLog: HookLogType; let hookLog: HookLogType;
const startTime = process.hrtime(); const startTime = process.hrtime();
let notification;
try { try {
// for (const hook of hooks) { notification =
const notification =
typeof hook.notification === 'string' typeof hook.notification === 'string'
? JSON.parse(hook.notification) ? JSON.parse(hook.notification)
: 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 (hook.condition) {
if (isBulkOperation) {
const filteredData = [];
for (const data of newData) {
if ( if (
!(await validateCondition( await validateCondition(
testFilters || (await hook.getFilters()), testFilters || (await hook.getFilters()),
data data
)
) {
filteredData.push(data);
}
if (!filteredData.length) {
return;
}
newData = filteredData;
}
} else {
if (
!(await validateCondition(
testFilters || (await hook.getFilters()),
newData
)) ))
) { ) {
return; return;
} }
} }
}
switch (notification?.type) { switch (notification?.type) {
case 'Email': case 'Email':
{ {
const res = await ( const res = await (
await NcPluginMgrv2.emailAdapter() await NcPluginMgrv2.emailAdapter(false)
)?.mailSend({ )?.mailSend({
to: parseBody(notification?.payload?.to, data), to: parseBody(notification?.payload?.to, newData),
subject: parseBody(notification?.payload?.subject, data), subject: parseBody(notification?.payload?.subject, newData),
html: parseBody(notification?.payload?.body, data), html: parseBody(notification?.payload?.body, newData),
}); });
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'ALL') {
hookLog = { hookLog = {
...hook, ...hook,
fk_hook_id: hook.id,
type: notification.type, type: notification.type,
payload: JSON.stringify(notification?.payload), payload: JSON.stringify(notification?.payload),
response: JSON.stringify(res), response: JSON.stringify(res),
triggered_by: user?.email, triggered_by: user?.email,
}; };
} }
}
break; break;
case 'URL': case 'URL':
{ {
const res = await handleHttpWebHook( const res = await handleHttpWebHook(
hook,
model,
view,
notification?.payload, notification?.payload,
user, user,
data prevData,
newData
); );
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'ALL') {
hookLog = { hookLog = {
...hook, ...hook,
fk_hook_id: hook.id,
type: notification.type, type: notification.type,
payload: JSON.stringify(notification?.payload), payload: JSON.stringify(notification?.payload),
response: JSON.stringify(res), 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, triggered_by: user?.email,
}; };
} }
}
break; break;
default: default:
{ {
const res = await ( const res = await (
await NcPluginMgrv2.webhookNotificationAdapters(notification.type) await NcPluginMgrv2.webhookNotificationAdapters(notification.type)
).sendMessage( ).sendMessage(
parseBody(notification?.payload?.body, data), parseBody(notification?.payload?.body, newData),
JSON.parse(JSON.stringify(notification?.payload), (_key, value) => { JSON.parse(JSON.stringify(notification?.payload), (_key, value) => {
return typeof value === 'string' ? parseBody(value, data) : value; return typeof value === 'string'
? parseBody(value, newData)
: value;
}) })
); );
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'ALL') {
hookLog = { hookLog = {
...hook, ...hook,
fk_hook_id: hook.id,
type: notification.type, type: notification.type,
payload: JSON.stringify(notification?.payload), payload: JSON.stringify(notification?.payload),
response: JSON.stringify(res), 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, triggered_by: user?.email,
}; };
} }
}
break; break;
} }
} catch (e) { } catch (e) {
console.log(e); console.log(e);
if (['ERROR', 'ALL'].includes(process.env.NC_AUTOMATION_LOG_LEVEL)) {
hookLog = { hookLog = {
...hook, ...hook,
type: notification.type,
payload: JSON.stringify(notification?.payload),
fk_hook_id: hook.id,
error_code: e.error_code, error_code: e.error_code,
error_message: e.message, error_message: e.message,
error: JSON.stringify(e), error: JSON.stringify(e),
triggered_by: user?.email,
}; };
}
if (throwErrorOnFailure) throw e; if (throwErrorOnFailure) throw e;
} finally { } finally {
if (hookLog) { if (hookLog) {
hookLog.execution_time = parseHrtimeToMilliSeconds( hookLog.execution_time = parseHrtimeToMilliSeconds(
process.hrtime(startTime) 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_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_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_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 // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -43,6 +44,7 @@ export default class XcMigrationSourcev2 {
'nc_026_map_view', 'nc_026_map_view',
'nc_027_add_comparison_sub_op', 'nc_027_add_comparison_sub_op',
'nc_028_add_enable_scanner_in_form_columns_meta_table', '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; return nc_027_add_comparison_sub_op;
case 'nc_028_add_enable_scanner_in_form_columns_meta_table': case 'nc_028_add_enable_scanner_in_form_columns_meta_table':
return 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 Noco from '../Noco';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
import { extractProps } from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
import { NcError } from '../meta/helpers/catchError';
import Model from './Model'; import Model from './Model';
import Filter from './Filter'; import Filter from './Filter';
import HookFilter from './HookFilter'; import HookFilter from './HookFilter';
@ -19,8 +20,8 @@ export default class Hook implements HookType {
description?: string; description?: string;
env?: string; env?: string;
type?: string; type?: string;
event?: 'after' | 'before'; event?: HookType['event'];
operation?: 'insert' | 'delete' | 'update'; operation?: HookType['operation'];
async?: BoolType; async?: BoolType;
payload?: string; payload?: string;
url?: string; url?: string;
@ -34,6 +35,7 @@ export default class Hook implements HookType {
project_id?: string; project_id?: string;
base_id?: string; base_id?: string;
version?: 'v1' | 'v2';
constructor(hook: Partial<Hook | HookReqType>) { constructor(hook: Partial<Hook | HookReqType>) {
Object.assign(this, hook); Object.assign(this, hook);
@ -78,8 +80,8 @@ export default class Hook implements HookType {
static async list( static async list(
param: { param: {
fk_model_id: string; fk_model_id: string;
event?: 'after' | 'before'; event?: HookType['event'];
operation?: 'insert' | 'delete' | 'update'; operation?: HookType['operation'];
}, },
ncMeta = Noco.ncMeta ncMeta = Noco.ncMeta
) { ) {
@ -135,17 +137,6 @@ export default class Hook implements HookType {
'base_id', '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') { if (insertObj.notification && typeof insertObj.notification === 'object') {
insertObj.notification = JSON.stringify(insertObj.notification); insertObj.notification = JSON.stringify(insertObj.notification);
} }
@ -156,6 +147,9 @@ export default class Hook implements HookType {
insertObj.base_id = model.base_id; insertObj.base_id = model.base_id;
} }
// new hook will set as version 2
insertObj.version = 'v2';
const { id } = await ncMeta.metaInsert2( const { id } = await ncMeta.metaInsert2(
null, null,
null, null,
@ -194,17 +188,16 @@ export default class Hook implements HookType {
'retry_interval', 'retry_interval',
'timeout', 'timeout',
'active', 'active',
'version',
]); ]);
if (updateObj.event) { if (
updateObj.event = updateObj.event.toLowerCase() as 'after' | 'before'; updateObj.version &&
} updateObj.operation &&
updateObj.version === 'v1' &&
if (updateObj.operation) { ['bulkInsert', 'bulkUpdate', 'bulkDelete'].includes(updateObj.operation)
updateObj.operation = updateObj.operation.toLowerCase() as ) {
| 'insert' NcError.badRequest(`${updateObj.operation} not supported in v1 hook`);
| 'delete'
| 'update';
} }
if (updateObj.notification && typeof updateObj.notification === 'object') { 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 { export default class HookLog implements HookLogType {
id?: string; id?: string;
base_id?: string; base_id?: string;
project_id?: string; project_id?: string;
fk_hook_id?: string; fk_hook_id?: string;
type?: string; type?: string;
event?: string; event?: HookLogType['event'];
operation?: string; operation?: HookLogType['operation'];
test_call?: boolean; test_call?: boolean;
payload?: string; payload?: string;
conditions?: string; conditions?: string;
@ -24,47 +23,49 @@ export default class HookLog implements HookLogType {
response?: string; response?: string;
triggered_by?: string; triggered_by?: string;
constructor(hook: Partial<HookLog>) { constructor(hookLog: Partial<HookLog>) {
Object.assign(this, hook); Object.assign(this, hookLog);
} }
static async list( static async list(
param: { param: {
fk_hook_id: string; fk_hook_id: string;
event?: 'after' | 'before'; event?: HookLogType['event'];
operation?: 'insert' | 'delete' | 'update'; operation?: HookLogType['operation'];
},
{
limit = 25,
offset = 0,
}: {
limit?: number;
offset?: number;
}, },
ncMeta = Noco.ncMeta ncMeta = Noco.ncMeta
) { ) {
// todo: redis cache ?? const hookLogs = await ncMeta.metaList2(null, null, MetaTable.HOOK_LOGS, {
// let hooks = await NocoCache.getList(CacheScope.HOOK, [param.fk_model_id]);
// if (!hooks.length) {
const hookLogs = await ncMeta.metaList(null, null, MetaTable.HOOK_LOGS, {
condition: { condition: {
fk_hook_id: param.fk_hook_id, 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)); return hookLogs?.map((h) => new HookLog(h));
} }
public static async insert(hookLog: Partial<HookLog>, ncMeta = Noco.ncMeta) { public static async insert(hookLog: Partial<HookLog>, ncMeta = Noco.ncMeta) {
if (process.env.NC_AUTOMATION_LOG_LEVEL === 'OFF') {
return;
}
const insertObj: any = extractProps(hookLog, [ const insertObj: any = extractProps(hookLog, [
'base_id', 'base_id',
'project_id', 'project_id',
@ -98,4 +99,21 @@ export default class HookLog implements HookLogType {
return await ncMeta.metaInsert2(null, null, MetaTable.HOOK_LOGS, insertObj); 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> { public async sendMessage(content: string, payload: any): Promise<any> {
for (const { webhook_url } of payload?.channels) { for (const { webhook_url } of payload?.channels) {
try { try {
await axios.post(webhook_url, { return await axios.post(webhook_url, {
content, content,
}); });
} catch (e) { } catch (e) {
console.log(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> { public async sendMessage(text: string, payload: any): Promise<any> {
for (const { webhook_url } of payload?.channels) { for (const { webhook_url } of payload?.channels) {
try { try {
await axios.post(webhook_url, { return await axios.post(webhook_url, {
text, text,
}); });
} catch (e) { } catch (e) {
console.log(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> { public async sendMessage(text: string, payload: any): Promise<any> {
for (const { webhook_url } of payload?.channels) { for (const { webhook_url } of payload?.channels) {
try { try {
await axios.post(webhook_url, { return await axios.post(webhook_url, {
text, text,
}); });
} catch (e) { } catch (e) {
console.log(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> { public async sendMessage(Text: string, payload: any): Promise<any> {
for (const { webhook_url } of payload?.channels) { for (const { webhook_url } of payload?.channels) {
try { try {
await axios.post(webhook_url, { return await axios.post(webhook_url, {
Text, Text,
}); });
} catch (e) { } catch (e) {
console.log(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) { } catch (e) {
console.log(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) { } catch (e) {
console.log(e); console.log(e);
throw e;
} }
} }
} }

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

@ -1,9 +1,13 @@
import { T } from 'nc-help'; import { T } from 'nc-help';
import { validatePayload } from '../meta/api/helpers'; import { validatePayload } from '../meta/api/helpers';
import { NcError } from '../meta/helpers/catchError'; import { NcError } from '../meta/helpers/catchError';
import { Hook, Model } from '../models'; import { Hook, HookLog, Model } from '../models';
import { invokeWebhook } from '../meta/helpers/webhookHelpers'; 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'; import type { HookReqType, HookTestReqType } from 'nocodb-sdk';
function validateHookPayload( function validateHookPayload(
@ -26,6 +30,10 @@ export async function hookList(param: { tableId: string }) {
return await Hook.list({ fk_model_id: param.tableId }); 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: { export async function hookCreate(param: {
tableId: string; tableId: string;
hook: HookReqType; hook: HookReqType;
@ -73,29 +81,44 @@ export async function hookTest(param: {
const model = await Model.getByIdOrName({ id: param.tableId }); const model = await Model.getByIdOrName({ id: param.tableId });
T.emit('evt', { evt_type: 'webhooks:tested' });
const { const {
hook, hook,
payload: { data, user }, payload: { data, user },
} = param.hookTest; } = param.hookTest;
try {
await invokeWebhook( await invokeWebhook(
new Hook(hook), new Hook(hook),
model, model,
null,
null,
data, data,
user, user,
(hook as any)?.filters, (hook as any)?.filters,
true,
true true
); );
} catch (e) {
T.emit('evt', { evt_type: 'webhooks:tested' }); throw e;
}
return true; return true;
} }
export async function tableSampleData(param: { export async function tableSampleData(param: {
tableId: string; tableId: string;
operation: 'insert' | 'update'; operation: HookType['operation'];
version: HookType['version'];
}) { }) {
const model = await Model.getByIdOrName({ id: param.tableId }); const model = await Model.getByIdOrName({ id: param.tableId });
if (param.version === 'v1') {
return await populateSamplePayload(model, false, param.operation); 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, ncAttachmentFieldSize: NC_ATTACHMENT_FIELD_SIZE,
ncMaxAttachmentsAllowed: +(process.env.NC_MAX_ATTACHMENTS_ALLOWED || 10), ncMaxAttachmentsAllowed: +(process.env.NC_MAX_ATTACHMENTS_ALLOWED || 10),
isCloud: process.env.NC_CLOUD === 'true', isCloud: process.env.NC_CLOUD === 'true',
automationLogLevel: process.env.NC_AUTOMATION_LOG_LEVEL || 'OFF',
}; };
return result; return result;

15
packages/nocodb/src/lib/utils/NcConfigFactory.ts

@ -380,22 +380,25 @@ export default class NcConfigFactory implements NcConfig {
typeof dbConfig?.connection?.ssl === 'object' typeof dbConfig?.connection?.ssl === 'object'
) { ) {
if (dbConfig.connection.ssl.caFilePath && !dbConfig.connection.ssl.ca) { if (dbConfig.connection.ssl.caFilePath && !dbConfig.connection.ssl.ca) {
dbConfig.connection.ssl.ca = await promisify(fs.readFile)( dbConfig.connection.ssl.ca = (
dbConfig.connection.ssl.caFilePath await promisify(fs.readFile)(dbConfig.connection.ssl.caFilePath)
).toString(); ).toString();
delete dbConfig.connection.ssl.caFilePath;
} }
if (dbConfig.connection.ssl.keyFilePath && !dbConfig.connection.ssl.key) { if (dbConfig.connection.ssl.keyFilePath && !dbConfig.connection.ssl.key) {
dbConfig.connection.ssl.key = await promisify(fs.readFile)( dbConfig.connection.ssl.key = (
dbConfig.connection.ssl.keyFilePath await promisify(fs.readFile)(dbConfig.connection.ssl.keyFilePath)
).toString(); ).toString();
delete dbConfig.connection.ssl.keyFilePath;
} }
if ( if (
dbConfig.connection.ssl.certFilePath && dbConfig.connection.ssl.certFilePath &&
!dbConfig.connection.ssl.cert !dbConfig.connection.ssl.cert
) { ) {
dbConfig.connection.ssl.cert = await promisify(fs.readFile)( dbConfig.connection.ssl.cert = (
dbConfig.connection.ssl.certFilePath await promisify(fs.readFile)(dbConfig.connection.ssl.certFilePath)
).toString(); ).toString();
delete dbConfig.connection.ssl.certFilePath;
} }
} }

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

@ -9,11 +9,12 @@ export default class Discord {
): Promise<any> { ): Promise<any> {
for (const { webhook_url } of webhooks) { for (const { webhook_url } of webhooks) {
try { try {
await axios.post(webhook_url, { return await axios.post(webhook_url, {
content, content,
}); });
} catch (e) { } catch (e) {
console.log(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> { ): Promise<any> {
for (const { webhook_url } of webhooks) { for (const { webhook_url } of webhooks) {
try { try {
await axios.post(webhook_url, { return await axios.post(webhook_url, {
text, text,
}); });
} catch (e) { } catch (e) {
console.log(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> { ): Promise<any> {
for (const { webhook_url } of webhooks) { for (const { webhook_url } of webhooks) {
try { try {
await axios.post(webhook_url, { return await axios.post(webhook_url, {
text, text,
}); });
} catch (e) { } catch (e) {
console.log(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) { } catch (e) {
console.log(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 ncProjectUpgraderV2_0090000 from './ncProjectUpgraderV2_0090000';
import ncProjectEnvUpgrader0011045 from './ncProjectEnvUpgrader0011045'; import ncProjectEnvUpgrader0011045 from './ncProjectEnvUpgrader0011045';
import ncProjectEnvUpgrader from './ncProjectEnvUpgrader'; import ncProjectEnvUpgrader from './ncProjectEnvUpgrader';
import ncHookUpgrader from './ncHookUpgrader';
import type { NcConfig } from '../../interface/config'; import type { NcConfig } from '../../interface/config';
import type NcMetaIO from '../meta/NcMetaIO'; import type NcMetaIO from '../meta/NcMetaIO';
@ -46,6 +47,7 @@ export default class NcUpgrader {
{ name: '0104004', handler: ncFilterUpgrader_0104004 }, { name: '0104004', handler: ncFilterUpgrader_0104004 },
{ name: '0105002', handler: ncStickyColumnUpgrader }, { name: '0105002', handler: ncStickyColumnUpgrader },
{ name: '0105003', handler: ncFilterUpgrader_0105003 }, { name: '0105003', handler: ncFilterUpgrader_0105003 },
{ name: '0105004', handler: ncHookUpgrader },
]; ];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) { if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
return; 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);
}

18
packages/nocodb/src/run/local.ts

@ -0,0 +1,18 @@
import path from 'path';
import cors from 'cors';
import express from 'express';
import Noco from '../lib/Noco';
const server = express();
server.enable('trust proxy');
server.use(cors());
server.use('/dashboard', express.static(path.join(__dirname, 'nc-gui')));
server.set('view engine', 'ejs');
(async () => {
const httpServer = server.listen(process.env.PORT || 8080, () => {
console.log(`App started successfully.\nVisit -> ${Noco.dashboardUrl}`);
});
server.use(await Noco.init({}, httpServer, server));
})().catch((e) => console.log(e));

686
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); 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, {})); console.log('Delete record', await Audit.projectAuditList(project.id, {}));
const rowDeletedAudit = (await Audit.projectAuditList(project.id, {})).find( const rowDeletedAudit = (await Audit.projectAuditList(project.id, {})).find(

53
packages/nocodb/webpack.local.config.js

@ -0,0 +1,53 @@
const nodeExternals = require('webpack-node-externals');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
const path = require('path');
module.exports = {
entry: './src/run/local.ts',
// devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
options: {
transpileOnly: true
}
},
},
],
},
optimization: {
minimize: true, //Update this to true or false
minimizer: [new TerserPlugin()],
nodeEnv: false
},
externals: [nodeExternals({
allowlist: ['nocodb-sdk']
})],
resolve: {
extensions: ['.tsx', '.ts', '.js', '.json'],
},
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'docker'),
library: 'libs',
libraryTarget: 'umd',
globalObject: "typeof self !== 'undefined' ? self : this"
},
node: {
fs: 'empty',
__dirname: false,
},
plugins: [
new webpack.EnvironmentPlugin([
'EE'
]),
],
target: 'node',
};

4
tests/playwright/pages/Dashboard/Kanban/index.ts

@ -65,6 +65,7 @@ export class KanbanPage extends BasePage {
const stacks = await this.get().locator(`.nc-kanban-stack`).count(); const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) { for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i); const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
// Since otherwise stack title will be repeated as title is in two divs, with one having hidden class // Since otherwise stack title will be repeated as title is in two divs, with one having hidden class
const stackTitle = await stack.locator(`.nc-kanban-stack-head >> [data-testid="truncate-label"]`); const stackTitle = await stack.locator(`.nc-kanban-stack-head >> [data-testid="truncate-label"]`);
await expect(stackTitle).toHaveText(order[i], { ignoreCase: true }); await expect(stackTitle).toHaveText(order[i], { ignoreCase: true });
@ -76,6 +77,7 @@ export class KanbanPage extends BasePage {
const stacks = await this.get().locator(`.nc-kanban-stack`).count(); const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) { for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i); const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
const stackFooter = await stack.locator(`.nc-kanban-data-count`).innerText(); const stackFooter = await stack.locator(`.nc-kanban-data-count`).innerText();
await expect(stackFooter).toContain(`${count[i]} record${count[i] !== 1 ? 's' : ''}`); await expect(stackFooter).toContain(`${count[i]} record${count[i] !== 1 ? 's' : ''}`);
} }
@ -86,6 +88,7 @@ export class KanbanPage extends BasePage {
const stacks = await this.get().locator(`.nc-kanban-stack`).count(); const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) { for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i); const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
const stackCards = stack.locator(`.nc-kanban-item`); const stackCards = stack.locator(`.nc-kanban-item`);
await expect(stackCards).toHaveCount(count[i]); await expect(stackCards).toHaveCount(count[i]);
} }
@ -96,6 +99,7 @@ export class KanbanPage extends BasePage {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(stackIndex); const stack = await this.get().locator(`.nc-kanban-stack`).nth(stackIndex);
for (let i = 0; i < order.length; i++) { for (let i = 0; i < order.length; i++) {
const card = await stack.locator(`.nc-kanban-item`).nth(i); const card = await stack.locator(`.nc-kanban-item`).nth(i);
await card.scrollIntoViewIfNeeded();
const cardTitle = await card.locator(`.nc-cell`); const cardTitle = await card.locator(`.nc-cell`);
await expect(cardTitle).toHaveText(order[i]); await expect(cardTitle).toHaveText(order[i]);
} }

3
tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts

@ -22,6 +22,7 @@ export class AttachmentCellPageObject extends BasePage {
// e.g. ['path/to/file1', 'path/to/file2'] // e.g. ['path/to/file1', 'path/to/file2']
// //
async addFile({ index, columnHeader, filePath }: { index?: number; columnHeader: string; filePath: string[] }) { async addFile({ index, columnHeader, filePath }: { index?: number; columnHeader: string; filePath: string[] }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
const attachFileAction = this.get({ index, columnHeader }) const attachFileAction = this.get({ index, columnHeader })
.locator('[data-testid="attachment-cell-file-picker-button"]') .locator('[data-testid="attachment-cell-file-picker-button"]')
.click(); .click();
@ -52,7 +53,7 @@ export class AttachmentCellPageObject extends BasePage {
let retryCount = 0; let retryCount = 0;
while (retryCount < 5) { while (retryCount < 5) {
const attachments = await this.get({ index, columnHeader }).locator('.nc-attachment'); const attachments = await this.get({ index, columnHeader }).locator('.nc-attachment');
console.log(await attachments.count()); // console.log(await attachments.count());
if ((await attachments.count()) === count) { if ((await attachments.count()) === count) {
break; break;
} }

1
tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts

@ -23,6 +23,7 @@ export class RatingCellPageObject extends BasePage {
} }
async verify({ index, columnHeader, rating }: { index?: number; columnHeader: string; rating: number }) { async verify({ index, columnHeader, rating }: { index?: number; columnHeader: string; rating: number }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await expect(await this.get({ index, columnHeader }).locator(`div[role="radio"][aria-checked="true"]`)).toHaveCount( await expect(await this.get({ index, columnHeader }).locator(`div[role="radio"][aria-checked="true"]`)).toHaveCount(
rating rating
); );

5
tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts

@ -85,9 +85,8 @@ export class SelectOptionCellPageObject extends BasePage {
if (multiSelect) { if (multiSelect) {
return await expect(this.cell.get({ index, columnHeader })).toContainText(option, { useInnerText: true }); return await expect(this.cell.get({ index, columnHeader })).toContainText(option, { useInnerText: true });
} }
return await expect( const text = await (await this.cell.get({ index, columnHeader }).locator('.ant-tag')).allInnerTexts();
this.cell.get({ index, columnHeader }).locator('.ant-select-selection-item > .ant-tag') return expect(text).toContain(option);
).toHaveText(option, { useInnerText: true });
} }
async verifyNoOptionsSelected({ index, columnHeader }: { index: number; columnHeader: string }) { async verifyNoOptionsSelected({ index, columnHeader }: { index: number; columnHeader: string }) {

9
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -114,6 +114,10 @@ export class CellPageObject extends BasePage {
// if text is found, return // if text is found, return
// if text is not found, throw error // if text is not found, throw error
let count = 0; let count = 0;
await this.get({
index,
columnHeader,
}).scrollIntoViewIfNeeded();
while (count < 5) { while (count < 5) {
const innerTexts = await this.get({ const innerTexts = await this.get({
index, index,
@ -265,9 +269,11 @@ export class CellPageObject extends BasePage {
value: string[]; value: string[];
}) { }) {
// const count = value.length; // const count = value.length;
const cell = this.get({ index, columnHeader }); const cell = await this.get({ index, columnHeader });
const chips = cell.locator('.chips > .chip'); const chips = cell.locator('.chips > .chip');
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
// verify chip count & contents // verify chip count & contents
if (count) await expect(chips).toHaveCount(count); if (count) await expect(chips).toHaveCount(count);
@ -316,6 +322,7 @@ export class CellPageObject extends BasePage {
} }
async copyToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters<Locator['click']>) { async copyToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters<Locator['click']>) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.get({ index, columnHeader }).click(...clickOptions); await this.get({ index, columnHeader }).click(...clickOptions);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable'); await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');

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

@ -97,7 +97,7 @@ export class ToolbarFieldsPage extends BasePage {
} }
async getFieldsTitles() { 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; return fields;
} }

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

@ -3,8 +3,12 @@ import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup'; import setup from '../setup';
import makeServer from '../setup/server'; import makeServer from '../setup/server';
import { WebhookFormPage } from '../pages/Dashboard/WebhookForm'; 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'; const hookPath = 'http://localhost:9090/hook';
let api: Api<any>;
// clear server data // clear server data
async function clearServerData({ request }) { async function clearServerData({ request }) {
@ -16,9 +20,28 @@ async function clearServerData({ request }) {
await expect(await response.json()).toBe(0); 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 // Retry since there can be lag between the time the hook is triggered and the time the server receives the request
let response; 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++) { for (let i = 0; i < 20; i++) {
response = await request.get(hookPath + '/count'); response = await request.get(hookPath + '/count');
if ((await response.json()) === count) { if ((await response.json()) === count) {
@ -30,17 +53,51 @@ async function verifyHookTrigger(count: number, value: string, request) {
if (count) { if (count) {
let response; 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++) { for (let i = 0; i < 20; i++) {
response = await request.get(hookPath + '/last'); 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; break;
} }
await new Promise(resolve => setTimeout(resolve, 150)); 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', () => { test.describe.serial('Webhook', () => {
// start a server locally for webhook tests // start a server locally for webhook tests
@ -52,13 +109,20 @@ test.describe.serial('Webhook', () => {
}); });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
context = await setup({ page }); context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project); dashboard = new DashboardPage(page, context.project);
webhook = dashboard.webhookForm; webhook = dashboard.webhookForm;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
}); });
test('CRUD', async ({ request, page }) => { test('CRUD', async ({ request, page }) => {
// todo: Waiting for the server to start // Waiting for the server to start
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
// close 'Team & Auth' tab // close 'Team & Auth' tab
@ -66,6 +130,14 @@ test.describe.serial('Webhook', () => {
await dashboard.closeTab({ title: 'Team & Auth' }); await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.createTable({ title: 'Test' }); 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 // after insert hook
await webhook.create({ await webhook.create({
title: 'hook-1', title: 'hook-1',
@ -77,12 +149,26 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title', columnHeader: 'Title',
value: 'Poole', 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 dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(1, 'Poole', request); await verifyHookTrigger(1, 'Poole', request);
await dashboard.grid.deleteRow(0); await dashboard.grid.deleteRow(0);
await verifyHookTrigger(1, 'Poole', request); 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 // after update hook
await webhook.create({ await webhook.create({
title: 'hook-2', title: 'hook-2',
@ -95,12 +181,27 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title', columnHeader: 'Title',
value: 'Poole', 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 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 dashboard.grid.deleteRow(0);
await verifyHookTrigger(2, 'Delaware', request); 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 // after delete hook
await webhook.create({ await webhook.create({
title: 'hook-3', title: 'hook-3',
@ -112,13 +213,29 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title', columnHeader: 'Title',
value: 'Poole', 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 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 dashboard.grid.deleteRow(0);
await verifyHookTrigger(3, 'Delaware', request); await verifyHookTrigger(3, 'Delaware', request, buildExpectedResponseData('records.after.delete', 'Delaware'));
///////////////////////////////////////////////////////////////////////////
// modify webhook // 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.open({ index: 0 });
await webhook.configureWebhook({ await webhook.configureWebhook({
title: 'hook-1-modified', title: 'hook-1-modified',
@ -140,13 +257,28 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title', columnHeader: 'Title',
value: 'Poole', value: 'Poole',
}); });
// for insert & edit, the hook should not be triggered (count doesn't change in this case)
await verifyHookTrigger(0, 'Poole', request); await verifyHookTrigger(0, 'Poole', request);
await dashboard.grid.editRow({ index: 0, value: 'Delaware' }); await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(0, 'Delaware', request); await verifyHookTrigger(0, 'Delaware', request);
await dashboard.grid.deleteRow(0); 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 // 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 }); 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, 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 clearServerData({ request });
await dashboard.grid.addNewRow({ await dashboard.grid.addNewRow({
index: 0, index: 0,
@ -224,15 +367,30 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title', columnHeader: 'Title',
value: 'Delaware', 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: 0, value: 'Delaware' });
await dashboard.grid.editRow({ index: 1, value: 'Poole' }); 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(1);
await dashboard.grid.deleteRow(0); 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.open({ index: 2 });
await webhook.deleteCondition({ save: true }); await webhook.deleteCondition({ save: true });
await webhook.open({ index: 1 }); await webhook.open({ index: 1 });
@ -251,12 +409,334 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title', columnHeader: 'Title',
value: 'Delaware', 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: 0, value: 'Delaware' });
await dashboard.grid.editRow({ index: 1, value: 'Poole' }); 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(1);
await dashboard.grid.deleteRow(0); 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);
}); });
}); });

4
tests/playwright/tests/columnAttachments.spec.ts

@ -39,12 +39,12 @@ test.describe('Attachment column', () => {
}); });
} }
await dashboard.grid.cell.attachment.addFile({ await dashboard.grid.cell.attachment.addFile({
index: 14, index: 4,
columnHeader: 'testAttach', columnHeader: 'testAttach',
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`], filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
}); });
await dashboard.grid.cell.attachment.verifyFile({ await dashboard.grid.cell.attachment.verifyFile({
index: 14, index: 4,
columnHeader: 'testAttach', columnHeader: 'testAttach',
}); });

160
tests/playwright/tests/megaTable.spec.ts

@ -0,0 +1,160 @@
import { test } from '@playwright/test';
import setup from '../setup';
import { UITypes } from 'nocodb-sdk';
import { Api } from 'nocodb-sdk';
let api: Api<any>;
// configuration
// To use, modify the test.skip to test.only
// Add columns as required to megaTblColumns
// Add row count as required to megaTblRows
const megaTblColumns = [
{ type: 'SingleLineText', count: 30 },
{ type: 'LongText', count: 100 },
{ type: 'Number', count: 30 },
{ type: 'Checkbox', count: 30 },
{ type: 'SingleSelect', count: 30 },
{ type: 'MultiSelect', count: 100 },
{ type: 'Date', count: 100 },
{ type: 'DateTime', count: 100 },
{ type: 'Email', count: 100 },
{ type: 'Currency', count: 100 },
{ type: 'Duration', count: 100 },
{ type: 'Rating', count: 100 },
];
const megaTblRows = 1000;
const bulkInsertAfterRows = 1000;
const formulaRowCnt = 100;
test.describe.serial('Test table', () => {
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
});
test.skip('mega table', async ({ page }) => {
let table_1;
const table_1_columns = [];
// a Primary key column & display column
table_1_columns.push(
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'SingleLineText',
title: 'SingleLineText',
uidt: UITypes.SingleLineText,
pv: true,
}
);
for (let i = 0; i < megaTblColumns.length; i++) {
for (let j = 0; j < megaTblColumns[i].count; j++) {
// skip if Formula
if (megaTblColumns[i].type === 'Formula') continue;
const column = {
column_name: `${megaTblColumns[i].type}${j}`,
title: `${megaTblColumns[i].type}${j}`,
uidt: UITypes[megaTblColumns[i].type],
};
if (megaTblColumns[i].type === 'SingleSelect' || megaTblColumns[i].type === 'MultiSelect') {
column['dtxp'] = "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'";
}
if (megaTblColumns[i].type === 'Email') {
column['meta'] = {
validate: true,
};
}
table_1_columns.push(column);
}
}
try {
const project = await api.project.read(context.project.id);
table_1 = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'table_1',
title: 'table_1',
columns: table_1_columns,
});
// run loop for formula count
for (let i = 0; i < formulaRowCnt; i++) {
table_1 = await api.dbTableColumn.create(table_1.id, {
column_name: `Formula${i}`,
title: `Formula${i}`,
uidt: UITypes.Formula,
formula_raw: '{SingleLineText}',
});
}
const table_1_rows = [];
for (let rowCnt = 0; rowCnt < megaTblRows; rowCnt++) {
const row = {
Id: rowCnt + 1,
SingleLineText: `SingleLineText${rowCnt + 1}`,
};
for (let colCnt = 0; colCnt < megaTblColumns.length; colCnt++) {
if (megaTblColumns[colCnt].type === 'Formula') continue;
for (let colInstanceCnt = 0; colInstanceCnt < megaTblColumns[colCnt].count; colInstanceCnt++) {
const columnName = `${megaTblColumns[colCnt].type}${colInstanceCnt}`;
if (megaTblColumns[colCnt].type === 'SingleLineText') {
row[columnName] = `SingleLineText${rowCnt + 1}`;
} else if (
megaTblColumns[colCnt].type === 'Number' ||
megaTblColumns[colCnt].type === 'Currency' ||
megaTblColumns[colCnt].type === 'Duration'
) {
row[columnName] = rowCnt + 1;
} else if (megaTblColumns[colCnt].type === 'Checkbox') {
row[columnName] = rowCnt % 2 === 0;
} else if (megaTblColumns[colCnt].type === 'SingleSelect') {
row[columnName] = 'jan';
} else if (megaTblColumns[colCnt].type === 'MultiSelect') {
row[columnName] = 'jan,feb,mar,apr';
} else if (megaTblColumns[colCnt].type === 'LongText') {
row[columnName] = `Some length text here. Some length text here`;
} else if (megaTblColumns[colCnt].type === 'DateTime') {
row[columnName] = '2023-04-25 16:25:11+05:30';
} else if (megaTblColumns[colCnt].type === 'Date') {
row[columnName] = '2023-04-25 16:25:11+05:30';
} else if (megaTblColumns[colCnt].type === 'Email') {
row[columnName] = 'raju@nocodb.com';
} else if (megaTblColumns[colCnt].type === 'Rating') {
row[columnName] = (rowCnt % 5) + 1;
}
}
}
table_1_rows.push(row);
// insert as soon as we have 1k records ready
if (table_1_rows.length === bulkInsertAfterRows) {
await api.dbTableRow.bulkCreate('noco', context.project.id, table_1.id, table_1_rows);
console.log(`table_1_rows ${rowCnt + 1} created`);
table_1_rows.length = 0;
}
}
if (table_1_rows.length > 0) {
await api.dbTableRow.bulkCreate('noco', context.project.id, table_1.id, table_1_rows);
console.log(`table_1_rows ${megaTblRows} created`);
}
} catch (e) {
console.log(e);
}
await page.reload();
});
});

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

@ -19,4 +19,23 @@ async function getTextExcludeIconText(selector) {
return text.trim(); 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