mirror of https://github.com/nocodb/nocodb
Browse Source
* 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.tspull/8576/head
Anbarasu
5 months ago
committed by
GitHub
41 changed files with 2521 additions and 595 deletions
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 2.8 KiB |
@ -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> |
@ -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> |
@ -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, |
||||
}); |
||||
} |
||||
} |
@ -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 }; |
@ -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)); |
||||
} |
||||
} |
@ -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; |
||||
} |
||||
} |
@ -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)); |
||||
}; |
Loading…
Reference in new issue