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
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