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 6 months 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": {
"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:backend": "pnpm --filter=nocodb run start",
"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>
<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 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">
{{ name ? name : user?.email }}
</div>
<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 class="flex items-center pr-2 justify-between">
<NcDropdown v-model:visible="isMenuOpen" placement="topLeft" overlay-class-name="!min-w-64">
<div
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" />
<NcTooltip>
<div class="flex max-w-32 truncate">
{{ name ? name : user?.email }}
</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>
<div class="bg-white max-h-50vh scrollbar-thin-dull min-w-64 !overflow-auto">
<LazyGeneralLanguageMenu />
</div>
</template>
</a-popover>
</template>
<template #title>
<span>
{{ name ? name : user?.email }}
</span>
</template>
</NcTooltip>
<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 />
<a
v-e="['c:nocodb:forum-open']"
href="https://community.nocodb.com"
v-e="['c:nocodb:discord']"
href="https://discord.gg/5RgZmkW"
target="_blank"
class="!underline-transparent"
rel="noopener"
rel="noopener noreferrer"
>
<NcMenuItem>
<GeneralIcon icon="ncHelp" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.forum') }} </span>
<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:docs-open']"
href="https://docs.nocodb.com"
v-e="['c:nocodb:reddit']"
href="https://www.reddit.com/r/NocoDB"
target="_blank"
class="!underline-transparent"
rel="noopener"
rel="noopener noreferrer"
>
<NcMenuItem>
<GeneralIcon icon="file" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.docs') }} </span>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="ncReddit" />
<span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </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>
<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>
<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>
<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 { isDataSourceLimitReached } = storeToRefs(useBases())
const dataSourcesReload = ref(false)
const tabsInfo: TabGroup = {

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

@ -12,8 +12,6 @@ const props = defineProps<{
}>()
const emit = defineEmits(['update:modelValue'])
const { baseRoles, workspaceRoles } = useRoles()
const basesStore = useBases()
const workspaceStore = useWorkspace()
@ -28,10 +26,6 @@ const orderedRoles = computed(() => {
return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles
})
const userRoles = computed(() => {
return props.type === 'base' ? baseRoles.value : workspaceRoles.value
})
const inviteData = reactive({
email: '',
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 { notifications, isRead, pageInfo } = storeToRefs(notificationStore)
const { isMobileMode } = useGlobal()
/*
const groupType = computed({
get() {
return isRead.value ? 'read' : 'unread'
},
set(value) {
isRead.value = value === 'read'
notificationStore.loadNotifications()
},
})
*/
const container = ref()
const { height } = useElementSize(container)
const { loadUnReadNotifications, loadReadNotifications, markAllAsRead } = notificationStore
const { unreadNotifications, readNotifications, readPageInfo, unreadPageInfo, notificationTab } = storeToRefs(notificationStore)
</script>
<template>
<div class="min-w-[350px] max-w-[350px] min-h-[400px] !rounded-2xl bg-white rounded-xl nc-card">
<div class="p-3" @click.stop>
<div class="flex items-center">
<span class="text-md font-medium text-[#212121]">
{{ $t('general.notification') }}
</span>
<div class="flex-grow"></div>
<div
v-if="!isRead && notifications?.length"
class="cursor-pointer text-xs text-gray-500 hover:text-primary"
@click.stop="notificationStore.markAllAsRead"
>
{{ $t('activity.markAllAsRead') }}
</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>
<div
ref="container"
style="box-shadow: 0px -12px 16px -4px rgba(0, 0, 0, 0.1), 0px -4px 6px -2px rgba(0, 0, 0, 0.06)"
:style="!isMobileMode ? 'width: min(80svw, 520px);' : ''"
:class="{
'max-h-[70vh] h-[620px]': !isMobileMode,
'h-[100svh] w-[100svw]': isMobileMode,
}"
class="!rounded-lg pt-4"
>
<div class="space-y-3">
<div class="flex px-6 justify-between items-center">
<span class="text-md font-bold text-gray-800" @click.stop> {{ $t('general.notification') }}s </span>
<InfiniteLoading
v-if="notifications && pageInfo && pageInfo.totalRows > notifications.length"
@infinite="notificationStore.loadNotifications(true)"
>
<template #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<a-spin />
</div>
<NcButton v-if="isMobileMode" size="small" type="secondary">
<GeneralIcon icon="close" class="text-gray-700" />
</NcButton>
</div>
<div
v-if="notificationTab !== 'read'"
:class="{
'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 #complete>
<span></span>
<div
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>
</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>
</template>
<style scoped>
.nc-card {
border: solid 1px #e1e3e6;
:deep(.ant-tabs-nav-wrap) {
@apply px-3;
}
:deep(.ant-tabs-nav-wrap) {
@apply px-6;
:deep(.ant-tabs-tab) {
@apply pb-1.5 pt-1;
}
:deep(.ant-tabs-nav) {

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

@ -1,42 +1,24 @@
<script setup lang="ts">
import { AppEvents } from 'nocodb-sdk'
import type { NotificationType } from 'nocodb-sdk'
const props = defineProps<{
item: any
item: NotificationType
}>()
const item = toRef(props, 'item')
const notificationStore = useNotification()
const { markAsRead } = notificationStore
const { toggleRead } = notificationStore
</script>
<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" />
<NotificationItemProjectInvite v-else-if="item.type === AppEvents.PROJECT_INVITE" :item="item" />
<NotificationItemWorkspaceInvite v-else-if="item.type === AppEvents.WORKSPACE_INVITE" :item="item" />
<NotificationItemProjectEvent
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"
/>
<NotificationItemMentionEvent v-else-if="['mention'].includes(item.type)" :item="item" />
<span v-else />
</div>
</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">
import type { ProjectInviteEventType } from 'nocodb-sdk'
const props = defineProps<{
item: any
item: ProjectInviteEventType
}>()
const { navigateToProject } = useGlobal()
@ -9,10 +11,11 @@ const item = toRef(props, 'item')
</script>
<template>
<NotificationItemWrapper :item="item" @click="navigateToProject({ baseId: item.body.id })">
<div class="text-xs">
<strong>{{ item.body.invited_by }}</strong> has invited you to collaborate on
<!-- <GeneralProjectIcon style="vertical-align: middle" :type="item.body.type" /> <strong>{{ item.body.title }}</strong> base. -->
<NotificationItemWrapper :item="item" @click="navigateToProject({ baseId: item.body.base.id })">
<div>
<span class="font-semibold">{{ item.body.user.display_name ?? item.body.user.email }}</span> has invited you to collaborate
on
<span class="font-semibold">{{ item.body.base.title }}</span> base.
</div>
</NotificationItemWrapper>
</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">
import type { WelcomeEventType } from 'nocodb-sdk'
const props = defineProps<{
item: any
item: WelcomeEventType
}>()
const router = useRouter()
const route = router.currentRoute
const item = toRef(props, 'item')
const navigateToHome = () => {
if (route.value.path !== '/') {
navigateTo(`/`)
}
}
</script>
<template>
<NotificationItemWrapper :item="item" @click="navigateToHome">
<template #avatar>
<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 :item="item">
<div>Welcome to <span class="font-semibold">NocoDB!</span> Were excited to have you onboard.</div>
</NotificationItemWrapper>
</template>

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

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

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

@ -1,38 +1,24 @@
<script lang="ts" setup>
const notificationStore = useNotification()
const { loadNotifications, markAsOpened } = notificationStore
onMounted(async () => {
await loadNotifications()
})
const onOpen = (visible: boolean) => {
if (visible) {
markAsOpened()
}
}
const { unreadCount } = toRefs(notificationStore)
</script>
<template>
<div class="cursor-pointer flex items-center">
<a-dropdown :trigger="['click']" @visible-change="onOpen">
<div class="relative leading-none">
<NcDropdown overlay-class-name="!shadow-none" placement="bottomRight" :trigger="['click']">
<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="menuDown" />
<span v-if="!notificationStore.isOpened && notificationStore.unreadCount" class="nc-count-badge">{{
notificationStore.unreadCount
}}</span>
</div>
</NcButton>
<template #overlay>
<NotificationCard />
</template>
</a-dropdown>
</NcDropdown>
</div>
</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<{
[key: number]: boolean
[key: string]: boolean
}>({})
const toggleSelectAll = (value: boolean) => {
filteredCollaborators.value.forEach((_, i) => {
filteredCollaborators.value.forEach((_) => {
selected[_.id] = value
})
}

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

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

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

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

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

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

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

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

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

@ -9,6 +9,7 @@ const {
loadComments,
deleteComment,
comments,
resolveComment,
audits,
isAuditLoading,
saveComment: _saveComment,
@ -32,6 +33,8 @@ const tab = ref<'comments' | 'audits'>('comments')
const { isUIAllowed } = useRoles()
const router = useRouter()
const hasEditPermission = computed(() => isUIAllowed('commentEdit'))
const editComment = ref<CommentType>()
@ -120,11 +123,28 @@ const saveComment = async () => {
isCommentMode.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 {
await _saveComment()
await nextTick(() => {
commentInputRef?.value?.setEditorContent('', true)
isExpandedFormCommentMode.value = true
})
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, () => {
setTimeout(() => {
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)
})
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>
<template>
@ -171,108 +232,112 @@ watch(commentsWrapperEl, () => {
<div class="font-medium text-center my-6 text-gray-500">{{ $t('activity.startCommenting') }}</div>
</div>
<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
: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
:email="comment.created_by_email"
:name="comment.created_display_name"
class="mt-0.5"
size="medium"
/>
<div class="flex-1 flex flex-col gap-1 max-w-[calc(100%_-_24px)]">
<div class="w-full flex justify-between gap-3 min-h-7">
<div class="flex items-center max-w-[calc(100%_-_40px)]">
<div class="w-full flex flex-wrap gap-3 items-center">
<NcTooltip
placement="bottomLeft"
:arrow-point-at-center="false"
class="truncate capitalize text-gray-800 font-weight-700 !text-[13px] max-w-42"
>
<template #title>
{{
(comment.created_by === user?.id
? comment.created_display_name?.trim() || comment.created_by_email
: comment.created_display_name?.trim()
? 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 class="flex items-start justify-between">
<div class="flex items-start gap-3">
<GeneralUserIcon
:email="comment.created_by_email"
:name="comment.created_display_name"
class="mt-0.5"
size="medium"
/>
<div class="flex h-[28px] items-center gap-3">
<NcTooltip class="truncate capitalize text-gray-800 font-weight-700 !text-[13px] max-w-42">
<template #title>
{{ comment.created_display_name?.trim() || comment.created_by_email || 'Shared source' }}
</template>
<span class="text-ellipsis capitalize overflow-hidden" :style="{}">
{{ createdBy(comment) }}
</span>
</NcTooltip>
<div class="text-xs text-gray-500">
{{ timesAgo(comment) }}
</div>
</div>
<div class="flex items-center opacity-0 transition-all group-hover:opacity-100 ease-out duration-400 gap-2">
<NcDropdown
v-if="comment.created_by_email === user!.email && !editComment"
overlay-class-name="!min-w-[160px]"
placement="bottomRight"
>
<NcButton class="nc-expand-form-more-actions !w-7 !h-7 !bg-transparent" size="xsmall" type="text">
<GeneralIcon class="text-md !hover:text-brand-500" icon="threeDotVertical" />
</div>
<div class="flex items-center gap-2">
<NcDropdown
v-if="(comment.created_by_email === user!.email && !editComment )"
:class="{
'opacity-0 group-hover:opacity-100': comment.created_by_email === user!.email && !editComment,
}"
overlay-class-name="!min-w-[160px]"
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>
<template #overlay>
<NcMenu>
<NcMenuItem
v-e="['c:row-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>
<NcDivider />
<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>
<template #title>Click to resolve </template>
</NcTooltip>
<NcTooltip v-else>
<template #title>{{ `Resolved by ${comment.resolved_display_name}` }}</template>
<div class="flex text-[#17803D] font-semibold items-center">
<NcButton class="!h-7 !bg-transparent" size="xsmall" type="text" @click="resolveComment(comment.id)">
<div class="flex items-center gap-2 !text-[#17803D]">
<span> Resolved </span>
<component :is="iconMap.checkCircle" />
</div>
</NcButton>
</div>
</NcTooltip>
</div>
</div>
</div>
<div class="flex-1 flex flex-col gap-1 mt-1 max-w-[calc(100%)]">
<SmartsheetExpandedFormRichComment
v-if="comment.id === editComment?.id"
ref="editRef"
v-model:value="value"
autofocus
: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"
sync-value-change
@save="onEditComment"
@ -286,7 +351,7 @@ watch(commentsWrapperEl, () => {
@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
:value="comment.comment"
class="!text-small !leading-18px !text-gray-800 -ml-1"
@ -362,14 +427,7 @@ watch(commentsWrapperEl, () => {
<template #title>
{{ audit.display_name?.trim() || audit.user || 'Shared source' }}
</template>
<span
class="text-ellipsis overflow-hidden font-bold text-gray-800"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
<span class="text-ellipsis break-keep inline whitespace-nowrap overflow-hidden font-bold text-gray-800">
{{ audit.display_name?.trim() || audit.user || 'Shared source' }}
</span>
</NcTooltip>
@ -460,4 +518,8 @@ watch(commentsWrapperEl, () => {
@apply !text-gray-400;
}
}
:deep(.expanded-form-comment-edit-input .nc-comment-rich-editor) {
@apply !pl-2 bg-white;
}
</style>

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

@ -243,7 +243,11 @@ defineExpose({
<EditorContent
ref="editorDom"
: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"
/>

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

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

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

@ -14,6 +14,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
Array<
CommentType & {
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[])
})
const loadComments = async () => {
if (!isUIAllowed('commentList') || !row.value) return
const loadComments = async (_rowId?: string) => {
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
@ -125,13 +126,21 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
comments.value = res.map((comment) => {
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 {
...comment,
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) {
message.error(e.message)
} catch (e: unknown) {
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
} finally {
isCommentsLoading.value = false
}
@ -139,19 +148,37 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const deleteComment = async (commentId: string) => {
if (!isUIAllowed('commentDelete')) return
const tempC = comments.value.find((c) => c.id === commentId)
try {
comments.value = comments.value.filter((c) => c.id !== commentId)
await $api.utils.commentDelete(commentId)
await loadComments()
} catch (e: any) {
message.error(e.message)
// update comment count in rowMeta
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 () => {
if (!isUIAllowed('auditList') || !row.value) return
const loadAudits = async (_rowId?: string) => {
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
@ -165,7 +192,13 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
})
).list?.reverse?.() || []
} catch (e: any) {
message.error(e.message)
message.error(
await extractSdkResponseErrorMsg(
e as Error & {
response: any
},
),
)
} finally {
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 () => {
try {
if (!row.value || !comment.value) return
@ -196,13 +264,28 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
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()])
comment.value = ''
} 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')
@ -244,7 +327,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
Object.assign(row.value, {
row: data,
rowMeta: {},
rowMeta: {
...row.value.rowMeta,
new: false,
},
oldRow: { ...data },
})
@ -406,7 +492,9 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
Object.assign(row.value, {
row: record,
oldRow: { ...record },
rowMeta: {},
rowMeta: {
...row.value.rowMeta,
},
})
} catch (e: any) {
message.error(`${t('msg.error.errorLoadingRecord')}`)
@ -449,6 +537,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
comments,
audits,
isAuditLoading,
resolveComment,
isCommentsLoading,
saveComment,
comment,

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

@ -1,74 +1,121 @@
import { defineStore } from 'pinia'
import type { NotificationType } from 'nocodb-sdk'
import type { Socket } from 'socket.io-client'
import io from 'socket.io-client'
export const useNotification = defineStore('notificationStore', () => {
const notifications = ref<NotificationType[]>([])
const isOpened = ref(false)
const readNotifications = ref<NotificationType[]>([])
const unreadNotifications = ref<NotificationType[]>([])
const readPageInfo = ref()
const unreadPageInfo = ref()
const pageInfo = ref()
const unreadCount = ref(0)
const isRead = ref(false)
const notificationTab = ref<'read' | 'unread'>('unread')
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) => {
const url = new URL(appInfo.value.ncSiteUrl, window.location.href.split(/[?#]/)[0]).href
unreadCount.value = unreadCount.value + 1
}
socket = io(`${url}${url.endsWith('/') ? '' : '/'}notifications`, {
extraHeaders: { 'xc-auth': token },
})
await pollNotifications()
} catch (e) {
// If network error, retry after 2 seconds
setTimeout(pollNotifications, 2000)
}
}
socket.on('notification', (data) => {
notifications.value = [data, ...notifications.value]
pageInfo.value.totalRows += 1
unreadCount.value += 1
isOpened.value = false
})
const loadReadNotifications = async (loadMore?: boolean) => {
try {
const response = await api.notification.list({
is_read: true,
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(
() => token.value,
(newToken, oldToken) => {
if (newToken && newToken !== oldToken) init(newToken)
else if (!newToken) socket?.disconnect()
},
{ immediate: true },
)
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]
const insertAndSort = (notification: NotificationType, oldState?: boolean) => {
if (oldState) {
readNotifications.value = readNotifications.value.filter((n) => n.id !== notification.id)
unreadNotifications.value = [notification, ...unreadNotifications.value].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
unreadCount.value = unreadCount.value + 1
} 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 = (response as any).unreadCount
unreadCount.value = unreadCount.value - 1
}
}
const markAsRead = async (notification: NotificationType) => {
if (notification.is_read) return
const toggleRead = async (notification: NotificationType, ignoreTrigger?: boolean) => {
if (ignoreTrigger) return
const currState = notification.is_read
await api.notification.update(notification.id!, {
is_read: true,
})
try {
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) => {
@ -76,23 +123,64 @@ export const useNotification = defineStore('notificationStore', () => {
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 () => {
isOpened.value = true
watch(notificationTab, async (tab) => {
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 {
notifications,
loadNotifications,
unreadNotifications,
readNotifications,
loadUnReadNotifications,
loadReadNotifications,
deleteNotification,
readPageInfo,
unreadPageInfo,
isLoading,
isRead,
pageInfo,
markAsRead,
notificationTab,
toggleRead,
markAllAsRead,
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
}
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 there is no timezone info, consider as UTC
// e.g. 2023-01-01 08:00:00 (MySQL)
@ -34,6 +34,7 @@ export const timeAgo = (date: any) => {
const years = Math.floor(days / 365)
if (seconds < 60) {
if (seconds < 0) return '1s ago'
return `${seconds}s ago`
}
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 NcIconsRowHeightExtraTall from '~icons/nc-icons/row-height-extra-tall'
import NcArticle from '~icons/nc-icons/article'
import NcNotification from '~icons/material-symbols/notifications-outline'
import NcCommentHere from '~icons/nc-icons/comment-here'
import NcAddDataSource from '~icons/nc-icons/add-data-source'
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 NcArrowUpRight from '~icons/nc-icons/arrow-up-right'
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 NcPhoneCall from '~icons/nc-icons/phone-call'
import NcItalic from '~icons/nc-icons/italic'
@ -396,6 +398,7 @@ export const iconMap = {
project: Project,
search: NcSearch,
calendar: Calendar,
checkCircle: NcCheckCircle,
error: h('span', { class: 'material-symbols' }, 'error'),
info: h(MsInfo, {}, () => 'info'),
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;
/** Foreign Key to View */
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 */
id?: IdType;
/** Is this filter grouped? */
@ -1637,6 +1641,8 @@ export interface LicenseReqType {
* Model for LinkToAnotherColumn Request
*/
export interface LinkToAnotherColumnReqType {
/** Foreign Key to child view */
childViewId?: IdOrNullType;
/** Foreign Key to chhild column */
childId: IdType;
/** Foreign Key to parent column */
@ -1661,6 +1667,7 @@ export interface LinkToAnotherRecordType {
/** Foreign Key to Column */
fk_column_id?: IdType;
fk_index_name?: string;
fk_relation_view_id?: string;
fk_mm_child_column_id?: string;
fk_mm_model_id?: string;
fk_mm_parent_column_id?: string;
@ -2458,6 +2465,11 @@ export type StringOrNullOrBooleanOrNumberType =
| boolean
| number;
/**
* Model for IdOrNull
*/
export type IdOrNullType = IdType | null;
/**
* Model for Table
*/
@ -2769,27 +2781,22 @@ export interface ProjectInviteEventType {
type: string;
body: {
/** The ID of the base being invited to */
id: string;
/** The title of the base being invited to */
title: string;
/** The type of the base being invited to */
type: string;
/** The email address of the user who invited the recipient */
invited_by: string;
};
}
export interface ProjectEventType {
/** The ID of the user */
fk_user_id: string;
type: string;
body: {
/** The ID of the base */
id: string;
/** The title of the base */
title: string;
/** The type of the base */
type: string;
base: {
/** The ID of the base being invited to */
id: string;
/** The title of the base being invited to */
title: string;
/** The type of the base being invited to */
type: string;
};
user: {
/** The ID of the user who invited to the base */
id: string;
/** The email address of the user who invited to the base */
email: string;
/** The display name of the user who invited to the base */
display_name?: string;
};
};
}
@ -2802,75 +2809,6 @@ export interface WelcomeEventType {
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 = {
/** Unique ID */
id?: IdType;
@ -2882,16 +2820,7 @@ export type NotificationType = {
type?: string;
updated_at?: any;
created_at?: any;
} & (
| ProjectInviteEventType
| ProjectEventType
| TableEventType
| ViewEventType
| ColumnEventType
| WelcomeEventType
| SortEventType
| FilterEventType
);
} & (ProjectInviteEventType | WelcomeEventType);
/**
* Model for Notification List
@ -11195,6 +11124,23 @@ export class Api<
}),
};
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
*

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

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

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

@ -8,22 +8,71 @@ import {
Patch,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { NotificationsService } from '~/services/notifications.service';
import { customAlphabet } from 'nanoid';
import type { Response } from 'express';
import { NotificationsService } from '~/services/notifications/notifications.service';
import { GlobalGuard } from '~/guards/global/global.guard';
import { extractProps } from '~/helpers/extractProps';
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()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class NotificationsController {
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')
// @Acl('notificationList')
async notificationList(@Req() req: Request) {
async notificationList(@Req() req: NcRequest) {
return this.notificationsService.notificationList({
user: req.user,
is_deleted: false,
@ -33,11 +82,10 @@ export class NotificationsController {
}
@Patch('/api/v1/notifications/:notificationId')
// @Acl('notificationUpdate')
async notificationUpdate(
@Param('notificationId') notificationId,
@Body() body,
@Req() req: Request,
@Req() req: NcRequest,
) {
return this.notificationsService.notificationUpdate({
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')
// @Acl('notificationDelete')
async notificationDelete(
@Param('notificationId') notificationId,
@Req() req: Request,
@Req() req: NcRequest,
) {
return this.notificationsService.notificationUpdate({
return this.notificationsService.notificationDelete({
notificationId,
body: {
is_deleted: true,
},
user: req.user,
});
}
@Post('/api/v1/notifications/mark-all-read')
@HttpCode(200)
async markAllRead(@Req() req: NcRequest) {
return this.notificationsService.markAllRead({
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;
headers?: Record<string, string | undefined> | IncomingHttpHeaders;
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_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_049_clear_notifications from '~/meta/migrations/v2/nc_049_clear_notifications';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -81,6 +82,7 @@ export default class XcMigrationSourcev2 {
'nc_046_comment_mentions',
'nc_047_comment_migration',
'nc_048_view_links',
'nc_049_clear_notifications',
]);
}
@ -164,6 +166,8 @@ export default class XcMigrationSourcev2 {
return nc_047_comment_migration;
case '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>,
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(comment, ['comment', 'resolved_by']);
const updateObj = extractProps(comment, [
'comment',
'resolved_by',
'resolved_by_email',
]);
await ncMeta.metaUpdate(
null,

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

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

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

@ -1,46 +1,25 @@
import { Logger } from '@nestjs/common';
import Redis from 'ioredis';
import type { InstanceCommands } from '~/interface/Jobs';
import { PubSubRedis } from '~/redis/pubsub-redis';
import { InstanceTypes } from '~/interface/Jobs';
export class JobsRedis {
private static initialized = false;
public static available = process.env.NC_REDIS_JOB_URL ? true : false;
export class JobsRedis extends PubSubRedis {
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: {
[key: string]: (...args) => Promise<void>;
} = {};
public static workerCallbacks: { [key: string]: (...args) => Promise<void> } =
{};
static async init() {
if (this.initialized) {
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);
static async initJobs() {
if (!this.initialized) {
if (!this.available) {
return;
}
if (process.env.NC_WORKER_CONTAINER === 'true') {
await this.redisSubscriber.subscribe(InstanceTypes.WORKER);
} else {
await this.redisSubscriber.subscribe(InstanceTypes.PRIMARY);
await this.init();
}
const onMessage = async (channel, message) => {
const args = message.split(':');
const command = args.shift();
@ -52,80 +31,25 @@ export class JobsRedis {
(await this.primaryCallbacks[command](...args));
}
};
this.redisSubscriber.on('message', onMessage);
}
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);
if (process.env.NC_WORKER_CONTAINER === 'true') {
await this.subscribe(InstanceTypes.WORKER, async (message) => {
await onMessage(InstanceTypes.WORKER, message);
});
} else {
try {
await this.redisClient.publish(channel, JSON.stringify(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];
await this.subscribe(InstanceTypes.PRIMARY, async (message) => {
await onMessage(InstanceTypes.PRIMARY, message);
});
}
}
static async workerCount(): Promise<number> {
if (!this.initialized) {
if (!JobsRedis.available) {
if (!this.available) {
return;
}
await this.init();
await this.initJobs();
}
return new Promise((resolve) => {
@ -146,11 +70,11 @@ export class JobsRedis {
static async emitWorkerCommand(command: InstanceCommands, ...args: any[]) {
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[]) {
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 { BaseUsersController } from '~/controllers/base-users.controller';
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 { NotificationsGateway } from '~/gateways/notifications/notifications.gateway';
import { CommandPaletteService } from '~/services/command-palette.service';
import { CommandPaletteController } from '~/controllers/command-palette.controller';
import { ExtensionsService } from '~/services/extensions.service';
@ -245,7 +244,6 @@ export const nocoModuleMetadata = {
SortsService,
SharedBasesService,
NotificationsService,
NotificationsGateway,
CommandPaletteService,
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": {
"get": {
"summary": "Notification list",
@ -25232,67 +25254,54 @@
"body": {
"type": "object",
"properties": {
"id": {
"type": "string",
"base": {
"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"
},
"title": {
"type": "string",
"description": "The title of the base being invited to"
},
"type": {
"type": "string",
"description": "The type of the base being invited to"
},
"invited_by": {
"type": "string",
"description": "The email address of the user who invited the recipient"
}
},
"required": [
"id",
"title",
"type",
"invited_by"
]
}
},
"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"
"user": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the user who invited to the base"
},
"email": {
"type": "string",
"description": "The email address of the user who invited to the base"
},
"display_name": {
"type": "string",
"description": "The display name of the user who invited to the base"
}
},
"required": [
"id",
"email"
]
}
},
"required": [
"id",
"title",
"type"
"base",
"user"
]
}
},
@ -25324,191 +25333,6 @@
"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": {
"allOf": [
{
@ -25541,26 +25365,8 @@
{
"$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/SortEvent"
},
{
"$ref": "#/components/schemas/FilterEvent"
}
]
}

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

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

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

@ -35,7 +35,7 @@ export class CommentsService {
base: await Base.getByTitleOrId(model.base_id),
model: model,
user: param.user,
comment: param.body.comment,
comment: res,
rowId: param.body.row_id,
req: param.req,
});
@ -43,7 +43,11 @@ export class CommentsService {
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);
if (comment.created_by !== param.user.id || comment.is_deleted) {
@ -58,9 +62,9 @@ export class CommentsService {
base: await Base.getByTitleOrId(model.base_id),
model: model,
user: param.user,
comment: comment.comment,
comment: comment,
rowId: comment.row_id,
req: {},
req: param.req,
});
return res;
}
@ -108,7 +112,10 @@ export class CommentsService {
base: await Base.getByTitleOrId(model.base_id),
model: model,
user: param.user,
comment: param.body.comment,
comment: {
...comment,
comment: param.body.comment,
},
rowId: comment.row_id,
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 './sanitiseUserObj';
export * from './emailUtils';
export * from './circularReplacer';
export const isEE = false;

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

@ -6,10 +6,12 @@ export const extractMentions = (richText: string) => {
const regex = /@\(([^)]+)\)/g;
const match: RegExpExecArray | null = regex.exec(richText);
while (match !== null) {
const userId = match[1].split('|')[0]; // Extracts the userId part from the matched string
mentions.push(userId);
let match: RegExpExecArray | null;
while ((match = regex.exec(richText)) !== null) {
const userId = match[1]?.split('|')[0]; // Extracts the userId part from the matched string
if (userId) {
mentions.push(userId);
}
}
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/nocodb-sdk'
- 'packages/nc-gui'
- 'packages/nc-mail-templates'
- 'packages/nocodb'
- 'tests/playwright'

6
tests/playwright/setup/index.ts

@ -465,7 +465,11 @@ const setup = async ({
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');

Loading…
Cancel
Save