Merge branch 'develop' into fix/conv-status

This commit is contained in:
Shivam Mishra
2025-07-07 19:13:46 +05:30
committed by GitHub
35 changed files with 1085 additions and 110 deletions
@@ -39,15 +39,21 @@ const chatMetadata = computed(() => props.chat.meta);
const backButtonUrl = computed(() => {
const {
params: { inbox_id: inboxId, label, teamId },
params: { inbox_id: inboxId, label, teamId, id: customViewId },
name,
} = route;
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({
accountId,
accountId: accountId.value,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
conversationType: conversationTypeMap[name],
customViewId,
});
});
@@ -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>
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import { sanitizeVariableSearchKey } from 'dashboard/helper/commons';
import MentionBox from '../mentions/MentionBox.vue';
export default {
@@ -16,6 +17,9 @@ export default {
...mapGetters({
customAttributes: 'attributes/getAttributes',
}),
sanitizedSearchKey() {
return sanitizeVariableSearchKey(this.searchKey);
},
items() {
return [
...this.standardAttributeVariables,
@@ -25,8 +29,8 @@ export default {
standardAttributeVariables() {
return MESSAGE_VARIABLES.filter(variable => {
return (
variable.label.includes(this.searchKey) ||
variable.key.includes(this.searchKey)
variable.label.includes(this.sanitizedSearchKey) ||
variable.key.includes(this.sanitizedSearchKey)
);
}).map(variable => ({
label: variable.key,
@@ -83,3 +83,16 @@ export const convertToPortalSlug = text => {
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
};
/**
* Strip curly braces, commas and leading/trailing whitespace from a search key.
* Eg. "{{contact.name}}," => "contact.name"
* @param {string} searchKey
* @returns {string}
*/
export const sanitizeVariableSearchKey = (searchKey = '') => {
return searchKey
.replace(/[{}]/g, '') // remove all curly braces
.replace(/,/g, '') // remove commas
.trim();
};
@@ -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':
@@ -4,6 +4,7 @@ import {
convertToAttributeSlug,
convertToCategorySlug,
convertToPortalSlug,
sanitizeVariableSearchKey,
} from '../commons';
describe('#getTypingUsersText', () => {
@@ -107,3 +108,37 @@ describe('convertToPortalSlug', () => {
expect(convertToPortalSlug('Room rental')).toBe('room-rental');
});
});
describe('sanitizeVariableSearchKey', () => {
it('removes braces', () => {
expect(sanitizeVariableSearchKey('{{contact.name}}')).toBe('contact.name');
});
it('removes right braces', () => {
expect(sanitizeVariableSearchKey('contact.name}}')).toBe('contact.name');
});
it('removes braces, comma and whitespace', () => {
expect(sanitizeVariableSearchKey(' {{contact.name }},')).toBe(
'contact.name'
);
});
it('trims whitespace', () => {
expect(sanitizeVariableSearchKey(' contact.name ')).toBe('contact.name');
});
it('handles multiple commas', () => {
expect(sanitizeVariableSearchKey('{{contact.name}},,')).toBe(
'contact.name'
);
});
it('returns empty string when only braces/commas/whitespace', () => {
expect(sanitizeVariableSearchKey(' { }, , ')).toBe('');
});
it('returns empty string for undefined input', () => {
expect(sanitizeVariableSearchKey()).toBe('');
});
});
@@ -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",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Account settings",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Настройки аккаунта",
@@ -58,7 +58,9 @@ const limitExceededMessage = computed(() => {
} else if (testLimit(nonWebInboxes)) {
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.INBOXES');
} else if (testLimit(agents)) {
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.AGENTS');
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.AGENTS', {
allowedAgents: agents.allowed,
});
}
return message;