feat: enable user mentions in task description & comments (#1754)
This commit is contained in:
parent
bf0fd2885d
commit
43a5ae1309
|
|
@ -65,6 +65,7 @@
|
|||
"@tiptap/extension-image": "3.10.2",
|
||||
"@tiptap/extension-link": "3.10.2",
|
||||
"@tiptap/extension-list": "3.10.2",
|
||||
"@tiptap/extension-mention": "3.10.2",
|
||||
"@tiptap/extension-table": "3.10.2",
|
||||
"@tiptap/extension-typography": "3.10.2",
|
||||
"@tiptap/extension-underline": "3.10.2",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ importers:
|
|||
'@tiptap/extension-list':
|
||||
specifier: 3.10.2
|
||||
version: 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)
|
||||
'@tiptap/extension-mention':
|
||||
specifier: 3.10.2
|
||||
version: 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)(@tiptap/suggestion@3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2))
|
||||
'@tiptap/extension-table':
|
||||
specifier: 3.10.2
|
||||
version: 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)
|
||||
|
|
@ -2184,6 +2187,13 @@ packages:
|
|||
'@tiptap/core': ^3.10.2
|
||||
'@tiptap/pm': ^3.10.2
|
||||
|
||||
'@tiptap/extension-mention@3.10.2':
|
||||
resolution: {integrity: sha512-/gyUIMNKBoXOIy0SvjNYJ7G/yiYsnwwbnBo2hQgDza4p53KgnpTzg623/6GHDZSk7zy2S3O+5yWlzEmtrUva3A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.10.2
|
||||
'@tiptap/pm': ^3.10.2
|
||||
'@tiptap/suggestion': ^3.10.2
|
||||
|
||||
'@tiptap/extension-ordered-list@3.10.2':
|
||||
resolution: {integrity: sha512-zs8wK1GNVedGENZPJOYUMtiLLPPASvJtabS2HTLPQGnpVeXfF0toftdVYDhGRGoQBBDLDjNyyW5ARzJwwXbTzQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -8961,6 +8971,12 @@ snapshots:
|
|||
'@tiptap/core': 3.10.2(@tiptap/pm@3.10.2)
|
||||
'@tiptap/pm': 3.10.2
|
||||
|
||||
'@tiptap/extension-mention@3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)(@tiptap/suggestion@3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.10.2(@tiptap/pm@3.10.2)
|
||||
'@tiptap/pm': 3.10.2
|
||||
'@tiptap/suggestion': 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)
|
||||
|
||||
'@tiptap/extension-ordered-list@3.10.2(@tiptap/extension-list@3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<template>
|
||||
<div class="mention-items">
|
||||
<template v-if="items.length">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
class="mention-item"
|
||||
:class="{ 'is-selected': index === selectedIndex }"
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<img
|
||||
:src="item.avatarUrl"
|
||||
alt=""
|
||||
class="mention-avatar"
|
||||
>
|
||||
<div class="mention-info">
|
||||
<p class="mention-name">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p
|
||||
v-if="item.label !== item.username"
|
||||
class="mention-username"
|
||||
>
|
||||
@{{ item.username }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="mention-item no-results"
|
||||
>
|
||||
{{ $t('task.mention.noUsersFound') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/* eslint-disable vue/component-api-style */
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
command: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 0,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
items() {
|
||||
this.selectedIndex = 0
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onKeyDown({event}: {event: KeyboardEvent}) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
this.upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (event.isComposing) {
|
||||
return false
|
||||
}
|
||||
this.enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
upHandler() {
|
||||
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
|
||||
},
|
||||
|
||||
downHandler() {
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
||||
},
|
||||
|
||||
enterHandler() {
|
||||
this.selectItem(this.selectedIndex)
|
||||
},
|
||||
|
||||
selectItem(index: number) {
|
||||
const item = this.items[index]
|
||||
|
||||
if (item) {
|
||||
this.command(item)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mention-items {
|
||||
padding: 0.2rem;
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--white);
|
||||
color: var(--grey-900);
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
min-inline-size: 200px;
|
||||
max-block-size: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
inline-size: 100%;
|
||||
text-align: start;
|
||||
background: transparent;
|
||||
border-radius: $radius;
|
||||
border: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
transition: background-color $transition;
|
||||
|
||||
&.is-selected, &:hover {
|
||||
background: var(--grey-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.no-results {
|
||||
color: var(--grey-500);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.mention-avatar {
|
||||
inline-size: 32px;
|
||||
block-size: 32px;
|
||||
border-radius: 50%;
|
||||
margin-inline-end: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mention-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-inline-size: 0;
|
||||
flex: 1;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mention-name {
|
||||
font-size: 0.9rem;
|
||||
color: var(--grey-800);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mention-username {
|
||||
font-size: 0.75rem;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -158,6 +158,7 @@ import Typography from '@tiptap/extension-typography'
|
|||
import Image from '@tiptap/extension-image'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import {Placeholder} from '@tiptap/extensions'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
|
||||
import {TaskItem, TaskList} from '@tiptap/extension-list'
|
||||
import HardBreak from '@tiptap/extension-hard-break'
|
||||
|
|
@ -166,6 +167,7 @@ import {Node} from '@tiptap/pm/model'
|
|||
|
||||
import Commands from './commands'
|
||||
import suggestionSetup from './suggestion'
|
||||
import mentionSuggestionSetup from './mentionSuggestion'
|
||||
|
||||
import {common, createLowlight} from 'lowlight'
|
||||
|
||||
|
|
@ -190,6 +192,8 @@ const props = withDefaults(defineProps<{
|
|||
placeholder?: string,
|
||||
editShortcut?: string,
|
||||
enableDiscardShortcut?: boolean,
|
||||
enableMentions?: boolean,
|
||||
mentionProjectId?: number,
|
||||
}>(), {
|
||||
uploadCallback: undefined,
|
||||
isEditEnabled: true,
|
||||
|
|
@ -198,6 +202,8 @@ const props = withDefaults(defineProps<{
|
|||
placeholder: '',
|
||||
editShortcut: '',
|
||||
enableDiscardShortcut: false,
|
||||
enableMentions: false,
|
||||
mentionProjectId: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
|
@ -480,6 +486,18 @@ const extensions : Extensions = [
|
|||
PasteHandler,
|
||||
]
|
||||
|
||||
// Add mention extension if enabled
|
||||
if (props.enableMentions && props.mentionProjectId > 0) {
|
||||
extensions.push(
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: mentionSuggestionSetup(props.mentionProjectId),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Add a custom extension for the Escape key
|
||||
if (props.enableDiscardShortcut) {
|
||||
extensions.push(Extension.create({
|
||||
|
|
@ -804,6 +822,21 @@ watch(
|
|||
border-radius: $radius;
|
||||
}
|
||||
|
||||
// Mention styles
|
||||
.mention {
|
||||
background-color: var(--grey-200);
|
||||
color: var(--grey-800);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-300);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--grey-200);
|
||||
color: var(--grey-700);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
import MentionList from './MentionList.vue'
|
||||
import ProjectUserService from '@/services/projectUsers'
|
||||
import { fetchAvatarBlobUrl, getDisplayName } from '@/models/user'
|
||||
import type { IUser } from '@/modelTypes/IUser'
|
||||
import type { MentionNodeAttrs } from '@tiptap/extension-mention'
|
||||
interface MentionItem extends MentionNodeAttrs {
|
||||
id: string
|
||||
label: string
|
||||
username: string
|
||||
avatarUrl: string
|
||||
}
|
||||
|
||||
async function searchUsersForProject(projectId: number, query: string): Promise<MentionItem[]> {
|
||||
const projectUserService = new ProjectUserService()
|
||||
|
||||
// Use server-side search with the 's' parameter
|
||||
const users = await projectUserService.getAll({ projectId }, { s: query }) as IUser[]
|
||||
|
||||
// Fetch avatar URLs for all users
|
||||
const usersWithAvatars = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const avatarUrl = await fetchAvatarBlobUrl(user, 32)
|
||||
return {
|
||||
id: String(user.id),
|
||||
label: getDisplayName(user),
|
||||
username: user.username,
|
||||
avatarUrl: avatarUrl as string,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return usersWithAvatars
|
||||
}
|
||||
|
||||
export default function mentionSuggestionSetup(projectId: number) {
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return {
|
||||
char: '@',
|
||||
|
||||
items: async ({ query }: { query: string }): Promise<MentionItem[]> => {
|
||||
if (!projectId) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
// Return a promise that resolves after debounce delay
|
||||
return new Promise((resolve) => {
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
// Use server-side search - the backend will handle searching by username and display name
|
||||
const users = await searchUsersForProject(projectId, query)
|
||||
|
||||
// Limit results to avoid overwhelming the UI
|
||||
const limit = query ? 10 : 5
|
||||
resolve(users.slice(0, limit))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users for mentions:', error)
|
||||
resolve([])
|
||||
}
|
||||
}, 300) // 300ms debounce delay
|
||||
})
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: VueRenderer
|
||||
let popupElement: HTMLElement | null = null
|
||||
let cleanupFloating: (() => void) | null = null
|
||||
|
||||
const virtualReference = {
|
||||
getBoundingClientRect: () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
} as DOMRect),
|
||||
}
|
||||
|
||||
return {
|
||||
onStart: (props: {
|
||||
editor: Editor
|
||||
clientRect?: (() => DOMRect | null) | null
|
||||
items: MentionItem[]
|
||||
command: (item: MentionItem) => void
|
||||
}) => {
|
||||
component = new VueRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create popup element
|
||||
popupElement = document.createElement('div')
|
||||
popupElement.style.position = 'absolute'
|
||||
popupElement.style.top = '0'
|
||||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '4700'
|
||||
popupElement.appendChild(component.element!)
|
||||
document.body.appendChild(popupElement) // Update virtual reference
|
||||
const rect = props.clientRect()
|
||||
if (rect) {
|
||||
virtualReference.getBoundingClientRect = () => rect
|
||||
// Set up floating positioning
|
||||
const updatePosition = () => {
|
||||
computePosition(virtualReference, popupElement!, {
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip(),
|
||||
shift({ padding: 8 }),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
if (popupElement) {
|
||||
popupElement.style.left = `${x}px`
|
||||
popupElement.style.top = `${y}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updatePosition()
|
||||
cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition)
|
||||
}
|
||||
},
|
||||
|
||||
onUpdate(props: {
|
||||
editor: Editor
|
||||
clientRect?: (() => DOMRect | null) | null
|
||||
items: MentionItem[]
|
||||
command: (item: MentionItem) => void
|
||||
}) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (!props.clientRect || !popupElement) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update virtual reference
|
||||
const rect = props.clientRect()
|
||||
if (rect) {
|
||||
virtualReference.getBoundingClientRect = () => rect
|
||||
}
|
||||
},
|
||||
|
||||
onKeyDown(props: { event: KeyboardEvent }) {
|
||||
if (props.event.key === 'Escape') {
|
||||
if (props.event.isComposing) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (popupElement) {
|
||||
popupElement.style.display = 'none'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (cleanupFloating) {
|
||||
cleanupFloating()
|
||||
}
|
||||
if (popupElement) {
|
||||
document.body.removeChild(popupElement)
|
||||
popupElement = null
|
||||
}
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +101,8 @@
|
|||
:bottom-actions="actions[c.id]"
|
||||
:show-save="true"
|
||||
:enable-discard-shortcut="true"
|
||||
:enable-mentions="true"
|
||||
:mention-project-id="projectId"
|
||||
initial-mode="preview"
|
||||
@update:modelValue="
|
||||
() => {
|
||||
|
|
@ -168,6 +170,8 @@
|
|||
}"
|
||||
:upload-callback="attachmentUpload"
|
||||
:placeholder="$t('task.comment.placeholder')"
|
||||
:enable-mentions="true"
|
||||
:mention-project-id="projectId"
|
||||
@save="addComment()"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -231,6 +235,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
|||
|
||||
const props = withDefaults(defineProps<{
|
||||
taskId: number,
|
||||
projectId: number,
|
||||
canWrite?: boolean
|
||||
initialComments: ITaskComment[]
|
||||
}>(), {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
:show-save="true"
|
||||
edit-shortcut="e"
|
||||
:enable-discard-shortcut="true"
|
||||
:enable-mentions="true"
|
||||
:mention-project-id="modelValue.projectId"
|
||||
@update:modelValue="saveWithDelay"
|
||||
@save="save"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -909,6 +909,9 @@
|
|||
"addedSuccess": "The comment was added successfully.",
|
||||
"permalink": "Copy permalink to this comment"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "No users found"
|
||||
},
|
||||
"deferDueDate": {
|
||||
"title": "Defer due date",
|
||||
"1day": "1 day",
|
||||
|
|
|
|||
|
|
@ -404,6 +404,7 @@
|
|||
<Comments
|
||||
:can-write="canWrite"
|
||||
:task-id="taskId"
|
||||
:project-id="task.projectId"
|
||||
:initial-comments="task.comments"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,25 +17,68 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func FindMentionedUsersInText(s *xorm.Session, text string) (users map[int64]*user.User, err error) {
|
||||
reg := regexp.MustCompile(`@\w+`)
|
||||
matches := reg.FindAllString(text, -1)
|
||||
if matches == nil {
|
||||
userIDs := extractMentionedUserIDs(text)
|
||||
if len(userIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
usernames := []string{}
|
||||
for _, match := range matches {
|
||||
usernames = append(usernames, strings.TrimPrefix(match, "@"))
|
||||
return user.GetUsersByIDs(s, userIDs)
|
||||
}
|
||||
|
||||
// extractMentionedUserIDs parses HTML content and extracts user IDs from mention spans.
|
||||
// It looks for <span class="mention" data-id="123"> elements and returns the user IDs.
|
||||
func extractMentionedUserIDs(htmlText string) []int64 {
|
||||
doc, err := html.Parse(strings.NewReader(htmlText))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return user.GetUsersByUsername(s, usernames, true)
|
||||
var userIDs []int64
|
||||
seen := make(map[int64]bool) // Deduplicate user IDs
|
||||
|
||||
var traverse func(*html.Node)
|
||||
traverse = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "span" {
|
||||
isMention := false
|
||||
var dataID string
|
||||
|
||||
// Check if this span has class="mention" and extract data-id
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key == "class" && strings.Contains(attr.Val, "mention") {
|
||||
isMention = true
|
||||
}
|
||||
if attr.Key == "data-id" {
|
||||
dataID = attr.Val
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a mention span with a valid data-id, extract the user ID
|
||||
if isMention && dataID != "" {
|
||||
if userID, err := strconv.ParseInt(dataID, 10, 64); err == nil {
|
||||
if !seen[userID] {
|
||||
userIDs = append(userIDs, userID)
|
||||
seen[userID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse child nodes
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
traverse(child)
|
||||
}
|
||||
}
|
||||
|
||||
traverse(doc)
|
||||
return userIDs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,31 +43,31 @@ func TestFindMentionedUsersInText(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "no users mentioned",
|
||||
text: "Lorem Ipsum dolor sit amet",
|
||||
text: "<p>Lorem Ipsum dolor sit amet</p>",
|
||||
},
|
||||
{
|
||||
name: "one user at the beginning",
|
||||
text: "@user1 Lorem Ipsum",
|
||||
text: `<p><span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Lorem Ipsum</p>`,
|
||||
wantUsers: []*user.User{user1},
|
||||
},
|
||||
{
|
||||
name: "one user at the end",
|
||||
text: "Lorem Ipsum @user1",
|
||||
text: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span></p>`,
|
||||
wantUsers: []*user.User{user1},
|
||||
},
|
||||
{
|
||||
name: "one user in the middle",
|
||||
text: "Lorem @user1 Ipsum",
|
||||
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum</p>`,
|
||||
wantUsers: []*user.User{user1},
|
||||
},
|
||||
{
|
||||
name: "same user multiple times",
|
||||
text: "Lorem @user1 Ipsum @user1 @user1",
|
||||
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span></p>`,
|
||||
wantUsers: []*user.User{user1},
|
||||
},
|
||||
{
|
||||
name: "Multiple users",
|
||||
text: "Lorem @user1 Ipsum @user2",
|
||||
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
|
||||
wantUsers: []*user.User{user1, user2},
|
||||
},
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ func TestSendingMentionNotification(t *testing.T) {
|
|||
task, err := GetTaskByIDSimple(s, 32)
|
||||
require.NoError(t, err)
|
||||
tc := &TaskComment{
|
||||
Comment: "Lorem Ipsum @user1 @user2 @user3 @user4 @user5 @user6",
|
||||
Comment: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span> <span class="mention" data-type="mention" data-id="3" data-label="user3">@user3</span> <span class="mention" data-type="mention" data-id="4" data-label="user4">@user4</span> <span class="mention" data-type="mention" data-id="5" data-label="user5">@user5</span> <span class="mention" data-type="mention" data-id="6" data-label="user6">@user6</span></p>`,
|
||||
TaskID: 32, // user2 has access to the project that task belongs to
|
||||
}
|
||||
err = tc.Create(s, u)
|
||||
|
|
@ -156,7 +156,7 @@ func TestSendingMentionNotification(t *testing.T) {
|
|||
task, err := GetTaskByIDSimple(s, 32)
|
||||
require.NoError(t, err)
|
||||
tc := &TaskComment{
|
||||
Comment: "Lorem Ipsum @user2",
|
||||
Comment: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
|
||||
TaskID: 32, // user2 has access to the project that task belongs to
|
||||
}
|
||||
err = tc.Create(s, u)
|
||||
|
|
@ -170,7 +170,7 @@ func TestSendingMentionNotification(t *testing.T) {
|
|||
_, err = notifyMentionedUsers(s, &task, tc.Comment, n)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = notifyMentionedUsers(s, &task, "Lorem Ipsum @user2 @user3", n)
|
||||
_, err = notifyMentionedUsers(s, &task, `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span> <span class="mention" data-type="mention" data-id="3" data-label="user3">@user3</span></p>`, n)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The second time mentioning the user in the same task should not create another notification
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ func TestTaskComment_Create(t *testing.T) {
|
|||
task, err := GetTaskByIDSimple(s, 32)
|
||||
require.NoError(t, err)
|
||||
tc := &TaskComment{
|
||||
Comment: "Lorem Ipsum @user2",
|
||||
Comment: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
|
||||
TaskID: 32, // user2 has access to the project that task belongs to
|
||||
}
|
||||
err = tc.Create(s, u)
|
||||
|
|
|
|||
Loading…
Reference in New Issue