Browse Source

feat: richtext Comments (#8564)

* feat: richtext Comments

* fix: minor corrections

* fix: minor corrections

* fix: minor corrections

* fix: update carat color

* fix: improved comment upgrader

* fix: coderabbit comment fixes

* fix: update some behaviours

* fix: build

* fix: test flaky

* fix: test flaky

* fix: test failing

* fix: remove tasks list. fix: update enter handling using lists

* fix: update icons

* fix: update dependencies
fix: mysql index name long
fix: generate API.ts
pull/8576/head
Anbarasu 5 months ago committed by GitHub
parent
commit
5b968efa2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      packages/nc-gui/assets/nc-icons/at-sign.svg
  2. 7
      packages/nc-gui/assets/nc-icons/strike-through.svg
  3. 5
      packages/nc-gui/components/nc/Dropdown.vue
  4. 28
      packages/nc-gui/components/smartsheet/Form.vue
  5. 295
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  6. 380
      packages/nc-gui/components/smartsheet/expanded-form/RichComment.vue
  7. 215
      packages/nc-gui/components/smartsheet/expanded-form/RichTextOptions.vue
  8. 19
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  9. 145
      packages/nc-gui/composables/useExpandedFormStore.ts
  10. 1
      packages/nc-gui/lang/en.json
  11. 16
      packages/nc-gui/package.json
  12. 26
      packages/nc-gui/utils/datetimeUtils.ts
  13. 6
      packages/nc-gui/utils/iconUtils.ts
  14. 252
      packages/nocodb-sdk/src/lib/Api.ts
  15. 4
      packages/nocodb-sdk/src/lib/enums.ts
  16. 56
      packages/nocodb/src/controllers/audits.controller.ts
  17. 90
      packages/nocodb/src/controllers/comments.controller.ts
  18. 4
      packages/nocodb/src/filters/global-exception/global-exception.filter.ts
  19. 9
      packages/nocodb/src/meta/meta.service.ts
  20. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  21. 142
      packages/nocodb/src/meta/migrations/v2/nc_046_comment_mentions.ts
  22. 30
      packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts
  23. 42
      packages/nocodb/src/models/Audit.ts
  24. 154
      packages/nocodb/src/models/Comment.ts
  25. 5
      packages/nocodb/src/models/Model.ts
  26. 3
      packages/nocodb/src/models/Notification.ts
  27. 1
      packages/nocodb/src/models/index.ts
  28. 4
      packages/nocodb/src/modules/noco.module.ts
  29. 256
      packages/nocodb/src/schema/swagger-v2.json
  30. 369
      packages/nocodb/src/schema/swagger.json
  31. 41
      packages/nocodb/src/services/app-hooks/app-hooks.service.ts
  32. 18
      packages/nocodb/src/services/app-hooks/interfaces.ts
  33. 52
      packages/nocodb/src/services/audits.service.ts
  34. 118
      packages/nocodb/src/services/comments.service.ts
  35. 2
      packages/nocodb/src/utils/acl.ts
  36. 3
      packages/nocodb/src/utils/globals.ts
  37. 17
      packages/nocodb/src/utils/richTextHelper.ts
  38. 274
      pnpm-lock.yaml
  39. 4
      tests/playwright/pages/Dashboard/Details/FieldsPage.ts
  40. 5
      tests/playwright/pages/Dashboard/common/Toolbar/index.ts
  41. 3
      tests/playwright/tests/db/views/viewCalendar.spec.ts

11
packages/nc-gui/assets/nc-icons/at-sign.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="at-sign" clip-path="url(#clip0_439_24587)">
<path id="Vector" d="M7.99992 10.6666C9.47268 10.6666 10.6666 9.47268 10.6666 7.99992C10.6666 6.52716 9.47268 5.33325 7.99992 5.33325C6.52716 5.33325 5.33325 6.52716 5.33325 7.99992C5.33325 9.47268 6.52716 10.6666 7.99992 10.6666Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M10.6666 5.33333V8.66666C10.6666 9.19709 10.8773 9.7058 11.2524 10.0809C11.6275 10.4559 12.1362 10.6667 12.6666 10.6667C13.197 10.6667 13.7057 10.4559 14.0808 10.0809C14.4559 9.7058 14.6666 9.19709 14.6666 8.66666V7.99999C14.6665 6.49535 14.1574 5.03498 13.2221 3.85635C12.2868 2.67772 10.9803 1.85014 9.51502 1.50819C8.04974 1.16624 6.51188 1.33002 5.15149 1.9729C3.7911 2.61579 2.68819 3.69996 2.0221 5.04914C1.356 6.39832 1.1659 7.93315 1.4827 9.40407C1.7995 10.875 2.60458 12.1955 3.76701 13.1508C4.92945 14.1062 6.38088 14.6402 7.8853 14.6661C9.38973 14.692 10.8587 14.2082 12.0533 13.2933" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_439_24587">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

7
packages/nc-gui/assets/nc-icons/strike-through.svg

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="strike-through">
<path id="Vector" d="M8.78581 12.8144L8.7858 12.8144L8.78075 12.8161C8.42892 12.9374 8.04691 13 7.63125 13C7.0617 13 6.56387 12.8877 6.12788 12.6731C5.69479 12.4578 5.33776 12.1536 5.05203 11.7548C4.85919 11.4783 4.71002 11.1643 4.60653 10.8088C4.60578 10.8063 4.60572 10.805 4.60571 10.8049L4.60571 10.8049L4.60573 10.8045C4.60577 10.8043 4.60639 10.801 4.61027 10.7953C4.61881 10.7828 4.63803 10.7668 4.66787 10.7613C4.69066 10.7571 4.71986 10.7599 4.75705 10.7832C4.79698 10.8084 4.83722 10.8531 4.86164 10.9117C5.05932 11.386 5.35201 11.7865 5.74021 12.1031L5.74019 12.1031L5.74442 12.1065C6.30071 12.5503 6.95605 12.7692 7.6875 12.7692C8.15265 12.7692 8.58829 12.6851 8.98574 12.507L8.98575 12.507L8.98917 12.5054C9.39374 12.3204 9.72685 12.0507 9.97355 11.6945C10.2302 11.3238 10.35 10.8938 10.35 10.4266C10.35 10.2805 10.3386 10.1376 10.313 10H10.4865C10.4944 10.0483 10.5 10.1354 10.5 10.3537C10.5 10.8007 10.4227 11.1786 10.2807 11.4978L10.2807 11.4978L10.2789 11.5019C10.1366 11.8294 9.94116 12.0969 9.69311 12.3122C9.437 12.5295 9.13644 12.6977 8.78581 12.8144Z" fill="#374151" stroke="currentColor"/>
<path id="Vector_2" d="M7.07937 7.54091L6.69045 7.41689C6.69002 7.41675 6.68958 7.41661 6.68914 7.41648C6.4336 7.33643 6.17378 7.23601 5.9098 7.11611L5.89921 7.11129L5.89927 7.11117C5.59699 6.96529 5.33529 6.76737 5.12024 6.51703L5.11627 6.51241L5.1163 6.51238C4.85636 6.2022 4.74739 5.81798 4.74739 5.40605C4.74739 4.97764 4.87095 4.58359 5.12364 4.24341C5.36495 3.91369 5.68549 3.66379 6.06999 3.4901C6.45874 3.31449 6.88407 3.23467 7.33682 3.24033L7.07937 7.54091ZM7.07937 7.54091L6.14982 7.55395C5.94013 7.48004 5.73664 7.39061 5.53917 7.28553C5.3363 7.17295 5.15642 7.03547 4.99793 6.87257C4.85081 6.71669 4.72946 6.52733 4.63646 6.29832C4.55046 6.08189 4.5 5.80784 4.5 5.46475C4.5 4.91851 4.62632 4.48585 4.85438 4.14174C5.09772 3.77635 5.42558 3.49592 5.84993 3.29831C6.27823 3.09886 6.77169 2.99574 7.34021 3.00013L7.34026 3.00014C7.91665 3.00453 8.41327 3.11833 8.84083 3.32988L8.84082 3.32991L8.84588 3.33235C9.27908 3.54065 9.63452 3.83839 9.91773 4.23096L9.91772 4.23096L9.91958 4.23351C10.1115 4.49596 10.2638 4.79674 10.3746 5.13963C10.3771 5.14708 10.3767 5.15078 10.3764 5.15294C10.3759 5.15595 10.3743 5.1621 10.3688 5.17053C10.3569 5.18863 10.3317 5.20933 10.2942 5.21656C10.2642 5.22235 10.2252 5.21858 10.1748 5.18558C10.1209 5.15027 10.0671 5.08814 10.0343 5.00929C9.94734 4.80085 9.83757 4.60662 9.70464 4.42792C9.43271 4.05163 9.08781 3.75898 8.67415 3.55645C8.26193 3.34973 7.81366 3.2461 7.33709 3.24033L7.07937 7.54091Z" fill="#374151" stroke="currentColor"/>
<path id="Vector_3" d="M2.66675 8H13.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

5
packages/nc-gui/components/nc/Dropdown.vue

@ -4,11 +4,13 @@ const props = withDefaults(
trigger?: Array<'click' | 'hover' | 'contextmenu'>
visible?: boolean | undefined
overlayClassName?: string | undefined
placement?: 'bottom' | 'top' | 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight' | 'topCenter' | 'bottomCenter'
autoClose?: boolean
}>(),
{
trigger: () => ['click'],
visible: undefined,
placement: 'bottom',
overlayClassName: undefined,
autoClose: true,
},
@ -20,6 +22,8 @@ const trigger = toRef(props, 'trigger')
const overlayClassName = toRef(props, 'overlayClassName')
const placement = toRef(props, 'placement')
const autoClose = computed(() => props.autoClose)
const overlayClassNameComputed = computed(() => {
@ -58,6 +62,7 @@ const onVisibleUpdate = (event: any) => {
<template>
<a-dropdown
:visible="visible"
:placement="placement"
:trigger="trigger"
:overlay-class-name="overlayClassNameComputed"
@update:visible="onVisibleUpdate"

28
packages/nc-gui/components/smartsheet/Form.vue

@ -1529,14 +1529,12 @@ useEventListener(
class="nc-form-hide-branding"
data-testid="nc-form-hide-branding"
:disabled="isLocked || !isEditable"
@change="
(value) => {
if (isLocked || !isEditable) return
(formViewData!.meta as Record<string,any>).hide_branding = value
updateView()
}
"
@change="(value) => {
if (isLocked || !isEditable) return
(formViewData!.meta as Record<string,any>).hide_branding = value
updateView()
}"
/>
<NcTooltip v-else placement="top">
@ -1558,14 +1556,12 @@ useEventListener(
class="nc-form-hide-banner"
data-testid="nc-form-hide-banner"
:disabled="isLocked || !isEditable"
@change="
(value) => {
if (isLocked || !isEditable) return
(formViewData!.meta as Record<string,any>).hide_banner = value
updateView()
}
"
@change="(value) => {
if (isLocked || !isEditable) return
(formViewData!.meta as Record<string,any>).hide_banner = value
updateView()
}"
/>
</div>
</div>

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

@ -1,18 +1,30 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import type { AuditType } from 'nocodb-sdk'
import { timeAgo } from 'nocodb-sdk'
import type { CommentType } from 'nocodb-sdk'
const props = defineProps<{
loading: boolean
}>()
const { loadCommentsAndLogs, commentsAndLogs, saveComment: _saveComment, comment, updateComment } = useExpandedFormStoreOrThrow()
const {
loadComments,
deleteComment,
comments,
audits,
isCommentsLoading,
isAuditLoading,
saveComment: _saveComment,
comment: newComment,
updateComment,
} = useExpandedFormStoreOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const commentsWrapperEl = ref<HTMLDivElement>()
const commentInputRef = ref<any>()
const editRef = ref<any>()
const { user, appInfo } = useGlobal()
const isExpandedFormLoading = computed(() => props.loading)
@ -23,25 +35,12 @@ const { isUIAllowed } = useRoles()
const hasEditPermission = computed(() => isUIAllowed('commentEdit'))
const editLog = ref<AuditType>()
const editComment = ref<CommentType>()
const isEditing = ref<boolean>(false)
const isCommentMode = ref(false)
const focusCommentInput: VNodeRef = (el) => {
if (!isExpandedFormLoading.value && (isCommentMode.value || isExpandedFormCommentMode.value) && !isEditing.value) {
if (isExpandedFormCommentMode.value) {
setTimeout(() => {
isExpandedFormCommentMode.value = false
}, 400)
}
return (el as HTMLInputElement)?.focus()
}
return el
}
const focusInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onKeyEsc(event)
@ -63,26 +62,26 @@ function onKeyEsc(event: KeyboardEvent) {
}
async function onEditComment() {
if (!isEditing.value || !editLog.value) return
if (!isEditing.value || !editComment.value) return
isCommentMode.value = true
await updateComment(editLog.value.id!, {
description: editLog.value.description,
await updateComment(editComment.value.id!, {
comment: editComment.value?.comment,
})
onStopEdit()
}
function onCancel() {
if (!isEditing.value) return
editLog.value = undefined
editComment.value = undefined
onStopEdit()
}
function onStopEdit() {
loadCommentsAndLogs()
loadComments()
isEditing.value = false
editLog.value = undefined
editComment.value = undefined
}
onKeyStroke('Enter', (event) => {
@ -91,26 +90,28 @@ onKeyStroke('Enter', (event) => {
}
})
const comments = computed(() => commentsAndLogs.value.filter((log) => log.op_type === 'COMMENT'))
const audits = computed(() => commentsAndLogs.value.filter((log) => log.op_type !== 'COMMENT' && log.details))
function editComment(log: AuditType) {
editLog.value = log
function editComments(comment: CommentType) {
editComment.value = comment
isEditing.value = true
}
const value = computed({
get() {
return editLog.value?.description?.substring(editLog.value?.description?.indexOf(':') + 1) ?? ''
return editComment.value?.comment || ''
},
set(val) {
if (!editLog.value) return
editLog.value.description = val
if (!editComment.value) return
editComment.value.comment = val
},
})
function scrollComments() {
if (commentsWrapperEl.value) commentsWrapperEl.value.scrollTop = commentsWrapperEl.value?.scrollHeight
if (commentsWrapperEl.value) {
commentsWrapperEl.value.scrollTo({
top: commentsWrapperEl.value.scrollHeight,
behavior: 'smooth',
})
}
}
const isSaving = ref(false)
@ -123,7 +124,10 @@ const saveComment = async () => {
try {
await _saveComment()
await nextTick(() => {
commentInputRef?.value?.setEditorContent('', true)
isExpandedFormCommentMode.value = true
})
scrollComments()
} catch (e) {
console.error(e)
@ -133,7 +137,11 @@ const saveComment = async () => {
}
watch(commentsWrapperEl, () => {
scrollComments()
setTimeout(() => {
nextTick(() => {
scrollComments()
})
}, 100)
})
</script>
@ -164,117 +172,139 @@ 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="log of comments" :key="log.id">
<div class="group gap-3 overflow-hidden hover:bg-gray-200 flex items-start px-3 pt-3 pb-4">
<GeneralUserIcon size="medium" :name="log.display_name" :email="log.user" class="mt-0.7" />
<div class="flex-1 flex flex-col gap-0.5 max-w-[calc(100%_-_24px)]">
<div v-for="comment of comments" :key="comment.id" class="nc-comment-item">
<div
:class="{
'hover:bg-gray-50 bg-white': comment.id !== editComment?.id,
}"
class="group gap-3 overflow-hidden flex items-start px-3 py-1"
>
<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 items-center">
<NcTooltip class="truncate max-w-42 mr-2" show-on-truncate-only>
<div class="w-full flex flex-wrap gap-3 items-center">
<NcTooltip
class="truncate capitalize text-gray-800 font-weight-700 !text-[13px] max-w-42"
show-on-truncate-only
>
<template #title>
{{ log.display_name?.trim() || log.user || 'Shared source' }}
{{ comment.created_display_name?.trim() || comment.created_by_email || 'Shared source' }}
</template>
<span
class="text-ellipsis overflow-hidden text-gray-500 font-weight-500 text-small"
class="text-ellipsis capitalize overflow-hidden"
:style="{
lineHeight: '18px',
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ log.display_name?.trim() || log.user || 'Shared source' }}
{{
comment.created_by === user?.id
? 'You'
: comment.created_display_name?.trim() || comment.created_by_email || 'Shared source'
}}
</span>
</NcTooltip>
<div v-if="log.id !== editLog?.id" class="text-xs text-gray-400">
<div class="text-xs text-gray-500">
{{
log.created_at !== log.updated_at ? `Edited ${timeAgo(log.updated_at)}` : timeAgo(log.created_at)
comment.created_at !== comment.updated_at
? `Edited ${timeAgo(comment.updated_at)}`
: timeAgo(comment.created_at)
}}
</div>
</div>
</div>
<NcDropdown
v-if="log.user === user!.email && !editLog"
placement="bottomRight"
overlay-class-name="!min-w-[160px]"
>
<NcButton
type="text"
size="xsmall"
class="nc-expand-form-more-actions !w-7 !h-7 !hover:(bg-transparent text-brand-500)"
<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"
>
<GeneralIcon icon="threeDotVertical" class="text-md invisible group-hover:visible" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem v-e="['c:row-expand:comment:edit']" class="text-gray-700" @click="editComment(log)">
<div class="flex gap-2 items-center">
<component :is="iconMap.rename" class="cursor-pointer" />
{{ $t('general.edit') }}
</div>
</NcMenuItem>
<!-- eslint-disable vue/no-constant-condition -->
<template v-if="false">
<NcDivider />
<NcMenuItem v-e="['c:row-expand:comment:delete']" class="!text-red-500 !hover:bg-red-50">
<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: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>
<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">
<GeneralIcon icon="delete" />
<component :is="iconMap.delete" class="cursor-pointer" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
</NcMenu>
</template>
</NcDropdown>
</div>
</div>
<a-textarea
v-if="log.id === editLog?.id"
:ref="focusInput"
<SmartsheetExpandedFormRichComment
v-if="comment.id === editComment?.id"
ref="editRef"
v-model:value="value"
class="!p-1.5 !m-0 w-full !rounded-md !text-gray-800 !text-small !leading-18px !min-h-[70px] nc-scrollbar-thin"
@keydown.stop="onKeyDown($event)"
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]"
data-testid="expanded-form-comment-input"
sync-value-change
@save="onEditComment"
@keydown.stop="onKeyDown"
@blur="
() => {
editComment = undefined
isEditing = false
}
"
@keydown.enter.exact.prevent="onEditComment"
/>
<div v-else class="nc-comment-description text-small leading-18px text-gray-800">
<pre>{{ log.description.substring(log.description.indexOf(':') + 1).trim() }}</pre>
</div>
<div v-if="log.id === editLog?.id" class="flex justify-end gap-1 mt-1">
<NcButton size="small" type="secondary" @click="onCancel"> Cancel </NcButton>
<NcButton v-e="['a:row-expand:comment:save']" size="small" @click="onEditComment"> Save </NcButton>
<div v-else class="text-small leading-18px text-gray-800">
<SmartsheetExpandedFormRichComment
:value="comment.comment"
class="!text-small !leading-18px !text-gray-800 -ml-1"
read-only
sync-value-change
/>
</div>
</div>
</div>
</div>
</div>
<div v-if="hasEditPermission" class="p-3 gap-2 flex">
<div class="flex flex-row w-full items-end gap-2">
<div class="expanded-form-comment-input-wrapper">
<a-textarea
:ref="focusCommentInput"
v-model:value="comment"
class="expanded-form-comment-input !py-1.5 !px-3 !m-0 w-full !border-1 !border-gray-200 !rounded-lg !bg-transparent !text-gray-800 !text-small !leading-18px !max-h-[694px] nc-scrollbar-thin"
auto-size
hide-details
:disabled="isSaving"
placeholder="Comment..."
data-testid="expanded-form-comment-input"
@keydown.stop
@keydown.enter.exact.prevent="saveComment"
/>
</div>
<NcButton
v-e="['a:row-expand:comment:save']"
size="small"
:loading="isSaving"
:disabled="!isSaving && !comment.length"
:icon-only="isSaving"
class="!disabled:bg-gray-100 !shadow-none"
@click="saveComment"
>
<GeneralIcon v-if="!isSaving" icon="send" />
</NcButton>
</div>
<div v-if="hasEditPermission" class="bg-gray-50 nc-comment-input !rounded-br-2xl gap-2 flex">
<SmartsheetExpandedFormRichComment
ref="commentInputRef"
v-model:value="newComment"
:hide-options="false"
placeholder="Comment..."
class="expanded-form-comment-input !m-0 pt-2 w-full !border-t-1 !border-gray-200 !bg-transparent !text-gray-800 !text-small !leading-18px !max-h-[566px]"
:autofocus="isExpandedFormCommentMode"
data-testid="expanded-form-comment-input"
@focus="isExpandedFormCommentMode = false"
@keydown.stop
@save="saveComment"
@keydown.enter.exact.prevent="saveComment"
/>
</div>
</div>
</div>
@ -304,7 +334,7 @@ watch(commentsWrapperEl, () => {
'pb-1': !appInfo.ee,
}"
>
<div v-if="isExpandedFormLoading" class="flex flex-col h-full">
<div v-if="isExpandedFormLoading || isAuditLoading" class="flex flex-col h-full">
<GeneralLoader class="!mt-16" size="xlarge" />
</div>
@ -318,14 +348,14 @@ watch(commentsWrapperEl, () => {
</div>
</template>
<div v-for="log of audits" :key="log.id" class="nc-audit-item">
<div v-for="audit of audits" :key="audit.id" class="nc-audit-item">
<div class="group gap-3 overflow-hidden flex items-start p-3">
<GeneralUserIcon size="medium" :email="log.user" :name="log.display_name" />
<GeneralUserIcon size="medium" :email="audit.user" :name="audit.display_name" />
<div class="flex-1 flex flex-col gap-1 max-w-[calc(100%_-_24px)]">
<div class="flex flex-wrap items-center min-h-7">
<NcTooltip class="truncate max-w-42 mr-2" show-on-truncate-only>
<template #title>
{{ log.display_name?.trim() || log.user || 'Shared source' }}
{{ audit.display_name?.trim() || audit.user || 'Shared source' }}
</template>
<span
class="text-ellipsis overflow-hidden font-bold text-gray-800"
@ -335,14 +365,14 @@ watch(commentsWrapperEl, () => {
display: 'inline',
}"
>
{{ log.display_name?.trim() || log.user || 'Shared source' }}
{{ audit.display_name?.trim() || audit.user || 'Shared source' }}
</span>
</NcTooltip>
<div v-if="log.id !== editLog?.id" class="text-xs text-gray-400">
{{ timeAgo(log.created_at) }}
<div class="text-xs text-gray-400">
{{ timeAgo(audit.created_at) }}
</div>
</div>
<div v-dompurify-html="log.details" class="text-sm font-medium"></div>
<div v-dompurify-html="audit.details" class="text-sm font-medium"></div>
</div>
</div>
</div>
@ -358,6 +388,12 @@ watch(commentsWrapperEl, () => {
@apply max-w-1/2;
}
.nc-comment-input {
:deep(.nc-comment-rich-editor) {
@apply !ml-1;
}
}
.nc-audit-item {
@apply border-b-1 gap-3 border-gray-200;
}
@ -386,6 +422,7 @@ watch(commentsWrapperEl, () => {
}
:deep(.ant-tabs) {
@apply !overflow-visible;
.ant-tabs-nav {
@apply px-3;
.ant-tabs-nav-list {
@ -407,21 +444,11 @@ watch(commentsWrapperEl, () => {
}
}
.nc-comment-description {
pre {
@apply !mb-0 py-[1px] !text-small !text-gray-700 !leading-18px;
white-space: break-spaces;
font-size: unset;
font-family: unset;
}
}
.expanded-form-comment-input-wrapper {
@apply flex-1 bg-white rounded-lg relative;
}
:deep(.expanded-form-comment-input) {
@apply transition-all duration-150;
@apply transition-all duration-150 min-h-8;
box-shadow: none;
&:focus {
&:focus,
&:focus-within {
@apply min-h-16;
}
&::placeholder {

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

@ -0,0 +1,380 @@
<script lang="ts" setup>
import StarterKit from '@tiptap/starter-kit'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import TurndownService from 'turndown'
import { marked } from 'marked'
import { generateJSON } from '@tiptap/html'
import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import { Link } from '~/helpers/dbTiptapExtensions/links'
const props = withDefaults(
defineProps<{
hideOptions?: boolean
value?: string | null
readOnly?: boolean
syncValueChange?: boolean
autofocus?: boolean
placeholder?: string
renderAsText?: boolean
}>(),
{
hideOptions: true,
},
)
const emits = defineEmits(['update:value', 'focus', 'blur', 'save'])
const isGrid = inject(IsGridInj, ref(false))
const isFocused = ref(false)
const keys = useMagicKeys()
const turndownService = new TurndownService({})
turndownService.addRule('lineBreak', {
filter: (node) => {
return node.nodeName === 'BR'
},
replacement: () => {
return '<br />'
},
})
turndownService.addRule('strikethrough', {
filter: ['s'],
replacement: (content) => {
return `~${content}~`
},
})
turndownService.keep(['u', 'del'])
const editorDom = ref<HTMLElement | null>(null)
const richTextLinkOptionRef = ref<HTMLElement | null>(null)
const vModel = useVModel(props, 'value', emits, { defaultValue: '' })
const tiptapExtensions = [
StarterKit.configure({
heading: false,
}),
Underline,
Link,
Placeholder.configure({
emptyEditorClass: 'is-editor-empty',
placeholder: props.placeholder,
}),
]
const editor = useEditor({
extensions: tiptapExtensions,
onUpdate: ({ editor }) => {
const markdown = turndownService
.turndown(editor.getHTML().replaceAll(/<p><\/p>/g, '<br />'))
.replaceAll(/\n\n<br \/>\n\n/g, '<br>\n\n')
vModel.value = markdown === '<br />' ? '' : markdown
},
editable: !props.readOnly,
autofocus: props.autofocus,
onFocus: () => {
isFocused.value = true
emits('focus')
},
onBlur: (e) => {
if (
!(e?.event?.relatedTarget as HTMLElement)?.closest('.comment-bubble-menu, .nc-comment-rich-editor, .nc-rich-text-comment')
) {
isFocused.value = false
emits('blur')
}
},
})
const setEditorContent = (contentMd: any, focusEndOfDoc?: boolean) => {
if (!editor.value) return
const selection = editor.value.view.state.selection
const contentHtml = contentMd ? marked.parse(contentMd) : '<p></p>'
const content = generateJSON(contentHtml, tiptapExtensions)
editor.value.chain().setContent(content).setTextSelection(selection.to).run()
setTimeout(() => {
if (focusEndOfDoc) {
const docSize = editor.value!.state.doc.nodeSize
editor.value
?.chain()
.setTextSelection(docSize - 1)
.run()
}
;(editor.value!.state as any).history$.prevRanges = null
;(editor.value!.state as any).history$.done.eventCount = 0
}, 100)
}
const onFocusWrapper = () => {
if (!props.readOnly && !keys.shift.value) {
editor.value?.chain().focus().run()
}
}
if (props.syncValueChange) {
watch([vModel, editor], () => {
setEditorContent(vModel.value)
})
}
useEventListener(
editorDom,
'focusout',
(e: FocusEvent) => {
const targetEl = e?.relatedTarget as HTMLElement
if (
targetEl?.classList?.contains('tiptap') ||
!targetEl?.closest('.comment-bubble-menu, .tippy-content, .nc-comment-rich-editor')
) {
isFocused.value = false
emits('blur')
}
},
true,
)
useEventListener(
richTextLinkOptionRef,
'focusout',
(e: FocusEvent) => {
const targetEl = e?.relatedTarget as HTMLElement
if (!targetEl && (e.target as HTMLElement)?.closest('.comment-bubble-menu, .tippy-content, .nc-comment-rich-editor')) return
if (!targetEl?.closest('.comment-bubble-menu, .tippy-content, .nc-comment-rich-editor')) {
isFocused.value = false
emits('blur')
}
},
true,
)
onClickOutside(editorDom, (e) => {
if (!isFocused.value) return
const targetEl = e?.target as HTMLElement
if (!targetEl?.closest('.tippy-content, .comment-bubble-menu, .nc-comment-rich-editor')) {
isFocused.value = false
emits('blur')
}
})
const triggerSaveFromList = ref(false)
const emitSave = (event: KeyboardEvent) => {
if (editor.value) {
if (triggerSaveFromList.value) {
// If Enter was pressed in the list, do not emit save
triggerSaveFromList.value = false
} else {
if (editor.value.isActive('bulletList') || editor.value.isActive('orderedList')) {
event.stopPropagation()
} else {
emits('save')
}
}
}
}
const handleEnterDown = (event: KeyboardEvent) => {
const isListsActive = editor.value?.isActive('bulletList') || editor.value?.isActive('orderedList')
if (isListsActive) {
triggerSaveFromList.value = true
setTimeout(() => {
triggerSaveFromList.value = false
}, 1000)
} else {
emitSave(event)
}
}
const handleKeyPress = (event: KeyboardEvent) => {
if (event.altKey && event.key === 'Enter') {
event.stopPropagation()
} else if (event.shiftKey && event.key === 'Enter') {
event.stopPropagation()
} else if (event.key === 'Enter') {
handleEnterDown(event)
} else if (event.key === 'Escape') {
isFocused.value = false
emits('blur')
}
}
defineExpose({
setEditorContent,
})
</script>
<template>
<div
:class="{
'readonly': readOnly,
'nc-rich-text-grid': isGrid,
}"
:tabindex="1"
class="nc-rich-text-comment flex flex-col w-full h-full"
@focus="onFocusWrapper"
>
<div v-if="renderAsText" class="truncate">
<span v-if="editor"> {{ editor?.getText() ?? '' }}</span>
</div>
<template v-else>
<CellRichTextLinkOptions
v-if="editor"
ref="richTextLinkOptionRef"
:editor="editor"
:is-form-field="true"
@blur="isFocused = false"
/>
<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"
@keydown.stop="handleKeyPress"
/>
<div v-if="!hideOptions" class="flex justify-between px-2 py-2 items-center">
<LazySmartsheetExpandedFormRichTextOptions :editor="editor" class="!bg-transparent" />
<NcButton
v-e="['a:row-expand:comment:save']"
:disabled="!vModel?.length"
class="!disabled:bg-gray-100 !h-7 !w-7 !shadow-none"
size="xsmall"
@click="emits('save')"
>
<GeneralIcon icon="send" />
</NcButton>
</div>
</template>
</div>
</template>
<style lang="scss">
.nc-rich-text-comment {
.readonly {
.nc-comment-rich-editor {
.ProseMirror {
resize: none;
white-space: pre-line;
}
}
}
.nc-comment-rich-editor {
&.nc-truncate {
.tiptap.ProseMirror {
display: -webkit-box;
max-width: 100%;
outline: none;
-webkit-box-orient: vertical;
word-break: break-word;
}
&.nc-line-clamp-1 .tiptap.ProseMirror {
-webkit-line-clamp: 1;
}
&.nc-line-clamp-2 .tiptap.ProseMirror {
-webkit-line-clamp: 2;
}
&.nc-line-clamp-3 .tiptap.ProseMirror {
-webkit-line-clamp: 3;
}
&.nc-line-clamp-4 .tiptap.ProseMirror {
-webkit-line-clamp: 4;
}
}
.tiptap p.is-editor-empty:first-child::before {
color: #9aa2af;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.ProseMirror {
@apply flex-grow !border-0 rounded-lg;
caret-color: #3366ff;
}
p {
@apply !m-0;
}
.ProseMirror-focused {
// remove all border
outline: none;
}
ul {
li {
@apply ml-4;
list-style-type: disc;
}
}
ol {
@apply !pl-4;
li {
list-style-type: decimal;
}
}
ul,
ol {
@apply !my-0;
}
// Pre tag is the parent wrapper for Code block
pre {
border-color: #d0d5dd;
border: 1px;
color: black;
font-family: 'JetBrainsMono', monospace;
padding: 1rem;
border-radius: 0.5rem;
@apply overflow-auto mt-3 bg-gray-100;
code {
@apply !px-0;
}
}
code {
@apply rounded-md px-2 py-1 bg-gray-100;
color: inherit;
font-size: 0.8rem;
}
blockquote {
border-left: 3px solid #d0d5dd;
padding: 0 1em;
color: #666;
margin: 1em 0;
font-style: italic;
}
hr {
@apply !border-gray-300;
border: 0;
border-top: 1px solid #ccc;
margin: 1.5em 0;
}
pre {
height: fit-content;
}
}
}
</style>

215
packages/nc-gui/components/smartsheet/expanded-form/RichTextOptions.vue

@ -0,0 +1,215 @@
<script lang="ts" setup>
import type { Editor } from '@tiptap/vue-3'
import MdiFormatStrikeThrough from '~icons/mdi/format-strikethrough'
interface Props {
editor: Editor | undefined
}
const props = withDefaults(defineProps<Props>(), {})
const { appInfo } = useGlobal()
const { editor } = toRefs(props)
const cmdOrCtrlKey = computed(() => {
return isMac() ? '⌘' : 'CTRL'
})
const shiftKey = computed(() => {
return isMac() ? '⇧' : 'Shift'
})
const tabIndex = computed(() => {
return -1
})
const onToggleLink = () => {
if (!editor.value) return
const activeNode = editor.value?.state?.selection?.$from?.nodeBefore || editor.value?.state?.selection?.$from?.nodeAfter
const isLinkMarkedStoredInEditor = editor.value?.state?.storedMarks?.some((mark: any) => mark.type.name === 'link')
const isActiveNodeMarkActive = activeNode?.marks?.some((mark: any) => mark.type.name === 'link') || isLinkMarkedStoredInEditor
if (isActiveNodeMarkActive) {
editor.value.chain().focus().unsetLink().run()
} else {
if (editor.value?.state.selection.empty) {
editor
.value!.chain()
.focus()
.insertContent(' ')
.setTextSelection({ from: editor.value?.state.selection.$from.pos, to: editor.value.state.selection.$from.pos + 1 })
.toggleLink({
href: '',
})
.setTextSelection({ from: editor.value?.state.selection.$from.pos, to: editor.value.state.selection.$from.pos + 1 })
.deleteSelection()
.run()
} else {
editor
.value!.chain()
.focus()
.setLink({
href: '',
})
.selectTextblockEnd()
.run()
}
setTimeout(() => {
const linkInput = document.querySelector('.nc-text-area-rich-link-option-input')
if (linkInput) {
;(linkInput as any).focus()
}
}, 100)
}
}
const newMentionNode = () => {
editor.value?.commands.insertContent('@')
editor.value?.chain().focus().run()
}
</script>
<template>
<div class="comment-bubble-menu bg-transparent flex-row rounded-lg flex">
<NcTooltip>
<template #title>
<div class="flex flex-col items-center">
<div>
{{ $t('labels.bold') }}
</div>
<div class="text-xs">{{ cmdOrCtrlKey }} B</div>
</div>
</template>
<NcButton
:class="{ 'is-active': editor?.isActive('bold') }"
:tabindex="tabIndex"
class="!h-7 !w-7 !hover:bg-gray-200"
size="xsmall"
type="text"
@click="editor?.chain().focus().toggleBold().run()"
>
<GeneralIcon icon="bold" />
</NcButton>
</NcTooltip>
<NcTooltip :disabled="editor?.isActive('italic')">
<template #title>
<div class="flex flex-col items-center">
<div>
{{ $t('labels.italic') }}
</div>
<div>{{ cmdOrCtrlKey }} I</div>
</div>
</template>
<NcButton
:class="{ 'is-active': editor?.isActive('italic') }"
:tabindex="tabIndex"
class="!h-7 !w-7 !hover:bg-gray-200"
size="xsmall"
type="text"
@click=";(editor?.chain().focus() as any).toggleItalic().run()"
>
<GeneralIcon icon="italic" />
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title>
<div class="flex flex-col items-center">
<div>
{{ $t('labels.underline') }}
</div>
<div>{{ cmdOrCtrlKey }} U</div>
</div>
</template>
<NcButton
:class="{ 'is-active': editor?.isActive('underline') }"
:tabindex="tabIndex"
class="!h-7 !w-7 !hover:bg-gray-200"
size="xsmall"
type="text"
@click="editor?.chain().focus().toggleUnderline().run()"
>
<GeneralIcon icon="underline" />
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title>
<div class="flex flex-col items-center">
<div>
{{ $t('labels.strike') }}
</div>
<div>{{ shiftKey }} {{ cmdOrCtrlKey }} S</div>
</div>
</template>
<NcButton
:class="{ 'is-active': editor?.isActive('strike') }"
:tabindex="tabIndex"
class="!h-7 !w-7 !hover:bg-gray-200"
size="xsmall"
type="text"
@click="editor?.chain().focus().toggleStrike().run()"
>
<GeneralIcon icon="strike" />
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title> {{ $t('general.link') }}</template>
<NcButton
:class="{ 'is-active': editor?.isActive('link') }"
:tabindex="tabIndex"
class="!h-7 !w-7 !hover:bg-gray-200"
size="xsmall"
type="text"
@click="onToggleLink"
>
<GeneralIcon icon="link2"></GeneralIcon>
</NcButton>
</NcTooltip>
<NcTooltip v-if="appInfo.ee">
<template #title>
<div class="flex flex-col items-center">
<div>
{{ $t('labels.mention') }}
</div>
<div>@</div>
</div>
</template>
<NcButton
:class="{ 'is-active': editor?.isActive('suggestions') }"
:tabindex="tabIndex"
class="!h-7 !w-7 !hover:bg-gray-200"
size="xsmall"
type="text"
@click="newMentionNode"
>
<GeneralIcon icon="atSign" />
</NcButton>
</NcTooltip>
</div>
</template>
<style lang="scss" scoped>
.comment-bubble-menu {
@apply !border-none;
.nc-button.is-active {
@apply text-brand-500;
outline: 1px;
}
.ant-select-selector {
@apply !rounded-md;
}
.ant-select-selector .ant-select-selection-item {
@apply !text-xs;
}
.ant-btn-loading-icon {
@apply pb-0.5;
}
}
</style>

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

@ -129,7 +129,8 @@ const {
saveRowAndStay,
row: _row,
save: _save,
loadCommentsAndLogs,
loadComments,
loadAudits,
clearColumns,
} = useProvideExpandedFormStore(meta, row)
@ -320,13 +321,13 @@ onMounted(async () => {
if (props.loadRow) {
await _loadRow()
await loadCommentsAndLogs()
await Promise.all([loadComments(), loadAudits()])
}
if (props.rowId) {
try {
await _loadRow(props.rowId)
await loadCommentsAndLogs()
await Promise.all([loadComments(), loadAudits()])
} catch (e: any) {
if (e.response?.status === 404) {
message.error(t('msg.noRecordFound'))
@ -458,7 +459,7 @@ const onConfirmDeleteRowClick = async () => {
watch(rowId, async (nRow) => {
await _loadRow(nRow)
await loadCommentsAndLogs()
await Promise.all([loadComments(), loadAudits()])
})
const showRightSections = computed(() => {
@ -617,7 +618,7 @@ export default {
{{ props.newRecordHeader ?? $t('activity.newRecord') }}
</div>
<div
v-else-if="displayValue && !row.rowMeta?.new"
v-else-if="displayValue && !row?.rowMeta?.new"
class="flex items-center font-bold text-gray-800 text-base max-w-[300px] xs:(w-auto max-w-[calc(100%_-_82px)]) overflow-hidden"
>
<span class="truncate">
@ -720,7 +721,7 @@ export default {
</NcButton>
</div>
</div>
<div ref="wrapper" class="flex flex-grow flex-row h-[calc(100%-4rem)] w-full overflow-hidden border-t-1 border-gray-200">
<div ref="wrapper" class="flex flex-grow flex-row h-[calc(100%-4rem)] w-full border-t-1 border-gray-200">
<div
:class="{
'w-full': !showRightSections,
@ -937,7 +938,7 @@ export default {
<div
v-if="showRightSections"
:class="{ active: commentsDrawer && isUIAllowed('commentList') }"
class="nc-comments-drawer border-l-1 relative border-gray-200 bg-gray-50 w-1/3 max-w-[340px] min-w-0 overflow-hidden h-full xs:hidden"
class="nc-comments-drawer border-l-1 relative border-gray-200 bg-gray-50 w-1/3 max-w-[340px] min-w-0 h-full xs:hidden rounded-br-2xl"
>
<SmartsheetExpandedFormComments :loading="isLoading" />
</div>
@ -981,10 +982,6 @@ export default {
.nc-drawer-expanded-form {
@apply xs:my-0;
.ant-modal-content {
@apply overflow-hidden;
}
.ant-drawer-content-wrapper {
@apply !h-[90vh];
.ant-drawer-content {

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

@ -1,4 +1,4 @@
import type { AuditType, ColumnType, TableType } from 'nocodb-sdk'
import type { AuditType, ColumnType, CommentType, TableType } from 'nocodb-sdk'
import { UITypes, ViewTypes, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import dayjs from 'dayjs'
@ -6,15 +6,23 @@ import dayjs from 'dayjs'
const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((meta: Ref<TableType>, _row: Ref<Row>) => {
const { $e, $state, $api } = useNuxtApp()
const { api, isLoading: isCommentsLoading, error: commentsError } = useApi()
const { t } = useI18n()
const isPublic = inject(IsPublicInj, ref(false))
const commentsOnly = ref(false)
const comments = ref<
Array<
CommentType & {
created_display_name: string
}
>
>([])
const audits = ref<Array<AuditType>>([])
const commentsAndLogs = ref<any[]>([])
const isCommentsLoading = ref(false)
const isAuditLoading = ref(false)
const comment = ref('')
@ -24,8 +32,14 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const changedColumns = ref(new Set<string>())
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const { base } = storeToRefs(useBase())
const baseUsers = computed(() => (meta.value.base_id ? basesUser.value.get(meta.value.base_id) || [] : []))
const { sharedView } = useSharedView()
const row = ref<Row>(
@ -87,24 +101,73 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
return extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
})
// actions
const loadCommentsAndLogs = async () => {
if (!isUIAllowed('commentList')) return
if (!row.value) return
const loadComments = async () => {
if (!isUIAllowed('commentList') || !row.value) return
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!rowId) return
commentsAndLogs.value =
(
await api.utils.commentList({
try {
isCommentsLoading.value = true
const res = ((
await $api.utils.commentList({
row_id: rowId,
fk_model_id: meta.value.id as string,
comments_only: commentsOnly.value,
})
).list?.reverse?.() || []
).list || []) as Array<
CommentType & {
created_display_name: string
}
>
comments.value = res.map((comment) => {
const user = baseUsers.value.find((u) => u.id === comment.created_by)
return {
...comment,
created_display_name: user?.display_name ?? (user?.email ?? '').split('@')[0],
}
})
} catch (e: any) {
message.error(e.message)
} finally {
isCommentsLoading.value = false
}
}
const deleteComment = async (commentId: string) => {
if (!isUIAllowed('commentDelete')) return
try {
await $api.utils.commentDelete(commentId)
await loadComments()
} catch (e: any) {
message.error(e.message)
}
}
const loadAudits = async () => {
if (!isUIAllowed('auditList') || !row.value) return
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!rowId) return
try {
isAuditLoading.value = true
audits.value =
(
await $api.utils.auditList({
row_id: rowId,
fk_model_id: meta.value.id as string,
})
).list?.reverse?.() || []
} catch (e: any) {
message.error(e.message)
} finally {
isAuditLoading.value = false
}
}
const isYou = (email: string) => {
@ -126,15 +189,15 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
if (!rowId) return
await api.utils.commentRow({
await $api.utils.commentRow({
fk_model_id: meta.value?.id as string,
row_id: rowId,
description: `The following comment has been created: ${comment.value}`,
comment: `${comment.value}`,
})
reloadTrigger?.trigger()
await loadCommentsAndLogs()
await Promise.all([loadComments(), loadAudits()])
comment.value = ''
} catch (e: any) {
@ -154,6 +217,8 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
kanbanClbk?: (row: Row, isNewRow: boolean) => void
} = {},
) => {
if (!meta.value.id) return
let data
const isNewRow = row.value.rowMeta?.new ?? false
@ -222,6 +287,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
obj[col] = row.value.row[col]
return obj
}, {} as Record<string, any>)
if (Object.keys(updateOrInsertObj).length) {
const id = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
@ -240,7 +306,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
addUndo({
redo: {
fn: async (id: string, data: Record<string, any>) => {
await $api.dbTableRow.update(NOCO, base.value.id as string, meta.value.id, encodeURIComponent(id), data)
await $api.dbTableRow.update(NOCO, base.value.id as string, meta.value.id!, encodeURIComponent(id), data)
await loadKanbanData()
reloadTrigger?.trigger()
@ -249,7 +315,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
},
undo: {
fn: async (id: string, data: Record<string, any>) => {
await $api.dbTableRow.update(NOCO, base.value.id as string, meta.value.id, encodeURIComponent(id), data)
await $api.dbTableRow.update(NOCO, base.value.id as string, meta.value.id!, encodeURIComponent(id), data)
await loadKanbanData()
reloadTrigger?.trigger()
},
@ -260,7 +326,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
if (commentsDrawer.value) {
await loadCommentsAndLogs()
await Promise.all([loadComments(), loadAudits()])
}
} else {
// No columns to update
@ -283,15 +349,18 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
const loadRow = async (rowId?: string, onlyVirtual = false, onlyNewColumns = false) => {
if (row.value.rowMeta.new) return
if (row.value.rowMeta.new || isPublic.value || !meta.value?.id) return
const recordId = rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!recordId) return
if (isPublic.value || !meta.value?.id) return
let record = await $api.dbTableRow.read(
NOCO,
// todo: base_id missing on view type
(base?.value?.id || (sharedView.value?.view as any)?.base_id) as string,
((base?.value?.id ?? meta.value?.base_id) || (sharedView.value?.view as any)?.base_id) as string,
meta.value.id as string,
encodeURIComponent(rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])),
encodeURIComponent(recordId),
{
getHiddenColumn: true,
},
@ -301,9 +370,9 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
if (onlyVirtual) {
record = {
...row.value.row,
...meta.value.columns.reduce((partialRecord, col) => {
if (isVirtualCol(col) && col.title in record) {
partialRecord[col.title] = record[col.title]
...(meta.value.columns ?? []).reduce((partialRecord, col) => {
if (isVirtualCol(col) && col.title && col.title in record) {
partialRecord[col.title] = (record as Record<string, any>)[col.title as string]
}
return partialRecord
}, {} as Record<string, any>),
@ -314,7 +383,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
if (onlyNewColumns) {
record = Object.keys(record).reduce((acc, curr) => {
if (!Object.prototype.hasOwnProperty.call(row.value.row, curr)) {
acc[curr] = record[curr]
acc[curr] = record(record as Record<string, any>)[curr]
} else {
acc[curr] = row.value.row[curr]
}
@ -331,11 +400,13 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const deleteRowById = async (rowId?: string) => {
try {
const recordId = rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
const res: { message?: string[] } | number = await $api.dbTableRow.delete(
NOCO,
base.value.id as string,
meta.value.id as string,
encodeURIComponent(rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])),
encodeURIComponent(recordId),
)
if (res.message) {
@ -351,17 +422,19 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
}
const updateComment = async (auditId: string, audit: Partial<AuditType>) => {
return await $api.utils.commentUpdate(auditId, audit)
const updateComment = async (commentId: string, comment: Partial<CommentType>) => {
return await $api.utils.commentUpdate(commentId, comment)
}
return {
...rowStore,
commentsOnly,
loadCommentsAndLogs,
commentsAndLogs,
loadComments,
deleteComment,
loadAudits,
comments,
audits,
isAuditLoading,
isCommentsLoading,
commentsError,
saveComment,
comment,
isYou,

1
packages/nc-gui/lang/en.json

@ -451,6 +451,7 @@
"labels": {
"connectionDetails": "Connection Details",
"metaSync": "Meta Sync",
"mention": "Mention",
"today": "Today",
"workspace": "Workspace",
"txt": "TXT Record value",

16
packages/nc-gui/package.json

@ -41,14 +41,14 @@
"@iconify/vue": "^4.1.2",
"@nuxt/image": "^1.3.0",
"@pinia/nuxt": "^0.5.1",
"@tiptap/extension-link": "2.2.6",
"@tiptap/extension-placeholder": "^2.2.6",
"@tiptap/extension-task-list": "2.2.6",
"@tiptap/extension-underline": "^2.2.6",
"@tiptap/html": "2.2.6",
"@tiptap/pm": "^2.2.6",
"@tiptap/starter-kit": "^2.2.6",
"@tiptap/vue-3": "2.2.6",
"@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-task-list": "2.4.0",
"@tiptap/extension-underline": "^2.4.0",
"@tiptap/html": "2.4.0",
"@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "2.4.0",
"@vue-flow/additional-components": "^1.3.3",
"@vue-flow/core": "^1.30.1",
"@vuelidate/core": "^2.0.3",

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

@ -24,5 +24,29 @@ export const timeAgo = (date: any) => {
date += '+00:00'
}
// show in local time
return dayjs(date).fromNow()
const diff = dayjs().diff(date)
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const months = Math.floor(days / 30)
const years = Math.floor(days / 365)
if (seconds < 60) {
return `${seconds}s ago`
}
if (minutes < 60) {
return `${minutes}m ago`
}
if (hours < 24) {
return `${hours}h ago`
}
if (days < 30) {
return `${days}d ago`
}
if (months < 12) {
return `${months}mo ago`
}
return `${years}y ago`
}

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

@ -136,8 +136,10 @@ import NcPhoneCall from '~icons/nc-icons/phone-call'
import NcItalic from '~icons/nc-icons/italic'
import NcBold from '~icons/nc-icons/bold'
import NcUnderline from '~icons/nc-icons/underline'
import NcCrop from '~icons/nc-icons/crop'
import NcLink from '~icons/nc-icons/link'
import NcAtSign from '~icons/nc-icons/at-sign'
import NcStrike from '~icons/nc-icons/strike-through'
import NcCrop from '~icons/nc-icons/crop'
import NcControlPanel from '~icons/nc-icons/control-panel'
import NcHome from '~icons/nc-icons/home'
import NcWorkspace from '~icons/nc-icons/workspace'
@ -337,6 +339,8 @@ import NcMessageCircle from '~icons/nc-icons/message-circle'
} as const */
export const iconMap = {
strike: NcStrike,
atSign: NcAtSign,
slash: NcSlash,
arrowUpRight: NcArrowUpRight,
ncWorkspace: NcWorkspace,

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

@ -540,7 +540,7 @@ export interface CommentReqType {
* Description for the target row
* @example This is the comment for the row
*/
description?: string;
comment?: string;
/**
* Foreign Key to Model
* @example md_ehn5izr99m7d45
@ -561,7 +561,12 @@ export interface CommentUpdateReqType {
* Description for the target row
* @example This is the comment for the row
*/
description?: string;
comment?: string;
/**
* Foreign Key to Model
* @example md_ehn5izr99m7d45
*/
fk_model_id?: string;
}
/**
@ -2963,6 +2968,144 @@ export interface CalendarColumnReqType {
order?: number;
}
/**
* Model for Comment
*/
export interface CommentType {
/** Unique ID */
id?: IdType;
/**
* Row ID
* @example rec0Adp9PMG9o7uJy
*/
row_id?: string;
/**
* Comment
* @example This is a comment
*/
comment?: string;
/**
* Created By User ID
* @example usr0Adp9PMG9o7uJy
*/
created_by?: IdType;
/**
* Created By User Email
* @example xxx@nocodb.com
*/
created_by_email?: string;
/**
* Resolved By User ID
* @example usr0Adp9PMG9o7uJy
*/
resolved_by?: IdType;
/**
* Resolved By User Email
* @example xxx@nocodb.com
*/
resolved_by_email?: string;
/**
* Parent Comment ID
* @example cmt043cx4r30343ff
*/
parent_comment_id?: IdType;
/**
* Source ID
* @example src0Adp9PMG9o7uJy
*/
source_id?: IdType;
/**
* Base ID
* @example bas0Adp9PMG9o7uJy
*/
base_id?: IdType;
/**
* Model ID
* @example mod0Adp9PMG9o7uJy
*/
fk_model_id?: IdType;
/**
* Created At
* @example 2020-05-20T12:00:00.000000Z
*/
created_at?: string;
/**
* Updated At
* @example 2020-05-20T12:00:00.000000Z
*/
updated_at?: string;
/** Whether the comment has been deleted by the user or not */
is_deleted?: boolean;
}
/**
* Model for User Comment Notification Preference
*/
export interface UserCommentNotificationPreferenceType {
/** Unique ID */
id?: IdType;
/** User ID */
row_id?: string;
/** User ID */
user_id?: IdType;
/**
* Source ID
* @example src0Adp9PMG9o7uJy
*/
source_id?: IdType;
/**
* Base ID
* @example bas0Adp9PMG9o7uJy
*/
base_id?: IdType;
/**
* Model ID
* @example mod0Adp9PMG9o7uJy
*/
fk_model_id?: IdType;
/** Is Read */
preference?: 'ALL_COMMENTS' | 'ONLY_MENTIONS';
/** Created At */
created_at?: string;
/** Updated At */
updated_at?: string;
}
/**
* Model for Comment Reactions
*/
export interface CommentReactionsType {
/** Unique ID */
id?: IdType;
/** Row ID */
row_id?: string;
/** Comment ID */
comment_id?: IdType;
/** Reaction */
reaction?: string;
/** User ID */
user_id?: IdType;
/**
* Source ID
* @example src0Adp9PMG9o7uJy
*/
source_id?: IdType;
/**
* Base ID
* @example bas0Adp9PMG9o7uJy
*/
base_id?: IdType;
/**
* Model ID
* @example mod0Adp9PMG9o7uJy
*/
fk_model_id?: IdType;
/** Created At */
created_at?: string;
/** Updated At */
updated_at?: string;
}
export interface ExtensionType {
/** Unique ID */
id?: IdType;
@ -9671,12 +9814,12 @@ export class Api<
};
utils = {
/**
* @description List all comments
* @description List all audits
*
* @tags Utils
* @name CommentList
* @summary List Comments in Audit
* @request GET:/api/v1/db/meta/audits/comments
* @name AuditList
* @summary List Audits
* @request GET:/api/v1/db/meta/audits
* @response `200` `{
list: (AuditType)[],
@ -9687,7 +9830,7 @@ export class Api<
}`
*/
commentList: (
auditList: (
query: {
/**
* Row ID
@ -9699,24 +9842,67 @@ export class Api<
* @example md_c6csq89tl37jm5
*/
fk_model_id: IdType;
},
params: RequestParams = {}
) =>
this.request<
{
list: AuditType[];
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/audits`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* @description List all comments
*
* @tags Utils
* @name CommentList
* @summary List Comments
* @request GET:/api/v1/db/meta/comments
* @response `200` `{
list: (CommentType)[],
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
commentList: (
query: {
/**
* Is showing comments only?
* @example true
* Row ID
* @example 10
*/
row_id: string;
/**
* Foreign Key to Model
* @example md_c6csq89tl37jm5
*/
comments_only?: boolean;
fk_model_id: IdType;
},
params: RequestParams = {}
) =>
this.request<
{
list: AuditType[];
list: CommentType[];
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/audits/comments`,
path: `/api/v1/db/meta/comments`,
method: 'GET',
query: query,
format: 'json',
@ -9724,13 +9910,13 @@ export class Api<
}),
/**
* @description Create a new comment in a row. Logged in Audit.
* @description Create a new comment in a row.
*
* @tags Utils
* @name CommentRow
* @summary Comment Rows
* @request POST:/api/v1/db/meta/audits/comments
* @response `200` `AuditType` OK
* @request POST:/api/v1/db/meta/comments
* @response `200` `CommentType` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
@ -9739,13 +9925,13 @@ export class Api<
*/
commentRow: (data: CommentReqType, params: RequestParams = {}) =>
this.request<
AuditType,
CommentType,
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/audits/comments`,
path: `/api/v1/db/meta/comments`,
method: 'POST',
body: data,
type: ContentType.Json,
@ -9754,21 +9940,21 @@ export class Api<
}),
/**
* @description Update comment in Audit
* @description Update comment
*
* @tags Utils
* @name CommentUpdate
* @summary Update Comment in Audit
* @request PATCH:/api/v1/db/meta/audits/{auditId}/comment
* @summary Update Comment
* @request PATCH:/api/v1/db/meta/comment/{commentId}/
* @response `200` `number` OK
*/
commentUpdate: (
auditId: string,
commentId: string,
data: CommentUpdateReqType,
params: RequestParams = {}
) =>
this.request<number, any>({
path: `/api/v1/db/meta/audits/${auditId}/comment`,
path: `/api/v1/db/meta/comment/${commentId}/`,
method: 'PATCH',
body: data,
type: ContentType.Json,
@ -9777,12 +9963,30 @@ export class Api<
}),
/**
* @description Delete comment
*
* @tags Utils
* @name CommentDelete
* @summary Delete Comment
* @request DELETE:/api/v1/db/meta/comment/{commentId}/
* @response `200` `number` OK
*/
commentDelete: (commentId: string, data: any, params: RequestParams = {}) =>
this.request<number, any>({
path: `/api/v1/db/meta/comment/${commentId}/`,
method: 'DELETE',
body: data,
format: 'json',
...params,
}),
/**
* @description Return the number of comments in the given query.
*
* @tags Utils
* @name CommentCount
* @summary Count Comments
* @request GET:/api/v1/db/meta/audits/comments/count
* @request GET:/api/v1/db/meta/comments/count
* @response `200` `({
\**
* The number of comments
@ -9829,7 +10033,7 @@ export class Api<
msg: string;
}
>({
path: `/api/v1/db/meta/audits/comments/count`,
path: `/api/v1/db/meta/comments/count`,
method: 'GET',
query: query,
format: 'json',

4
packages/nocodb-sdk/src/lib/enums.ts

@ -147,6 +147,10 @@ export enum AppEvents {
EXTENSION_CREATE = 'extension.create',
EXTENSION_UPDATE = 'extension.update',
EXTENSION_DELETE = 'extension.delete',
COMMENT_CREATE = 'comment.create',
COMMENT_DELETE = 'comment.delete',
COMMENT_UPDATE = 'comment.update',
}
export enum ClickhouseTables {

56
packages/nocodb/src/controllers/audits.controller.ts

@ -4,9 +4,7 @@ import {
Get,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
@ -22,14 +20,12 @@ import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
export class AuditsController {
constructor(private readonly auditsService: AuditsService) {}
@Post(['/api/v1/db/meta/audits/comments', '/api/v2/meta/audits/comments'])
@HttpCode(200)
@Acl('commentRow')
async commentRow(@Req() req: Request) {
return await this.auditsService.commentRow({
user: (req as any).user,
body: req.body,
});
@Get(['/api/v1/db/meta/audits/', '/api/v2/meta/audits/'])
@Acl('auditList')
async auditListRow(@Req() req: Request) {
return new PagedResponseImpl(
await this.auditsService.auditOnlyList({ query: req.query as any }),
);
}
@Post([
@ -45,31 +41,6 @@ export class AuditsController {
});
}
@Get(['/api/v1/db/meta/audits/comments', '/api/v2/meta/audits/comments'])
@Acl('commentList')
async commentList(@Req() req: Request) {
return new PagedResponseImpl(
await this.auditsService.commentList({ query: req.query }),
);
}
@Patch([
'/api/v1/db/meta/audits/:auditId/comment',
'/api/v2/meta/audits/:auditId/comment',
])
@Acl('commentUpdate')
async commentUpdate(
@Param('auditId') auditId: string,
@Req() req: Request,
@Body() body: any,
) {
return await this.auditsService.commentUpdate({
auditId,
userEmail: req.user?.email,
body: body,
});
}
@Get([
'/api/v1/db/meta/projects/:baseId/audits/',
'/api/v2/meta/bases/:baseId/audits/',
@ -87,19 +58,4 @@ export class AuditsController {
},
);
}
@Get([
'/api/v1/db/meta/audits/comments/count',
'/api/v2/meta/audits/comments/count',
])
@Acl('commentsCount')
async commentsCount(
@Query('fk_model_id') fk_model_id: string,
@Query('ids') ids: string[],
) {
return await this.auditsService.commentsCount({
fk_model_id,
ids,
});
}
}

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

@ -0,0 +1,90 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { CommentsService } from '~/services/comments.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { NcRequest } from '~/interface/config';
@Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Get(['/api/v1/db/meta/comments', '/api/v2/meta/comments'])
@Acl('commentList')
async commentList(@Req() req: any) {
return new PagedResponseImpl(
await this.commentsService.commentList({ query: req.query }),
);
}
@Post(['/api/v1/db/meta/comments', '/api/v2/meta/comments'])
@HttpCode(200)
@Acl('commentRow')
async commentRow(@Req() req: NcRequest, @Body() body: any) {
return await this.commentsService.commentRow({
user: req.user,
body: body,
req,
});
}
@Delete([
'/api/v1/db/meta/comment/:commentId',
'/api/v2/meta/comment/:commentId',
])
@Acl('commentDelete')
async commentDelete(
@Req() req: NcRequest,
@Param('commentId') commentId: string,
) {
return await this.commentsService.commentDelete({
commentId,
user: req.user,
});
}
@Patch([
'/api/v1/db/meta/comment/:commentId',
'/api/v2/meta/comment/:commentId',
])
@Acl('commentUpdate')
async commentUpdate(
@Param('commentId') commentId: string,
@Req() req: any,
@Body() body: any,
) {
return await this.commentsService.commentUpdate({
commentId: commentId,
user: req.user,
body: body,
req,
});
}
@Get(['/api/v1/db/meta/comments/count', '/api/v2/meta/comments/count'])
@Acl('commentsCount')
async commentsCount(
@Query('fk_model_id') fk_model_id: string,
@Query('ids') ids: string[],
) {
return await this.commentsService.commentsCount({
fk_model_id,
ids,
});
}
}

4
packages/nocodb/src/filters/global-exception/global-exception.filter.ts

@ -41,7 +41,9 @@ export class GlobalExceptionFilter implements ExceptionFilter {
}
// try to extract db error for unknown errors
const dbError = !(exception instanceof NcBaseError) ? extractDBError(exception) : null;
const dbError = !(exception instanceof NcBaseError)
? extractDBError(exception)
: null;
// skip unnecessary error logging
if (

9
packages/nocodb/src/meta/meta.service.ts

@ -252,6 +252,15 @@ export class MetaService {
case MetaTable.EXTENSIONS:
prefix = 'ext';
break;
case MetaTable.COMMENTS:
prefix = 'com';
break;
case MetaTable.COMMENTS_REACTIONS:
prefix = 'cre';
break;
case MetaTable.USER_COMMENTS_NOTIFICATIONS_PREFERENCE:
prefix = 'cnp';
break;
default:
prefix = 'nc';
break;

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

@ -32,6 +32,7 @@ import * as nc_042_user_block from '~/meta/migrations/v2/nc_042_user_block';
import * as nc_043_user_refresh_token from '~/meta/migrations/v2/nc_043_user_refresh_token';
import * as nc_044_view_column_index from '~/meta/migrations/v2/nc_044_view_column_index';
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';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -75,6 +76,7 @@ export default class XcMigrationSourcev2 {
'nc_043_user_refresh_token',
'nc_044_view_column_index',
'nc_045_extensions',
'nc_046_comment_mentions',
]);
}
@ -152,6 +154,8 @@ export default class XcMigrationSourcev2 {
return nc_044_view_column_index;
case 'nc_045_extensions':
return nc_045_extensions;
case 'nc_046_comment_mentions':
return nc_046_comment_mentions;
}
}
}

142
packages/nocodb/src/meta/migrations/v2/nc_046_comment_mentions.ts

@ -0,0 +1,142 @@
import { Logger } from '@nestjs/common';
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const logger = new Logger('nc_046_comment_mentions');
const up = async (knex: Knex) => {
await knex.schema.createTable(MetaTable.COMMENTS, (table) => {
table.string('id', 20).primary();
table.string('row_id', 255);
table.string('comment', 3000);
table.string('created_by', 255);
table.string('created_by_email', 255);
table.string('resolved_by', 255);
table.string('resolved_by_email', 255);
table.string('parent_comment_id', 20);
table.string('source_id', 20);
table.string('base_id', 128);
table.string('fk_model_id', 20);
table.boolean('is_deleted');
table.index(['row_id', 'fk_model_id']);
table.timestamps(true, true);
});
await knex.schema.createTable(
MetaTable.USER_COMMENTS_NOTIFICATIONS_PREFERENCE,
(table) => {
table.string('id', 20).primary();
table.string('row_id', 255);
table.string('user_id', 255);
table.string('fk_model_id', 20);
table.string('source_id', 20);
table.string('base_id', 128);
table.string('preferences', 255);
table.index(
['user_id', 'row_id', 'fk_model_id'],
'user_comments_preference_index',
);
table.timestamps(true, true);
},
);
await knex.schema.createTable(MetaTable.COMMENTS_REACTIONS, (table) => {
table.string('id', 20).primary();
table.string('row_id', 255).index();
table.string('comment_id', 20).index();
table.string('source_id', 20);
table.string('fk_modal_id', 20);
table.string('base_id', 128);
table.string('reaction', 255);
table.string('created_by', 255);
table.timestamps(true, true);
});
logger.log('nc_046_comment_mentions: Tables Created');
knex
.select(
`${MetaTable.AUDIT}.id`,
`${MetaTable.AUDIT}.row_id`,
`${MetaTable.AUDIT}.description`,
`${MetaTable.AUDIT}.user as user_email`,
`${MetaTable.AUDIT}.source_id`,
`${MetaTable.AUDIT}.base_id`,
`${MetaTable.AUDIT}.fk_model_id`,
`${MetaTable.AUDIT}.created_at`,
`${MetaTable.AUDIT}.updated_at`,
`${MetaTable.USERS}.id as user_id`,
)
.from(MetaTable.AUDIT)
.where(`${MetaTable.AUDIT}.op_type`, 'COMMENT')
.leftJoin(
MetaTable.USERS,
`${MetaTable.AUDIT}.user`,
`${MetaTable.USERS}.email`,
)
.then(async (rows) => {
if (!rows.length) return;
logger.log('nc_046_comment_mentions: Data from Audit Table Selected');
const formattedRows = rows.map((row) => ({
id: row.id,
row_id: row.row_id,
comment: row.description
.substring(row.description.indexOf(':') + 1)
.trim(),
created_by: row.user_id,
created_by_email: row.user_email,
source_id: row.source_id,
base_id: row.base_id,
fk_model_id: row.fk_model_id,
created_at: row.created_at,
updated_at: row.updated_at,
}));
logger.log('nc_046_comment_mentions: Data from Audit Table Formatted');
await knex(MetaTable.COMMENTS).insert(formattedRows);
logger.log('nc_046_comment_mentions: Data from Audit Table Migrated');
});
};
const down = async (knex: Knex) => {
await knex.schema.dropTable(MetaTable.COMMENTS);
await knex.schema.dropTable(MetaTable.USER_COMMENTS_NOTIFICATIONS_PREFERENCE);
await knex.schema.dropTable(MetaTable.COMMENTS_REACTIONS);
};
export { up, down };

30
packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts

@ -11,9 +11,9 @@ import type {
NestMiddleware,
} from '@nestjs/common';
import {
Audit,
Base,
Column,
Comment,
Extension,
Filter,
FormViewColumn,
@ -135,9 +135,9 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
else if (
[
'/api/v1/db/meta/audits/rows/:rowId/update',
'/api/v1/db/meta/audits/comments',
'/api/v2/meta/audits/rows/:rowId/update',
'/api/v2/meta/audits/comments',
'/api/v1/db/meta/comments',
'/api/v2/meta/comments',
].some(
(auditInsertOrUpdatePath) => req.route.path === auditInsertOrUpdatePath,
) &&
@ -152,10 +152,12 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
// extract fk_model_id from query params only if it's audit get endpoint
else if (
[
'/api/v1/db/meta/audits/comments/count',
'/api/v1/db/meta/audits/comments',
'/api/v2/meta/audits/comments/count',
'/api/v2/meta/audits/comments',
'/api/v2/meta/comments/count',
'/api/v1/db/meta/comments/count',
'/api/v2/meta/comments',
'/api/v1/db/meta/comments',
'/api/v1/db/meta/audits',
'/api/v2/meta/audits',
].some((auditReadPath) => req.route.path === auditReadPath) &&
req.method === 'GET' &&
req.query.fk_model_id
@ -166,14 +168,14 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
req.ncBaseId = model?.base_id;
} else if (
[
'/api/v1/db/meta/audits/:auditId/comment',
'/api/v2/meta/audits/:auditId/comment',
].some((auditPatchPath) => req.route.path === auditPatchPath) &&
req.method === 'PATCH' &&
req.params.auditId
'/api/v1/db/meta/comment/:commentId',
'/api/v2/meta/comment/:commentId',
].some((commentPatchPath) => req.route.path === commentPatchPath) &&
(req.method === 'PATCH' || req.method === 'DELETE') &&
req.params.commentId
) {
const audit = await Audit.get(params.auditId);
req.ncBaseId = audit?.base_id;
const comment = await Comment.get(params.commentId);
req.ncBaseId = comment?.base_id;
}
// extract base id from query params only if it's userMe endpoint or webhook plugin list
else if (

42
packages/nocodb/src/models/Audit.ts

@ -122,22 +122,7 @@ export default class Audit implements AuditType {
}
}
public static async commentsCount(args: {
ids: string[];
fk_model_id: string;
}) {
const audits = await Noco.ncMeta
.knex(MetaTable.AUDIT)
.count('id', { as: 'count' })
.select('row_id')
.whereIn('row_id', args.ids)
.where('fk_model_id', args.fk_model_id)
.where('op_type', AuditOperationTypes.COMMENT)
.groupBy('row_id');
return audits?.map((a) => new Audit(a));
}
public static async commentsList(args) {
public static async auditList(args) {
const query = Noco.ncMeta
.knex(MetaTable.AUDIT)
.join(
@ -148,11 +133,9 @@ export default class Audit implements AuditType {
.select(`${MetaTable.AUDIT}.*`, `${MetaTable.USERS}.display_name`)
.where('row_id', args.row_id)
.where('fk_model_id', args.fk_model_id)
.where('op_type', '!=', AuditOperationTypes.COMMENT)
.orderBy('created_at', 'desc');
if ((args.comments_only as any) == 'true')
query.where('op_type', AuditOperationTypes.COMMENT);
const audits = await query;
return audits?.map((a) => new Audit(a));
@ -199,27 +182,6 @@ export default class Audit implements AuditType {
)?.count;
}
static async deleteRowComments(fk_model_id: string, ncMeta = Noco.ncMeta) {
return ncMeta.metaDelete(null, null, MetaTable.AUDIT, {
fk_model_id,
});
}
static async commentUpdate(
auditId: string,
audit: Partial<AuditType>,
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(audit, ['description']);
return await ncMeta.metaUpdate(
null,
null,
MetaTable.AUDIT,
updateObj,
auditId,
);
}
static async sourceAuditList(sourceId: string, { limit = 25, offset = 0 }) {
return await Noco.ncMeta.metaList2(null, null, MetaTable.AUDIT, {
condition: { source_id: sourceId },

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

@ -0,0 +1,154 @@
import type { CommentType } from 'nocodb-sdk';
import Noco from '~/Noco';
import { MetaTable } from '~/utils/globals';
import { prepareForDb } from '~/utils/modelUtils';
import { extractProps } from '~/helpers/extractProps';
import Model from '~/models/Model';
export default class Comment implements CommentType {
id?: string;
fk_model_id?: string;
row_id?: string;
comment?: string;
parent_comment_id?: string;
source_id?: string;
base_id?: string;
created_by?: string;
resolved_by?: string;
created_by_email?: string;
resolved_by_email?: string;
is_deleted?: boolean;
constructor(comment: Partial<Comment>) {
Object.assign(this, comment);
}
public static async get(commentId: string, ncMeta = Noco.ncMeta) {
const comment = await ncMeta.metaGet2(
null,
null,
MetaTable.COMMENTS,
commentId,
);
return comment && new Comment(comment);
}
public static async list(
{
row_id,
fk_model_id,
}: {
row_id: string;
fk_model_id: string;
},
ncMeta = Noco.ncMeta,
) {
const commentList = await ncMeta
.knex(MetaTable.COMMENTS)
.select(`${MetaTable.COMMENTS}.*`)
.where('row_id', row_id)
.where('fk_model_id', fk_model_id)
.where(function () {
this.whereNull('is_deleted').orWhere('is_deleted', '!=', true);
})
.orderBy('created_at', 'asc');
return commentList.map((comment) => new Comment(comment));
}
public static async insert(comment: Partial<Comment>, ncMeta = Noco.ncMeta) {
const insertObj = extractProps(comment, [
'id',
'fk_model_id',
'row_id',
'comment',
'parent_comment_id',
'source_id',
'base_id',
'fk_model_id',
'created_by',
'created_by_email',
]);
if ((!insertObj.base_id || !insertObj.source_id) && insertObj.fk_model_id) {
const model = await Model.getByIdOrName(
{ id: insertObj.fk_model_id },
ncMeta,
);
insertObj.base_id = model.base_id;
insertObj.source_id = model.source_id;
}
const res = await ncMeta.metaInsert2(
null,
null,
MetaTable.COMMENTS,
prepareForDb(insertObj),
);
return res;
}
public static async update(
commentId: string,
comment: Partial<Comment>,
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(comment, ['comment', 'resolved_by']);
await ncMeta.metaUpdate(
null,
null,
MetaTable.COMMENTS,
prepareForDb(updateObj),
commentId,
);
return Comment.get(commentId, ncMeta);
}
static async delete(commentId: string, ncMeta = Noco.ncMeta) {
await ncMeta.metaUpdate(
null,
null,
MetaTable.COMMENTS,
{ is_deleted: true },
commentId,
);
return true;
}
static async deleteRowComments(fk_model_id: string, ncMeta = Noco.ncMeta) {
return ncMeta.metaUpdate(
null,
null,
MetaTable.COMMENTS,
{
is_deleted: true,
},
{
fk_model_id,
},
);
}
public static async commentsCount(args: {
ids: string[];
fk_model_id: string;
}) {
const audits = await Noco.ncMeta
.knex(MetaTable.COMMENTS)
.count('id', { as: 'count' })
.select('row_id')
.whereIn('row_id', args.ids)
.where('fk_model_id', args.fk_model_id)
.where(function () {
this.whereNull('is_deleted').orWhere('is_deleted', '!=', true);
})
.groupBy('row_id');
return audits?.map((a) => new Comment(a));
}
}

5
packages/nocodb/src/models/Model.ts

@ -6,13 +6,12 @@ import {
ViewTypes,
} from 'nocodb-sdk';
import dayjs from 'dayjs';
import type { BoolType, TableReqType, TableType } from 'nocodb-sdk';
import type { XKnex } from '~/db/CustomKnex';
import type { LinksColumn, LinkToAnotherRecordColumn } from '~/models/index';
import Hook from '~/models/Hook';
import Audit from '~/models/Audit';
import View from '~/models/View';
import Comment from '~/models/Comment';
import Column from '~/models/Column';
import { extractProps } from '~/helpers/extractProps';
import { sanitize } from '~/helpers/sqlSanitize';
@ -433,7 +432,7 @@ export default class Model implements TableType {
}
async delete(ncMeta = Noco.ncMeta, force = false): Promise<boolean> {
await Audit.deleteRowComments(this.id, ncMeta);
await Comment.deleteRowComments(this.id, ncMeta);
for (const view of await this.getViews(true, ncMeta)) {
await view.delete(ncMeta);

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

@ -66,6 +66,9 @@ export default class Notification {
condition,
limit,
offset,
orderBy: {
created_at: 'desc',
},
},
);

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

@ -42,3 +42,4 @@ export { default as Notification } from './Notification';
export { default as PresignedUrl } from './PresignedUrl';
export { default as UserRefreshToken } from './UserRefreshToken';
export { default as Extension } from './Extension';
export { default as Comment } from './Comment';

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

@ -33,6 +33,7 @@ import { SourcesController } from '~/controllers/sources.controller';
import { CachesController } from '~/controllers/caches.controller';
import { CalendarsController } from '~/controllers/calendars.controller';
import { ColumnsController } from '~/controllers/columns.controller';
import { CommentsController } from '~/controllers/comments.controller';
import { FiltersController } from '~/controllers/filters.controller';
import { FormColumnsController } from '~/controllers/form-columns.controller';
import { FormsController } from '~/controllers/forms.controller';
@ -64,6 +65,7 @@ import { SourcesService } from '~/services/sources.service';
import { CachesService } from '~/services/caches.service';
import { CalendarsService } from '~/services/calendars.service';
import { ColumnsService } from '~/services/columns.service';
import { CommentsService } from '~/services/comments.service';
import { FiltersService } from '~/services/filters.service';
import { FormColumnsService } from '~/services/form-columns.service';
import { FormsService } from '~/services/forms.service';
@ -148,6 +150,7 @@ export const nocoModuleMetadata = {
CachesController,
CalendarsController,
ColumnsController,
CommentsController,
FiltersController,
FormColumnsController,
FormsController,
@ -214,6 +217,7 @@ export const nocoModuleMetadata = {
CachesService,
CalendarsService,
ColumnsService,
CommentsService,
FiltersService,
FormColumnsService,
FormsService,

256
packages/nocodb/src/schema/swagger-v2.json

@ -8434,15 +8434,15 @@
"parameters": []
}
},
"/api/v2/meta/audits/comments": {
"/api/v2/meta/audits": {
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
],
"get": {
"summary": "List Comments in Audit",
"operationId": "utils-comment-list",
"summary": "List Audits",
"operationId": "utils-audit-list",
"responses": {
"200": {
"description": "OK",
@ -8517,7 +8517,7 @@
"$ref": "#/components/responses/BadRequest"
}
},
"description": "List all comments",
"description": "List all audits",
"parameters": [
{
"schema": {
@ -8539,14 +8539,113 @@
"required": true,
"description": "Foreign Key to Model"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"tags": [
"Utils"
]
}
},
"/api/v2/meta/comments": {
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
],
"get": {
"summary": "List Comments",
"operationId": "utils-comment-list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list": {
"x-stoplight": {
"id": "5zto1xohsngbu"
},
"type": "array",
"items": {
"$ref": "#/components/schemas/Comment",
"x-stoplight": {
"id": "d22zkup0c0l80"
}
}
}
},
"required": [
"list"
]
},
"examples": {
"Example 1": {
"value": {
"list": [
{
"id": "adt_3sii7erfwrlegb",
"source_id": null,
"base_id": "p_63b4q0qengen1x",
"fk_model_id": "md_5mipbdg6ketmv8",
"row_id": "1",
"created_by": "",
"resolved_by": "",
"parent_comment_id": null,
"status": null,
"comment": "bar",
"created_at": "2023-03-13T09:39:14.225Z",
"updated_at": "2023-03-13T09:39:14.225Z"
},
{
"id": "adt_3sii7erfwrlegb",
"source_id": null,
"base_id": "p_63b4q0qengen1x",
"fk_model_id": "md_5mipbdg6ketmv8",
"row_id": "1",
"created_by": "",
"resolved_by": "",
"parent_comment_id": null,
"status": null,
"comment": "bar",
"created_at": "2023-03-13T09:39:14.225Z",
"updated_at": "2023-03-13T09:39:14.225Z"
}
]
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"description": "List all comments",
"parameters": [
{
"schema": {
"type": "boolean",
"example": true
"type": "string",
"example": "10"
},
"in": "query",
"name": "comments_only",
"description": "Is showing comments only?"
"name": "row_id",
"required": true,
"description": "Row ID"
},
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "md_c6csq89tl37jm5"
},
"in": "query",
"name": "fk_model_id",
"required": true,
"description": "Foreign Key to Model"
},
{
"$ref": "#/components/parameters/xc-auth"
@ -8565,18 +8664,23 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Audit"
"$ref": "#/components/schemas/Comment"
},
"examples": {
"Example 1": {
"value": {
"user": "w@nocodb.com",
"row_id": "1",
"fk_model_id": "md_5mipbdg6ketmv8",
"op_type": "COMMENT",
"description": "qq",
"id": "adt_3sii7erfwrlegb",
"source_id": null,
"base_id": "p_63b4q0qengen1x",
"id": "adt_vx58jpp0loo8qy"
"fk_model_id": "md_5mipbdg6ketmv8",
"row_id": "1",
"created_by": "",
"resolved_by": "",
"parent_comment_id": null,
"status": null,
"comment": "bar",
"created_at": "2023-03-13T09:39:14.225Z",
"updated_at": "2023-03-13T09:39:14.225Z"
}
}
}
@ -8596,7 +8700,7 @@
"examples": {
"Example 1": {
"value": {
"description": "This is the comment for the row",
"comment": "This is the comment for the row",
"fk_model_id": "md_ehn5izr99m7d45",
"row_id": "3"
}
@ -8616,17 +8720,17 @@
]
}
},
"/api/v2/meta/audits/{auditId}/comment": {
"/api/v2/meta/comment/{commentId}": {
"parameters": [
{
"schema": {
"type": "string",
"example": "adt_zlskd6rlf3liay"
},
"name": "auditId",
"name": "commentId",
"in": "path",
"required": true,
"description": "Audit ID"
"description": "Comment ID"
},
{
"name": "xc-auth",
@ -8639,7 +8743,7 @@
}
],
"patch": {
"summary": "Update Comment in Audit",
"summary": "Update Comment",
"operationId": "utils-comment-update",
"responses": {
"200": {
@ -8662,7 +8766,7 @@
"tags": [
"Utils"
],
"description": "Update comment in Audit",
"description": "Update comment",
"requestBody": {
"content": {
"application/json": {
@ -8672,16 +8776,46 @@
"examples": {
"Example 1": {
"value": {
"description": "This is the comment for the row"
"comment": "This is the comment for the row"
}
}
}
}
}
}
},
"delete": {
"summary": "Delete Comment",
"operationId": "utils-comment-delete",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "number",
"example": 1
},
"examples": {
"Example 1": {
"value": 1
}
}
}
}
}
},
"tags": [
"Utils"
],
"description": "Delete comment",
"requestBody": {
"content": {
}
}
}
},
"/api/v2/meta/audits/comments/count": {
"/api/v2/meta/comments/count": {
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
@ -11750,6 +11884,66 @@
],
"title": "Attachment Response Model"
},
"Comment": {
"description": "Model for Comment",
"type": "object",
"properties": {
"id": {
"$ref": "#/components/schemas/Id",
"description": "Unique ID"
},
"row_id": {
"type": "string",
"example": "rec0Adp9PMG9o7uJy",
"description": "Row ID"
},
"comment": {
"type": "string",
"example": "This is a comment",
"description": "Comment"
},
"created_by": {
"$ref": "#/components/schemas/Id",
"example": "usr0Adp9PMG9o7uJy",
"description": "Created By"
},
"resolved_by": {
"$ref": "#/components/schemas/Id",
"example": "usr0Adp9PMG9o7uJy",
"description": "Resolved By"
},
"parent_comment_id": {
"$ref": "#/components/schemas/Id",
"example": "cmt043cx4r30343ff",
"description": "Parent Comment ID"
},
"source_id": {
"$ref": "#/components/schemas/Id",
"example": "src0Adp9PMG9o7uJy",
"description": "Source ID"
},
"base_id": {
"$ref": "#/components/schemas/Id",
"example": "bas0Adp9PMG9o7uJy",
"description": "Base ID"
},
"fk_model_id": {
"$ref": "#/components/schemas/Id",
"example": "mod0Adp9PMG9o7uJy",
"description": "Model ID"
},
"created_at": {
"type": "string",
"example": "2020-05-20T12:00:00.000000Z",
"description": "Created At"
},
"updated_at": {
"type": "string",
"example": "2020-05-20T12:00:00.000000Z",
"description": "Updated At"
}
}
},
"Audit": {
"description": "Model for Audit",
"examples": [
@ -12692,7 +12886,7 @@
"description": "Model for Comment Request",
"examples": [
{
"description": "This is the comment for the row",
"comment": "This is the comment for the row",
"fk_model_id": "md_ehn5izr99m7d45",
"row_id": "3"
}
@ -12700,7 +12894,7 @@
"title": "Comment Request Model",
"type": "object",
"properties": {
"description": {
"comment": {
"type": "string",
"description": "Description for the target row",
"example": "This is the comment for the row"
@ -12731,16 +12925,22 @@
},
"examples": [
{
"description": "This is the comment for the row"
"comment": "This is the comment for the row",
"fk_model_id": "md_ehn5izr99m7d45"
}
],
"title": "Comment Update Request Model",
"type": "object",
"properties": {
"description": {
"comment": {
"type": "string",
"description": "Description for the target row",
"example": "This is the comment for the row"
},
"fk_model_id": {
"type": "string",
"description": "Foreign Key to Model",
"example": "md_ehn5izr99m7d45"
}
}
},
@ -14976,7 +15176,7 @@
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Baes ID\n"
"description": "Base ID\n"
},
"base_id": {
"$ref": "#/components/schemas/Id",

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

@ -13529,15 +13529,15 @@
"parameters": []
}
},
"/api/v1/db/meta/audits/comments": {
"/api/v1/db/meta/audits": {
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
],
"get": {
"summary": "List Comments in Audit",
"operationId": "utils-comment-list",
"summary": "List Audits",
"operationId": "utils-audit-list",
"responses": {
"200": {
"description": "OK",
@ -13612,7 +13612,7 @@
"$ref": "#/components/responses/BadRequest"
}
},
"description": "List all comments",
"description": "List all audits",
"parameters": [
{
"schema": {
@ -13634,14 +13634,113 @@
"required": true,
"description": "Foreign Key to Model"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"tags": [
"Utils"
]
}
},
"/api/v1/db/meta/comments": {
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
],
"get": {
"summary": "List Comments",
"operationId": "utils-comment-list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list": {
"x-stoplight": {
"id": "5zto1xohsngbu"
},
"type": "array",
"items": {
"$ref": "#/components/schemas/Comment",
"x-stoplight": {
"id": "d22zkup0c0l80"
}
}
}
},
"required": [
"list"
]
},
"examples": {
"Example 1": {
"value": {
"list": [
{
"id": "adt_3sii7erfwrlegb",
"source_id": null,
"base_id": "p_63b4q0qengen1x",
"fk_model_id": "md_5mipbdg6ketmv8",
"row_id": "1",
"created_by": "",
"resolved_by": "",
"parent_comment_id": null,
"status": null,
"comment": "bar",
"created_at": "2023-03-13T09:39:14.225Z",
"updated_at": "2023-03-13T09:39:14.225Z"
},
{
"id": "adt_3sii7erfwrlegb",
"source_id": null,
"base_id": "p_63b4q0qengen1x",
"fk_model_id": "md_5mipbdg6ketmv8",
"row_id": "1",
"created_by": "",
"resolved_by": "",
"parent_comment_id": null,
"status": null,
"comment": "bar",
"created_at": "2023-03-13T09:39:14.225Z",
"updated_at": "2023-03-13T09:39:14.225Z"
}
]
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"description": "List all comments",
"parameters": [
{
"schema": {
"type": "boolean",
"example": true
"type": "string",
"example": "10"
},
"in": "query",
"name": "comments_only",
"description": "Is showing comments only?"
"name": "row_id",
"required": true,
"description": "Row ID"
},
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "md_c6csq89tl37jm5"
},
"in": "query",
"name": "fk_model_id",
"required": true,
"description": "Foreign Key to Model"
},
{
"$ref": "#/components/parameters/xc-auth"
@ -13660,18 +13759,23 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Audit"
"$ref": "#/components/schemas/Comment"
},
"examples": {
"Example 1": {
"value": {
"user": "w@nocodb.com",
"row_id": "1",
"fk_model_id": "md_5mipbdg6ketmv8",
"op_type": "COMMENT",
"description": "qq",
"id": "adt_3sii7erfwrlegb",
"source_id": null,
"base_id": "p_63b4q0qengen1x",
"id": "adt_vx58jpp0loo8qy"
"fk_model_id": "md_5mipbdg6ketmv8",
"row_id": "1",
"created_by": "",
"resolved_by": "",
"parent_comment_id": null,
"status": null,
"comment": "bar",
"created_at": "2023-03-13T09:39:14.225Z",
"updated_at": "2023-03-13T09:39:14.225Z"
}
}
}
@ -13703,7 +13807,7 @@
"tags": [
"Utils"
],
"description": "Create a new comment in a row. Logged in Audit.",
"description": "Create a new comment in a row.",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
@ -13711,17 +13815,17 @@
]
}
},
"/api/v1/db/meta/audits/{auditId}/comment": {
"/api/v1/db/meta/comment/{commentId}/": {
"parameters": [
{
"schema": {
"type": "string",
"example": "adt_zlskd6rlf3liay"
},
"name": "auditId",
"name": "commentId",
"in": "path",
"required": true,
"description": "Audit ID"
"description": "Comment ID"
},
{
"name": "xc-auth",
@ -13734,7 +13838,7 @@
}
],
"patch": {
"summary": "Update Comment in Audit",
"summary": "Update Comment",
"operationId": "utils-comment-update",
"responses": {
"200": {
@ -13757,7 +13861,7 @@
"tags": [
"Utils"
],
"description": "Update comment in Audit",
"description": "Update comment",
"requestBody": {
"content": {
"application/json": {
@ -13767,16 +13871,46 @@
"examples": {
"Example 1": {
"value": {
"description": "This is the comment for the row"
"comment": "This is the comment for the row"
}
}
}
}
}
}
},
"delete": {
"summary": "Delete Comment",
"operationId": "utils-comment-delete",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "number",
"example": 1
},
"examples": {
"Example 1": {
"value": 1
}
}
}
}
}
},
"tags": [
"Utils"
],
"description": "Delete comment",
"requestBody": {
"content": {
}
}
}
},
"/api/v1/db/meta/audits/comments/count": {
"/api/v1/db/meta/comments/count": {
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
@ -18782,7 +18916,7 @@
"description": "Model for Comment Request",
"examples": [
{
"description": "This is the comment for the row",
"comment": "This is the comment for the row",
"fk_model_id": "md_ehn5izr99m7d45",
"row_id": "3"
}
@ -18790,7 +18924,7 @@
"title": "Comment Request Model",
"type": "object",
"properties": {
"description": {
"comment": {
"type": "string",
"description": "Description for the target row",
"example": "This is the comment for the row"
@ -18821,17 +18955,23 @@
},
"examples": [
{
"description": "This is the comment for the row"
"comment": "This is the comment for the row",
"fk_model_id": "md_ehn5izr99m7d45"
}
],
"title": "Comment Update Request Model",
"type": "object",
"properties": {
"description": {
"comment": {
"type": "string",
"description": "Description for the target row",
"example": "This is the comment for the row"
}
},
"fk_model_id": {
"type": "string",
"description": "Foreign Key to Model",
"example": "md_ehn5izr99m7d45"
}
}
},
"Filter": {
@ -25606,6 +25746,179 @@
"id": "psbv6c6y9qvbu"
}
},
"Comment": {
"description": "Model for Comment",
"type": "object",
"properties": {
"id": {
"$ref": "#/components/schemas/Id",
"description": "Unique ID"
},
"row_id": {
"type": "string",
"example": "rec0Adp9PMG9o7uJy",
"description": "Row ID"
},
"comment": {
"type": "string",
"example": "This is a comment",
"description": "Comment"
},
"created_by": {
"$ref": "#/components/schemas/Id",
"example": "usr0Adp9PMG9o7uJy",
"description": "Created By User ID"
},
"created_by_email": {
"type": "string",
"example": "xxx@nocodb.com",
"description": "Created By User Email"
},
"resolved_by": {
"$ref": "#/components/schemas/Id",
"example": "usr0Adp9PMG9o7uJy",
"description": "Resolved By User ID"
},
"resolved_by_email": {
"type": "string",
"example": "xxx@nocodb.com",
"description": "Resolved By User Email"
},
"parent_comment_id": {
"$ref": "#/components/schemas/Id",
"example": "cmt043cx4r30343ff",
"description": "Parent Comment ID"
},
"source_id": {
"$ref": "#/components/schemas/Id",
"example": "src0Adp9PMG9o7uJy",
"description": "Source ID"
},
"base_id": {
"$ref": "#/components/schemas/Id",
"example": "bas0Adp9PMG9o7uJy",
"description": "Base ID"
},
"fk_model_id": {
"$ref": "#/components/schemas/Id",
"example": "mod0Adp9PMG9o7uJy",
"description": "Model ID"
},
"created_at": {
"type": "string",
"example": "2020-05-20T12:00:00.000000Z",
"description": "Created At"
},
"updated_at": {
"type": "string",
"example": "2020-05-20T12:00:00.000000Z",
"description": "Updated At"
},
"is_deleted": {
"type": "boolean",
"description": "Whether the comment has been deleted by the user or not"
}
}
},
"UserCommentNotificationPreference": {
"description" : "Model for User Comment Notification Preference",
"type": "object",
"properties": {
"id": {
"$ref": "#/components/schemas/Id",
"description": "Unique ID"
},
"row_id": {
"type": "string",
"description": "User ID"
},
"user_id": {
"$ref": "#/components/schemas/Id",
"description": "User ID"
},
"source_id": {
"$ref": "#/components/schemas/Id",
"example": "src0Adp9PMG9o7uJy",
"description": "Source ID"
},
"base_id": {
"$ref": "#/components/schemas/Id",
"example": "bas0Adp9PMG9o7uJy",
"description": "Base ID"
},
"fk_model_id": {
"$ref": "#/components/schemas/Id",
"example": "mod0Adp9PMG9o7uJy",
"description": "Model ID"
},
"preference": {
"type": "string",
"enum": [
"ALL_COMMENTS",
"ONLY_MENTIONS"
],
"description": "Is Read"
},
"created_at": {
"type": "string",
"description": "Created At"
},
"updated_at": {
"type": "string",
"description": "Updated At"
}
}
},
"CommentReactions": {
"description": "Model for Comment Reactions",
"type": "object",
"properties": {
"id": {
"$ref": "#/components/schemas/Id",
"description": "Unique ID"
},
"row_id": {
"type": "string",
"description": "Row ID"
},
"comment_id": {
"$ref": "#/components/schemas/Id",
"description": "Comment ID"
},
"reaction": {
"type": "string",
"description": "Reaction"
},
"user_id": {
"$ref": "#/components/schemas/Id",
"description": "User ID"
},
"source_id": {
"$ref": "#/components/schemas/Id",
"example": "src0Adp9PMG9o7uJy",
"description": "Source ID"
},
"base_id": {
"$ref": "#/components/schemas/Id",
"example": "bas0Adp9PMG9o7uJy",
"description": "Base ID"
},
"fk_model_id": {
"$ref": "#/components/schemas/Id",
"example": "mod0Adp9PMG9o7uJy",
"description": "Model ID"
},
"created_at": {
"type": "string",
"description": "Created At"
},
"updated_at": {
"type": "string",
"description": "Updated At"
}
}
},
"Extension": {
"type": "object",
"properties": {

41
packages/nocodb/src/services/app-hooks/app-hooks.service.ts

@ -1,42 +1,42 @@
import { Inject, Injectable } from '@nestjs/common';
import type { AppEvents } from 'nocodb-sdk';
import type {
ApiCreatedEvent,
ApiTokenCreateEvent,
ApiTokenDeleteEvent,
AttachmentEvent,
BaseEvent,
ColumnEvent,
FilterEvent,
FormColumnEvent,
GridColumnEvent,
MetaDiffEvent,
OrgUserInviteEvent,
PluginEvent,
PluginTestEvent,
ProjectCreateEvent,
ProjectDeleteEvent,
ProjectInviteEvent,
ProjectUpdateEvent,
ProjectUserResendInviteEvent,
ProjectUserUpdateEvent,
RelationEvent,
RowCommentEvent,
SharedBaseEvent,
SortEvent,
SyncSourceEvent,
TableEvent,
UIAclEvent,
UserEmailVerificationEvent,
UserPasswordChangeEvent,
UserPasswordForgotEvent,
UserPasswordResetEvent,
ViewColumnEvent,
WebhookEvent,
} from './interfaces';
import type { AppEvents } from 'nocodb-sdk';
import type {
ColumnEvent,
FilterEvent,
ProjectCreateEvent,
ProjectDeleteEvent,
ProjectInviteEvent,
ProjectUpdateEvent,
SortEvent,
TableEvent,
UserSigninEvent,
UserSignupEvent,
ViewColumnEvent,
ViewEvent,
WebhookEvent,
WelcomeEvent,
} from '~/services/app-hooks/interfaces';
import { IEventEmitter } from '~/modules/event-emitter/event-emitter.interface';
@ -52,6 +52,14 @@ export class AppHooksService {
@Inject('IEventEmitter') protected readonly eventEmitter: IEventEmitter,
) {}
on(
event:
| AppEvents.COMMENT_CREATE
| AppEvents.COMMENT_UPDATE
| AppEvents.COMMENT_DELETE,
listener: (data: RowCommentEvent) => void,
);
on(
event: AppEvents.PROJECT_INVITE,
listener: (data: ProjectInviteEvent) => void,
@ -164,6 +172,13 @@ export class AppHooksService {
| AppEvents.SHARED_VIEW_CREATE,
data: ViewEvent,
): void;
emit(
event:
| AppEvents.COMMENT_CREATE
| AppEvents.COMMENT_UPDATE
| AppEvents.COMMENT_DELETE,
data: RowCommentEvent,
): void;
emit(
event:
| AppEvents.FILTER_UPDATE

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

@ -1,17 +1,15 @@
import type { SyncSource } from '~/models';
import type {
ApiTokenReqType,
PluginTestReqType,
PluginType,
SourceType,
} from 'nocodb-sdk';
import type {
BaseType,
ColumnType,
FilterType,
HookType,
PluginTestReqType,
PluginType,
ProjectUserReqType,
SortType,
SourceType,
TableType,
UserType,
ViewType,
@ -30,6 +28,15 @@ export interface ProjectInviteEvent extends NcBaseEvent {
ip?: string;
}
export interface RowCommentEvent extends NcBaseEvent {
base: BaseType;
user: UserType;
model: TableType;
rowId: string;
comment: string;
ip?: string;
}
export interface ProjectUserUpdateEvent extends NcBaseEvent {
base: BaseType;
user: UserType;
@ -213,4 +220,5 @@ export type AppEventPayload =
| ViewEvent
| FilterEvent
| SortEvent
| RowCommentEvent
| ColumnEvent;

52
packages/nocodb/src/services/audits.service.ts

@ -1,28 +1,19 @@
import { Injectable } from '@nestjs/common';
import DOMPurify from 'isomorphic-dompurify';
import { AuditOperationSubTypes, AuditOperationTypes } from 'nocodb-sdk';
import type { AuditRowUpdateReqType, CommentUpdateReqType } from 'nocodb-sdk';
import type { AuditRowUpdateReqType } from 'nocodb-sdk';
import { AppHooksListenerService } from '~/services/app-hooks-listener.service';
import { NcError } from '~/helpers/catchError';
import { validatePayload } from '~/helpers';
import { Audit, Model } from '~/models';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
@Injectable()
export class AuditsService {
constructor(
protected readonly appHooksListenerService: AppHooksListenerService,
protected readonly appHooksService: AppHooksService,
) {}
async commentRow(param: { body: AuditRowUpdateReqType; user: any }) {
validatePayload('swagger.json#/components/schemas/CommentReq', param.body);
return await Audit.insert({
...param.body,
user: param.user?.email,
op_type: AuditOperationTypes.COMMENT,
});
}
async auditRowUpdate(param: { rowId: string; body: AuditRowUpdateReqType }) {
validatePayload(
'swagger.json#/components/schemas/AuditRowUpdateReq',
@ -63,8 +54,13 @@ export class AuditsService {
// })
}
async commentList(param: { query: any }) {
return await Audit.commentsList(param.query);
async auditOnlyList(param: {
query: {
row_id: string;
fk_model_id: string;
};
}) {
return await Audit.auditList(param.query);
}
async auditList(param: { query: any; baseId: string }) {
@ -75,34 +71,6 @@ export class AuditsService {
return await Audit.baseAuditCount(param.baseId, param.query?.sourceId);
}
async commentsCount(param: { fk_model_id: string; ids: string[] }) {
return await Audit.commentsCount({
fk_model_id: param.fk_model_id as string,
ids: param.ids as string[],
});
}
async commentUpdate(param: {
auditId: string;
userEmail: string;
body: CommentUpdateReqType;
}) {
validatePayload(
'swagger.json#/components/schemas/CommentUpdateReq',
param.body,
);
const log = await Audit.get(param.auditId);
if (log.op_type !== AuditOperationTypes.COMMENT) {
NcError.forbidden('Only comments can be updated');
}
if (log.user !== param.userEmail) {
NcError.unauthorized('Unauthorized access');
}
return await Audit.commentUpdate(param.auditId, param.body);
}
async baseAuditList(param: { query: any; sourceId: any }) {
return await Audit.baseAuditList(param.sourceId, param.query);
}

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

@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import { AppEvents } from 'nocodb-sdk';
import { Base, Model } from '../models';
import type {
CommentReqType,
CommentUpdateReqType,
UserType,
} from 'nocodb-sdk';
import type { NcRequest } from '~/interface/config';
import { NcError } from '~/helpers/catchError';
import { validatePayload } from '~/helpers';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import Comment from '~/models/Comment';
@Injectable()
export class CommentsService {
constructor(protected readonly appHooksService: AppHooksService) {}
async commentRow(param: {
body: CommentReqType;
user: UserType;
req: NcRequest;
}) {
validatePayload('swagger.json#/components/schemas/CommentReq', param.body);
const res = await Comment.insert({
...param.body,
created_by: param.user?.id,
created_by_email: param.user?.email,
});
const model = await Model.getByIdOrName({ id: param.body.fk_model_id });
this.appHooksService.emit(AppEvents.COMMENT_CREATE, {
base: await Base.getByTitleOrId(model.base_id),
model: model,
user: param.user,
comment: param.body.comment,
rowId: param.body.row_id,
req: param.req,
});
return res;
}
async commentDelete(param: { commentId: string; user: UserType }) {
const comment = await Comment.get(param.commentId);
if (comment.created_by !== param.user.id || comment.is_deleted) {
NcError.unauthorized('Unauthorized access');
}
const res = await Comment.delete(param.commentId);
const model = await Model.getByIdOrName({ id: comment.fk_model_id });
this.appHooksService.emit(AppEvents.COMMENT_DELETE, {
base: await Base.getByTitleOrId(model.base_id),
model: model,
user: param.user,
comment: comment.comment,
rowId: comment.row_id,
req: {},
});
return res;
}
async commentList(param: {
query: {
row_id: string;
fk_model_id: string;
};
}) {
return await Comment.list(param.query);
}
async commentsCount(param: { fk_model_id: string; ids: string[] }) {
return await Comment.commentsCount({
fk_model_id: param.fk_model_id as string,
ids: param.ids as string[],
});
}
async commentUpdate(param: {
commentId: string;
user: UserType;
body: CommentUpdateReqType;
req: NcRequest;
}) {
validatePayload(
'swagger.json#/components/schemas/CommentUpdateReq',
param.body,
);
const comment = await Comment.get(param.commentId);
if (comment.created_by !== param.user.id || comment.is_deleted) {
NcError.unauthorized('Unauthorized access');
}
const res = await Comment.update(param.commentId, {
comment: param.body.comment,
});
const model = await Model.getByIdOrName({ id: param.body.fk_model_id });
this.appHooksService.emit(AppEvents.COMMENT_UPDATE, {
base: await Base.getByTitleOrId(model.base_id),
model: model,
user: param.user,
comment: param.body.comment,
rowId: comment.row_id,
req: param.req,
});
return res;
}
}

2
packages/nocodb/src/utils/acl.ts

@ -92,6 +92,7 @@ const permissionScopes = {
'swaggerJson',
'commentList',
'commentsCount',
'commentDelete',
'commentUpdate',
'hideAllColumns',
'showAllColumns',
@ -210,6 +211,7 @@ const rolePermissions:
commentsCount: true,
commentRow: true,
commentUpdate: true,
commentDelete: true,
},
},
[ProjectRoles.EDITOR]: {

3
packages/nocodb/src/utils/globals.ts

@ -48,6 +48,9 @@ export enum MetaTable {
NOTIFICATION = 'notification',
USER_REFRESH_TOKENS = 'nc_user_refresh_tokens',
EXTENSIONS = 'nc_extensions',
COMMENTS = 'nc_comments',
USER_COMMENTS_NOTIFICATIONS_PREFERENCE = 'nc_user_comment_notifications_preference',
COMMENTS_REACTIONS = 'nc_comment_reactions',
}
export enum MetaTableOldV2 {

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

@ -0,0 +1,17 @@
export const extractMentions = (richText: string) => {
const mentions: string[] = [];
// The Mentions are stored as follows @(userId|email|display_name) in the rich text
// Extracts the userId from the content
const regex = /@\(([^)]+)\)/g;
let match: RegExpExecArray | null;
match = regex.exec(richText);
while (match !== null) {
const userId = match[1].split('|')[0]; // Extracts the userId part from the matched string
mentions.push(userId);
}
return Array.from(new Set(mentions));
};

274
pnpm-lock.yaml

@ -53,29 +53,29 @@ importers:
specifier: ^0.5.1
version: 0.5.1(vue@3.4.27)
'@tiptap/extension-link':
specifier: 2.2.6
version: 2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6)
specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0)
'@tiptap/extension-placeholder':
specifier: ^2.2.6
version: 2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6)
specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0)
'@tiptap/extension-task-list':
specifier: 2.2.6
version: 2.2.6(@tiptap/core@2.3.1)
specifier: 2.4.0
version: 2.4.0(@tiptap/core@2.3.1)
'@tiptap/extension-underline':
specifier: ^2.2.6
version: 2.2.6(@tiptap/core@2.3.1)
specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.3.1)
'@tiptap/html':
specifier: 2.2.6
version: 2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6)
specifier: 2.4.0
version: 2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0)
'@tiptap/pm':
specifier: ^2.2.6
version: 2.2.6
specifier: ^2.4.0
version: 2.4.0
'@tiptap/starter-kit':
specifier: ^2.2.6
version: 2.2.6(@tiptap/pm@2.2.6)
specifier: ^2.4.0
version: 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/vue-3':
specifier: 2.2.6
version: 2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6)(vue@3.4.27)
specifier: 2.4.0
version: 2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0)(vue@3.4.27)
'@vue-flow/additional-components':
specifier: ^1.3.3
version: 1.3.3(@vue-flow/core@1.31.0)(vue@3.4.27)
@ -8861,248 +8861,248 @@ packages:
resolution: {integrity: sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==}
dev: false
/@tiptap/core@2.2.6(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-v7S7RhQhTXQo9KSk2jM/jJlTd3clU2FsJA3Omjz7GbgYtPSy67qSiaTbH/tWq12GzDHbKymx+oQnKmyx+yPucA==}
/@tiptap/core@2.3.1(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-ycpQlmczAOc05TgB5sc3RUTEEBXAVmS8MR9PqQzg96qidaRfVkgE+2w4k7t83PMHl2duC0MGqOCy96pLYwSpeg==}
peerDependencies:
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/pm': 2.2.6
'@tiptap/pm': 2.4.0
dev: false
/@tiptap/core@2.3.1(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-ycpQlmczAOc05TgB5sc3RUTEEBXAVmS8MR9PqQzg96qidaRfVkgE+2w4k7t83PMHl2duC0MGqOCy96pLYwSpeg==}
/@tiptap/core@2.4.0(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-YJSahk8pkxpCs8SflCZfTnJpE7IPyUWIylfgXM2DefjRQa5DZ+c6sNY0s/zbxKYFQ6AuHVX40r9pCfcqHChGxQ==}
peerDependencies:
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/pm': 2.2.6
'@tiptap/pm': 2.4.0
dev: false
/@tiptap/extension-blockquote@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-Qoq4Tl4wyEGfuBrMFth5hWP1SroJtgDYPnyzAZeLiGzF3Yxtu7FFqjGtD1/Bos9ftnFVCAj+nIXnuKsM1YUaGg==}
/@tiptap/extension-blockquote@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-nJJy4KsPgQqWTTDOWzFRdjCfG5+QExfZj44dulgDFNh+E66xhamnbM70PklllXJgEcge7xmT5oKM0gKls5XgFw==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-bold@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-PI/jNH7rmi6hBvWy/z+3KUTYqeaDXBUjidM74gWP6OLV28HTJ5SkIPCriYe4u2j2Wc/nk3gPxs4/hPOAu/YiXA==}
/@tiptap/extension-bold@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-csnW6hMDEHoRfxcPRLSqeJn+j35Lgtt1YRiOwn7DlS66sAECGRuoGfCvQSPij0TCDp4VCR9if5Sf8EymhnQumQ==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-bubble-menu@2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-nRWxbgkInhdGUL+e6iISgALcWh8A1PxeVB66w7yYZHS/WoZO0DXdXYT/BWb/XmEJ8r6B4c9SDZRklCiXT8dSXw==}
/@tiptap/extension-bubble-menu@2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-s99HmttUtpW3rScWq8rqk4+CGCwergNZbHLTkF6Rp6TSboMwfp+rwL5Q/JkcAG9KGLso1vGyXKbt1xHOvm8zMw==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.3.1(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.3.1(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
tippy.js: 6.3.7
dev: false
/@tiptap/extension-bullet-list@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-bSrmYlWfj/bXXoBMVB+gCTlsficVVzWi1jcAjAn+qNAENkhampmlFIUG4DiKGYtn18ZoTbyLgQGDMCO3SBdeDQ==}
/@tiptap/extension-bullet-list@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-9S5DLIvFRBoExvmZ+/ErpTvs4Wf1yOEs8WXlKYUCcZssK7brTFj99XDwpHFA29HKDwma5q9UHhr2OB2o0JYAdw==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-code-block@2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-834gVybNyI4nY6NINqnOosFPa4WKylMQTraEY2KhUH2XU1mh0Ni7EgyK10dfZvOUj90OjaxZtXkyZrZ89RTxog==}
/@tiptap/extension-code-block@2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-QWGdv1D56TBGbbJSj2cIiXGJEKguPiAl9ONzJ/Ql1ZksiQsYwx0YHriXX6TOC//T4VIf6NSClHEtwtxWBQ/Csg==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
dev: false
/@tiptap/extension-code@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-UGsSFvVWrWWWQFU4atk+b/qeewTLadOZG/BHZXQDloyP5eJ1SkgUVy9nv3y2cT8QWRbvF6sxkV+SdFoWnvaG3Q==}
/@tiptap/extension-code@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-wjhBukuiyJMq4cTcK3RBTzUPV24k5n1eEPlpmzku6ThwwkMdwynnMGMAmSF3fErh3AOyOUPoTTjgMYN2d10SJA==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-document@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-yT9m5Oo9U/xAypcylaLiDE8qmVd3SCZSc8s5lqyC1OW+psb1oC0d14+TgKetO2s8K2wAbW2DxYG3yoxWffGYsQ==}
/@tiptap/extension-document@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-3jRodQJZDGbXlRPERaloS+IERg/VwzpC1IO6YSJR9jVIsBO6xC29P3cKTQlg1XO7p6ZH/0ksK73VC5BzzTwoHg==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-dropcursor@2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-mCeIbbfe4rl8CuxVQvT7iYSKGVX/ls1LOwALwlHJz5Uw5l3VknAJdjEmHt6hNFdHu162JivL02Il0QYQ8BZwvA==}
/@tiptap/extension-dropcursor@2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-c46HoG2PEEpSZv5rmS5UX/lJ6/kP1iVO0Ax+6JrNfLEIiDULUoi20NqdjolEa38La2VhWvs+o20OviiTOKEE9g==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
dev: false
/@tiptap/extension-floating-menu@2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-6ONKC6Dx8zCc5YffXpnQ9FxGRoUp5Jm9mOO3losgiDFhdJqaO7SCk1ziOiD7enoWqIc2shZh8ADnqttCfnFVFQ==}
/@tiptap/extension-floating-menu@2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-vLb9v+htbHhXyty0oaXjT3VC8St4xuGSHWUB9GuAJAQ+NajIO6rBPbLUmm9qM0Eh2zico5mpSD1Qtn5FM6xYzg==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.3.1(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.3.1(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
tippy.js: 6.3.7
dev: false
/@tiptap/extension-gapcursor@2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-HDYu+FmL9V+khsiT5904Dy2qG6KrAvnXEjZk1+vVul0TabnQvl2rqHjTxmev3P1rOYTgePmaWXazxAWFIvbMBQ==}
/@tiptap/extension-gapcursor@2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-F4y/0J2lseohkFUw9P2OpKhrJ6dHz69ZScABUvcHxjznJLd6+0Zt7014Lw5PA8/m2d/w0fX8LZQ88pZr4quZPQ==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
dev: false
/@tiptap/extension-hard-break@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-gwavC76sn26XQLyDaDtf28KIcbhMYPP+C5pkbRvAhVSckQB3Ebz3GRttVbm/jp+Uifp3bmoQEzISGCONEdKQoQ==}
/@tiptap/extension-hard-break@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-3+Z6zxevtHza5IsDBZ4lZqvNR3Kvdqwxq/QKCKu9UhJN1DUjsg/l1Jn2NilSQ3NYkBYh2yJjT8CMo9pQIu776g==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-heading@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-XOmY+uezm42xSO1ero2bRBMdQxWytpxLJS+2shK0QogZ3sDplnfWfP5KV9Z2juXjTdPgPWG0ZaHzIIaLquEcfA==}
/@tiptap/extension-heading@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-fYkyP/VMo7YHO76YVrUjd95Qeo0cubWn/Spavmwm1gLTHH/q7xMtbod2Z/F0wd6QHnc7+HGhO7XAjjKWDjldaw==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-history@2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-c2Aeozc+pHcpqghLjXRX/tGU/C+Gp6hApUWPXdhZw5Y/ARj6ZRwx2/ym2K8MOrJ36/W7gc7Xyxd9ZbG7m7pcjA==}
/@tiptap/extension-history@2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-gr5qsKAXEVGr1Lyk1598F7drTaEtAxqZiuuSwTCzZzkiwgEQsWMWTWc9F8FlneCEaqe1aIYg6WKWlmYPaFwr0w==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
dev: false
/@tiptap/extension-horizontal-rule@2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-zyLU+Xlk8y3yBCblE8pFwqAP2Rju1csyAu45hi3NCJ6HDGQGdjy8oh+Xa8y2kTPxRNMZARxqB+vCiEoW3YZn2A==}
/@tiptap/extension-horizontal-rule@2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-yDgxy+YxagcEsBbdWvbQiXYxsv3noS1VTuGwc9G7ZK9xPmBHJ5y0agOkB7HskwsZvJHoaSqNRsh7oZTkf0VR3g==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
dev: false
/@tiptap/extension-italic@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-wB+Y6p2gbc1f2hKYeGNXRQ7P2xi3+JzD3PjSyC9Ss/yyujZhxSOtxBF0nzFXdI+7nmN0Qm4inwPDU/DVrIPb+A==}
/@tiptap/extension-italic@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-aaW/L9q+KNHHK+X73MPloHeIsT191n3VLd3xm6uUcFDnUNvzYJ/q65/1ZicdtCaOLvTutxdrEvhbkrVREX6a8g==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-link@2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-Jj0oXSfQ8gZlzzwd669B8sEKBkoK8xV31Lu55tRv9PKHSU6p9CUqBuxY8qR+cquCtO28f3u0cdl5o4HzeIUL5A==}
/@tiptap/extension-link@2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-r3PjT0bjSKAorHAEBPA0icSMOlqALbxVlWU9vAc+Q3ndzt7ht0CTPNewzFF9kjzARABVt1cblXP/2+c0qGzcsg==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.3.1(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.3.1(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
linkifyjs: 4.1.3
dev: false
/@tiptap/extension-list-item@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-3xig1q0jtOyV49TkAbvxBoOJdNypwq6vLYerfblhj6dK+hIIZUM33S+SmGl2+QaB25VwyeSHjiCvrJjB9PKWHQ==}
/@tiptap/extension-list-item@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-reUVUx+2cI2NIAqMZhlJ9uK/+zvRzm1GTmlU2Wvzwc7AwLN4yemj6mBDsmBLEXAKPvitfLh6EkeHaruOGymQtg==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-ordered-list@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-h4HOv+TAMnoueh3CzUY2/Pp2n8eCdEQtKSfiMtHSO3NTTSlst0XEvq+3Z4K81F+ni3baXc+JUALP5dRVpI4apQ==}
/@tiptap/extension-ordered-list@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-Zo0c9M0aowv+2+jExZiAvhCB83GZMjZsxywmuOrdUbq5EGYKb7q8hDyN3hkrktVHr9UPXdPAYTmLAHztTOHYRA==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-paragraph@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-M2rM3pfzziUb7xS9x2dANCokO89okbqg5IqU4VPkZhk0Mfq9czyCatt58TYkAsE3ccsGhdTYtFBTDeKBtsHUqg==}
/@tiptap/extension-paragraph@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-+yse0Ow67IRwcACd9K/CzBcxlpr9OFnmf0x9uqpaWt1eHck1sJnti6jrw5DVVkyEBHDh/cnkkV49gvctT/NyCw==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-placeholder@2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-eHPadx48gneDD8bTZeRnG4hOvRvctBPY5JlA03QQIoarrbmqsyv3zZSW8smBsRai9kwbXLhytdEFGruTKV9PjQ==}
/@tiptap/extension-placeholder@2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-SmWOjgWpmhFt0BPOnL65abCUH0wS5yksUJgtANn5bQoHF4HFSsyl7ETRmgf0ykxdjc7tzOg31FfpWVH4wzKSYg==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.3.1(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.3.1(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
dev: false
/@tiptap/extension-strike@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-0fRh0SwPgqi+ZKD2NpRrmIAHdsgf27ddEUfvlIuFG5b9zqFa6pRZGpXW/6LyBwU0+0bkjW8/Wg3otyaRGjvZGw==}
/@tiptap/extension-strike@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-pE1uN/fQPOMS3i+zxPYMmPmI3keubnR6ivwM+KdXWOMnBiHl9N4cNpJgq1n2eUUGKLurC2qrQHpnVyGAwBS6Vg==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-task-list@2.2.6(@tiptap/core@2.3.1):
resolution: {integrity: sha512-4ofrnm0jwk9OC5QH+b5wR4ck+J5wi+uOq7Qm2234GAqohjOzr4ndae68NSIv2XviDk04askCoZc+yZBxNwkhmQ==}
/@tiptap/extension-task-list@2.4.0(@tiptap/core@2.3.1):
resolution: {integrity: sha512-vmUB3wEJU81QbiHUygBlselQW8YIW8/85UTwANvWx8+KEWyM7EUF4utcm5R2UobIprIcWb4hyVkvW/5iou25gg==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.3.1(@tiptap/pm@2.2.6)
'@tiptap/core': 2.3.1(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-text@2.2.6(@tiptap/core@2.2.6):
resolution: {integrity: sha512-wVpo0I/2tJsBK/2yNZfRXOsThOfHCdTY+FDNO/USx9MCJaJ3LPs3H1AuGO549zNmZgkD+1MqcZqrYt9n4i03cw==}
/@tiptap/extension-text@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-LV0bvE+VowE8IgLca7pM8ll7quNH+AgEHRbSrsI3SHKDCYB9gTHMjWaAkgkUVaO1u0IfCrjnCLym/PqFKa+vvg==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-underline@2.2.6(@tiptap/core@2.3.1):
resolution: {integrity: sha512-RaYEWuBHS6VQ2KXk+pP2b3xDN4vxmTb7+CF84mumR+CJUK4uAx01IDBUof+h/a4Sa58suNLQ6eHY33NmxPppnQ==}
/@tiptap/extension-underline@2.4.0(@tiptap/core@2.3.1):
resolution: {integrity: sha512-guWojb7JxUwLz4OKzwNExJwOkhZjgw/ttkXCMBT0PVe55k998MMYe1nvN0m2SeTW9IxurEPtScH4kYJ0XuSm8Q==}
peerDependencies:
'@tiptap/core': ^2.0.0
dependencies:
'@tiptap/core': 2.3.1(@tiptap/pm@2.2.6)
'@tiptap/core': 2.3.1(@tiptap/pm@2.4.0)
dev: false
/@tiptap/html@2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-RdZ5Zr2b+LShyKaQKlWB3eLSzznH54Er8/78wKLuudE2xiUS1t25O1YgIrGUA1AFC1woSPJsS1uEvxwvKqE6eg==}
/@tiptap/html@2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-iM0sa6t0Hb5GTXnjdKvMDtD3KZgA4Mwx3QADeqfR10EjfPNlkh/BHU83oIhss/2JVRBXiUUDnNxW9cfpHX37/g==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.3.1(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.3.1(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
zeed-dom: 0.10.11
dev: false
/@tiptap/pm@2.2.6:
resolution: {integrity: sha512-gSKJtsaMLiYNwcAdwgnlTVM9zHiHy6/WgJvXFmIoOnUgvMN10Bbr+KO5hoffwgLCCSpIWw6qJoVKMpHBexLm0w==}
/@tiptap/pm@2.4.0:
resolution: {integrity: sha512-B1HMEqGS4MzIVXnpgRZDLm30mxDWj51LkBT/if1XD+hj5gm8B9Q0c84bhvODX6KIs+c6z+zsY9VkVu8w9Yfgxg==}
dependencies:
prosemirror-changeset: 2.2.1
prosemirror-collab: 1.3.1
@ -9124,43 +9124,43 @@ packages:
prosemirror-view: 1.32.7
dev: false
/@tiptap/starter-kit@2.2.6(@tiptap/pm@2.2.6):
resolution: {integrity: sha512-dWdLcx7g9DTYYzlnStft8vNLrnn+nUWj5Hx4i1dRRW31hBvIxnPwFYcEPKd+7xguozuUX5g+P4OYI6M3LOUxlA==}
dependencies:
'@tiptap/core': 2.2.6(@tiptap/pm@2.2.6)
'@tiptap/extension-blockquote': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-bold': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-bullet-list': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-code': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-code-block': 2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6)
'@tiptap/extension-document': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-dropcursor': 2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6)
'@tiptap/extension-gapcursor': 2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6)
'@tiptap/extension-hard-break': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-heading': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-history': 2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6)
'@tiptap/extension-horizontal-rule': 2.2.6(@tiptap/core@2.2.6)(@tiptap/pm@2.2.6)
'@tiptap/extension-italic': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-list-item': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-ordered-list': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-paragraph': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-strike': 2.2.6(@tiptap/core@2.2.6)
'@tiptap/extension-text': 2.2.6(@tiptap/core@2.2.6)
/@tiptap/starter-kit@2.4.0(@tiptap/pm@2.4.0):
resolution: {integrity: sha512-DYYzMZdTEnRn9oZhKOeRCcB+TjhNz5icLlvJKoHoOGL9kCbuUyEf8WRR2OSPckI0+KUIPJL3oHRqO4SqSdTjfg==}
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/extension-blockquote': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-bold': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-bullet-list': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-code': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-code-block': 2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0)
'@tiptap/extension-document': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-dropcursor': 2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0)
'@tiptap/extension-gapcursor': 2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0)
'@tiptap/extension-hard-break': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-heading': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-history': 2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0)
'@tiptap/extension-horizontal-rule': 2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0)
'@tiptap/extension-italic': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-list-item': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-ordered-list': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-paragraph': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-strike': 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-text': 2.4.0(@tiptap/core@2.4.0)
transitivePeerDependencies:
- '@tiptap/pm'
dev: false
/@tiptap/vue-3@2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6)(vue@3.4.27):
resolution: {integrity: sha512-F8hC133AF/48cvZReJun5TV35NtRcoH8LVEGsuHGNkH7BvJjXAciomvEO4HlSfqz1YT8M/hzRGNg1/R6ixv3bw==}
/@tiptap/vue-3@2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0)(vue@3.4.27):
resolution: {integrity: sha512-NCw1Y4ScIrMCKC9YlepUHSAB8jq/PQ2f+AbZKh5bY2t/kMSJYLCJVHq9NFzG4TQtktgMGWCcEQVcDJ7YNpsfxw==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
vue: latest
dependencies:
'@tiptap/core': 2.3.1(@tiptap/pm@2.2.6)
'@tiptap/extension-bubble-menu': 2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6)
'@tiptap/extension-floating-menu': 2.2.6(@tiptap/core@2.3.1)(@tiptap/pm@2.2.6)
'@tiptap/pm': 2.2.6
'@tiptap/core': 2.3.1(@tiptap/pm@2.4.0)
'@tiptap/extension-bubble-menu': 2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0)
'@tiptap/extension-floating-menu': 2.4.0(@tiptap/core@2.3.1)(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
vue: 3.4.27
dev: false

4
tests/playwright/pages/Dashboard/Details/FieldsPage.ts

@ -279,7 +279,9 @@ export class FieldsPage extends BasePage {
await fieldActionDropdown.getByTestId(`nc-field-item-action-${action}`).click();
if (action === 'copy-id') {
await field.getByTestId('nc-field-item-action-button').click();
await field.getByTestId('nc-field-item-action-button').click({
force: true,
});
}
await fieldActionDropdown.waitFor({ state: 'hidden' });

5
tests/playwright/pages/Dashboard/common/Toolbar/index.ts

@ -85,7 +85,10 @@ export class ToolbarPage extends BasePage {
async clickCalendarViewSettings() {
const menuOpen = await this.calendarRange.get().isVisible();
await this.btn_calendarSettings.click();
await this.rootPage.waitForTimeout(500);
await this.btn_calendarSettings.click({
force: true,
});
// Wait for the menu to close
if (menuOpen) await this.calendarRange.get().waitFor({ state: 'hidden' });

3
tests/playwright/tests/db/views/viewCalendar.spec.ts

@ -156,7 +156,8 @@ test.describe('Calendar View', () => {
fromTitle: 'EndDate',
});
await toolbar.clickCalendarViewSettings();
// We close the menu on new range is set
// await toolbar.clickCalendarViewSettings();
// Verify Sidebar
const calendar = dashboard.calendar;

Loading…
Cancel
Save