diff --git a/packages/nc-gui/components.d.ts b/packages/nc-gui/components.d.ts index 39a1d38222..029fe9bc9e 100644 --- a/packages/nc-gui/components.d.ts +++ b/packages/nc-gui/components.d.ts @@ -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'] diff --git a/packages/nc-gui/components/cell/RichText.vue b/packages/nc-gui/components/cell/RichText.vue index de50543f0d..2c1f7d449b 100644 --- a/packages/nc-gui/components/cell/RichText.vue +++ b/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(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) => { }" >
- +
@@ -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; diff --git a/packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue b/packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue index 7f9af40d3c..58f30aac6d 100644 --- a/packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue +++ b/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(), { 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') +} diff --git a/packages/nc-gui/components/cell/TextArea.vue b/packages/nc-gui/components/cell/TextArea.vue index f811b42bed..2f8f8d1b8b 100644 --- a/packages/nc-gui/components/cell/TextArea.vue +++ b/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() + } +}) diff --git a/packages/nc-gui/components/notification/Item/MentionEvent.vue b/packages/nc-gui/components/notification/Item/MentionEvent.vue new file mode 100644 index 0000000000..936032a137 --- /dev/null +++ b/packages/nc-gui/components/notification/Item/MentionEvent.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/nc-gui/components/notification/Item/RowMentionEvent.vue b/packages/nc-gui/components/notification/Item/RowMentionEvent.vue new file mode 100644 index 0000000000..936032a137 --- /dev/null +++ b/packages/nc-gui/components/notification/Item/RowMentionEvent.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/nc-gui/components/smartsheet/expanded-form/index.vue b/packages/nc-gui/components/smartsheet/expanded-form/index.vue index 06076a01ba..2e2cbc5f27 100644 --- a/packages/nc-gui/components/smartsheet/expanded-form/index.vue +++ b/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', + }) + } +} + + + + diff --git a/packages/nc-gui/helpers/tiptapExtensions/mention/index.ts b/packages/nc-gui/helpers/tiptapExtensions/mention/index.ts new file mode 100644 index 0000000000..a49ef3095a --- /dev/null +++ b/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, +}) diff --git a/packages/nc-gui/helpers/tiptapExtensions/mention/suggestion.ts b/packages/nc-gui/helpers/tiptapExtensions/mention/suggestion.ts new file mode 100644 index 0000000000..af6503d080 --- /dev/null +++ b/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) => { + 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) { + component.updateProps(props) + + if (!props.clientRect) { + return + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props: Record) { + if (props.event.key === 'Escape') { + popup[0].hide() + + return true + } + + return component.ref?.onKeyDown(props) + }, + + onExit() { + popup[0].destroy() + component.destroy() + }, + } + }, +} diff --git a/packages/nc-gui/package.json b/packages/nc-gui/package.json index e572423973..e949bac46a 100644 --- a/packages/nc-gui/package.json +++ b/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", diff --git a/packages/nc-mail-templates/src/templates/MentionRow.vue b/packages/nc-mail-templates/src/templates/MentionRow.vue new file mode 100644 index 0000000000..e5fbacb0ee --- /dev/null +++ b/packages/nc-mail-templates/src/templates/MentionRow.vue @@ -0,0 +1,72 @@ + + + diff --git a/packages/nocodb-sdk/src/lib/enums.ts b/packages/nocodb-sdk/src/lib/enums.ts index 8659b950cd..375cfc6f5f 100644 --- a/packages/nocodb-sdk/src/lib/enums.ts +++ b/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 { diff --git a/packages/nocodb/src/db/BaseModelSqlv2.ts b/packages/nocodb/src/db/BaseModelSqlv2.ts index ecb9bd61c5..f002e74481 100644 --- a/packages/nocodb/src/db/BaseModelSqlv2.ts +++ b/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 | Array>, + req, + ) { + return; + } + public async beforeInsert(data: any, _trx: any, req): Promise { 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( diff --git a/packages/nocodb/src/services/app-hooks/app-hooks.service.ts b/packages/nocodb/src/services/app-hooks/app-hooks.service.ts index d41cf6438f..4292a4748d 100644 --- a/packages/nocodb/src/services/app-hooks/app-hooks.service.ts +++ b/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 diff --git a/packages/nocodb/src/services/app-hooks/interfaces.ts b/packages/nocodb/src/services/app-hooks/interfaces.ts index d302b9095c..bf69881d44 100644 --- a/packages/nocodb/src/services/app-hooks/interfaces.ts +++ b/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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d097c672d2..74ca780c53 100644 --- a/pnpm-lock.yaml +++ b/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)