mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 18:47:51 +00:00
feat: Add the ability to mention team in private message (#11758)
This PR allows agents to mention entire teams in private messages using `@team_name` syntax. When a team is mentioned, all team members with inbox access are automatically notified. The scheme changes can be found [here](https://github.com/chatwoot/prosemirror-schema/pull/34). --------- Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
@@ -13,41 +14,89 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['selectAgent']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
const teams = useMapGetter('teams/getTeams');
|
||||
|
||||
const tagAgentsRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.searchKey) {
|
||||
return agents.value;
|
||||
}
|
||||
return agents.value.filter(agent =>
|
||||
agent.name.toLowerCase().includes(props.searchKey.toLowerCase())
|
||||
const search = props.searchKey?.trim().toLowerCase() || '';
|
||||
|
||||
const buildItems = (list, type, infoKey) =>
|
||||
list
|
||||
.map(item => ({
|
||||
...item,
|
||||
type,
|
||||
displayName: item.name,
|
||||
displayInfo: item[infoKey],
|
||||
}))
|
||||
.filter(item =>
|
||||
search ? item.displayName.toLowerCase().includes(search) : true
|
||||
);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: t('CONVERSATION.MENTION.AGENTS'),
|
||||
data: buildItems(agents.value, 'user', 'email'),
|
||||
},
|
||||
{
|
||||
title: t('CONVERSATION.MENTION.TEAMS'),
|
||||
data: buildItems(teams.value, 'team', 'description'),
|
||||
},
|
||||
];
|
||||
|
||||
return categories.flatMap(({ title, data }) =>
|
||||
data.length
|
||||
? [
|
||||
{ type: 'header', title, id: `${title.toLowerCase()}-header` },
|
||||
...data,
|
||||
]
|
||||
: []
|
||||
);
|
||||
});
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
return items.value.filter(item => item.type !== 'header');
|
||||
});
|
||||
|
||||
const getSelectableIndex = item => {
|
||||
return selectableItems.value.findIndex(
|
||||
selectableItem =>
|
||||
selectableItem.type === item.type && selectableItem.id === item.id
|
||||
);
|
||||
};
|
||||
|
||||
const adjustScroll = () => {
|
||||
nextTick(() => {
|
||||
if (tagAgentsRef.value) {
|
||||
tagAgentsRef.value.scrollTop = 50 * selectedIndex.value;
|
||||
const selectedElement = tagAgentsRef.value.querySelector(
|
||||
`#mention-item-${selectedIndex.value}`
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
emit('selectAgent', items.value[selectedIndex.value]);
|
||||
emit('selectAgent', selectableItems.value[selectedIndex.value]);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
items: selectableItems,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
watch(items, newListOfAgents => {
|
||||
watch(selectableItems, newListOfAgents => {
|
||||
if (newListOfAgents.length < selectedIndex.value + 1) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
@@ -69,40 +118,61 @@ const onAgentSelect = index => {
|
||||
v-if="items.length"
|
||||
ref="tagAgentsRef"
|
||||
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
v-for="(agent, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="agent.id"
|
||||
:class="{
|
||||
'bg-n-alpha-black2': index === selectedIndex,
|
||||
'last:mb-0': items.length <= 4,
|
||||
}"
|
||||
class="flex items-center px-2 py-1 rounded-md"
|
||||
@click="onAgentSelect(index)"
|
||||
@mouseover="onHover(index)"
|
||||
v-for="item in items"
|
||||
:id="
|
||||
item.type === 'header'
|
||||
? undefined
|
||||
: `mention-item-${getSelectableIndex(item)}`
|
||||
"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
>
|
||||
<div class="ltr:mr-2 rtl:ml-2">
|
||||
<Avatar :src="agent.thumbnail" :name="agent.name" rounded-full />
|
||||
</div>
|
||||
<!-- Section Header -->
|
||||
<div
|
||||
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
v-if="item.type === 'header'"
|
||||
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
|
||||
>
|
||||
<h5
|
||||
class="mb-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis"
|
||||
:class="{
|
||||
'text-n-slate-12': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ agent.name }}
|
||||
</h5>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<!-- Selectable Item -->
|
||||
<div
|
||||
v-else
|
||||
:class="{
|
||||
'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
class="flex items-center px-2 py-1 rounded-md cursor-pointer"
|
||||
role="option"
|
||||
@click="onAgentSelect(getSelectableIndex(item))"
|
||||
@mouseover="onHover(getSelectableIndex(item))"
|
||||
>
|
||||
<div class="ltr:mr-2 rtl:ml-2">
|
||||
<Avatar
|
||||
:src="item.thumbnail"
|
||||
:name="item.displayName"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
|
||||
:class="{
|
||||
'text-n-slate-11': index === selectedIndex,
|
||||
}"
|
||||
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ agent.email }}
|
||||
<h5
|
||||
class="overflow-hidden mb-0 text-sm capitalize whitespace-nowrap text-n-slate-11 text-ellipsis"
|
||||
:class="{
|
||||
'text-n-slate-12': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.displayName }}
|
||||
</h5>
|
||||
<div
|
||||
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
|
||||
:class="{
|
||||
'text-n-slate-11': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.displayInfo }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -301,11 +301,18 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
const createNode = (editorView, nodeType, content) => {
|
||||
const { state } = editorView;
|
||||
switch (nodeType) {
|
||||
case 'mention':
|
||||
return state.schema.nodes.mention.create({
|
||||
case 'mention': {
|
||||
const mentionType = content.type || 'user';
|
||||
const displayName = content.displayName || content.name;
|
||||
|
||||
const mentionNode = state.schema.nodes.mention.create({
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
userFullName: displayName,
|
||||
mentionType,
|
||||
});
|
||||
|
||||
return mentionNode;
|
||||
}
|
||||
case 'cannedResponse':
|
||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
case 'variable':
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('getContentNode', () => {
|
||||
{
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
mentionType: 'user',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
} from '../editorHelper';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||
@@ -18,12 +19,28 @@ const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'paragraph+' },
|
||||
paragraph: {
|
||||
content: 'text*',
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
|
||||
},
|
||||
text: {
|
||||
group: 'inline',
|
||||
toDOM: node => node.text, // Represents text as its actual string value.
|
||||
},
|
||||
mention: {
|
||||
attrs: {
|
||||
userId: { default: '' },
|
||||
userFullName: { default: '' },
|
||||
mentionType: { default: 'user' },
|
||||
},
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
toDOM: node => [
|
||||
'span',
|
||||
{ class: 'mention' },
|
||||
`@${node.attrs.userFullName}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -439,3 +456,173 @@ describe('setURLWithQueryAndSize', () => {
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentNode', () => {
|
||||
let mockEditorView;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEditorView = {
|
||||
state: {
|
||||
schema: {
|
||||
nodes: {
|
||||
mention: {
|
||||
create: vi.fn(attrs => ({
|
||||
type: { name: 'mention' },
|
||||
attrs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
text: vi.fn(content => ({ type: { name: 'text' }, text: content })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('mention node creation', () => {
|
||||
it('creates a user mention node with correct attributes', () => {
|
||||
const userContent = {
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
const result = getContentNode(mockEditorView, 'mention', userContent, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
userFullName: 'John Doe',
|
||||
mentionType: 'user',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: {
|
||||
type: { name: 'mention' },
|
||||
attrs: {
|
||||
userId: '123',
|
||||
userFullName: 'John Doe',
|
||||
mentionType: 'user',
|
||||
},
|
||||
},
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a team mention node with correct attributes', () => {
|
||||
const teamContent = {
|
||||
id: '456',
|
||||
name: 'Support Team',
|
||||
type: 'team',
|
||||
};
|
||||
|
||||
const result = getContentNode(mockEditorView, 'mention', teamContent, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '456',
|
||||
userFullName: 'Support Team',
|
||||
mentionType: 'team',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: {
|
||||
type: { name: 'mention' },
|
||||
attrs: {
|
||||
userId: '456',
|
||||
userFullName: 'Support Team',
|
||||
mentionType: 'team',
|
||||
},
|
||||
},
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to user mention type when type is not specified', () => {
|
||||
const contentWithoutType = {
|
||||
id: '789',
|
||||
name: 'Jane Smith',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithoutType, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '789',
|
||||
userFullName: 'Jane Smith',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses displayName over name when both are provided', () => {
|
||||
const contentWithDisplayName = {
|
||||
id: '101',
|
||||
name: 'john_doe',
|
||||
displayName: 'John Doe (Admin)',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithDisplayName, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '101',
|
||||
userFullName: 'John Doe (Admin)',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing displayName by falling back to name', () => {
|
||||
const contentWithoutDisplayName = {
|
||||
id: '102',
|
||||
name: 'jane_smith',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithoutDisplayName, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '102',
|
||||
userFullName: 'jane_smith',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported node types', () => {
|
||||
it('returns null node for unsupported type', () => {
|
||||
const result = getContentNode(mockEditorView, 'unsupported', 'content', {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: null,
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
"NEXT_WEEK": "Next week"
|
||||
}
|
||||
},
|
||||
"MENTION": {
|
||||
"AGENTS": "Agents",
|
||||
"TEAMS": "Teams"
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "Snooze until",
|
||||
"APPLY": "Snooze",
|
||||
|
||||
Reference in New Issue
Block a user