Browse Source

feat: Notifications (#8622)

* feat: notifications wip

* feat: wip

* feat: longpoll and notifications.controller.ts

* feat: longpoll and notifications.controller.ts

* feat: enable email notifications

* fix: notification styles and list

* fix: update swagger feat: connect poller to frontend

* fix: minor ui corrections

* feat: move notifications to ee feat: scroll to commentId fix: polling fail on network error fix: unreadcount not updating fix: add workspace to comment mention event

* fix: pubsub for notifications

* fix: warning maxListeners

* fix: update ui

* fix: minor fixes

* chore: move pub-sub to redis folder

* fix: update ui and schema feat: optimistic comment update and create

* fix: row empty during inital load causing row not loading

* fix: build

* fix: some updated

* fix: minor ui corrections

* fix: manage local state manually for interactivity

* fix: remove prev notifcation data

* fix: review comments

* fix: code rabbit comments

* fix: code rabbit comments

* feat: delete notifications

* fix: code rabbit comments

* fix: row RowMeta manipulation fix: overflow notifications

* fix: invalid offset

* fix: updated widths

* fix: tests

* fix: playwright

* feat: resolved by comments

* feat: update layout

* fix: wait 5 seconds before polling start, after polling starts, reload the notifications

* fix: bug fixes

* fix: disable long polling for playwright

* fix: update migration

* fix: lint

* fix: code rabbit comments

* fix: resolve tooltip

* feat: resolve ee

* fix: build failing

* fix: review comments

* fix: dependency synx

* fix: update notification style
pull/8671/head
Anbarasu 4 weeks ago committed by GitHub
parent
commit
55425f57de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      package.json
  2. 6
      packages/nc-gui/assets/nc-icons/bell.svg
  3. 11
      packages/nc-gui/assets/nc-icons/check-circle.svg
  4. 222
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  5. 2
      packages/nc-gui/components/dashboard/settings/Modal.vue
  6. 6
      packages/nc-gui/components/dlg/InviteDlg.vue
  7. 169
      packages/nc-gui/components/notification/Card.vue
  8. 28
      packages/nc-gui/components/notification/Item.vue
  9. 17
      packages/nc-gui/components/notification/Item/ColumnEvent.vue
  10. 17
      packages/nc-gui/components/notification/Item/FilterViewEvent.vue
  11. 38
      packages/nc-gui/components/notification/Item/ProjectEvent.vue
  12. 13
      packages/nc-gui/components/notification/Item/ProjectInvite.vue
  13. 38
      packages/nc-gui/components/notification/Item/SharedViewEvent.vue
  14. 17
      packages/nc-gui/components/notification/Item/SortViewEvent.vue
  15. 38
      packages/nc-gui/components/notification/Item/TableEvent.vue
  16. 38
      packages/nc-gui/components/notification/Item/ViewEvent.vue
  17. 21
      packages/nc-gui/components/notification/Item/Welcome.vue
  18. 119
      packages/nc-gui/components/notification/Item/Wrapper.vue
  19. 36
      packages/nc-gui/components/notification/Menu.vue
  20. 4
      packages/nc-gui/components/project/AccessSettings.vue
  21. 11
      packages/nc-gui/components/project/AllTables.vue
  22. 4
      packages/nc-gui/components/project/View.vue
  23. 4
      packages/nc-gui/components/smartsheet/Toolbar.vue
  24. 5
      packages/nc-gui/components/smartsheet/column/LinkOptions.vue
  25. 258
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  26. 6
      packages/nc-gui/components/smartsheet/expanded-form/RichComment.vue
  27. 2
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  28. 121
      packages/nc-gui/composables/useExpandedFormStore.ts
  29. 204
      packages/nc-gui/store/notification.ts
  30. 3
      packages/nc-gui/utils/datetimeUtils.ts
  31. 5
      packages/nc-gui/utils/iconUtils.ts
  32. 1
      packages/nc-mail-templates/.gitignore
  33. 23
      packages/nc-mail-templates/package.json
  34. 33
      packages/nc-mail-templates/src/index.ts
  35. 73
      packages/nc-mail-templates/src/templates/Mention.vue
  36. 68
      packages/nc-mail-templates/src/templates/Welcome.vue
  37. 15
      packages/nc-mail-templates/src/templates/assets/DiscordIcon.ts
  38. 15
      packages/nc-mail-templates/src/templates/assets/GithubIcon.ts
  39. 18
      packages/nc-mail-templates/src/templates/assets/LinkedIcon.ts
  40. 15
      packages/nc-mail-templates/src/templates/assets/TwitterIcon.ts
  41. 15
      packages/nc-mail-templates/src/templates/assets/YoutubeIcon.ts
  42. 15
      packages/nc-mail-templates/src/templates/assets/index.ts
  43. 212
      packages/nc-mail-templates/src/templates/components/Footer.ts
  44. 30
      packages/nc-mail-templates/src/templates/components/Header.ts
  45. 56
      packages/nc-mail-templates/src/templates/components/HtmlWrapper.ts
  46. 9
      packages/nc-mail-templates/src/templates/components/index.ts
  47. 29
      packages/nc-mail-templates/tsconfig.json
  48. 146
      packages/nocodb-sdk/src/lib/Api.ts
  49. 3
      packages/nocodb/src/controllers/comments.controller.ts
  50. 84
      packages/nocodb/src/controllers/notifications.controller.ts
  51. 19
      packages/nocodb/src/gateways/notifications/notifications.gateway.spec.ts
  52. 59
      packages/nocodb/src/gateways/notifications/notifications.gateway.ts
  53. 1
      packages/nocodb/src/interface/config.ts
  54. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  55. 14
      packages/nocodb/src/meta/migrations/v2/nc_049_clear_notifications.ts
  56. 6
      packages/nocodb/src/models/Comment.ts
  57. 27
      packages/nocodb/src/models/Notification.ts
  58. 114
      packages/nocodb/src/modules/jobs/redis/jobs-redis.ts
  59. 4
      packages/nocodb/src/modules/noco.module.ts
  60. 94
      packages/nocodb/src/redis/pubsub-redis.ts
  61. 324
      packages/nocodb/src/schema/swagger.json
  62. 3
      packages/nocodb/src/services/app-hooks/interfaces.ts
  63. 17
      packages/nocodb/src/services/comments.service.ts
  64. 141
      packages/nocodb/src/services/notifications.service.ts
  65. 0
      packages/nocodb/src/services/notifications/notifications.service.spec.ts
  66. 237
      packages/nocodb/src/services/notifications/notifications.service.ts
  67. 12
      packages/nocodb/src/utils/circularReplacer.ts
  68. 1
      packages/nocodb/src/utils/index.ts
  69. 10
      packages/nocodb/src/utils/richTextHelper.ts
  70. 1216
      pnpm-lock.yaml
  71. 1
      pnpm-workspace.yaml
  72. 6
      tests/playwright/setup/index.ts

2
package.json

@ -32,7 +32,7 @@
] ]
}, },
"scripts": { "scripts": {
"bootstrap": "pnpm --filter=nocodb-sdk install && pnpm --filter=nocodb-sdk run build && pnpm --filter=nocodb --filter=nc-gui --filter=playwright install", "bootstrap": "pnpm --filter=nocodb-sdk install && pnpm --filter=nocodb-sdk run build && pnpm --filter=nocodb --filter=nc-mail-templates --filter=nc-gui --filter=playwright install",
"start:frontend": "pnpm --filter=nc-gui run dev", "start:frontend": "pnpm --filter=nc-gui run dev",
"start:backend": "pnpm --filter=nocodb run start", "start:backend": "pnpm --filter=nocodb run start",
"lint:staged:playwright": "cd ./tests/playwright; pnpm dlx lint-staged; cd -", "lint:staged:playwright": "cd ./tests/playwright; pnpm dlx lint-staged; cd -",

6
packages/nc-gui/assets/nc-icons/bell.svg

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="bell">
<path id="Vector" d="M12 5.33325C12 4.27239 11.5786 3.25497 10.8284 2.50482C10.0783 1.75468 9.06087 1.33325 8 1.33325C6.93913 1.33325 5.92172 1.75468 5.17157 2.50482C4.42143 3.25497 4 4.27239 4 5.33325C4 9.99992 2 11.3333 2 11.3333H14C14 11.3333 12 9.99992 12 5.33325Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M9.15335 14C9.03614 14.2021 8.86791 14.3698 8.6655 14.4864C8.46309 14.6029 8.2336 14.6643 8.00001 14.6643C7.76643 14.6643 7.53694 14.6029 7.33453 14.4864C7.13212 14.3698 6.96389 14.2021 6.84668 14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 801 B

11
packages/nc-gui/assets/nc-icons/check-circle.svg

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="check-circle" clip-path="url(#clip0_494_50692)">
<path id="Vector" d="M14.6667 7.38674V8.00007C14.6659 9.43769 14.2003 10.8365 13.3396 11.988C12.4788 13.1394 11.2689 13.9817 9.89025 14.3893C8.51163 14.797 7.03818 14.748 5.68966 14.2498C4.34113 13.7516 3.18978 12.8308 2.40732 11.6248C1.62485 10.4188 1.2532 8.99212 1.34779 7.55762C1.44239 6.12312 1.99815 4.75762 2.9322 3.66479C3.86625 2.57195 5.12853 1.81033 6.5308 1.4935C7.93307 1.17668 9.40019 1.32163 10.7133 1.90674" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M14.6667 2.66675L8 9.34008L6 7.34008" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_494_50692">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 940 B

222
packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue

@ -55,127 +55,139 @@ onMounted(() => {
<template> <template>
<div class="flex w-full flex-col py-0.9 px-1 border-gray-200 gap-y-1"> <div class="flex w-full flex-col py-0.9 px-1 border-gray-200 gap-y-1">
<NcDropdown v-model:visible="isMenuOpen" placement="topLeft" overlay-class-name="!min-w-64"> <div class="flex items-center pr-2 justify-between">
<div <NcDropdown v-model:visible="isMenuOpen" placement="topLeft" overlay-class-name="!min-w-64">
class="flex flex-row py-1 px-3 gap-x-2 items-center hover:bg-gray-200 rounded-lg cursor-pointer h-8" <div
data-testid="nc-sidebar-userinfo" class="flex flex-row py-1 px-3 gap-x-2 items-center hover:bg-gray-200 rounded-lg cursor-pointer h-8"
> data-testid="nc-sidebar-userinfo"
<GeneralUserIcon :email="user?.email" size="auto" :name="user?.display_name" /> >
<div class="flex truncate"> <GeneralUserIcon :email="user?.email" size="auto" :name="user?.display_name" />
{{ name ? name : user?.email }} <NcTooltip>
</div> <div class="flex max-w-32 truncate">
<GeneralIcon icon="chevronDown" class="flex-none !min-w-5 transform rotate-180 !text-gray-500" /> {{ name ? name : user?.email }}
</div>
<template #overlay>
<NcMenu data-testid="nc-sidebar-userinfo">
<NcMenuItem data-testid="nc-sidebar-user-logout" @click="logout">
<div v-e="['c:user:logout']" class="flex gap-2 items-center">
<GeneralLoader v-if="isLoggingOut" class="!ml-0.5 !mr-0.5 !max-h-4.5 !-mt-0.5" />
<GeneralIcon v-else icon="signout" class="menu-icon" />
<span class="menu-btn"> {{ $t('general.logout') }}</span>
</div> </div>
</NcMenuItem>
<NcDivider />
<a
v-e="['c:nocodb:discord']"
href="https://discord.gg/5RgZmkW"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="ncDiscord" />
<span class="menu-btn"> {{ $t('labels.community.joinDiscord') }} </span>
</NcMenuItem>
</a>
<a
v-e="['c:nocodb:reddit']"
href="https://www.reddit.com/r/NocoDB"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="ncReddit" />
<span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span>
</NcMenuItem>
</a>
<a
v-e="['c:nocodb:twitter']"
href="https://twitter.com/nocodb"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper group">
<GeneralIcon class="social-icon text-gray-500 group-hover:text-gray-800" icon="ncTwitter" />
<span class="menu-btn"> {{ $t('labels.twitter') }} </span>
</NcMenuItem>
</a>
<template v-if="!appInfo.ee">
<NcDivider />
<a-popover key="language" class="lang-menu !py-1.5" placement="rightBottom">
<NcMenuItem>
<div v-e="['c:translate:open']" class="flex gap-2 items-center">
<GeneralIcon icon="translate" class="group-hover:text-black nc-language ml-0.25 menu-icon" />
{{ $t('labels.language') }}
<div class="flex items-center text-gray-400 text-xs">{{ $t('labels.community.communityTranslated') }}</div>
<div class="flex-1" />
<MaterialSymbolsChevronRightRounded
class="transform group-hover:(scale-115 text-accent) text-xl text-gray-400"
/>
</div>
</NcMenuItem>
<template #content> <template #title>
<div class="bg-white max-h-50vh scrollbar-thin-dull min-w-64 !overflow-auto"> <span>
<LazyGeneralLanguageMenu /> {{ name ? name : user?.email }}
</div> </span>
</template> </template>
</a-popover> </NcTooltip>
</template>
<template v-if="!isMobileMode"> <GeneralIcon icon="chevronDown" class="flex-none !min-w-5 transform rotate-180 !text-gray-500" />
</div>
<template #overlay>
<NcMenu data-testid="nc-sidebar-userinfo">
<NcMenuItem data-testid="nc-sidebar-user-logout" @click="logout">
<div v-e="['c:user:logout']" class="flex gap-2 items-center">
<GeneralLoader v-if="isLoggingOut" class="!ml-0.5 !mr-0.5 !max-h-4.5 !-mt-0.5" />
<GeneralIcon v-else icon="signout" class="menu-icon" />
<span class="menu-btn"> {{ $t('general.logout') }}</span>
</div>
</NcMenuItem>
<NcDivider /> <NcDivider />
<a <a
v-e="['c:nocodb:forum-open']" v-e="['c:nocodb:discord']"
href="https://community.nocodb.com" href="https://discord.gg/5RgZmkW"
target="_blank" target="_blank"
class="!underline-transparent" class="!underline-transparent"
rel="noopener" rel="noopener noreferrer"
> >
<NcMenuItem> <NcMenuItem class="social-icon-wrapper">
<GeneralIcon icon="ncHelp" class="menu-icon mt-0.5" /> <GeneralIcon class="social-icon" icon="ncDiscord" />
<span class="menu-btn"> {{ $t('title.forum') }} </span> <span class="menu-btn"> {{ $t('labels.community.joinDiscord') }} </span>
</NcMenuItem> </NcMenuItem>
</a> </a>
<a <a
v-e="['c:nocodb:docs-open']" v-e="['c:nocodb:reddit']"
href="https://docs.nocodb.com" href="https://www.reddit.com/r/NocoDB"
target="_blank" target="_blank"
class="!underline-transparent" class="!underline-transparent"
rel="noopener" rel="noopener noreferrer"
> >
<NcMenuItem> <NcMenuItem class="social-icon-wrapper">
<GeneralIcon icon="file" class="menu-icon mt-0.5" /> <GeneralIcon class="social-icon" icon="ncReddit" />
<span class="menu-btn"> {{ $t('title.docs') }} </span> <span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span>
</NcMenuItem> </NcMenuItem>
</a> </a>
<a
<NcDivider /> v-e="['c:nocodb:twitter']"
href="https://twitter.com/nocodb"
<DashboardSidebarEEMenuOption v-if="isEeUI" /> target="_blank"
class="!underline-transparent"
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile"> rel="noopener noreferrer"
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem> >
</nuxt-link> <NcMenuItem class="social-icon-wrapper group">
</template> <GeneralIcon class="social-icon text-gray-500 group-hover:text-gray-800" icon="ncTwitter" />
</NcMenu> <span class="menu-btn"> {{ $t('labels.twitter') }} </span>
</template> </NcMenuItem>
</NcDropdown> </a>
<template v-if="!appInfo.ee">
<NcDivider />
<a-popover key="language" class="lang-menu !py-1.5" placement="rightBottom">
<NcMenuItem>
<div v-e="['c:translate:open']" class="flex gap-2 items-center">
<GeneralIcon icon="translate" class="group-hover:text-black nc-language ml-0.25 menu-icon" />
{{ $t('labels.language') }}
<div class="flex items-center text-gray-400 text-xs">{{ $t('labels.community.communityTranslated') }}</div>
<div class="flex-1" />
<MaterialSymbolsChevronRightRounded
class="transform group-hover:(scale-115 text-accent) text-xl text-gray-400"
/>
</div>
</NcMenuItem>
<template #content>
<div class="bg-white max-h-50vh scrollbar-thin-dull min-w-64 !overflow-auto">
<LazyGeneralLanguageMenu />
</div>
</template>
</a-popover>
</template>
<template v-if="!isMobileMode">
<NcDivider />
<a
v-e="['c:nocodb:forum-open']"
href="https://community.nocodb.com"
target="_blank"
class="!underline-transparent"
rel="noopener"
>
<NcMenuItem>
<GeneralIcon icon="ncHelp" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.forum') }} </span>
</NcMenuItem>
</a>
<a
v-e="['c:nocodb:docs-open']"
href="https://docs.nocodb.com"
target="_blank"
class="!underline-transparent"
rel="noopener"
>
<NcMenuItem>
<GeneralIcon icon="file" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.docs') }} </span>
</NcMenuItem>
</a>
<NcDivider />
<DashboardSidebarEEMenuOption v-if="isEeUI" />
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile">
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
</nuxt-link>
</template>
</NcMenu>
</template>
</NcDropdown>
<LazyNotificationMenu />
</div>
<template v-if="isMobileMode || appInfo.ee"></template> <template v-if="isMobileMode || appInfo.ee"></template>
<div v-else class="flex flex-row w-full justify-between pt-0.5 truncate"> <div v-else class="flex flex-row w-full justify-between pt-0.5 truncate">

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

@ -48,8 +48,6 @@ const { $e } = useNuxtApp()
const { t } = useI18n() const { t } = useI18n()
const { isDataSourceLimitReached } = storeToRefs(useBases())
const dataSourcesReload = ref(false) const dataSourcesReload = ref(false)
const tabsInfo: TabGroup = { const tabsInfo: TabGroup = {

6
packages/nc-gui/components/dlg/InviteDlg.vue

@ -12,8 +12,6 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const { baseRoles, workspaceRoles } = useRoles()
const basesStore = useBases() const basesStore = useBases()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
@ -28,10 +26,6 @@ const orderedRoles = computed(() => {
return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles
}) })
const userRoles = computed(() => {
return props.type === 'base' ? baseRoles.value : workspaceRoles.value
})
const inviteData = reactive({ const inviteData = reactive({
email: '', email: '',
roles: orderedRoles.value.NO_ACCESS, roles: orderedRoles.value.NO_ACCESS,

169
packages/nc-gui/components/notification/Card.vue

@ -4,83 +4,126 @@ import InfiniteLoading from 'v3-infinite-loading'
const notificationStore = useNotification() const notificationStore = useNotification()
const { notifications, isRead, pageInfo } = storeToRefs(notificationStore) const { isMobileMode } = useGlobal()
/* const container = ref()
const groupType = computed({
get() { const { height } = useElementSize(container)
return isRead.value ? 'read' : 'unread'
}, const { loadUnReadNotifications, loadReadNotifications, markAllAsRead } = notificationStore
set(value) {
isRead.value = value === 'read' const { unreadNotifications, readNotifications, readPageInfo, unreadPageInfo, notificationTab } = storeToRefs(notificationStore)
notificationStore.loadNotifications()
},
})
*/
</script> </script>
<template> <template>
<div class="min-w-[350px] max-w-[350px] min-h-[400px] !rounded-2xl bg-white rounded-xl nc-card"> <div
<div class="p-3" @click.stop> ref="container"
<div class="flex items-center"> style="box-shadow: 0px -12px 16px -4px rgba(0, 0, 0, 0.1), 0px -4px 6px -2px rgba(0, 0, 0, 0.06)"
<span class="text-md font-medium text-[#212121]"> :style="!isMobileMode ? 'width: min(80svw, 520px);' : ''"
{{ $t('general.notification') }} :class="{
</span> 'max-h-[70vh] h-[620px]': !isMobileMode,
<div class="flex-grow"></div> 'h-[100svh] w-[100svw]': isMobileMode,
<div }"
v-if="!isRead && notifications?.length" class="!rounded-lg pt-4"
class="cursor-pointer text-xs text-gray-500 hover:text-primary" >
@click.stop="notificationStore.markAllAsRead" <div class="space-y-3">
> <div class="flex px-6 justify-between items-center">
{{ $t('activity.markAllAsRead') }} <span class="text-md font-bold text-gray-800" @click.stop> {{ $t('general.notification') }}s </span>
</div>
</div>
</div>
<a-divider class="!my-0" />
<div
class="overflow-y-auto max-h-[max(60vh,500px)] min-h-100"
:class="{
'flex items-center justify-center': !notifications?.length,
}"
>
<template v-if="!notifications?.length">
<div class="flex flex-col gap-2 items-center justify-center">
<div class="text-sm text-gray-400">{{ $t('msg.noNewNotifications') }}</div>
<GeneralIcon icon="inbox" class="!text-40px text-gray-400" />
</div>
</template>
<template v-else>
<template v-for="item in notifications" :key="item.id">
<NotificationItem class="" :item="item" />
<a-divider class="!my-0" />
</template>
<InfiniteLoading <NcButton v-if="isMobileMode" size="small" type="secondary">
v-if="notifications && pageInfo && pageInfo.totalRows > notifications.length" <GeneralIcon icon="close" class="text-gray-700" />
@infinite="notificationStore.loadNotifications(true)" </NcButton>
> </div>
<template #spinner> <div
<div class="flex flex-row w-full justify-center mt-2"> v-if="notificationTab !== 'read'"
<a-spin /> :class="{
</div> 'text-gray-400': !unreadNotifications?.length,
}"
class="cursor-pointer right-5 pointer-events-auto top-12.5 z-2 absolute text-[13px] text-gray-600 font-weight-semibold"
@click.stop="markAllAsRead"
>
{{ $t('activity.markAllAsRead') }}
</div>
<NcTabs v-model:activeKey="notificationTab">
<a-tab-pane key="unread">
<template #tab>
<span
:class="{
'font-semibold': notificationTab === 'unread',
}"
class="text-xs"
>
Unread
</span>
</template> </template>
<template #complete> <div
<span></span> class="overflow-y-auto"
:style="`height: ${height - 72}px`"
:class="{
'flex flex-col items-center min-h-[48svh] justify-center': !unreadNotifications?.length,
}"
>
<template v-if="!unreadNotifications?.length">
<div class="text-sm !text-gray-500">{{ $t('msg.noNewNotifications') }}</div>
<GeneralIcon icon="inbox" class="!text-40px !text-gray-500" />
</template>
<template v-else>
<NotificationItem v-for="item in unreadNotifications" :key="item.id" :item="item" />
<InfiniteLoading
v-if="unreadNotifications && unreadPageInfo && unreadPageInfo.totalRows > unreadNotifications.length"
@infinite="loadUnReadNotifications(true)"
>
</InfiniteLoading>
</template>
</div>
</a-tab-pane>
<a-tab-pane key="read">
<template #tab>
<span
:class="{
'font-semibold': notificationTab === 'read',
}"
class="text-xs"
>
Read
</span>
</template> </template>
</InfiniteLoading>
</template> <div
class="overflow-y-auto"
:style="!isMobileMode ? `height: ${height - 72}px` : ''"
:class="{
'flex flex-col items-center min-h-[48svh] justify-center': !readNotifications?.length,
}"
>
<template v-if="!readNotifications?.length">
<div class="text-sm text-gray-500">{{ $t('msg.noNewNotifications') }}</div>
<GeneralIcon icon="inbox" class="!text-40px text-gray-500" />
</template>
<template v-else>
<NotificationItem v-for="item in readNotifications" :key="item.id" :item="item" />
<InfiniteLoading
v-if="readNotifications && readPageInfo && readPageInfo.totalRows > readNotifications.length"
@infinite="loadReadNotifications(true)"
>
</InfiniteLoading>
</template>
</div>
</a-tab-pane>
</NcTabs>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.nc-card { :deep(.ant-tabs-nav-wrap) {
border: solid 1px #e1e3e6; @apply px-3;
} }
:deep(.ant-tabs-nav-wrap) { :deep(.ant-tabs-tab) {
@apply px-6; @apply pb-1.5 pt-1;
} }
:deep(.ant-tabs-nav) { :deep(.ant-tabs-nav) {

28
packages/nc-gui/components/notification/Item.vue

@ -1,42 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { AppEvents } from 'nocodb-sdk' import { AppEvents } from 'nocodb-sdk'
import type { NotificationType } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
item: any item: NotificationType
}>() }>()
const item = toRef(props, 'item') const item = toRef(props, 'item')
const notificationStore = useNotification() const notificationStore = useNotification()
const { markAsRead } = notificationStore const { toggleRead } = notificationStore
</script> </script>
<template> <template>
<div class="select-none" @click="markAsRead(item)"> <div class="select-none" @click="toggleRead(item, item.is_read)">
<NotificationItemWelcome v-if="item.type === AppEvents.WELCOME" :item="item" /> <NotificationItemWelcome v-if="item.type === AppEvents.WELCOME" :item="item" />
<NotificationItemProjectInvite v-else-if="item.type === AppEvents.PROJECT_INVITE" :item="item" /> <NotificationItemProjectInvite v-else-if="item.type === AppEvents.PROJECT_INVITE" :item="item" />
<NotificationItemWorkspaceInvite v-else-if="item.type === AppEvents.WORKSPACE_INVITE" :item="item" /> <NotificationItemWorkspaceInvite v-else-if="item.type === AppEvents.WORKSPACE_INVITE" :item="item" />
<NotificationItemProjectEvent <NotificationItemMentionEvent v-else-if="['mention'].includes(item.type)" :item="item" />
v-else-if="[AppEvents.PROJECT_CREATE, AppEvents.PROJECT_DELETE, AppEvents.PROJECT_UPDATE].includes(item.type)"
:item="item"
/>
<NotificationItemTableEvent
v-else-if="[AppEvents.TABLE_CREATE, AppEvents.TABLE_DELETE, AppEvents.TABLE_UPDATE].includes(item.type)"
:item="item"
/>
<NotificationItemViewEvent
v-else-if="[AppEvents.VIEW_CREATE, AppEvents.VIEW_DELETE, AppEvents.VIEW_UPDATE].includes(item.type)"
:item="item"
/>
<NotificationItemSharedViewEvent
v-else-if="[AppEvents.SHARED_VIEW_CREATE, AppEvents.SHARED_VIEW_DELETE, AppEvents.SHARED_VIEW_UPDATE].includes(item.type)"
:item="item"
/>
<NotificationItemWorkspaceEvent
v-else-if="[AppEvents.WORKSPACE_CREATE, AppEvents.WORKSPACE_DELETE, AppEvents.WORKSPACE_UPDATE].includes(item.type)"
:item="item"
/>
<span v-else /> <span v-else />
</div> </div>
</template> </template>

17
packages/nc-gui/components/notification/Item/ColumnEvent.vue

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
</script>
<template>
<NotificationItemWrapper :item="item">
<div class="text-xs">
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong>
</div>
</NotificationItemWrapper>
</template>

17
packages/nc-gui/components/notification/Item/FilterViewEvent.vue

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
</script>
<template>
<NotificationItemWrapper :item="item">
<div class="text-xs">
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong>
</div>
</NotificationItemWrapper>
</template>

38
packages/nc-gui/components/notification/Item/ProjectEvent.vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
import { AppEvents } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
const { navigateToProject } = useGlobal()
const action = computed(() => {
switch (item.value.type) {
case AppEvents.PROJECT_CREATE:
return 'created'
case AppEvents.PROJECT_UPDATE:
return 'updated'
case AppEvents.PROJECT_DELETE:
return 'deleted'
}
})
const onClick = () => {
if (item.value.type === AppEvents.PROJECT_DELETE) return
navigateToProject({ baseId: item.value.body.id })
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="onClick">
<div class="text-xs gap-2">
Base
<GeneralProjectIcon style="vertical-align: middle" :type="item.body.type" /> <strong>{{ item.body.title }}</strong>
{{ action }} successfully
</div>
</NotificationItemWrapper>
</template>

13
packages/nc-gui/components/notification/Item/ProjectInvite.vue

@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProjectInviteEventType } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
item: any item: ProjectInviteEventType
}>() }>()
const { navigateToProject } = useGlobal() const { navigateToProject } = useGlobal()
@ -9,10 +11,11 @@ const item = toRef(props, 'item')
</script> </script>
<template> <template>
<NotificationItemWrapper :item="item" @click="navigateToProject({ baseId: item.body.id })"> <NotificationItemWrapper :item="item" @click="navigateToProject({ baseId: item.body.base.id })">
<div class="text-xs"> <div>
<strong>{{ item.body.invited_by }}</strong> has invited you to collaborate on <span class="font-semibold">{{ item.body.user.display_name ?? item.body.user.email }}</span> has invited you to collaborate
<!-- <GeneralProjectIcon style="vertical-align: middle" :type="item.body.type" /> <strong>{{ item.body.title }}</strong> base. --> on
<span class="font-semibold">{{ item.body.base.title }}</span> base.
</div> </div>
</NotificationItemWrapper> </NotificationItemWrapper>
</template> </template>

38
packages/nc-gui/components/notification/Item/SharedViewEvent.vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
import { AppEvents } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
const { navigateToProject } = useGlobal()
const action = computed(() => {
switch (item.value.type) {
case AppEvents.VIEW_CREATE:
return 'created'
case AppEvents.VIEW_UPDATE:
return 'updated'
case AppEvents.VIEW_DELETE:
return 'deleted'
}
})
const onClick = () => {
if (item.value.type === AppEvents.VIEW_DELETE) return
navigateToProject({ baseId: item.value.body.id })
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="onClick">
<div class="text-xs gap-2">
Shared view
<strong>{{ item.body.title }}</strong>
{{ action }} successfully
</div>
</NotificationItemWrapper>
</template>

17
packages/nc-gui/components/notification/Item/SortViewEvent.vue

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
</script>
<template>
<NotificationItemWrapper :item="item">
<div class="text-xs">
<strong>{{ item.data?.user?.name }}</strong> {{ item.data?.action }} <strong>{{ item.data?.table?.name }}</strong>
</div>
</NotificationItemWrapper>
</template>

38
packages/nc-gui/components/notification/Item/TableEvent.vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { TableEventType } from 'nocodb-sdk'
import { AppEvents } from 'nocodb-sdk'
const props = defineProps<{
item: TableEventType
}>()
const item = toRef(props, 'item')
const { navigateToProject } = useGlobal()
const action = computed(() => {
switch (item.value.type) {
case AppEvents.TABLE_CREATE:
return 'created'
case AppEvents.TABLE_UPDATE:
return 'updated'
case AppEvents.TABLE_DELETE:
return 'deleted'
}
})
const onClick = () => {
if (item.value.type === AppEvents.TABLE_DELETE) return
navigateToProject({ baseId: item.value.body.id })
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="onClick">
<div class="text-xs gap-2">
Table
<strong>{{ item.body.title }}</strong>
{{ action }} successfully
</div>
</NotificationItemWrapper>
</template>

38
packages/nc-gui/components/notification/Item/ViewEvent.vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { ProjectEventType } from 'nocodb-sdk'
import { AppEvents } from 'nocodb-sdk'
const props = defineProps<{
item: ProjectEventType
}>()
const item = toRef(props, 'item')
const { navigateToProject } = useGlobal()
const action = computed(() => {
switch (item.value.type) {
case AppEvents.VIEW_CREATE:
return 'created'
case AppEvents.VIEW_UPDATE:
return 'updated'
case AppEvents.VIEW_DELETE:
return 'deleted'
}
})
const onClick = () => {
if (item.value.type === AppEvents.VIEW_DELETE) return
navigateToProject({ baseId: item.value.body.id })
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="onClick">
<div class="text-xs gap-2">
View
<strong>{{ item.body.title }}</strong>
{{ action }} successfully
</div>
</NotificationItemWrapper>
</template>

21
packages/nc-gui/components/notification/Item/Welcome.vue

@ -1,26 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WelcomeEventType } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
item: any item: WelcomeEventType
}>() }>()
const router = useRouter()
const route = router.currentRoute
const item = toRef(props, 'item') const item = toRef(props, 'item')
const navigateToHome = () => {
if (route.value.path !== '/') {
navigateTo(`/`)
}
}
</script> </script>
<template> <template>
<NotificationItemWrapper :item="item" @click="navigateToHome"> <NotificationItemWrapper :item="item">
<template #avatar> <div>Welcome to <span class="font-semibold">NocoDB!</span> Were excited to have you onboard.</div>
<img src="~/assets/img/icons/64x64.png" class="w-6" />
</template>
<div class="text-xs">Welcome to <strong>NocoHUB!</strong> Were excited to have you onboard.</div>
</NotificationItemWrapper> </NotificationItemWrapper>
</template> </template>

119
packages/nc-gui/components/notification/Item/Wrapper.vue

@ -1,94 +1,73 @@
<script setup lang="ts"> <script setup lang="ts">
import { timeAgo } from 'nocodb-sdk' import type { NotificationType } from 'nocodb-sdk'
import { timeAgo } from '~/utils/datetimeUtils'
const props = defineProps<{ const props = defineProps<{
item: { item: NotificationType
created_at: any
}
}>() }>()
const item = toRef(props, 'item') const item = toRef(props, 'item')
const { isMobileMode } = useGlobal()
const notificationStore = useNotification() const notificationStore = useNotification()
const { markAsRead } = notificationStore const { toggleRead, deleteNotification } = notificationStore
</script> </script>
<template> <template>
<div <div class="flex pl-6 pr-4 w-full overflow-x-hidden group py-4 hover:bg-gray-50 gap-3 relative cursor-pointer">
class="flex items-center gap-1 cursor-pointer nc-notification-item-wrapper" <div class="w-9.625">
:class="{
active: !item.is_read,
}"
>
<div class="nc-notification-dot" :class="{ active: !item.is_read }"></div>
<div class="nc-avatar-wrapper">
<slot name="avatar"> <slot name="avatar">
<div class="nc-notification-avatar"></div> <img src="~assets/img/brand/nocodb-logo.svg" alt="NocoDB" class="w-8" />
</slot> </slot>
</div> </div>
<div class="flex-grow ml-3">
<div class="flex items-center"> <div class="text-[13px] min-h-12 w-full leading-5">
<slot /> <slot />
</div> </div>
<div <div v-if="item" class="text-xs whitespace-nowrap absolute right-4.1 bottom-5 text-gray-600">
v-if="item" {{ timeAgo(item.created_at) }}
class="text-xs text-gray-500 mt-1" </div>
<div class="flex items-start">
<NcTooltip v-if="!item.is_read">
<template #title>
<span>Mark as read</span>
</template>
<NcButton
:class="{
'!opacity-100': isMobileMode,
}"
type="secondary"
class="!border-0 transition-all duration-100 opacity-0 !group-hover:opacity-100"
size="xsmall"
@click.stop="() => toggleRead(item)"
>
<GeneralIcon icon="check" class="text-gray-700" />
</NcButton>
</NcTooltip>
<NcDropdown
v-else
:class="{ :class="{
'text-primary': !item.is_read, '!opacity-100': isMobileMode,
}" }"
class="transition-all duration-100 opacity-0 !group-hover:opacity-100"
> >
{{ timeAgo(item.created_at) }} <NcButton size="xsmall" type="secondary" @click.stop>
</div> <GeneralIcon icon="threeDotVertical" />
</div> </NcButton>
<div @click.stop>
<a-dropdown>
<GeneralIcon v-if="!item.is_read" icon="threeDotVertical" class="nc-notification-menu-icon" />
<template #overlay> <template #overlay>
<a-menu> <NcMenu>
<a-menu-item @click="markAsRead(item)"> <NcMenuItem @click.stop="() => toggleRead(item)"> Mark as unread </NcMenuItem>
<div class="p-2 text-xs">Mark as read</div> <NcDivider />
</a-menu-item> <NcMenuItem class="!text-red-500 !hover:bg-red-50" @click.stop="deleteNotification(item)"> Delete </NcMenuItem>
</a-menu> </NcMenu>
</template> </template>
</a-dropdown> </NcDropdown>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss"></style>
.nc-avatar-wrapper {
@apply min-w-6 h-6 flex items-center justify-center;
}
.nc-notification-avatar {
@apply w-6 h-6 rounded-full text-white font-weight-bold uppercase bg-gray-100;
font-size: 0.7rem;
}
.nc-notification-dot {
@apply min-w-2 min-h-2 mr-1 rounded-full;
&.active {
@apply bg-accent bg-opacity-100;
}
}
.nc-notification-item-wrapper {
.nc-notification-menu-icon {
@apply !text-12px text-gray-500 opacity-0 transition-opacity duration-200 cursor-pointer;
}
&:hover {
.nc-notification-menu-icon {
@apply opacity-100;
}
}
&.active {
@apply bg-primary bg-opacity-4;
}
@apply py-3 px-3;
}
</style>

36
packages/nc-gui/components/notification/Menu.vue

@ -1,38 +1,24 @@
<script lang="ts" setup> <script lang="ts" setup>
const notificationStore = useNotification() const notificationStore = useNotification()
const { loadNotifications, markAsOpened } = notificationStore const { unreadCount } = toRefs(notificationStore)
onMounted(async () => {
await loadNotifications()
})
const onOpen = (visible: boolean) => {
if (visible) {
markAsOpened()
}
}
</script> </script>
<template> <template>
<div class="cursor-pointer flex items-center"> <div class="cursor-pointer flex items-center">
<a-dropdown :trigger="['click']" @visible-change="onOpen"> <NcDropdown overlay-class-name="!shadow-none" placement="bottomRight" :trigger="['click']">
<div class="relative leading-none"> <NcButton size="small" class="!border-none !bg-gray-50" type="secondary">
<span
v-if="unreadCount"
:key="unreadCount"
class="bg-red-500 w-2 h-2 border-1 border-white rounded-[6px] absolute top-[5px] left-[15px]"
></span>
<GeneralIcon icon="notification" /> <GeneralIcon icon="notification" />
<GeneralIcon icon="menuDown" /> </NcButton>
<span v-if="!notificationStore.isOpened && notificationStore.unreadCount" class="nc-count-badge">{{
notificationStore.unreadCount
}}</span>
</div>
<template #overlay> <template #overlay>
<NotificationCard /> <NotificationCard />
</template> </template>
</a-dropdown> </NcDropdown>
</div> </div>
</template> </template>
<style scoped>
.nc-count-badge {
@apply absolute flex items-center top-[-6px] right-[-6px] px-1 min-w-[14px] h-[14px] rounded-full bg-accent bg-opacity-100 text-white !text-[9px] !z-21;
}
</style>

4
packages/nc-gui/components/project/AccessSettings.vue

@ -153,11 +153,11 @@ onMounted(async () => {
}) })
const selected = reactive<{ const selected = reactive<{
[key: number]: boolean [key: string]: boolean
}>({}) }>({})
const toggleSelectAll = (value: boolean) => { const toggleSelectAll = (value: boolean) => {
filteredCollaborators.value.forEach((_, i) => { filteredCollaborators.value.forEach((_) => {
selected[_.id] = value selected[_.id] = value
}) })
} }

11
packages/nc-gui/components/project/AllTables.vue

@ -1,16 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { SourceType, TableType } from 'nocodb-sdk' import type { SourceType, TableType } from 'nocodb-sdk'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import NcTooltip from '~/components/nc/Tooltip.vue'
const { activeTables } = storeToRefs(useTablesStore()) const { activeTables } = storeToRefs(useTablesStore())
const { openTable } = useTablesStore() const { openTable } = useTablesStore()
const { openedProject, isDataSourceLimitReached } = storeToRefs(useBases()) const { openedProject } = storeToRefs(useBases())
const { base } = useBase() const { base } = useBase()
const isNewBaseModalOpen = ref(false)
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -68,12 +65,6 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
close(1000) close(1000)
} }
} }
const onCreateBaseClick = () => {
if (isDataSourceLimitReached.value) return
isNewBaseModalOpen.value = true
}
</script> </script>
<template> <template>

4
packages/nc-gui/components/project/View.vue

@ -35,14 +35,10 @@ const currentBase = computedAsync(async () => {
const { isUIAllowed, baseRoles } = useRoles() const { isUIAllowed, baseRoles } = useRoles()
const { base } = storeToRefs(useBase())
const { projectPageTab } = storeToRefs(useConfigStore()) const { projectPageTab } = storeToRefs(useConfigStore())
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
const baseSettingsState = ref('')
const userCount = computed(() => (activeProjectId.value ? basesUser.value.get(activeProjectId.value)?.length : 0)) const userCount = computed(() => (activeProjectId.value ? basesUser.value.get(activeProjectId.value)?.length : 0))
watch( watch(

4
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -1,8 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
const { isGrid, isGallery, isKanban, isMap, isCalendar } = useSmartsheetStoreOrThrow() const { isGrid, isGallery, isKanban, isMap, isCalendar } = useSmartsheetStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
const { isLeftSidebarOpen } = storeToRefs(useSidebarStore()) const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
@ -16,8 +14,6 @@ const isTab = computed(() => {
if (!isCalendar.value) return false if (!isCalendar.value) return false
return width.value > 1200 return width.value > 1200
}) })
const { allowCSVDownload } = useSharedView()
</script> </script>
<template> <template>

5
packages/nc-gui/components/smartsheet/column/LinkOptions.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs, useColumnCreateStoreOrThrow, useVModel } from '#imports' import { useColumnCreateStoreOrThrow, useVModel } from '#imports'
const props = defineProps<{ const props = defineProps<{
value: any value: any
@ -9,9 +9,6 @@ const emit = defineEmits(['update:value'])
const { t } = useI18n() const { t } = useI18n()
const viewsStore = useViewsStore()
const { viewsByTable } = storeToRefs(viewsStore)
const vModel = useVModel(props, 'value', emit) const vModel = useVModel(props, 'value', emit)
const { validateInfos, setAdditionalValidations } = useColumnCreateStoreOrThrow() const { validateInfos, setAdditionalValidations } = useColumnCreateStoreOrThrow()

258
packages/nc-gui/components/smartsheet/expanded-form/Comments.vue

@ -9,6 +9,7 @@ const {
loadComments, loadComments,
deleteComment, deleteComment,
comments, comments,
resolveComment,
audits, audits,
isAuditLoading, isAuditLoading,
saveComment: _saveComment, saveComment: _saveComment,
@ -32,6 +33,8 @@ const tab = ref<'comments' | 'audits'>('comments')
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const router = useRouter()
const hasEditPermission = computed(() => isUIAllowed('commentEdit')) const hasEditPermission = computed(() => isUIAllowed('commentEdit'))
const editComment = ref<CommentType>() const editComment = ref<CommentType>()
@ -120,11 +123,28 @@ const saveComment = async () => {
isCommentMode.value = true isCommentMode.value = true
isSaving.value = true isSaving.value = true
// Optimistic Insert
comments.value = [
...comments.value,
{
id: `temp-${new Date().getTime()}`,
comment: newComment.value,
created_at: new Date().toISOString(),
created_by: user.value?.id,
created_by_email: user.value?.email,
created_display_name: user.value?.display_name ?? '',
},
]
commentInputRef?.value?.setEditorContent('', true)
await nextTick(() => {
scrollComments()
})
try { try {
await _saveComment() await _saveComment()
await nextTick(() => { await nextTick(() => {
commentInputRef?.value?.setEditorContent('', true)
isExpandedFormCommentMode.value = true isExpandedFormCommentMode.value = true
}) })
scrollComments() scrollComments()
@ -135,13 +155,54 @@ const saveComment = async () => {
} }
} }
function scrollToComment(commentId: string) {
const commentEl = document.querySelector(`.${commentId}`)
if (commentEl) {
commentEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
watch(commentsWrapperEl, () => { watch(commentsWrapperEl, () => {
setTimeout(() => { setTimeout(() => {
nextTick(() => { nextTick(() => {
scrollComments() const query = router.currentRoute.value.query
const commentId = query.commentId
if (commentId) {
router.push({
query: {
rowId: query.rowId,
},
})
scrollToComment(commentId as string)
} else {
scrollComments()
}
}) })
}, 100) }, 100)
}) })
const timesAgo = (comment: CommentType) => {
return comment.created_at !== comment.updated_at ? `Edited ${timeAgo(comment.updated_at!)}` : timeAgo(comment.created_at!)
}
const createdBy = (
comment: CommentType & {
created_display_name?: string
},
) => {
if (comment.created_by === user.value?.id) {
return 'You'
} else if (comment.created_display_name?.trim()) {
return comment.created_by_email || 'Shared source'
} else if (comment.created_by_email) {
return comment.created_by_email
} else {
return 'Shared source'
}
}
</script> </script>
<template> <template>
@ -171,108 +232,112 @@ watch(commentsWrapperEl, () => {
<div class="font-medium text-center my-6 text-gray-500">{{ $t('activity.startCommenting') }}</div> <div class="font-medium text-center my-6 text-gray-500">{{ $t('activity.startCommenting') }}</div>
</div> </div>
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin"> <div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin">
<div v-for="comment of comments" :key="comment.id" class="nc-comment-item"> <div v-for="comment of comments" :key="comment.id" :class="`${comment.id}`" class="nc-comment-item">
<div <div
:class="{ :class="{
'hover:bg-gray-200': comment.id !== editComment?.id, 'hover:bg-gray-200 bg-[#F9F9FA]': comment.id !== editComment?.id,
'bg-gray-200': comment.id === editComment?.id,
'!bg-[#E7E7E9]': comment.resolved_by,
}" }"
class="group gap-3 overflow-hidden flex items-start px-3 py-1" class="group gap-3 overflow-hidden px-3 py-2"
> >
<GeneralUserIcon <div class="flex items-start justify-between">
:email="comment.created_by_email" <div class="flex items-start gap-3">
:name="comment.created_display_name" <GeneralUserIcon
class="mt-0.5" :email="comment.created_by_email"
size="medium" :name="comment.created_display_name"
/> class="mt-0.5"
<div class="flex-1 flex flex-col gap-1 max-w-[calc(100%_-_24px)]"> size="medium"
<div class="w-full flex justify-between gap-3 min-h-7"> />
<div class="flex items-center max-w-[calc(100%_-_40px)]"> <div class="flex h-[28px] items-center gap-3">
<div class="w-full flex flex-wrap gap-3 items-center"> <NcTooltip class="truncate capitalize text-gray-800 font-weight-700 !text-[13px] max-w-42">
<NcTooltip <template #title>
placement="bottomLeft" {{ comment.created_display_name?.trim() || comment.created_by_email || 'Shared source' }}
:arrow-point-at-center="false" </template>
class="truncate capitalize text-gray-800 font-weight-700 !text-[13px] max-w-42" <span class="text-ellipsis capitalize overflow-hidden" :style="{}">
> {{ createdBy(comment) }}
<template #title> </span>
{{ </NcTooltip>
(comment.created_by === user?.id
? comment.created_display_name?.trim() || comment.created_by_email <div class="text-xs text-gray-500">
: comment.created_display_name?.trim() {{ timesAgo(comment) }}
? comment.created_by_email
: comment.created_display_name?.trim()) || 'Shared source'
}}
</template>
<span
class="text-ellipsis capitalize overflow-hidden"
:style="{
lineHeight: '18px',
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{
comment.created_by === user?.id
? 'You'
: comment.created_display_name?.trim() || comment.created_by_email || 'Shared source'
}}
</span>
</NcTooltip>
<div class="text-xs text-gray-500">
{{
comment.created_at !== comment.updated_at
? `Edited ${timeAgo(comment.updated_at)}`
: timeAgo(comment.created_at)
}}
</div>
</div> </div>
</div> </div>
</div>
<div class="flex items-center opacity-0 transition-all group-hover:opacity-100 ease-out duration-400 gap-2"> <div class="flex items-center gap-2">
<NcDropdown <NcDropdown
v-if="comment.created_by_email === user!.email && !editComment" v-if="(comment.created_by_email === user!.email && !editComment )"
overlay-class-name="!min-w-[160px]" :class="{
placement="bottomRight" 'opacity-0 group-hover:opacity-100': comment.created_by_email === user!.email && !editComment,
> }"
<NcButton class="nc-expand-form-more-actions !w-7 !h-7 !bg-transparent" size="xsmall" type="text"> overlay-class-name="!min-w-[160px]"
<GeneralIcon class="text-md !hover:text-brand-500" icon="threeDotVertical" /> placement="bottomRight"
>
<NcButton class="nc-expand-form-more-actions !w-7 !h-7 !bg-transparent" size="xsmall" type="text">
<GeneralIcon class="text-md" icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem
v-e="['c:comment-expand:comment:edit']"
class="text-gray-700"
@click="editComments(comment)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.rename" class="cursor-pointer" />
{{ $t('general.edit') }}
</div>
</NcMenuItem>
<NcMenuItem
v-e="['c:row-expand:comment:delete']"
class="!text-red-500 !hover:bg-red-50"
@click="deleteComment(comment.id!)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.delete" class="cursor-pointer" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<div v-if="appInfo.ee">
<NcTooltip v-if="!comment.resolved_by">
<NcButton
class="!w-7 !h-7 !bg-transparent opacity-0 group-hover:opacity-100"
size="xsmall"
type="text"
@click="resolveComment(comment.id!)"
>
<GeneralIcon class="text-md" icon="checkCircle" />
</NcButton> </NcButton>
<template #overlay>
<NcMenu> <template #title>Click to resolve </template>
<NcMenuItem </NcTooltip>
v-e="['c:row-expand:comment:edit']"
class="text-gray-700" <NcTooltip v-else>
@click="editComments(comment)" <template #title>{{ `Resolved by ${comment.resolved_display_name}` }}</template>
> <div class="flex text-[#17803D] font-semibold items-center">
<div class="flex gap-2 items-center"> <NcButton class="!h-7 !bg-transparent" size="xsmall" type="text" @click="resolveComment(comment.id)">
<component :is="iconMap.rename" class="cursor-pointer" /> <div class="flex items-center gap-2 !text-[#17803D]">
{{ $t('general.edit') }} <span> Resolved </span>
</div> <component :is="iconMap.checkCircle" />
</NcMenuItem> </div>
<NcDivider /> </NcButton>
<NcMenuItem </div>
v-e="['c:row-expand:comment:delete']" </NcTooltip>
class="!text-red-500 !hover:bg-red-50"
@click="deleteComment(comment.id!)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.delete" class="cursor-pointer" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div> </div>
</div> </div>
</div>
<div class="flex-1 flex flex-col gap-1 mt-1 max-w-[calc(100%)]">
<SmartsheetExpandedFormRichComment <SmartsheetExpandedFormRichComment
v-if="comment.id === editComment?.id" v-if="comment.id === editComment?.id"
ref="editRef" ref="editRef"
v-model:value="value" v-model:value="value"
autofocus autofocus
:hide-options="false" :hide-options="false"
class="expanded-form-comment-input !pt-1 !pb-0.5 !pl-2 !m-0 w-full !border-1 !border-gray-200 !rounded-lg !bg-transparent !text-gray-800 !text-small !leading-18px !max-h-[694px]" class="expanded-form-comment-edit-input expanded-form-comment-input !pt-2 !pb-0.5 !pl-2 !m-0 w-full !border-1 !border-gray-200 !rounded-lg !bg-white !text-gray-800 !text-small !leading-18px !max-h-[694px]"
data-testid="expanded-form-comment-input" data-testid="expanded-form-comment-input"
sync-value-change sync-value-change
@save="onEditComment" @save="onEditComment"
@ -286,7 +351,7 @@ watch(commentsWrapperEl, () => {
@keydown.enter.exact.prevent="onEditComment" @keydown.enter.exact.prevent="onEditComment"
/> />
<div v-else class="text-small leading-18px text-gray-800"> <div v-else class="text-small pl-9 leading-18px text-gray-800">
<SmartsheetExpandedFormRichComment <SmartsheetExpandedFormRichComment
:value="comment.comment" :value="comment.comment"
class="!text-small !leading-18px !text-gray-800 -ml-1" class="!text-small !leading-18px !text-gray-800 -ml-1"
@ -362,14 +427,7 @@ watch(commentsWrapperEl, () => {
<template #title> <template #title>
{{ audit.display_name?.trim() || audit.user || 'Shared source' }} {{ audit.display_name?.trim() || audit.user || 'Shared source' }}
</template> </template>
<span <span class="text-ellipsis break-keep inline whitespace-nowrap overflow-hidden font-bold text-gray-800">
class="text-ellipsis overflow-hidden font-bold text-gray-800"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ audit.display_name?.trim() || audit.user || 'Shared source' }} {{ audit.display_name?.trim() || audit.user || 'Shared source' }}
</span> </span>
</NcTooltip> </NcTooltip>
@ -460,4 +518,8 @@ watch(commentsWrapperEl, () => {
@apply !text-gray-400; @apply !text-gray-400;
} }
} }
:deep(.expanded-form-comment-edit-input .nc-comment-rich-editor) {
@apply !pl-2 bg-white;
}
</style> </style>

6
packages/nc-gui/components/smartsheet/expanded-form/RichComment.vue

@ -243,7 +243,11 @@ defineExpose({
<EditorContent <EditorContent
ref="editorDom" ref="editorDom"
:editor="editor" :editor="editor"
class="flex flex-col nc-comment-rich-editor px-1.5 w-full scrollbar-thin scrollbar-thumb-gray-200 nc-truncate scrollbar-track-transparent" :class="{
'px-1.5': !props.readOnly,
'px-[0.25rem]': props.readOnly,
}"
class="flex flex-col nc-comment-rich-editor w-full scrollbar-thin scrollbar-thumb-gray-200 nc-truncate scrollbar-track-transparent"
@keydown.stop="handleKeyPress" @keydown.stop="handleKeyPress"
/> />

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

@ -331,7 +331,7 @@ if (isKanban.value) {
provide(IsExpandedFormOpenInj, isExpanded) provide(IsExpandedFormOpenInj, isExpanded)
const triggerRowLoad = async (rowId?: string) => { const triggerRowLoad = async (rowId?: string) => {
await Promise.allSettled([loadComments(), loadAudits(), _loadRow(rowId)]) await Promise.allSettled([loadComments(rowId), loadAudits(rowId), _loadRow(rowId)])
isLoading.value = false isLoading.value = false
} }

121
packages/nc-gui/composables/useExpandedFormStore.ts

@ -14,6 +14,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
Array< Array<
CommentType & { CommentType & {
created_display_name: string created_display_name: string
resolved_display_name?: string
} }
> >
>([]) >([])
@ -102,10 +103,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
return extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]) return extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
}) })
const loadComments = async () => { const loadComments = async (_rowId?: string) => {
if (!isUIAllowed('commentList') || !row.value) return if (!isUIAllowed('commentList') || (!row.value && !_rowId)) return
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]) const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]) ?? _rowId
if (!rowId) return if (!rowId) return
@ -125,13 +126,21 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
comments.value = res.map((comment) => { comments.value = res.map((comment) => {
const user = baseUsers.value.find((u) => u.id === comment.created_by) const user = baseUsers.value.find((u) => u.id === comment.created_by)
const resolvedUser = comment.resolved_by ? baseUsers.value.find((u) => u.id === comment.resolved_by) : null
return { return {
...comment, ...comment,
created_display_name: user?.display_name ?? (user?.email ?? '').split('@')[0], created_display_name: user?.display_name ?? (user?.email ?? '').split('@')[0],
resolved_display_name: resolvedUser ? resolvedUser.display_name ?? resolvedUser.email.split('@')[0] : null,
} }
}) })
} catch (e: any) { } catch (e: unknown) {
message.error(e.message) message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
} finally { } finally {
isCommentsLoading.value = false isCommentsLoading.value = false
} }
@ -139,19 +148,37 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const deleteComment = async (commentId: string) => { const deleteComment = async (commentId: string) => {
if (!isUIAllowed('commentDelete')) return if (!isUIAllowed('commentDelete')) return
const tempC = comments.value.find((c) => c.id === commentId)
try { try {
comments.value = comments.value.filter((c) => c.id !== commentId)
await $api.utils.commentDelete(commentId) await $api.utils.commentDelete(commentId)
await loadComments()
} catch (e: any) { // update comment count in rowMeta
message.error(e.message) Object.assign(row.value, {
...row.value,
rowMeta: {
...row.value.rowMeta,
commentCount: (row.value.rowMeta.commentCount ?? 1) - 1,
},
})
} catch (e: unknown) {
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
comments.value = [...comments.value, tempC]
} }
} }
const loadAudits = async () => { const loadAudits = async (_rowId?: string) => {
if (!isUIAllowed('auditList') || !row.value) return if (!isUIAllowed('auditList') || (!row.value && !_rowId)) return
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]) const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]) ?? _rowId
if (!rowId) return if (!rowId) return
@ -165,7 +192,13 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}) })
).list?.reverse?.() || [] ).list?.reverse?.() || []
} catch (e: any) { } catch (e: any) {
message.error(e.message) message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
} finally { } finally {
isAuditLoading.value = false isAuditLoading.value = false
} }
@ -182,6 +215,41 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
} }
} }
const resolveComment = async (commentId: string) => {
const tempC = comments.value.find((c) => c.id === commentId)
try {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return {
...c,
resolved_by: tempC.resolved_by ? null : $state.user?.value?.id,
resolved_by_email: tempC.resolved_by ? null : $state.user?.value?.email,
resolved_display_name: tempC.resolved_by
? null
: $state.user?.value?.display_name ?? $state.user?.value?.email.split('@')[0],
}
}
return c
})
await $api.utils.commentResolve(commentId)
} catch (e: unknown) {
comments.value = comments.value.map((c) => {
if (c.id === commentId) {
return tempC
}
return c
})
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
}
}
const saveComment = async () => { const saveComment = async () => {
try { try {
if (!row.value || !comment.value) return if (!row.value || !comment.value) return
@ -196,13 +264,28 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
comment: `${comment.value}`.replace(/(<br \/>)+$/g, ''), comment: `${comment.value}`.replace(/(<br \/>)+$/g, ''),
}) })
reloadTrigger?.trigger() // Increase Comment Count in rowMeta
Object.assign(row.value, {
rowMeta: {
...row.value.rowMeta,
commentCount: (row.value.rowMeta.commentCount ?? 0) + 1,
},
})
// reloadTrigger?.trigger()
await Promise.all([loadComments(), loadAudits()]) await Promise.all([loadComments(), loadAudits()])
comment.value = '' comment.value = ''
} catch (e: any) { } catch (e: any) {
message.error(e.message) comments.value = comments.value.filter((c) => !(c.id ?? '').startsWith('temp-'))
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
} }
$e('a:row-expand:comment') $e('a:row-expand:comment')
@ -244,7 +327,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
Object.assign(row.value, { Object.assign(row.value, {
row: data, row: data,
rowMeta: {}, rowMeta: {
...row.value.rowMeta,
new: false,
},
oldRow: { ...data }, oldRow: { ...data },
}) })
@ -406,7 +492,9 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
Object.assign(row.value, { Object.assign(row.value, {
row: record, row: record,
oldRow: { ...record }, oldRow: { ...record },
rowMeta: {}, rowMeta: {
...row.value.rowMeta,
},
}) })
} catch (e: any) { } catch (e: any) {
message.error(`${t('msg.error.errorLoadingRecord')}`) message.error(`${t('msg.error.errorLoadingRecord')}`)
@ -449,6 +537,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
comments, comments,
audits, audits,
isAuditLoading, isAuditLoading,
resolveComment,
isCommentsLoading, isCommentsLoading,
saveComment, saveComment,
comment, comment,

204
packages/nc-gui/store/notification.ts

@ -1,74 +1,121 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { NotificationType } from 'nocodb-sdk' import type { NotificationType } from 'nocodb-sdk'
import type { Socket } from 'socket.io-client'
import io from 'socket.io-client'
export const useNotification = defineStore('notificationStore', () => { export const useNotification = defineStore('notificationStore', () => {
const notifications = ref<NotificationType[]>([]) const readNotifications = ref<NotificationType[]>([])
const isOpened = ref(false)
const unreadNotifications = ref<NotificationType[]>([])
const readPageInfo = ref()
const unreadPageInfo = ref()
const pageInfo = ref()
const unreadCount = ref(0) const unreadCount = ref(0)
const isRead = ref(false) const notificationTab = ref<'read' | 'unread'>('unread')
const { api, isLoading } = useApi() const { api, isLoading } = useApi()
const { appInfo, token } = useGlobal() const pollNotifications = async () => {
try {
const res = await api.notification.poll()
let socket: Socket if (res.status === 'success') {
if (notificationTab.value === 'unread') {
unreadNotifications.value = [JSON.parse(res.data), ...unreadNotifications.value]
}
const init = (token) => { unreadCount.value = unreadCount.value + 1
const url = new URL(appInfo.value.ncSiteUrl, window.location.href.split(/[?#]/)[0]).href }
socket = io(`${url}${url.endsWith('/') ? '' : '/'}notifications`, { await pollNotifications()
extraHeaders: { 'xc-auth': token }, } catch (e) {
}) // If network error, retry after 2 seconds
setTimeout(pollNotifications, 2000)
}
}
socket.on('notification', (data) => { const loadReadNotifications = async (loadMore?: boolean) => {
notifications.value = [data, ...notifications.value] try {
pageInfo.value.totalRows += 1 const response = await api.notification.list({
unreadCount.value += 1 is_read: true,
isOpened.value = false limit: 10,
}) offset: loadMore ? readNotifications.value.length : 0,
})
if (loadMore) {
readNotifications.value = [...readNotifications.value, ...response.list]
} else {
readNotifications.value = response.list
}
readPageInfo.value = response.pageInfo
unreadCount.value = Number(response.unreadCount)
} catch (e) {
console.log(e)
}
}
socket.emit('subscribe', {}) const loadUnReadNotifications = async (loadMore?: boolean) => {
try {
const response = await api.notification.list({
is_read: false,
limit: 10,
offset: loadMore ? unreadNotifications.value.length : 0,
})
if (loadMore) {
unreadNotifications.value = [...unreadNotifications.value, ...response.list]
} else {
unreadNotifications.value = response.list
}
unreadPageInfo.value = response.pageInfo
unreadCount.value = Number(response.unreadCount)
} catch (e) {
console.log(e)
}
} }
watch( const insertAndSort = (notification: NotificationType, oldState?: boolean) => {
() => token.value, if (oldState) {
(newToken, oldToken) => { readNotifications.value = readNotifications.value.filter((n) => n.id !== notification.id)
if (newToken && newToken !== oldToken) init(newToken)
else if (!newToken) socket?.disconnect() unreadNotifications.value = [notification, ...unreadNotifications.value].sort((a, b) => {
}, return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
{ immediate: true }, })
)
unreadCount.value = unreadCount.value + 1
const loadNotifications = async (loadMore = false) => {
const response = await api.notification.list({
is_read: isRead.value,
limit: 10,
offset: loadMore ? notifications.value.length : 0,
})
if (loadMore) {
notifications.value = [...notifications.value, ...response.list]
} else { } else {
notifications.value = response.list readNotifications.value = [notification, ...readNotifications.value].sort((a, b) => {
} return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
unreadNotifications.value = unreadNotifications.value.filter((n) => n.id !== notification.id)
pageInfo.value = response.pageInfo unreadCount.value = unreadCount.value - 1
unreadCount.value = (response as any).unreadCount }
} }
const markAsRead = async (notification: NotificationType) => { const toggleRead = async (notification: NotificationType, ignoreTrigger?: boolean) => {
if (notification.is_read) return if (ignoreTrigger) return
const currState = notification.is_read
await api.notification.update(notification.id!, { try {
is_read: true, await api.notification.update(notification.id!, {
}) is_read: !currState,
})
notification.is_read = !currState
notification.is_read = true insertAndSort(notification, currState)
} catch (e) {
message.error(
`Failed to update Notification: ${await extractSdkResponseErrorMsgv2(e as Error & { response: { data: string } })}`,
)
}
} }
const markAllAsRead = async (notification: NotificationType) => { const markAllAsRead = async (notification: NotificationType) => {
@ -76,23 +123,64 @@ export const useNotification = defineStore('notificationStore', () => {
await api.notification.markAllAsRead() await api.notification.markAllAsRead()
await loadNotifications() await Promise.allSettled([loadReadNotifications(), loadUnReadNotifications()])
}
const deleteNotification = async (notification: NotificationType) => {
try {
readNotifications.value = readNotifications.value.filter((n) => n.id !== notification.id)
await api.notification.delete(notification.id!)
} catch (e) {
readNotifications.value = [notification, ...readNotifications.value]
message.error(
`Failed to delete Notification: ${await extractSdkResponseErrorMsgv2(
e as Error & {
response: {
data: string
}
},
)}`,
)
}
} }
const markAsOpened = async () => { watch(notificationTab, async (tab) => {
isOpened.value = true if (tab === 'read') {
await loadReadNotifications()
} else {
await loadUnReadNotifications()
}
})
const init = async () => {
await Promise.allSettled([loadReadNotifications(), loadUnReadNotifications()])
// For playwright, polling will cause the test to hang indefinitely
// as we wait for the networkidle event. So, we disable polling for playwright
if (!(window as any).isPlaywright) {
pollNotifications()
}
} }
onMounted(init)
return { return {
notifications, unreadNotifications,
loadNotifications, readNotifications,
loadUnReadNotifications,
loadReadNotifications,
deleteNotification,
readPageInfo,
unreadPageInfo,
isLoading, isLoading,
isRead, notificationTab,
pageInfo, toggleRead,
markAsRead,
markAllAsRead, markAllAsRead,
unreadCount, unreadCount,
isOpened,
markAsOpened,
} }
}) })
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useNotification, import.meta.hot))
}

3
packages/nc-gui/utils/datetimeUtils.ts

@ -17,7 +17,7 @@ export function parseStringDateTime(
return v return v
} }
export const timeAgo = (date: any) => { export const timeAgo = (date: string) => {
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(date)) { if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(date)) {
// if there is no timezone info, consider as UTC // if there is no timezone info, consider as UTC
// e.g. 2023-01-01 08:00:00 (MySQL) // e.g. 2023-01-01 08:00:00 (MySQL)
@ -34,6 +34,7 @@ export const timeAgo = (date: any) => {
const years = Math.floor(days / 365) const years = Math.floor(days / 365)
if (seconds < 60) { if (seconds < 60) {
if (seconds < 0) return '1s ago'
return `${seconds}s ago` return `${seconds}s ago`
} }
if (minutes < 60) { if (minutes < 60) {

5
packages/nc-gui/utils/iconUtils.ts

@ -110,7 +110,6 @@ import NcIconsRowHeightShort from '~icons/nc-icons/row-height-short'
import NcIconsRowHeightTall from '~icons/nc-icons/row-height-tall' import NcIconsRowHeightTall from '~icons/nc-icons/row-height-tall'
import NcIconsRowHeightExtraTall from '~icons/nc-icons/row-height-extra-tall' import NcIconsRowHeightExtraTall from '~icons/nc-icons/row-height-extra-tall'
import NcArticle from '~icons/nc-icons/article' import NcArticle from '~icons/nc-icons/article'
import NcNotification from '~icons/material-symbols/notifications-outline'
import NcCommentHere from '~icons/nc-icons/comment-here' import NcCommentHere from '~icons/nc-icons/comment-here'
import NcAddDataSource from '~icons/nc-icons/add-data-source' import NcAddDataSource from '~icons/nc-icons/add-data-source'
import NcDatabaseIcon from '~icons/nc-icons/database' import NcDatabaseIcon from '~icons/nc-icons/database'
@ -131,6 +130,9 @@ import NcDownload from '~icons/nc-icons/download'
import NcOffice from '~icons/nc-icons/office' import NcOffice from '~icons/nc-icons/office'
import NcArrowUpRight from '~icons/nc-icons/arrow-up-right' import NcArrowUpRight from '~icons/nc-icons/arrow-up-right'
import NcSlash from '~icons/nc-icons/slash' import NcSlash from '~icons/nc-icons/slash'
import NcNotification from '~icons/nc-icons/bell'
import NcCheckCircle from '~icons/nc-icons/check-circle'
// import NcProjectGray from '~icons/nc-icons/project-gray' // import NcProjectGray from '~icons/nc-icons/project-gray'
import NcPhoneCall from '~icons/nc-icons/phone-call' import NcPhoneCall from '~icons/nc-icons/phone-call'
import NcItalic from '~icons/nc-icons/italic' import NcItalic from '~icons/nc-icons/italic'
@ -396,6 +398,7 @@ export const iconMap = {
project: Project, project: Project,
search: NcSearch, search: NcSearch,
calendar: Calendar, calendar: Calendar,
checkCircle: NcCheckCircle,
error: h('span', { class: 'material-symbols' }, 'error'), error: h('span', { class: 'material-symbols' }, 'error'),
info: h(MsInfo, {}, () => 'info'), info: h(MsInfo, {}, () => 'info'),
inbox: h('span', { class: 'material-symbols' }, 'inbox'), inbox: h('span', { class: 'material-symbols' }, 'inbox'),

1
packages/nc-mail-templates/.gitignore vendored

@ -0,0 +1 @@
.dist

23
packages/nc-mail-templates/package.json

@ -0,0 +1,23 @@
{
"name": "nc-mail-templates",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"generate": "pnpm build && scripts/move_templates.sh",
"build": "tsx ./src/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"html-entities-decoder": "^1.0.5",
"typescript": "^5.3.3",
"vue-extensible-mail": "^0.0.3 "
},
"devDependencies": {
"tsx": "^4.10.4",
"@types/html-entities-decoder": "^1.0.2"
}
}

33
packages/nc-mail-templates/src/index.ts

@ -0,0 +1,33 @@
import {createEmailClient} from "vue-extensible-mail";
import components from "./templates/components";
import assets from "./templates/assets";
import * as fs from "node:fs";
import decode from 'html-entities-decoder';
const emailClient = createEmailClient({
path: "./src/templates",
components: {
...components,
...assets
},
});
const exportString = 'export default `'
if (fs.existsSync("./.dist")) {
fs.rmdirSync("./.dist", {recursive: true});
}
for (const file of fs.readdirSync("./src/templates")) {
if (file.endsWith(".vue")) {
const markup = decode(await emailClient.renderEmail(file, {}, {}))
if (!fs.existsSync("./.dist")) {
fs.mkdirSync("./.dist");
}
fs.writeFileSync(`./.dist/${file.replace(".vue", ".ts")}`, exportString + markup + "`")
}
}

73
packages/nc-mail-templates/src/templates/Mention.vue

@ -0,0 +1,73 @@
<script lang="ts" setup>
import {defaultComponents} from "vue-extensible-mail";
const { Container } = defaultComponents;
</script>
<template>
<HtmlWrapper>
<Header />
<Container>
<div style="max-width: 400px">
<h1
style="
font-size: 40px;
font-weight: bold;
color: #101015;
margin-block: 64px;
"
>
You have been mentioned.
</h1>
<p
style="
font-size: 14px;
color: #1f293a;
margin-block: 64px;
padding-bottom: 24px;
padding-top: 24px;
"
>
<strong> <%- name %> </strong>
has mentioned you in a comment on the
<strong> <%- display_name %> </strong>
in the
<strong> <%- table %> / <%- base %> </strong>.
</p>
<a href="<%- url %>" style="text-decoration: none">
<div
style="
cursor: pointer;
border-radius: 8px;
background-color: #3366ff;
border: none;
color: white;
padding-bottom: 15px;
padding-top: 15px;
max-width: 426px;
width: 100%;
margin-block: 64px;
text-align: center;
"
>
<span> View Comment </span>
</div>
</a>
<p
style="
color: #374151;
font-size: 14px;
padding-top: 24px;
padding-bottom: 24px;
"
>
NocoDB is solution for all your database needs.
</p>
</div>
<Footer style="width: 100%" />
</Container>
</HtmlWrapper>
</template>

68
packages/nc-mail-templates/src/templates/Welcome.vue

@ -0,0 +1,68 @@
<script lang="ts" setup>
import {defaultComponents} from "vue-extensible-mail";
const { Container } = defaultComponents;
</script>
<template>
<HtmlWrapper>
<Header />
<Container>
<div>
<h1
style="
font-size: 40px;
font-weight: bold;
color: #101015;
margin-block: 64px;
"
>
Welcome to NocoDB!
</h1>
<p
style="
font-size: 16px;
color: #1f293a;
margin-block: 64px;
padding-bottom: 24px;
padding-top: 24px;
"
>
Were excited to have you onboard.
</p>
<a href="https://www.nocodb.com" style="text-decoration: none">
<div
style="
cursor: pointer;
border-radius: 8px;
background-color: #3366ff;
border: none;
color: white;
padding-bottom: 15px;
padding-top: 15px;
max-width: 426px;
width: 100%;
margin-block: 64px;
text-align: center;
"
>
<span> Explore NocoDB </span>
</div>
</a>
<p
style="
color: #374151;
font-size: 14px;
padding-top: 24px;
padding-bottom: 24px;
"
>
NocoDB is solution for all your database needs.
</p>
</div>
<Footer style="width: 100%" />
</Container>
</HtmlWrapper>
</template>

15
packages/nc-mail-templates/src/templates/assets/DiscordIcon.ts

@ -0,0 +1,15 @@
import {defineComponent, h} from "vue";
export default defineComponent({
name: "Discord Icon",
setup() {
return () => {
return h("img", {
src: "https://app.nocodb.com/discord.png",
width: "24",
height: "24",
alt: "Discord Icon",
});
};
},
});

15
packages/nc-mail-templates/src/templates/assets/GithubIcon.ts

@ -0,0 +1,15 @@
import {defineComponent, h} from "vue";
export default defineComponent({
name: "GithubIcon",
setup() {
return () => {
return h("img", {
src: "https://app.nocodb.com/github.png",
alt: "Github Icon",
width: "24",
height: "24",
});
};
},
});

18
packages/nc-mail-templates/src/templates/assets/LinkedIcon.ts

@ -0,0 +1,18 @@
import {defineComponent, h} from "vue";
export default defineComponent({
name: "LinkedinIcon",
setup() {
return () => {
return h(
"img",
{
src: "https://app.nocodb.com/linkedin.png",
alt: "LinkedIn Icon",
width: "24",
height: "24",
},
);
};
},
});

15
packages/nc-mail-templates/src/templates/assets/TwitterIcon.ts

@ -0,0 +1,15 @@
import {defineComponent, h} from "vue";
export default defineComponent({
name: "TwitterIcon",
setup() {
return () => {
return h("img", {
width: "24",
height: "24",
src: "https://app.nocodb.com/twitter.png",
alt: "Twitter Icon",
});
};
},
});

15
packages/nc-mail-templates/src/templates/assets/YoutubeIcon.ts

@ -0,0 +1,15 @@
import {defineComponent, h} from "vue";
export default defineComponent({
name: "YoutubeIcon",
setup() {
return () => {
return h("img", {
src: "https://app.nocodb.com/youtube.png",
alt: "Youtube Icon",
width: "24",
height: "24",
});
};
},
});

15
packages/nc-mail-templates/src/templates/assets/index.ts

@ -0,0 +1,15 @@
import DiscordIcon from "./DiscordIcon.ts";
import GithubIcon from "./GithubIcon.ts";
import LinkedIcon from "./LinkedIcon.ts";
import TwitterIcon from "./TwitterIcon.ts";
import YoutubeIcon from "./YoutubeIcon.ts";
export default {
GithubIcon,
TwitterIcon,
LinkedIcon,
DiscordIcon,
YoutubeIcon
}

212
packages/nc-mail-templates/src/templates/components/Footer.ts

@ -0,0 +1,212 @@
import {defineComponent, h} from "vue";
import Icons from "../assets";
export default defineComponent({
name: "Header",
setup() {
return () => {
return h(
"div",
{
style: {
width: "100%",
"max-width": "426px",
'padding-block': "24px",
'margin-block': "64px",
'text-justify': "distribute",
},
},
[
h(
"div",
{
style: {
width: "100%",
"max-width": "426px",
'padding-block': "24px",
display: "flex",
'justify-content': "space-between",
'align-items': "center",
},
},
[
h("img", {
src: "https://i.ibb.co/4tbw6Wf/logo.png",
alt: "NocoDB",
style: {
height: "40px",
},
}),
h(
'div',
{
style: {
display: "flex",
gap: "16px",
},
},
[
h(
"a",
{
href: "https://github.com/nocodb",
style: {
'text-decoration': "none",
color: "black",
'margin-left': "4px",
},
},
h(Icons.GithubIcon)
),
h(
"a",
{
href: "https://x.com/nocodb",
style: {
'text-decoration': "none",
color: "black",
'margin-left': "4px",
},
},
h(Icons.TwitterIcon)
),
h(
"a",
{
href: "https://www.youtube.com/@nocodb/videos",
style: {
'text-decoration': "none",
color: "black",
'margin-left': "4px",
},
},
h(Icons.YoutubeIcon)
),
h(
"a",
{
href: "https://discord.gg/c7GEYrvFtT",
style: {
'text-decoration': "none",
color: "black",
'margin-left': "4px",
},
},
h(Icons.DiscordIcon)
),
h(
"a",
{
href: "https://www.linkedin.com/company/nocodb/",
style: {
'text-decoration': "none",
color: "black",
'margin-left': "4px",
},
},
h(Icons.LinkedIcon)
),
]
),
]
),
h(
"div",
{
style: {
width: "100%",
'padding-top': "24px",
},
},
[
h(
"a",
{
href: "https://blog.nocodb.com/",
style: {
'text-decoration': "none",
color: "#6A7184",
'font-size': "14px",
},
},
"Blog"
),
" | ",
h(
"a",
{
href: "https://docs.nocodb.com/",
style: {
'text-decoration': "none",
color: "#6A7184",
'font-size': "14px",
},
},
"Getting Started"
),
" | ",
h(
"a",
{
href: "https://docs.nocodb.com/",
style: {
'text-decoration': "none",
color: "#6A7184",
'font-size': "14px",
},
},
"Documentation"
),
" | ",
h(
"a",
{
href: "https://nocodb.com/terms-of-service",
style: {
'text-decoration': "none",
color: "#6A7184",
'font-size': "14px",
},
},
"Terms of Service"
),
]
),
h(
"div",
{
style: {
'padding-top': "24px",
width: "100%",
'justify-content': "flex-start",
'align-items': "items-start",
color: "#6A7184",
'text-align': "left",
},
},
"2024 - NocoDB Inc "
),
h(
"div",
{
style: {
'padding-top': "24px",
width: "100%",
'justify-content': "flex-start",
'align-items': "items-start",
color: "#6A7184",
'text-align': "left",
},
},
"All rights reserved "
),
]
);
};
},
});

30
packages/nc-mail-templates/src/templates/components/Header.ts

@ -0,0 +1,30 @@
import {defineComponent, h} from "vue";
export default defineComponent({
name: "Header",
setup() {
return () => {
return h(
"div",
{
style: {
width: "100%",
'background-color': "#f4f4f5",
'padding-top': "48px",
'padding-bottom': "48px",
'text-align': "center",
},
},
[
h("img", {
src: "https://i.ibb.co/4tbw6Wf/logo.png",
alt: "NocoDB",
style: {
height: "48px",
},
}),
]
);
};
},
});

56
packages/nc-mail-templates/src/templates/components/HtmlWrapper.ts

@ -0,0 +1,56 @@
import {defineComponent, h} from "vue";
export default defineComponent({
name: "HtmlWrapper",
setup(_, { slots }) {
return () => {
return h(
"html",
{
dir: "ltr",
lang: "en",
},
[
h("head", {}, [
h("meta", { charset: "utf-8" }),
h("meta", { name: "x-apple-disable-message-reformatting" }),
h("meta", {
content: "text/html; charset=UTF-8",
"http-equiv": "Content-Type",
}),
h("meta", { name: "viewport", content: "width=device-width" }),
h("link", {
href: "https://fonts.googleapis.com/css?family=Manrope",
rel: "stylesheet",
type: "text/css",
}),
h(
"style",
{},
`
@font-face {
src: url(https://fonts.gstatic.com/s/manrope/v15/xn7gYHE41ni1AdIRggqxSvfedN62Zw.woff2) format('woff2');
font-family: Maprope, sans-serif;
}
* {
font-family: Manrope, sans-serif;
}
body {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: Manrope, sans-serif;
}
`
),
]),
h("body", {}, slots?.default()),
]
);
};
},
});

9
packages/nc-mail-templates/src/templates/components/index.ts

@ -0,0 +1,9 @@
import Footer from "./Footer.ts"
import Header from "./Header.ts"
import HtmlWrapper from "./HtmlWrapper.ts"
export default {
Footer,
Header,
HtmlWrapper
}

29
packages/nc-mail-templates/tsconfig.json

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "preserve",
"jsxImportSource": "vue",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"esModuleInterop": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

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

@ -691,6 +691,10 @@ export interface FilterType {
fk_parent_id?: StringOrNullType; fk_parent_id?: StringOrNullType;
/** Foreign Key to View */ /** Foreign Key to View */
fk_view_id?: StringOrNullType; fk_view_id?: StringOrNullType;
/** Foreign Key to dynamic value Column */
fk_value_col_id?: StringOrNullType;
/** Foreign Key to Link Column */
fk_link_col_id?: StringOrNullType;
/** Unique ID */ /** Unique ID */
id?: IdType; id?: IdType;
/** Is this filter grouped? */ /** Is this filter grouped? */
@ -1637,6 +1641,8 @@ export interface LicenseReqType {
* Model for LinkToAnotherColumn Request * Model for LinkToAnotherColumn Request
*/ */
export interface LinkToAnotherColumnReqType { export interface LinkToAnotherColumnReqType {
/** Foreign Key to child view */
childViewId?: IdOrNullType;
/** Foreign Key to chhild column */ /** Foreign Key to chhild column */
childId: IdType; childId: IdType;
/** Foreign Key to parent column */ /** Foreign Key to parent column */
@ -1661,6 +1667,7 @@ export interface LinkToAnotherRecordType {
/** Foreign Key to Column */ /** Foreign Key to Column */
fk_column_id?: IdType; fk_column_id?: IdType;
fk_index_name?: string; fk_index_name?: string;
fk_relation_view_id?: string;
fk_mm_child_column_id?: string; fk_mm_child_column_id?: string;
fk_mm_model_id?: string; fk_mm_model_id?: string;
fk_mm_parent_column_id?: string; fk_mm_parent_column_id?: string;
@ -2458,6 +2465,11 @@ export type StringOrNullOrBooleanOrNumberType =
| boolean | boolean
| number; | number;
/**
* Model for IdOrNull
*/
export type IdOrNullType = IdType | null;
/** /**
* Model for Table * Model for Table
*/ */
@ -2769,27 +2781,22 @@ export interface ProjectInviteEventType {
type: string; type: string;
body: { body: {
/** The ID of the base being invited to */ /** The ID of the base being invited to */
id: string; base: {
/** The title of the base being invited to */ /** The ID of the base being invited to */
title: string; id: string;
/** The type of the base being invited to */ /** The title of the base being invited to */
type: string; title: string;
/** The email address of the user who invited the recipient */ /** The type of the base being invited to */
invited_by: string; type: string;
}; };
} user: {
/** The ID of the user who invited to the base */
export interface ProjectEventType { id: string;
/** The ID of the user */ /** The email address of the user who invited to the base */
fk_user_id: string; email: string;
type: string; /** The display name of the user who invited to the base */
body: { display_name?: string;
/** The ID of the base */ };
id: string;
/** The title of the base */
title: string;
/** The type of the base */
type: string;
}; };
} }
@ -2802,75 +2809,6 @@ export interface WelcomeEventType {
body: object; body: object;
} }
export interface SortEventType {
/** The ID of the user who created sort */
fk_user_id: string;
type: string;
body: object;
}
export interface FilterEventType {
/** The ID of the user who created filter */
fk_user_id: string;
type: string;
body: object;
}
export interface TableEventType {
/** The ID of the user who triggered the event */
fk_user_id: string;
/** The type of the event */
type: string;
body: {
/** The title of the table associated with the event */
title: string;
/** The ID of the base that the table belongs to */
base_id: string;
/** The ID of the source that the table belongs to */
source_id: string;
/** The ID of the table associated with the event */
id: string;
};
}
export interface ViewEventType {
/** The ID of the user who triggered the event */
fk_user_id: string;
/** The type of the event */
type: string;
body: {
/** The title of the view associated with the event */
title: string;
/** The ID of the base that the view belongs to */
base_id: string;
/** The ID of the source that the view belongs to */
source_id: string;
/** The ID of the view associated with the event */
id: string;
/** The ID of the model that the view is based on */
fk_model_id: string;
};
}
export interface ColumnEventType {
/** The ID of the user who triggered the event */
fk_user_id: string;
/** The type of the event */
type: string;
body: {
/** The title of the column associated with the event */
title: string;
/** The ID of the base that the column belongs to */
base_id: string;
/** The ID of the source that the column belongs to */
source_id: string;
/** The ID of the column associated with the event */
id: string;
/** The ID of the model that the column belongs to */
fk_model_id: string;
};
}
export type NotificationType = { export type NotificationType = {
/** Unique ID */ /** Unique ID */
id?: IdType; id?: IdType;
@ -2882,16 +2820,7 @@ export type NotificationType = {
type?: string; type?: string;
updated_at?: any; updated_at?: any;
created_at?: any; created_at?: any;
} & ( } & (ProjectInviteEventType | WelcomeEventType);
| ProjectInviteEventType
| ProjectEventType
| TableEventType
| ViewEventType
| ColumnEventType
| WelcomeEventType
| SortEventType
| FilterEventType
);
/** /**
* Model for Notification List * Model for Notification List
@ -11195,6 +11124,23 @@ export class Api<
}), }),
}; };
notification = { notification = {
/**
* @description Poll notifications
*
* @tags Notification
* @name Poll
* @summary Notification Poll
* @request GET:/api/v1/notifications/poll
* @response `200` `object` OK
*/
poll: (params: RequestParams = {}) =>
this.request<object, any>({
path: `/api/v1/notifications/poll`,
method: 'GET',
format: 'json',
...params,
}),
/** /**
* @description List notifications * @description List notifications
* *

3
packages/nocodb/src/controllers/comments.controller.ts

@ -21,7 +21,7 @@ import { NcRequest } from '~/interface/config';
@Controller() @Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard) @UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class CommentsController { export class CommentsController {
constructor(private readonly commentsService: CommentsService) {} constructor(protected readonly commentsService: CommentsService) {}
@Get(['/api/v1/db/meta/comments', '/api/v2/meta/comments']) @Get(['/api/v1/db/meta/comments', '/api/v2/meta/comments'])
@Acl('commentList') @Acl('commentList')
@ -54,6 +54,7 @@ export class CommentsController {
return await this.commentsService.commentDelete({ return await this.commentsService.commentDelete({
commentId, commentId,
user: req.user, user: req.user,
req,
}); });
} }

84
packages/nocodb/src/controllers/notifications.controller.ts

@ -8,22 +8,71 @@ import {
Patch, Patch,
Post, Post,
Req, Req,
Res,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express'; import { customAlphabet } from 'nanoid';
import { NotificationsService } from '~/services/notifications.service'; import type { Response } from 'express';
import { NotificationsService } from '~/services/notifications/notifications.service';
import { GlobalGuard } from '~/guards/global/global.guard'; import { GlobalGuard } from '~/guards/global/global.guard';
import { extractProps } from '~/helpers/extractProps'; import { extractProps } from '~/helpers/extractProps';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { NcRequest } from '~/interface/config';
import { NcError } from '~/helpers/catchError';
import { PubSubRedis } from '~/redis/pubsub-redis';
const nanoidv2 = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 14);
const POLL_INTERVAL = 30000;
@Controller() @Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard) @UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class NotificationsController { export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {} constructor(private readonly notificationsService: NotificationsService) {}
@Get('/api/v1/notifications/poll')
async notificationPoll(
@Req() req: NcRequest,
@Res()
res: Response & {
resId: string;
},
) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
res.resId = nanoidv2();
if (!req.user?.id) {
NcError.authenticationRequired();
}
this.notificationsService.addConnection(req.user.id, res);
if (PubSubRedis.available) {
await PubSubRedis.subscribe(
`notification:${req.user.id}`,
async (data) => {
this.notificationsService.sendToConnections(req.user.id, data);
},
);
}
res.on('close', async () => {
this.notificationsService.removeConnection(req.user.id, res);
if (PubSubRedis.available) {
await PubSubRedis.unsubscribe(`notification:${req.user.id}`);
}
});
setTimeout(() => {
if (!res.headersSent) {
res.send({
status: 'refresh',
});
}
}, POLL_INTERVAL);
}
@Get('/api/v1/notifications') @Get('/api/v1/notifications')
// @Acl('notificationList') async notificationList(@Req() req: NcRequest) {
async notificationList(@Req() req: Request) {
return this.notificationsService.notificationList({ return this.notificationsService.notificationList({
user: req.user, user: req.user,
is_deleted: false, is_deleted: false,
@ -33,11 +82,10 @@ export class NotificationsController {
} }
@Patch('/api/v1/notifications/:notificationId') @Patch('/api/v1/notifications/:notificationId')
// @Acl('notificationUpdate')
async notificationUpdate( async notificationUpdate(
@Param('notificationId') notificationId, @Param('notificationId') notificationId,
@Body() body, @Body() body,
@Req() req: Request, @Req() req: NcRequest,
) { ) {
return this.notificationsService.notificationUpdate({ return this.notificationsService.notificationUpdate({
notificationId, notificationId,
@ -46,25 +94,21 @@ export class NotificationsController {
}); });
} }
@Post('/api/v1/notifications/mark-all-read')
@HttpCode(200)
async markAllRead(@Req() req: Request) {
return this.notificationsService.markAllRead({
user: req.user,
});
}
@Delete('/api/v1/notifications/:notificationId') @Delete('/api/v1/notifications/:notificationId')
// @Acl('notificationDelete')
async notificationDelete( async notificationDelete(
@Param('notificationId') notificationId, @Param('notificationId') notificationId,
@Req() req: Request, @Req() req: NcRequest,
) { ) {
return this.notificationsService.notificationUpdate({ return this.notificationsService.notificationDelete({
notificationId, notificationId,
body: { user: req.user,
is_deleted: true, });
}, }
@Post('/api/v1/notifications/mark-all-read')
@HttpCode(200)
async markAllRead(@Req() req: NcRequest) {
return this.notificationsService.markAllRead({
user: req.user, user: req.user,
}); });
} }

19
packages/nocodb/src/gateways/notifications/notifications.gateway.spec.ts

@ -1,19 +0,0 @@
import { Test } from '@nestjs/testing';
import { NotificationsGateway } from './notifications.gateway';
import type { TestingModule } from '@nestjs/testing';
describe('NotificationsGateway', () => {
let gateway: NotificationsGateway;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [NotificationsGateway],
}).compile();
gateway = module.get<NotificationsGateway>(NotificationsGateway);
});
it('should be defined', () => {
expect(gateway).toBeDefined();
});
});

59
packages/nocodb/src/gateways/notifications/notifications.gateway.ts

@ -1,59 +0,0 @@
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server } from 'socket.io';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { AuthGuard } from '@nestjs/passport';
import type { Socket } from 'socket.io';
import type { OnModuleInit } from '@nestjs/common';
import type { NotificationType } from 'nocodb-sdk';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
@WebSocketGateway({
cors: {
origin: '*',
allowedHeaders: ['xc-auth'],
credentials: true,
},
namespace: 'notifications',
})
export class NotificationsGateway implements OnModuleInit {
@WebSocketServer()
server: Server;
private clients: { [id: string]: Socket } = {};
constructor(private readonly appHooks: AppHooksService) {}
async onModuleInit() {
this.server
.use(async (socket, next) => {
try {
const context = new ExecutionContextHost([socket.handshake as any]);
const guard = new (AuthGuard('jwt'))(context);
await guard.canActivate(context);
} catch {}
next();
})
.on('connection', (socket) => {
if ((socket?.handshake as any)?.user?.id)
this.clients[(socket?.handshake as any)?.user?.id] = socket;
});
// todo: fix
this.appHooks.on(
'notification' as any,
this.notificationHandler.bind(this),
);
}
// todo: verify if this is the right way to do it, since we are binding this context to the handler
onModuleDestroy() {
this.appHooks.removeListener('notification', this.notificationHandler);
}
private notificationHandler(notification: NotificationType) {
if (notification?.fk_user_id && this.clients[notification.fk_user_id]) {
this.clients[notification.fk_user_id]?.emit('notification', notification);
}
}
}

1
packages/nocodb/src/interface/config.ts

@ -331,4 +331,5 @@ export interface NcRequest {
ncBaseId?: string; ncBaseId?: string;
headers?: Record<string, string | undefined> | IncomingHttpHeaders; headers?: Record<string, string | undefined> | IncomingHttpHeaders;
clientIp?: string; clientIp?: string;
query?: Record<string, any>;
} }

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

@ -35,6 +35,7 @@ import * as nc_045_extensions from '~/meta/migrations/v2/nc_045_extensions';
import * as nc_046_comment_mentions from '~/meta/migrations/v2/nc_046_comment_mentions'; import * as nc_046_comment_mentions from '~/meta/migrations/v2/nc_046_comment_mentions';
import * as nc_047_comment_migration from '~/meta/migrations/v2/nc_047_comment_migration'; import * as nc_047_comment_migration from '~/meta/migrations/v2/nc_047_comment_migration';
import * as nc_048_view_links from '~/meta/migrations/v2/nc_048_view_links'; import * as nc_048_view_links from '~/meta/migrations/v2/nc_048_view_links';
import * as nc_049_clear_notifications from '~/meta/migrations/v2/nc_049_clear_notifications';
// Create a custom migration source class // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -81,6 +82,7 @@ export default class XcMigrationSourcev2 {
'nc_046_comment_mentions', 'nc_046_comment_mentions',
'nc_047_comment_migration', 'nc_047_comment_migration',
'nc_048_view_links', 'nc_048_view_links',
'nc_049_clear_notifications',
]); ]);
} }
@ -164,6 +166,8 @@ export default class XcMigrationSourcev2 {
return nc_047_comment_migration; return nc_047_comment_migration;
case 'nc_048_view_links': case 'nc_048_view_links':
return nc_048_view_links; return nc_048_view_links;
case 'nc_049_clear_notifications':
return nc_049_clear_notifications;
} }
} }
} }

14
packages/nocodb/src/meta/migrations/v2/nc_049_clear_notifications.ts

@ -0,0 +1,14 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex(MetaTable.NOTIFICATION).update({
is_deleted: true,
});
};
const down = async (_knex: Knex) => {
// no-op
};
export { up, down };

6
packages/nocodb/src/models/Comment.ts

@ -95,7 +95,11 @@ export default class Comment implements CommentType {
comment: Partial<Comment>, comment: Partial<Comment>,
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
) { ) {
const updateObj = extractProps(comment, ['comment', 'resolved_by']); const updateObj = extractProps(comment, [
'comment',
'resolved_by',
'resolved_by_email',
]);
await ncMeta.metaUpdate( await ncMeta.metaUpdate(
null, null,

27
packages/nocodb/src/models/Notification.ts

@ -2,7 +2,7 @@ import type { AppEvents } from 'nocodb-sdk';
import { extractProps } from '~/helpers/extractProps'; import { extractProps } from '~/helpers/extractProps';
import Noco from '~/Noco'; import Noco from '~/Noco';
import { MetaTable } from '~/utils/globals'; import { MetaTable } from '~/utils/globals';
import { parseMetaProp, stringifyMetaProp } from '~/utils/modelUtils'; import { prepareForDb, prepareForResponse } from '~/utils/modelUtils';
export default class Notification { export default class Notification {
id?: string; id?: string;
@ -30,16 +30,26 @@ export default class Notification {
'is_deleted', 'is_deleted',
]); ]);
insertData.body = stringifyMetaProp(insertData, 'body');
return await ncMeta.metaInsert2( return await ncMeta.metaInsert2(
null, null,
null, null,
MetaTable.NOTIFICATION, MetaTable.NOTIFICATION,
insertData, prepareForDb(insertData, 'body'),
); );
} }
public static async get(
params: {
fk_user_id: string;
id: string;
},
ncMeta = Noco.ncMeta,
) {
const condition = extractProps(params, ['id', 'fk_user_id']);
return await ncMeta.metaGet2(null, null, MetaTable.NOTIFICATION, condition);
}
public static async list( public static async list(
params: { params: {
fk_user_id: string; fk_user_id: string;
@ -73,9 +83,8 @@ export default class Notification {
); );
for (const notification of notifications) { for (const notification of notifications) {
notification.body = parseMetaProp(notification, 'body'); prepareForResponse(notification, 'body');
} }
return notifications; return notifications;
} }
@ -112,15 +121,11 @@ export default class Notification {
'is_deleted', 'is_deleted',
]); ]);
if ('body' in updateData) {
updateData.body = stringifyMetaProp(updateData, 'body');
}
return await ncMeta.metaUpdate( return await ncMeta.metaUpdate(
null, null,
null, null,
MetaTable.NOTIFICATION, MetaTable.NOTIFICATION,
updateData, prepareForDb(updateData, 'body'),
id, id,
); );
} }

114
packages/nocodb/src/modules/jobs/redis/jobs-redis.ts

@ -1,46 +1,25 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import Redis from 'ioredis';
import type { InstanceCommands } from '~/interface/Jobs'; import type { InstanceCommands } from '~/interface/Jobs';
import { PubSubRedis } from '~/redis/pubsub-redis';
import { InstanceTypes } from '~/interface/Jobs'; import { InstanceTypes } from '~/interface/Jobs';
export class JobsRedis { export class JobsRedis extends PubSubRedis {
private static initialized = false;
public static available = process.env.NC_REDIS_JOB_URL ? true : false;
protected static logger = new Logger(JobsRedis.name); protected static logger = new Logger(JobsRedis.name);
private static redisClient: Redis;
private static redisSubscriber: Redis;
private static unsubscribeCallbacks: { [key: string]: () => Promise<void> } =
{};
public static primaryCallbacks: { public static primaryCallbacks: {
[key: string]: (...args) => Promise<void>; [key: string]: (...args) => Promise<void>;
} = {}; } = {};
public static workerCallbacks: { [key: string]: (...args) => Promise<void> } = public static workerCallbacks: { [key: string]: (...args) => Promise<void> } =
{}; {};
static async init() { static async initJobs() {
if (this.initialized) { if (!this.initialized) {
return; if (!this.available) {
} return;
}
if (!JobsRedis.available) {
return;
}
this.initialized = true;
this.redisClient = new Redis(process.env.NC_REDIS_JOB_URL);
this.redisSubscriber = new Redis(process.env.NC_REDIS_JOB_URL);
if (process.env.NC_WORKER_CONTAINER === 'true') { await this.init();
await this.redisSubscriber.subscribe(InstanceTypes.WORKER);
} else {
await this.redisSubscriber.subscribe(InstanceTypes.PRIMARY);
} }
const onMessage = async (channel, message) => { const onMessage = async (channel, message) => {
const args = message.split(':'); const args = message.split(':');
const command = args.shift(); const command = args.shift();
@ -52,80 +31,25 @@ export class JobsRedis {
(await this.primaryCallbacks[command](...args)); (await this.primaryCallbacks[command](...args));
} }
}; };
if (process.env.NC_WORKER_CONTAINER === 'true') {
this.redisSubscriber.on('message', onMessage); await this.subscribe(InstanceTypes.WORKER, async (message) => {
} await onMessage(InstanceTypes.WORKER, message);
});
static async publish(channel: string, message: string | any) {
if (!this.initialized) {
if (!JobsRedis.available) {
return;
}
await this.init();
}
if (typeof message === 'string') {
await this.redisClient.publish(channel, message);
} else { } else {
try { await this.subscribe(InstanceTypes.PRIMARY, async (message) => {
await this.redisClient.publish(channel, JSON.stringify(message)); await onMessage(InstanceTypes.PRIMARY, message);
} catch (e) { });
this.logger.error(e);
}
}
}
static async subscribe(
channel: string,
callback: (message: any) => Promise<void>,
) {
if (!this.initialized) {
if (!JobsRedis.available) {
return;
}
await this.init();
}
await this.redisSubscriber.subscribe(channel);
const onMessage = async (_channel, message) => {
try {
message = JSON.parse(message);
} catch (e) {}
await callback(message);
};
this.redisSubscriber.on('message', onMessage);
this.unsubscribeCallbacks[channel] = async () => {
await this.redisSubscriber.unsubscribe(channel);
this.redisSubscriber.off('message', onMessage);
};
}
static async unsubscribe(channel: string) {
if (!this.initialized) {
if (!JobsRedis.available) {
return;
}
await this.init();
}
if (this.unsubscribeCallbacks[channel]) {
await this.unsubscribeCallbacks[channel]();
delete this.unsubscribeCallbacks[channel];
} }
} }
static async workerCount(): Promise<number> { static async workerCount(): Promise<number> {
if (!this.initialized) { if (!this.initialized) {
if (!JobsRedis.available) { if (!this.available) {
return; return;
} }
await this.init(); await this.init();
await this.initJobs();
} }
return new Promise((resolve) => { return new Promise((resolve) => {
@ -146,11 +70,11 @@ export class JobsRedis {
static async emitWorkerCommand(command: InstanceCommands, ...args: any[]) { static async emitWorkerCommand(command: InstanceCommands, ...args: any[]) {
const data = `${command}${args.length ? `:${args.join(':')}` : ''}`; const data = `${command}${args.length ? `:${args.join(':')}` : ''}`;
await JobsRedis.publish(InstanceTypes.WORKER, data); await this.publish(InstanceTypes.WORKER, data);
} }
static async emitPrimaryCommand(command: InstanceCommands, ...args: any[]) { static async emitPrimaryCommand(command: InstanceCommands, ...args: any[]) {
const data = `${command}${args.length ? `:${args.join(':')}` : ''}`; const data = `${command}${args.length ? `:${args.join(':')}` : ''}`;
await JobsRedis.publish(InstanceTypes.PRIMARY, data); await this.publish(InstanceTypes.PRIMARY, data);
} }
} }

4
packages/nocodb/src/modules/noco.module.ts

@ -94,9 +94,8 @@ import { ViewsService } from '~/services/views.service';
import { ApiDocsService } from '~/services/api-docs/api-docs.service'; import { ApiDocsService } from '~/services/api-docs/api-docs.service';
import { BaseUsersController } from '~/controllers/base-users.controller'; import { BaseUsersController } from '~/controllers/base-users.controller';
import { BaseUsersService } from '~/services/base-users/base-users.service'; import { BaseUsersService } from '~/services/base-users/base-users.service';
import { NotificationsService } from '~/services/notifications.service'; import { NotificationsService } from '~/services/notifications/notifications.service';
import { NotificationsController } from '~/controllers/notifications.controller'; import { NotificationsController } from '~/controllers/notifications.controller';
import { NotificationsGateway } from '~/gateways/notifications/notifications.gateway';
import { CommandPaletteService } from '~/services/command-palette.service'; import { CommandPaletteService } from '~/services/command-palette.service';
import { CommandPaletteController } from '~/controllers/command-palette.controller'; import { CommandPaletteController } from '~/controllers/command-palette.controller';
import { ExtensionsService } from '~/services/extensions.service'; import { ExtensionsService } from '~/services/extensions.service';
@ -245,7 +244,6 @@ export const nocoModuleMetadata = {
SortsService, SortsService,
SharedBasesService, SharedBasesService,
NotificationsService, NotificationsService,
NotificationsGateway,
CommandPaletteService, CommandPaletteService,
ExtensionsService, ExtensionsService,

94
packages/nocodb/src/redis/pubsub-redis.ts

@ -0,0 +1,94 @@
import { Logger } from '@nestjs/common';
import Redis from 'ioredis';
export class PubSubRedis {
static initialized = false;
static available = process.env.NC_REDIS_JOB_URL ? true : false;
protected static logger = new Logger(PubSubRedis.name);
static redisClient: Redis;
private static redisSubscriber: Redis;
private static unsubscribeCallbacks: { [key: string]: () => Promise<void> } =
{};
private static callbacks: Record<string, (...args) => Promise<void>> = {};
public static async init() {
if (!PubSubRedis.available) {
return;
}
PubSubRedis.redisClient = new Redis(process.env.NC_REDIS_JOB_URL);
PubSubRedis.redisSubscriber = new Redis(process.env.NC_REDIS_JOB_URL);
PubSubRedis.redisSubscriber.on('message', async (channel, message) => {
const [command, ...args] = message.split(':');
const callback = PubSubRedis.callbacks[command];
if (callback) await callback(...args);
});
PubSubRedis.initialized = true;
}
static async publish(channel: string, message: string | Record<string, any>) {
if (!PubSubRedis.initialized) {
if (!PubSubRedis.available) {
return;
}
await PubSubRedis.init();
}
try {
if (typeof message === 'string') {
await PubSubRedis.redisClient.publish(channel, message);
} else {
await PubSubRedis.redisClient.publish(channel, JSON.stringify(message));
}
} catch (e) {
PubSubRedis.logger.error(e);
}
}
static async unsubscribe(channel: string) {
if (!PubSubRedis.initialized) {
if (!PubSubRedis.available) {
return;
}
await PubSubRedis.init();
}
if (PubSubRedis.unsubscribeCallbacks[channel]) {
await PubSubRedis.unsubscribeCallbacks[channel]();
delete PubSubRedis.unsubscribeCallbacks[channel];
}
}
static async subscribe(
channel: string,
callback: (message: any) => Promise<void>,
) {
if (!PubSubRedis.initialized) {
if (!PubSubRedis.available) {
return;
}
await PubSubRedis.init();
}
await PubSubRedis.redisSubscriber.subscribe(channel);
const onMessage = async (_channel, message) => {
try {
message = JSON.parse(message);
} catch (e) {}
await callback(message);
};
PubSubRedis.redisSubscriber.on('message', onMessage);
PubSubRedis.unsubscribeCallbacks[channel] = async () => {
await PubSubRedis.redisSubscriber.unsubscribe(channel);
PubSubRedis.redisSubscriber.off('message', onMessage);
};
}
}

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

@ -16142,6 +16142,28 @@
] ]
} }
}, },
"/api/v1/notifications/poll": {
"get": {
"summary": "Notification Poll",
"operationId": "notification-poll",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Notification"
],
"description": "Poll notifications"
}
},
"/api/v1/notifications": { "/api/v1/notifications": {
"get": { "get": {
"summary": "Notification list", "summary": "Notification list",
@ -25232,67 +25254,54 @@
"body": { "body": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "base": {
"type": "string", "type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the base being invited to"
},
"title": {
"type": "string",
"description": "The title of the base being invited to"
},
"type": {
"type": "string",
"description": "The type of the base being invited to"
}
},
"required": [
"id",
"title",
"type"
],
"description": "The ID of the base being invited to" "description": "The ID of the base being invited to"
}, },
"title": { "user": {
"type": "string", "type": "object",
"description": "The title of the base being invited to" "properties": {
}, "id": {
"type": { "type": "string",
"type": "string", "description": "The ID of the user who invited to the base"
"description": "The type of the base being invited to" },
}, "email": {
"invited_by": { "type": "string",
"type": "string", "description": "The email address of the user who invited to the base"
"description": "The email address of the user who invited the recipient" },
} "display_name": {
}, "type": "string",
"required": [ "description": "The display name of the user who invited to the base"
"id", }
"title", },
"type", "required": [
"invited_by" "id",
] "email"
} ]
},
"required": [
"fk_user_id",
"type",
"body"
]
},
"ProjectEvent": {
"type": "object",
"properties": {
"fk_user_id": {
"type": "string",
"description": "The ID of the user"
},
"type": {
"type": "string"
},
"body": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the base"
},
"title": {
"type": "string",
"description": "The title of the base"
},
"type": {
"type": "string",
"description": "The type of the base"
} }
}, },
"required": [ "required": [
"id", "base",
"title", "user"
"type"
] ]
} }
}, },
@ -25324,191 +25333,6 @@
"body" "body"
] ]
}, },
"SortEvent": {
"type": "object",
"properties": {
"fk_user_id": {
"type": "string",
"description": "The ID of the user who created sort"
},
"type": {
"type": "string"
},
"body": {
"type": "object"
}
},
"required": [
"fk_user_id",
"type",
"body"
]
},
"FilterEvent": {
"type": "object",
"properties": {
"fk_user_id": {
"type": "string",
"description": "The ID of the user who created filter"
},
"type": {
"type": "string"
},
"body": {
"type": "object"
}
},
"required": [
"fk_user_id",
"type",
"body"
]
},
"TableEvent": {
"type": "object",
"properties": {
"fk_user_id": {
"type": "string",
"description": "The ID of the user who triggered the event"
},
"type": {
"type": "string",
"description": "The type of the event"
},
"body": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the table associated with the event"
},
"base_id": {
"type": "string",
"description": "The ID of the base that the table belongs to"
},
"source_id": {
"type": "string",
"description": "The ID of the source that the table belongs to"
},
"id": {
"type": "string",
"description": "The ID of the table associated with the event"
}
},
"required": [
"title",
"base_id",
"source_id",
"id"
]
}
},
"required": [
"fk_user_id",
"type",
"body"
]
},
"ViewEvent": {
"type": "object",
"properties": {
"fk_user_id": {
"type": "string",
"description": "The ID of the user who triggered the event"
},
"type": {
"type": "string",
"description": "The type of the event"
},
"body": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the view associated with the event"
},
"base_id": {
"type": "string",
"description": "The ID of the base that the view belongs to"
},
"source_id": {
"type": "string",
"description": "The ID of the source that the view belongs to"
},
"id": {
"type": "string",
"description": "The ID of the view associated with the event"
},
"fk_model_id": {
"type": "string",
"description": "The ID of the model that the view is based on"
}
},
"required": [
"title",
"base_id",
"source_id",
"id",
"fk_model_id"
]
}
},
"required": [
"fk_user_id",
"type",
"body"
]
},
"ColumnEvent": {
"type": "object",
"properties": {
"fk_user_id": {
"type": "string",
"description": "The ID of the user who triggered the event"
},
"type": {
"type": "string",
"description": "The type of the event"
},
"body": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the column associated with the event"
},
"base_id": {
"type": "string",
"description": "The ID of the base that the column belongs to"
},
"source_id": {
"type": "string",
"description": "The ID of the source that the column belongs to"
},
"id": {
"type": "string",
"description": "The ID of the column associated with the event"
},
"fk_model_id": {
"type": "string",
"description": "The ID of the model that the column belongs to"
}
},
"required": [
"title",
"base_id",
"source_id",
"id",
"fk_model_id"
]
}
},
"required": [
"fk_user_id",
"type",
"body"
]
},
"Notification": { "Notification": {
"allOf": [ "allOf": [
{ {
@ -25541,26 +25365,8 @@
{ {
"$ref": "#/components/schemas/ProjectInviteEvent" "$ref": "#/components/schemas/ProjectInviteEvent"
}, },
{
"$ref": "#/components/schemas/ProjectEvent"
},
{
"$ref": "#/components/schemas/TableEvent"
},
{
"$ref": "#/components/schemas/ViewEvent"
},
{
"$ref": "#/components/schemas/ColumnEvent"
},
{ {
"$ref": "#/components/schemas/WelcomeEvent" "$ref": "#/components/schemas/WelcomeEvent"
},
{
"$ref": "#/components/schemas/SortEvent"
},
{
"$ref": "#/components/schemas/FilterEvent"
} }
] ]
} }

3
packages/nocodb/src/services/app-hooks/interfaces.ts

@ -3,6 +3,7 @@ import type {
ApiTokenReqType, ApiTokenReqType,
BaseType, BaseType,
ColumnType, ColumnType,
CommentType,
FilterType, FilterType,
HookType, HookType,
PluginTestReqType, PluginTestReqType,
@ -33,7 +34,7 @@ export interface RowCommentEvent extends NcBaseEvent {
user: UserType; user: UserType;
model: TableType; model: TableType;
rowId: string; rowId: string;
comment: string; comment: CommentType;
ip?: string; ip?: string;
} }

17
packages/nocodb/src/services/comments.service.ts

@ -35,7 +35,7 @@ export class CommentsService {
base: await Base.getByTitleOrId(model.base_id), base: await Base.getByTitleOrId(model.base_id),
model: model, model: model,
user: param.user, user: param.user,
comment: param.body.comment, comment: res,
rowId: param.body.row_id, rowId: param.body.row_id,
req: param.req, req: param.req,
}); });
@ -43,7 +43,11 @@ export class CommentsService {
return res; return res;
} }
async commentDelete(param: { commentId: string; user: UserType }) { async commentDelete(param: {
commentId: string;
user: UserType;
req: NcRequest;
}) {
const comment = await Comment.get(param.commentId); const comment = await Comment.get(param.commentId);
if (comment.created_by !== param.user.id || comment.is_deleted) { if (comment.created_by !== param.user.id || comment.is_deleted) {
@ -58,9 +62,9 @@ export class CommentsService {
base: await Base.getByTitleOrId(model.base_id), base: await Base.getByTitleOrId(model.base_id),
model: model, model: model,
user: param.user, user: param.user,
comment: comment.comment, comment: comment,
rowId: comment.row_id, rowId: comment.row_id,
req: {}, req: param.req,
}); });
return res; return res;
} }
@ -108,7 +112,10 @@ export class CommentsService {
base: await Base.getByTitleOrId(model.base_id), base: await Base.getByTitleOrId(model.base_id),
model: model, model: model,
user: param.user, user: param.user,
comment: param.body.comment, comment: {
...comment,
comment: param.body.comment,
},
rowId: comment.row_id, rowId: comment.row_id,
req: param.req, req: param.req,
}); });

141
packages/nocodb/src/services/notifications.service.ts

@ -1,141 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AppEvents } from 'nocodb-sdk';
import type {
ProjectInviteEvent,
WelcomeEvent,
} from '~/services/app-hooks/interfaces';
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import type { UserType } from 'nocodb-sdk';
import type { NcRequest } from '~/interface/config';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { NcError } from '~/helpers/catchError';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { Notification } from '~/models';
@Injectable()
export class NotificationsService implements OnModuleInit, OnModuleDestroy {
constructor(protected readonly appHooks: AppHooksService) {}
protected async insertNotification(
insertData: Partial<Notification>,
req: NcRequest,
) {
this.appHooks.emit('notification' as any, { ...insertData, req } as any);
await Notification.insert(insertData);
}
protected async hookHandler({
event,
data,
}: {
event: AppEvents;
data: any;
}) {
const { req } = data;
switch (event) {
case AppEvents.PROJECT_INVITE:
{
const { base, user, invitedBy } = data as ProjectInviteEvent;
await this.insertNotification(
{
fk_user_id: user.id,
type: AppEvents.PROJECT_INVITE,
body: {
id: base.id,
title: base.title,
type: base.type,
invited_by: invitedBy.email,
},
},
req,
);
}
break;
case AppEvents.WELCOME:
{
const { user, req } = data as WelcomeEvent;
await this.insertNotification(
{
fk_user_id: user.id,
type: AppEvents.WELCOME,
body: {},
},
req,
);
}
break;
}
}
// todo: verify if this is the right way to do it, since we are binding this context to the handler
onModuleDestroy() {
this.appHooks.removeAllListener(this.hookHandler);
}
onModuleInit() {
this.appHooks.onAll(this.hookHandler.bind(this));
}
async notificationList(param: {
user: UserType;
limit?: number;
offset?: number;
is_read?: boolean;
is_deleted?: boolean;
}) {
try {
// Define the pagination parameters
const { limit = 10, offset = 0 } = param; // Number of rows to skip before returning results
const list = await Notification.list({
fk_user_id: param.user.id,
limit,
offset,
is_deleted: false,
});
const count = await Notification.count({
fk_user_id: param.user.id,
is_deleted: false,
});
const unreadCount = await Notification.count({
fk_user_id: param.user.id,
is_deleted: false,
is_read: false,
});
return new PagedResponseImpl(
list,
{
limit: param.limit,
offset: param.offset,
count,
},
{ unreadCount },
);
} catch (e) {
console.log(e);
throw e;
}
}
async notificationUpdate(param: { notificationId: string; body; user: any }) {
await Notification.update(param.notificationId, param.body);
return true;
}
async markAllRead(param: { user: any }) {
if (!param.user?.id) {
NcError.badRequest('User id is required');
}
await Notification.markAllAsRead(param.user.id);
return true;
}
}

0
packages/nocodb/src/services/notifications.service.spec.ts → packages/nocodb/src/services/notifications/notifications.service.spec.ts

237
packages/nocodb/src/services/notifications/notifications.service.ts

@ -0,0 +1,237 @@
import { Injectable, Logger } from '@nestjs/common';
import { AppEvents } from 'nocodb-sdk';
import type {
ProjectInviteEvent,
WelcomeEvent,
} from '~/services/app-hooks/interfaces';
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import type { UserType } from 'nocodb-sdk';
import type { NcRequest } from '~/interface/config';
import type { Response } from 'express';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { NcError } from '~/helpers/catchError';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { Notification } from '~/models';
import { getCircularReplacer } from '~/utils';
import { PubSubRedis } from '~/redis/pubsub-redis';
@Injectable()
export class NotificationsService implements OnModuleInit, OnModuleDestroy {
private logger: Logger = new Logger(NotificationsService.name);
constructor(protected readonly appHooks: AppHooksService) {}
connections = new Map<
string,
(Response & {
resId: string;
})[]
>();
addConnection = (userId: string, res: Response & { resId: string }) => {
if (!this.connections.has(userId)) {
this.connections.set(userId, [] as (Response & { resId: string })[]);
}
this.connections.get(userId).push(res);
};
removeConnection = (userId: string, res: Response & { resId: string }) => {
if (!this.connections.has(userId)) {
return;
}
const userConnections = this.connections.get(userId);
const idx = userConnections.findIndex((c) => c.resId === res.resId);
if (idx > -1) {
userConnections.splice(idx, 1);
}
if (userConnections.length === 0) {
this.connections.delete(userId);
} else {
this.connections.set(userId, userConnections);
}
};
sendToConnections(key: string, payload: string): void {
const connections = this.connections.get(String(key));
for (const res of connections ?? []) {
res.send({
status: 'success',
data: payload,
});
}
this.removeConnectionByUserId(key);
}
removeConnectionByUserId(userId: string) {
this.connections.delete(userId);
}
protected async insertNotification(
insertData: Partial<Notification>,
_req: NcRequest,
) {
await Notification.insert(insertData);
if (PubSubRedis.available) {
await PubSubRedis.publish(
`notification:${insertData.fk_user_id}`,
JSON.stringify(insertData, getCircularReplacer()),
);
}
this.sendToConnections(
insertData.fk_user_id,
JSON.stringify(insertData, getCircularReplacer()),
);
}
async notificationList(param: {
user: UserType;
limit?: number;
offset?: number;
is_read?: boolean;
is_deleted?: boolean;
}) {
try {
const { limit = 10, offset = 0, is_read } = param;
const list = await Notification.list({
fk_user_id: param.user.id,
is_read,
limit,
offset,
is_deleted: false,
});
const count = await Notification.count({
fk_user_id: param.user.id,
is_deleted: false,
});
const unreadCount = await Notification.count({
fk_user_id: param.user.id,
is_deleted: false,
is_read: false,
});
return new PagedResponseImpl(
list,
{
limit: param.limit,
offset: param.offset,
count,
},
{ unreadCount },
);
} catch (e) {
this.logger.error(e);
throw e;
}
}
async notificationUpdate(param: {
notificationId: string;
body;
user: UserType;
}) {
const notification = Notification.get({
id: param.notificationId,
fk_user_id: param.user.id,
});
if (!notification) {
NcError.unauthorized('Unauthorized to update notification');
}
await Notification.update(param.notificationId, param.body);
return true;
}
async notificationDelete(param: { notificationId: string; user: UserType }) {
const notification = Notification.get({
id: param.notificationId,
fk_user_id: param.user.id,
});
if (!notification) {
NcError.unauthorized('Unauthorized to delete notification');
}
await Notification.update(param.notificationId, {
is_deleted: true,
});
}
async markAllRead(param: { user: UserType }) {
if (!param.user?.id) {
NcError.badRequest('User id is required');
}
await Notification.markAllAsRead(param.user.id);
return true;
}
protected async hookHandler({
event,
data,
}: {
event: AppEvents;
data: ProjectInviteEvent | WelcomeEvent;
}) {
const { req } = data;
switch (event) {
case AppEvents.PROJECT_INVITE:
{
const { base, user, invitedBy } = data as ProjectInviteEvent;
await this.insertNotification(
{
fk_user_id: user.id,
type: AppEvents.PROJECT_INVITE,
body: {
base: {
id: base.id,
title: base.title,
type: base.type,
},
user: {
id: invitedBy.id,
email: invitedBy.email,
displayName: invitedBy.display_name,
},
},
},
req,
);
}
break;
case AppEvents.WELCOME:
{
const { user, req } = data as WelcomeEvent;
await this.insertNotification(
{
fk_user_id: user.id,
type: AppEvents.WELCOME,
body: {},
},
req,
);
}
break;
}
}
onModuleDestroy() {
this.appHooks.removeAllListener(this.hookHandler);
}
onModuleInit() {
this.appHooks.onAll(this.hookHandler.bind(this));
}
}

12
packages/nocodb/src/utils/circularReplacer.ts

@ -0,0 +1,12 @@
export const getCircularReplacer = () => {
const seen = new WeakSet();
return (_, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return;
}
seen.add(value);
}
return value;
};
};

1
packages/nocodb/src/utils/index.ts

@ -1,5 +1,6 @@
export * from './dataUtils'; export * from './dataUtils';
export * from './sanitiseUserObj'; export * from './sanitiseUserObj';
export * from './emailUtils'; export * from './emailUtils';
export * from './circularReplacer';
export const isEE = false; export const isEE = false;

10
packages/nocodb/src/utils/richTextHelper.ts

@ -6,10 +6,12 @@ export const extractMentions = (richText: string) => {
const regex = /@\(([^)]+)\)/g; const regex = /@\(([^)]+)\)/g;
const match: RegExpExecArray | null = regex.exec(richText); let match: RegExpExecArray | null;
while (match !== null) { while ((match = regex.exec(richText)) !== null) {
const userId = match[1].split('|')[0]; // Extracts the userId part from the matched string const userId = match[1]?.split('|')[0]; // Extracts the userId part from the matched string
mentions.push(userId); if (userId) {
mentions.push(userId);
}
} }
return Array.from(new Set(mentions)); return Array.from(new Set(mentions));

1216
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

1
pnpm-workspace.yaml

@ -1,5 +1,6 @@
packages: packages:
- 'packages/nocodb-sdk' - 'packages/nocodb-sdk'
- 'packages/nc-gui' - 'packages/nc-gui'
- 'packages/nc-mail-templates'
- 'packages/nocodb' - 'packages/nocodb'
- 'tests/playwright' - 'tests/playwright'

6
tests/playwright/setup/index.ts

@ -465,7 +465,11 @@ const setup = async ({
baseUrl = url ? url : `/#/nc/${base.id}`; baseUrl = url ? url : `/#/nc/${base.id}`;
} }
await page.goto(baseUrl, { waitUntil: 'networkidle' }); await page.addInitScript(() => (window.isPlaywright = true));
await page.goto(baseUrl, {
waitUntil: 'networkidle',
});
console.timeEnd('Setup'); console.timeEnd('Setup');

Loading…
Cancel
Save