Browse Source

Merge pull request #9766 from nocodb/nc-mention-richtext

Nc feat/long-text-mentions
nc-nc-expand-rows
Anbarasu 2 months ago committed by GitHub
parent
commit
e126456ca7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      packages/nc-gui/components.d.ts
  2. 145
      packages/nc-gui/components/cell/RichText.vue
  3. 58
      packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue
  4. 86
      packages/nc-gui/components/cell/TextArea.vue
  5. 1
      packages/nc-gui/components/notification/Item.vue
  6. 7
      packages/nc-gui/components/notification/Item/MentionEvent.vue
  7. 7
      packages/nc-gui/components/notification/Item/RowMentionEvent.vue
  8. 42
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  9. 125
      packages/nc-gui/helpers/tiptapExtensions/mention/MentionList.vue
  10. 36
      packages/nc-gui/helpers/tiptapExtensions/mention/index.ts
  11. 61
      packages/nc-gui/helpers/tiptapExtensions/mention/suggestion.ts
  12. 15
      packages/nc-gui/package.json
  13. 72
      packages/nc-mail-templates/src/templates/MentionRow.vue
  14. 2
      packages/nocodb-sdk/src/lib/enums.ts
  15. 12
      packages/nocodb/src/db/BaseModelSqlv2.ts
  16. 9
      packages/nocodb/src/services/app-hooks/app-hooks.service.ts
  17. 9
      packages/nocodb/src/services/app-hooks/interfaces.ts
  18. 81
      pnpm-lock.yaml

3
packages/nc-gui/components.d.ts vendored

@ -32,7 +32,6 @@ declare module 'vue' {
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutFooter: typeof import('ant-design-vue/es')['LayoutFooter']
@ -40,7 +39,6 @@ declare module 'vue' {
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
@ -69,7 +67,6 @@ declare module 'vue' {
ATimeline: typeof import('ant-design-vue/es')['Timeline']
ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATree: typeof import('ant-design-vue/es')['Tree']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
CilFullscreen: typeof import('~icons/cil/fullscreen')['default']

145
packages/nc-gui/components/cell/RichText.vue

@ -9,7 +9,8 @@ import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import { TaskItem } from '~/helpers/dbTiptapExtensions/task-item'
import { Link } from '~/helpers/dbTiptapExtensions/links'
import type { RichTextBubbleMenuOptions } from '#imports'
import { Mention } from '~/helpers/tiptapExtensions/mention'
import suggestion from '~/helpers/tiptapExtensions/mention/suggestion'
const props = withDefaults(
defineProps<{
@ -30,10 +31,12 @@ const props = withDefaults(
},
)
const emits = defineEmits(['update:value', 'focus', 'blur'])
const emits = defineEmits(['update:value', 'focus', 'blur', 'close'])
const { fullMode, isFormField, hiddenBubbleMenuOptions } = toRefs(props)
const { appInfo } = useGlobal()
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const rowHeight = inject(RowHeightInj, ref(1 as const))
@ -56,6 +59,16 @@ const isFocused = ref(false)
const keys = useMagicKeys()
const meta = inject(MetaInj)!
const { user } = useGlobal()
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const baseUsers = computed(() => (meta.value.base_id ? basesUser.value.get(meta.value.base_id) || [] : []))
const localRowHeight = computed(() => {
if (readOnlyCell.value && !isExpandedFormOpen.value && (isGallery.value || isKanban.value)) return 6
@ -100,6 +113,68 @@ turndownService.addRule('strikethrough', {
turndownService.keep(['u', 'del'])
if (appInfo.value.ee) {
const renderer = new marked.Renderer()
renderer.paragraph = (text: string) => {
const regex = /@\(([^)]+)\)/g
const replacement = (match: string, content: string) => {
const id = content.split('|')[0]
let bUser = baseUsers.value.find((user) => user.id === id)
if (!bUser) {
bUser = {
id,
email: content.split('|')[1],
display_name: content.split('|')[2],
} as any
}
const processedContent = bUser?.display_name && bUser.display_name.length > 0 ? bUser.display_name : bUser?.email
const colorStyles = bUser?.id === user.value?.id ? '' : 'bg-[#D4F7E0] text-[#17803D]'
const span = document.createElement('span')
span.setAttribute('data-type', 'mention')
span.setAttribute(
'data-id',
JSON.stringify({
id: bUser?.id,
email: bUser?.email,
name: bUser?.display_name ?? '',
isSameUser: bUser?.id === user.value?.id,
}),
)
span.setAttribute('class', `${colorStyles} mention font-semibold m-0.5 rounded-md px-1`)
span.textContent = `@${processedContent}`
return span.outerHTML
}
return text.replace(regex, replacement)
}
marked.use({ renderer })
turndownService.addRule('mention', {
filter: (node) => {
return node.nodeName === 'SPAN' && node.classList.contains('mention')
},
replacement: (content) => {
content = content.substring(1).split('|')[0]
const user = baseUsers.value
.map((user) => ({
id: user.id,
label: user?.display_name && user.display_name.length > 0 ? user.display_name : user.email,
name: user.display_name,
email: user.email,
}))
.find((user) => user.label.toLowerCase() === content.toLowerCase()) as any
return `@(${user.id}|${user.email}|${user.display_name ?? ''})`
},
})
}
const checkListItem = {
name: 'checkListItem',
level: 'block',
@ -145,6 +220,24 @@ const richTextLinkOptionRef = ref<HTMLElement | null>(null)
const vModel = useVModel(props, 'value', emits, { defaultValue: '' })
const tiptapExtensions = [
...(appInfo.value.ee
? [
Mention.configure({
suggestion: {
...suggestion,
items: ({ query }) =>
baseUsers.value
.filter((user) => user.deleted !== true)
.map((user) => ({
id: user.id,
name: user.display_name,
email: user.email,
}))
.filter((user) => (user.name ?? user.email).toLowerCase().includes(query.toLowerCase())),
},
}),
]
: []),
StarterKit.configure({
heading: isFormField.value ? false : undefined,
}),
@ -176,7 +269,11 @@ const editor = useEditor({
emits('focus')
},
onBlur: (e) => {
if (!(e?.event?.relatedTarget as HTMLElement)?.closest('.bubble-menu, .nc-textarea-rich-editor, .nc-rich-text')) {
if (
!(e?.event?.relatedTarget as HTMLElement)?.closest(
'.bubble-menu, .nc-textarea-rich-editor, .nc-rich-text, .tippy-box, .mention, .nc-mention-list, .tippy-content',
)
) {
isFocused.value = false
emits('blur')
}
@ -248,7 +345,12 @@ useEventListener(
'focusout',
(e: FocusEvent) => {
const targetEl = e?.relatedTarget as HTMLElement
if (targetEl?.classList?.contains('tiptap') || !targetEl?.closest('.bubble-menu, .tippy-content, .nc-textarea-rich-editor')) {
if (
targetEl?.classList?.contains('tiptap') ||
!targetEl?.closest(
'.bubble-menu, .tippy-content, .nc-textarea-rich-editor, .tippy-box, .mention, .nc-mention-list, .tippy-content',
)
) {
isFocused.value = false
emits('blur')
}
@ -260,9 +362,19 @@ useEventListener(
'focusout',
(e: FocusEvent) => {
const targetEl = e?.relatedTarget as HTMLElement
if (!targetEl && (e.target as HTMLElement)?.closest('.bubble-menu, .tippy-content, .nc-textarea-rich-editor')) return
if (!targetEl?.closest('.bubble-menu, .tippy-content, .nc-textarea-rich-editor')) {
if (
!targetEl &&
(e.target as HTMLElement)?.closest(
'.bubble-menu, .tippy-content, .nc-textarea-rich-editor, .tippy-box, .mention, .nc-mention-list, .tippy-content',
)
)
return
if (
!targetEl?.closest(
'.bubble-menu, .tippy-content, .nc-textarea-rich-editor, .tippy-box, .mention, .nc-mention-list, .tippy-content',
)
) {
isFocused.value = false
emits('blur')
}
@ -274,7 +386,11 @@ onClickOutside(editorDom, (e) => {
const targetEl = e?.target as HTMLElement
if (!targetEl?.closest('.bubble-menu,.tippy-content, .nc-textarea-rich-editor')) {
if (
!targetEl?.closest(
'.bubble-menu,.tippy-content, .nc-textarea-rich-editor, .tippy-box, .mention, .nc-mention-list, .tippy-content',
)
) {
isFocused.value = false
emits('blur')
}
@ -309,7 +425,14 @@ onClickOutside(editorDom, (e) => {
}"
>
<div class="scrollbar-thin scrollbar-thumb-gray-200 hover:scrollbar-thumb-gray-300 scrollbar-track-transparent">
<CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" embed-mode :is-form-field="isFormField" />
<CellRichTextSelectedBubbleMenu
v-if="editor"
:editor="editor"
embed-mode
:is-form-field="isFormField"
:enable-close-button="fullMode"
@close="emits('close')"
/>
</div>
</div>
<CellRichTextSelectedBubbleMenuPopup v-if="editor && !isFormField && !isForm" :editor="editor" />
@ -582,6 +705,10 @@ onClickOutside(editorDom, (e) => {
pre {
height: fit-content;
}
.mention span {
display: none;
}
}
.nc-form-field-bubble-menu-wrapper {
@apply absolute -bottom-9 left-1/2 z-50 rounded-lg;

58
packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue

@ -17,15 +17,21 @@ interface Props {
embedMode?: boolean
isFormField?: boolean
hiddenOptions?: RichTextBubbleMenuOptions[]
enableCloseButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
embedMode: false,
isFormField: false,
hiddenOptions: () => [],
enableCloseButton: false,
})
const { editor, embedMode, isFormField, hiddenOptions } = toRefs(props)
const emits = defineEmits(['close'])
const { editor, embedMode, isFormField, hiddenOptions, enableCloseButton } = toRefs(props)
const { appInfo } = useGlobal()
const isEditColumn = inject(EditColumnInj, ref(false))
@ -100,6 +106,32 @@ const isOptionVisible = (option: RichTextBubbleMenuOptions) => {
const showDivider = (options: RichTextBubbleMenuOptions[]) => {
return !isFormField.value || options.some((o) => !hiddenOptions.value.includes(o))
}
const newMentionNode = () => {
if (!editor.value) return
const lastCharacter = editor.value.state.doc.textBetween(
editor.value.state.selection.$from.pos - 1,
editor.value.state.selection.$from.pos,
)
if (lastCharacter === '@') {
editor.value
.chain()
.deleteRange({ from: editor.value.state.selection.$from.pos - 1, to: editor.value.state.selection.$from.pos })
.run()
} else if (lastCharacter !== ' ') {
editor.value?.commands.insertContent(' @')
editor.value?.chain().focus().run()
} else {
editor.value?.commands.insertContent('@')
editor.value?.chain().focus().run()
}
}
const closeTextArea = () => {
emits('close')
}
</script>
<template>
@ -341,6 +373,26 @@ const showDivider = (options: RichTextBubbleMenuOptions[]) => {
</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
size="small"
:class="{ 'is-active': editor?.isActive('suggestions') }"
:tabindex="tabIndex"
type="text"
@click="newMentionNode"
>
<GeneralIcon icon="atSign" />
</NcButton>
</NcTooltip>
<div
v-if="
showDivider([
@ -374,6 +426,10 @@ const showDivider = (options: RichTextBubbleMenuOptions[]) => {
</div>
</NcButton>
</NcTooltip>
<NcButton v-if="enableCloseButton" class="mr-2" type="text" size="small" @click="closeTextArea">
<GeneralIcon icon="close" />
</NcButton>
</div>
</template>

86
packages/nc-gui/components/cell/TextArea.vue

@ -87,6 +87,16 @@ watch(isVisible, () => {
onClickOutside(inputWrapperRef, (e) => {
if ((e.target as HTMLElement)?.className.includes('nc-long-text-toggle-expand')) return
const targetEl = e?.target as HTMLElement
if (
targetEl?.closest(
'.bubble-menu, .tippy-content, .nc-textarea-rich-editor, .tippy-box, .mention, .nc-mention-list, .tippy-content',
)
) {
return
}
isVisible.value = false
})
@ -195,6 +205,72 @@ watch(inputWrapperRef, () => {
modal.parentElement.removeEventListener('mouseup', stopPropagation)
}
})
const handleClose = () => {
isVisible.value = false
}
const STORAGE_KEY = 'nc-long-text-expanded-modal-size'
const { width: widthTextArea, height: heightTextArea } = useElementSize(inputRef)
watch([widthTextArea, heightTextArea], () => {
if (isVisible.value) {
const size = {
width: widthTextArea.value,
height: heightTextArea.value,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(size))
}
})
const updateSize = () => {
try {
const size = localStorage.getItem(STORAGE_KEY)
let elem = document.querySelector('.nc-text-area-expanded') as HTMLElement
if (isRichMode.value) {
elem = document.querySelector('.nc-long-text-expanded-modal .nc-textarea-rich-editor .tiptap') as HTMLElement
}
const parsedJSON = JSON.parse(size)
if (parsedJSON && elem) {
elem.style.width = `${parsedJSON.width}px`
elem.style.height = `${parsedJSON.height}px`
}
} catch (e) {
console.error(e)
}
}
watch([isVisible, inputRef], (value) => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
if (!isVisible.value) {
return
}
localStorage.setItem(STORAGE_KEY, JSON.stringify({ width, height }))
}
})
if (value) {
if (isRichMode.value) {
setTimeout(() => {
observer.observe(document.querySelector('.nc-long-text-expanded-modal .nc-textarea-rich-editor .tiptap') as HTMLElement)
updateSize()
}, 50)
} else {
updateSize()
}
} else {
observer.disconnect()
}
})
</script>
<template>
@ -351,12 +427,18 @@ watch(inputWrapperRef, () => {
{{ column.title }}
</span>
</div>
<div class="flex-1" />
<NcButton class="mr-2" type="text" size="small" @click="isVisible = false">
<GeneralIcon icon="close" />
</NcButton>
</div>
<div v-if="!isRichMode" class="p-3 pb-0 h-full">
<a-textarea
ref="inputRef"
v-model:value="vModel"
class="nc-text-area-expanded !py-1 !px-3 !text-black !cursor-text !min-h-[210px] !rounded-lg focus:border-brand-500 disabled:!bg-gray-50 nc-longtext-scrollbar"
class="nc-text-area-expanded !py-1 !px-3 !text-black !transition-none !cursor-text !min-h-[210px] !rounded-lg focus:border-brand-500 disabled:!bg-gray-50 nc-longtext-scrollbar"
:placeholder="$t('activity.enterText')"
:style="{ resize: 'both' }"
:disabled="readOnly"
@ -365,7 +447,7 @@ watch(inputWrapperRef, () => {
/>
</div>
<LazyCellRichText v-else v-model:value="vModel" show-menu full-mode :read-only="readOnly" />
<LazyCellRichText v-else v-model:value="vModel" show-menu full-mode :read-only="readOnly" @close="handleClose" />
</div>
</a-modal>
</div>

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

@ -19,6 +19,7 @@ const { toggleRead } = notificationStore
<NotificationItemProjectInvite v-else-if="item.type === AppEvents.PROJECT_INVITE" :item="item" />
<NotificationItemWorkspaceInvite v-else-if="item.type === AppEvents.WORKSPACE_INVITE" :item="item" />
<NotificationItemMentionEvent v-else-if="['mention'].includes(item.type)" :item="item" />
<NotificationItemRowMentionEvent v-else-if="AppEvents.ROW_USER_MENTION === item.type" :item="item" />
<span v-else />
</div>
</template>

7
packages/nc-gui/components/notification/Item/MentionEvent.vue

@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div></div>
</template>
<style scoped lang="scss"></style>

7
packages/nc-gui/components/notification/Item/RowMentionEvent.vue

@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div></div>
</template>
<style scoped lang="scss"></style>

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

@ -69,6 +69,8 @@ const maintainDefaultViewOrder = toRef(props, 'maintainDefaultViewOrder')
const route = useRoute()
const router = useRouter()
const isPublic = inject(IsPublicInj, ref(false))
// to check if a expanded form which is not yet saved exist or not
@ -548,6 +550,8 @@ const isReadOnlyVirtualCell = (column: ColumnType) => {
)
}
const mentionedCell = ref('')
// Small hack. We need to scroll to the bottom of the form after its mounted and back to top.
// So that tab to next row works properly, as otherwise browser will focus to save button
// when we reach to the bottom of the visual scrollable area, not the actual bottom of the form
@ -560,7 +564,25 @@ watch([expandedFormScrollWrapper, isLoading], () => {
expandedFormScrollWrapperEl.scrollTop = expandedFormScrollWrapperEl.scrollHeight
setTimeout(() => {
expandedFormScrollWrapperEl.scrollTop = 0
nextTick(() => {
const query = router.currentRoute.value.query
const columnId = query.columnId
if (columnId) {
router.push({
query: {
rowId: query.rowId,
},
})
mentionedCell.value = columnId as string
scrollToColumn(columnId as string)
onClickOutside(document.querySelector(`[col-id="${columnId}"]`)! as HTMLDivElement, () => {
mentionedCell.value = null
})
} else {
expandedFormScrollWrapperEl.scrollTop = 0
}
})
}, 125)
}
})
@ -584,6 +606,16 @@ watch(
emits('updateRowCommentCount', commentCount)
},
)
function scrollToColumn(columnId: string) {
const columnEl = document.querySelector(`.${columnId}`)
if (columnEl) {
columnEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
</script>
<script lang="ts">
@ -812,6 +844,7 @@ export default {
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(col),
'!select-text nc-readonly-div-data-cell': readOnly,
'nc-mentioned-cell': col.id === mentionedCell,
}"
>
<LazySmartsheetVirtualCell
@ -888,6 +921,7 @@ export default {
:class="{
'!select-text nc-system-field': isReadOnlyVirtualCell(col),
'!bg-gray-50 !select-text nc-readonly-div-data-cell': readOnly,
'nc-mentioned-cell': col.id === mentionedCell,
}"
>
<LazySmartsheetVirtualCell
@ -1126,6 +1160,12 @@ export default {
}
}
}
.nc-mentioned-cell {
box-shadow: 0px 0px 0px 2px var(--ant-primary-color-outline) !important;
@apply !border-brand-500 !border-1;
}
.nc-data-cell:focus-within {
@apply !border-1 !border-brand-500;
}

125
packages/nc-gui/helpers/tiptapExtensions/mention/MentionList.vue

@ -0,0 +1,125 @@
<script>
export default {
props: {
items: {
type: Array,
required: true,
},
command: {
type: Function,
required: true,
},
},
data() {
return {
selectedIndex: 0,
user: null,
}
},
watch: {
items() {
this.selectedIndex = 0
},
selectedIndex() {
nextTick(() => {
this.scrollToSelected()
})
},
},
created() {
const { user } = useGlobal()
this.user = user
},
methods: {
onKeyDown({ event }) {
if (event.key === 'ArrowUp') {
this.upHandler()
return true
}
if (event.key === 'ArrowDown') {
this.downHandler()
return true
}
if (event.key === 'Enter') {
event.stopPropagation()
event.preventDefault()
this.enterHandler(event)
return true
}
if (event.key === 'Tab') {
this.selectItem(this.selectedIndex)
return true
}
return false
},
scrollToSelected() {
const selectedElement = this.$el.querySelector('.is-selected')
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest' })
}
},
upHandler() {
this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length
},
downHandler() {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
},
enterHandler(e) {
this.selectItem(this.selectedIndex, e)
},
selectItem(index, _e) {
const item = this.items[index]
if (item) {
this.command({
id: {
...item,
isSameUser: `${item?.id === this.user?.id}`,
},
})
}
},
},
}
</script>
<template>
<div class="w-64 bg-white scroll-smooth nc-mention-list nc-scrollbar-md border-1 border-gray-200 rounded-lg max-h-64 !py-2">
<template v-if="items.length">
<div
v-for="(item, index) in items"
:key="index"
:class="{ 'is-selected': index === selectedIndex }"
class="py-2 flex hover:bg-gray-100 transition-all cursor-pointer items-center text-gray-800 pl-4"
@click="selectItem(index, $event)"
>
<GeneralUserIcon :email="item.email" :name="item.name" class="w-4 h-4 mr-2" size="medium" />
<div class="max-w-64 truncate">
{{ item.name && item.name.length > 0 ? item.name : item.email }}
</div>
</div>
</template>
<div v-else class="px-4">No users</div>
</div>
</template>
<style lang="scss" scoped>
.is-selected {
@apply bg-gray-100;
}
</style>

36
packages/nc-gui/helpers/tiptapExtensions/mention/index.ts

@ -0,0 +1,36 @@
import * as TipTapMention from '@tiptap/extension-mention'
export const Mention = TipTapMention.Mention.extend({
renderHTML({ HTMLAttributes }) {
const attributes =
typeof HTMLAttributes['data-id'] !== 'object' ? JSON.parse(HTMLAttributes['data-id']) : HTMLAttributes['data-id']
const innerText = attributes.name && attributes.name.length > 0 ? attributes.name : attributes.email
const styles =
attributes.isSameUser === true || attributes.isSameUser === 'true'
? 'bg-[#D4F7E0] text-[#17803D]'
: 'bg-brand-50 text-brand-500'
return [
'span',
{
'class': `mention font-semibold ${styles} rounded-md px-1`,
'data-type': 'mention',
'data-id': JSON.stringify(HTMLAttributes['data-id']),
},
[
'span',
{
style: 'font-weight: 800;',
},
'@',
],
innerText,
]
},
renderText({ node }) {
return `@${node.attrs.id.name || node.attrs.id.email || node.attrs.id.id}`
},
deleteTriggerWithBackspace: true,
})

61
packages/nc-gui/helpers/tiptapExtensions/mention/suggestion.ts

@ -0,0 +1,61 @@
import { VueRenderer } from '@tiptap/vue-3'
import tippy from 'tippy.js'
import MentionList from './MentionList.vue'
export default {
render: () => {
let component: VueRenderer
let popup: any
return {
onStart: (props: Record<string, any>) => {
component = new VueRenderer(MentionList, {
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props: Record<string, any>) {
component.updateProps(props)
if (!props.clientRect) {
return
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
})
},
onKeyDown(props: Record<string, any>) {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
},
}
},
}

15
packages/nc-gui/package.json

@ -44,6 +44,7 @@
"@sentry/tracing": "^7.72.0",
"@sentry/vue": "^7.72.0",
"@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-mention": "^2.9.1",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-task-list": "2.4.0",
"@tiptap/extension-underline": "^2.4.0",
@ -57,6 +58,7 @@
"@vuelidate/validators": "^2.0.4",
"@vueuse/core": "^10.7.2",
"@vueuse/integrations": "^10.7.2",
"@vueuse/motion": "^2.2.5",
"ant-design-vue": "^3.2.20",
"chart.js": "^4.4.2",
"crossoriginworker": "^1.1.0",
@ -89,6 +91,10 @@
"pinia": "^2.1.7",
"plyr": "^3.7.8",
"qrcode": "^1.5.3",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"rfdc": "^1.3.1",
"showdown": "^2.1.0",
"socket.io-client": "^4.7.5",
@ -96,6 +102,7 @@
"splitpanes": "^3.1.5",
"tinycolor2": "^1.6.0",
"turndown": "^7.1.3",
"unified": "^11.0.5",
"unique-names-generator": "^4.7.1",
"v3-infinite-loading": "^1.3.1",
"validator": "^13.11.0",
@ -112,13 +119,7 @@
"vue3-text-clamp": "^0.1.2",
"vuedraggable": "^4.1.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"unified": "^11.0.5",
"youtube-vue3": "^0.1.15",
"@vueuse/motion": "^2.2.5"
"youtube-vue3": "^0.1.15"
},
"devDependencies": {
"@antfu/eslint-config": "^0.26.3",

72
packages/nc-mail-templates/src/templates/MentionRow.vue

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

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

@ -155,6 +155,8 @@ export enum AppEvents {
INTEGRATION_DELETE = 'integration.delete',
INTEGRATION_CREATE = 'integration.create',
INTEGRATION_UPDATE = 'integration.update',
ROW_USER_MENTION = 'row.user.mention',
}
export enum ClickhouseTables {

12
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -6,6 +6,7 @@ import utc from 'dayjs/plugin/utc.js';
import timezone from 'dayjs/plugin/timezone';
import equal from 'fast-deep-equal';
import {
AppEvents,
AuditOperationSubTypes,
AuditOperationTypes,
extractFilterFromXwhere,
@ -72,6 +73,7 @@ import { defaultLimitConfig } from '~/helpers/extractLimitAndOffset';
import generateLookupSelectQuery from '~/db/generateLookupSelectQuery';
import { getAliasGenerator } from '~/utils';
import applyAggregation from '~/db/aggregation';
import { extractMentions } from '~/utils/richTextHelper';
dayjs.extend(utc);
@ -6257,6 +6259,14 @@ class BaseModelSqlv2 {
* Hooks
* */
public async handleRichTextMentions(
prevData,
newData: Record<string, any> | Array<Record<string, any>>,
req,
) {
return;
}
public async beforeInsert(data: any, _trx: any, req): Promise<void> {
await this.handleHooks('before.insert', null, data, req);
}
@ -6342,6 +6352,8 @@ class BaseModelSqlv2 {
ip: req?.clientIp,
user: req?.user?.email,
});
await this.handleRichTextMentions(prevData, newData, req);
}
public async afterBulkDelete(

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

@ -40,6 +40,7 @@ import type {
WelcomeEvent,
} from '~/services/app-hooks/interfaces';
import type { IntegrationEvent } from '~/services/app-hooks/interfaces';
import type { RowMentionEvent } from '~/services/app-hooks/interfaces';
import { IEventEmitter } from '~/modules/event-emitter/event-emitter.interface';
const ALL_EVENTS = '__nc_all_events__';
@ -126,6 +127,11 @@ export class AppHooksService {
| AppEvents.COLUMN_CREATE,
listener: (data: ColumnEvent) => void,
): () => void;
on(
event: AppEvents.ROW_USER_MENTION,
listener: (data: RowMentionEvent) => void,
): () => void;
on(
event:
| AppEvents.INTEGRATION_UPDATE
@ -271,9 +277,12 @@ export class AppHooksService {
event: AppEvents.USER_EMAIL_VERIFICATION,
data: UserEmailVerificationEvent,
): void;
emit(event: AppEvents.ROW_USER_MENTION, data: RowMentionEvent): void;
emit(event: AppEvents.EXTENSION_CREATE, data: any): void;
emit(event: AppEvents.EXTENSION_UPDATE, data: any): void;
emit(event: AppEvents.EXTENSION_DELETE, data: any): void;
emit(
event:
| AppEvents.INTEGRATION_CREATE

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

@ -39,6 +39,14 @@ export interface RowCommentEvent extends NcBaseEvent {
ip?: string;
}
export interface RowMentionEvent extends NcBaseEvent {
model: TableType;
rowId: string;
user: UserType;
column: ColumnType;
mentions: string[];
}
export interface ProjectUserUpdateEvent extends NcBaseEvent {
base: BaseType;
user: UserType;
@ -229,6 +237,7 @@ export type AppEventPayload =
| FilterEvent
| SortEvent
| RowCommentEvent
| RowMentionEvent
| WebhookTriggerEvent
| ColumnEvent;

81
pnpm-lock.yaml

@ -55,6 +55,9 @@ importers:
'@tiptap/extension-link':
specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)
'@tiptap/extension-mention':
specifier: ^2.9.1
version: 2.9.1(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)(@tiptap/suggestion@2.9.1(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0))
'@tiptap/extension-placeholder':
specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)
@ -549,7 +552,7 @@ importers:
version: 5.0.8(eslint@8.56.0)(typescript@5.6.2)
eslint-plugin-import:
specifier: ^2.29.1
version: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.4.5))(eslint@8.56.0)
version: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.56.0)(typescript@5.6.2))(eslint@8.56.0)
eslint-plugin-prettier:
specifier: ^4.2.1
version: 4.2.1(eslint-config-prettier@8.10.0(eslint@8.56.0))(eslint@8.56.0)(prettier@2.8.8)
@ -4850,6 +4853,13 @@ packages:
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/extension-mention@2.9.1':
resolution: {integrity: sha512-2IzunpivdNtDNdtAXwRiQbNhTm87zrbkhz1cCE+2y9pWiX1QLXyx0HQq/DIAjxp6v7y4sIh+5UTUTFlH7vD9wQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/suggestion': ^2.7.0
'@tiptap/extension-ordered-list@2.4.0':
resolution: {integrity: sha512-Zo0c9M0aowv+2+jExZiAvhCB83GZMjZsxywmuOrdUbq5EGYKb7q8hDyN3hkrktVHr9UPXdPAYTmLAHztTOHYRA==}
peerDependencies:
@ -4898,6 +4908,12 @@ packages:
'@tiptap/starter-kit@2.4.0':
resolution: {integrity: sha512-DYYzMZdTEnRn9oZhKOeRCcB+TjhNz5icLlvJKoHoOGL9kCbuUyEf8WRR2OSPckI0+KUIPJL3oHRqO4SqSdTjfg==}
'@tiptap/suggestion@2.9.1':
resolution: {integrity: sha512-MMxwpbtocxUsbmc8qtFY1AQYNTW5i/M4aNSv9zsKKRISaS5hMD7XVrw2eod0x0yEqZU3izLiPDZPmgr8glF+jQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/vue-3@2.4.0':
resolution: {integrity: sha512-NCw1Y4ScIrMCKC9YlepUHSAB8jq/PQ2f+AbZKh5bY2t/kMSJYLCJVHq9NFzG4TQtktgMGWCcEQVcDJ7YNpsfxw==}
peerDependencies:
@ -5610,36 +5626,24 @@ packages:
'@vue/compiler-core@3.4.27':
resolution: {integrity: sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg==}
'@vue/compiler-core@3.5.11':
resolution: {integrity: sha512-PwAdxs7/9Hc3ieBO12tXzmTD+Ln4qhT/56S+8DvrrZ4kLDn4Z/AMUr8tXJD0axiJBS0RKIoNaR0yMuQB9v9Udg==}
'@vue/compiler-core@3.5.12':
resolution: {integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==}
'@vue/compiler-dom@3.4.27':
resolution: {integrity: sha512-kUTvochG/oVgE1w5ViSr3KUBh9X7CWirebA3bezTbB5ZKBQZwR2Mwj9uoSKRMFcz4gSMzzLXBPD6KpCLb9nvWw==}
'@vue/compiler-dom@3.5.11':
resolution: {integrity: sha512-pyGf8zdbDDRkBrEzf8p7BQlMKNNF5Fk/Cf/fQ6PiUz9at4OaUfyXW0dGJTo2Vl1f5U9jSLCNf0EZJEogLXoeew==}
'@vue/compiler-dom@3.5.12':
resolution: {integrity: sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==}
'@vue/compiler-sfc@3.4.27':
resolution: {integrity: sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA==}
'@vue/compiler-sfc@3.5.11':
resolution: {integrity: sha512-gsbBtT4N9ANXXepprle+X9YLg2htQk1sqH/qGJ/EApl+dgpUBdTv3yP7YlR535uHZY3n6XaR0/bKo0BgwwDniw==}
'@vue/compiler-sfc@3.5.12':
resolution: {integrity: sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==}
'@vue/compiler-ssr@3.4.27':
resolution: {integrity: sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==}
'@vue/compiler-ssr@3.5.11':
resolution: {integrity: sha512-P4+GPjOuC2aFTk1Z4WANvEhyOykcvEd5bIj2KVNGKGfM745LaXGr++5njpdBTzVz5pZifdlR1kpYSJJpIlSePA==}
'@vue/compiler-ssr@3.5.12':
resolution: {integrity: sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==}
@ -5698,9 +5702,6 @@ packages:
'@vue/shared@3.4.27':
resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==}
'@vue/shared@3.5.11':
resolution: {integrity: sha512-W8GgysJVnFo81FthhzurdRAWP/byq3q2qIw70e0JWblzVhjgOMiC2GyovXrZTFQJnFVryYaKGP3Tc9vYzYm6PQ==}
'@vue/shared@3.5.12':
resolution: {integrity: sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==}
@ -6016,6 +6017,7 @@ packages:
acorn-import-assertions@1.9.0:
resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==}
deprecated: package has been renamed to acorn-import-attributes
peerDependencies:
acorn: ^8
@ -20432,6 +20434,12 @@ snapshots:
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/extension-mention@2.9.1(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)(@tiptap/suggestion@2.9.1(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0))':
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
'@tiptap/suggestion': 2.9.1(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)
'@tiptap/extension-ordered-list@2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))':
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
@ -20512,6 +20520,11 @@ snapshots:
transitivePeerDependencies:
- '@tiptap/pm'
'@tiptap/suggestion@2.9.1(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)':
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
'@tiptap/vue-3@2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)(vue@3.5.12(typescript@5.6.2))':
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
@ -21536,7 +21549,7 @@ snapshots:
dependencies:
'@babel/types': 7.25.7
'@rollup/pluginutils': 5.1.2(rollup@4.17.2)
'@vue/compiler-sfc': 3.5.11
'@vue/compiler-sfc': 3.5.12
ast-kit: 0.11.2(rollup@4.17.2)
local-pkg: 0.4.3
magic-string-ast: 0.3.0
@ -21570,14 +21583,6 @@ snapshots:
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-core@3.5.11':
dependencies:
'@babel/parser': 7.25.7
'@vue/shared': 3.5.11
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-core@3.5.12':
dependencies:
'@babel/parser': 7.25.7
@ -21591,11 +21596,6 @@ snapshots:
'@vue/compiler-core': 3.4.27
'@vue/shared': 3.4.27
'@vue/compiler-dom@3.5.11':
dependencies:
'@vue/compiler-core': 3.5.11
'@vue/shared': 3.5.11
'@vue/compiler-dom@3.5.12':
dependencies:
'@vue/compiler-core': 3.5.12
@ -21613,18 +21613,6 @@ snapshots:
postcss: 8.4.38
source-map-js: 1.2.0
'@vue/compiler-sfc@3.5.11':
dependencies:
'@babel/parser': 7.25.7
'@vue/compiler-core': 3.5.11
'@vue/compiler-dom': 3.5.11
'@vue/compiler-ssr': 3.5.11
'@vue/shared': 3.5.11
estree-walker: 2.0.2
magic-string: 0.30.11
postcss: 8.4.47
source-map-js: 1.2.1
'@vue/compiler-sfc@3.5.12':
dependencies:
'@babel/parser': 7.25.7
@ -21642,11 +21630,6 @@ snapshots:
'@vue/compiler-dom': 3.4.27
'@vue/shared': 3.4.27
'@vue/compiler-ssr@3.5.11':
dependencies:
'@vue/compiler-dom': 3.5.11
'@vue/shared': 3.5.11
'@vue/compiler-ssr@3.5.12':
dependencies:
'@vue/compiler-dom': 3.5.12
@ -21775,8 +21758,6 @@ snapshots:
'@vue/shared@3.4.27': {}
'@vue/shared@3.5.11': {}
'@vue/shared@3.5.12': {}
'@vue/test-utils@2.4.6':
@ -32811,7 +32792,7 @@ snapshots:
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.3)
'@babel/plugin-transform-typescript': 7.24.1(@babel/core@7.24.3)
'@vue/babel-plugin-jsx': 1.1.5(@babel/core@7.24.3)
'@vue/compiler-dom': 3.5.11
'@vue/compiler-dom': 3.5.12
kolorist: 1.8.0
magic-string: 0.30.11
vite: 5.2.11(@types/node@20.11.30)(sass@1.71.1)(terser@5.27.0)

Loading…
Cancel
Save