diff --git a/.changeset/rotten-dryers-allow.md b/.changeset/rotten-dryers-allow.md new file mode 100644 index 0000000000000..154dea5727801 --- /dev/null +++ b/.changeset/rotten-dryers-allow.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Add pagination & tooltips to agent's dropdown on forwarding modal diff --git a/apps/meteor/app/livechat/imports/server/rest/users.ts b/apps/meteor/app/livechat/imports/server/rest/users.ts index 680bdc7e80d2a..196970583249e 100644 --- a/apps/meteor/app/livechat/imports/server/rest/users.ts +++ b/apps/meteor/app/livechat/imports/server/rest/users.ts @@ -9,16 +9,15 @@ import { hasAtLeastOnePermissionAsync } from '../../../../authorization/server/f import { findAgents, findManagers } from '../../../server/api/lib/users'; import { Livechat } from '../../../server/lib/Livechat'; +const emptyStringArray: string[] = []; + API.v1.addRoute( 'livechat/users/:type', { authRequired: true, permissionsRequired: { - GET: { - permissions: ['manage-livechat-agents'], - operation: 'hasAll', - }, - POST: { permissions: ['view-livechat-manager'], operation: 'hasAll' }, + 'POST': ['view-livechat-manager'], + '*': emptyStringArray, }, validateParams: { GET: isLivechatUsersManagerGETProps, @@ -39,9 +38,13 @@ API.v1.addRoute( return API.v1.unauthorized(); } + const { onlyAvailable, excludeId, showIdleAgents } = this.queryParams; return API.v1.success( await findAgents({ text, + onlyAvailable, + excludeId, + showIdleAgents, pagination: { offset, count, diff --git a/apps/meteor/app/livechat/server/api/lib/users.ts b/apps/meteor/app/livechat/server/api/lib/users.ts index 948cde0f8bfdf..49ac5682a6c04 100644 --- a/apps/meteor/app/livechat/server/api/lib/users.ts +++ b/apps/meteor/app/livechat/server/api/lib/users.ts @@ -1,6 +1,7 @@ import type { ILivechatAgent, IRole } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { FilterOperators } from 'mongodb'; /** * @param {IRole['_id']} role the role id @@ -10,18 +11,39 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; async function findUsers({ role, text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, pagination: { offset, count, sort }, }: { role: IRole['_id']; text?: string; + onlyAvailable?: boolean; + excludeId?: string; + showIdleAgents?: boolean; pagination: { offset: number; count: number; sort: any }; }): Promise<{ users: ILivechatAgent[]; count: number; offset: number; total: number }> { - const query = {}; + const query: FilterOperators = {}; + const orConditions: FilterOperators['$or'] = []; if (text) { const filterReg = new RegExp(escapeRegExp(text), 'i'); - Object.assign(query, { - $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }], - }); + orConditions.push({ $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }] }); + } + + if (onlyAvailable) { + query.statusLivechat = 'available'; + } + + if (excludeId) { + query._id = { $ne: excludeId }; + } + + if (!showIdleAgents) { + orConditions.push({ $or: [{ status: { $exists: true, $ne: 'offline' }, roles: { $ne: 'bot' } }, { roles: 'bot' }] }); + } + + if (orConditions.length) { + query.$and = orConditions; } const [ @@ -52,14 +74,23 @@ async function findUsers({ } export async function findAgents({ text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, pagination: { offset, count, sort }, }: { text?: string; + onlyAvailable: boolean; + excludeId?: string; + showIdleAgents?: boolean; pagination: { offset: number; count: number; sort: any }; }): Promise> { return findUsers({ role: 'livechat-agent', text, + onlyAvailable, + excludeId, + showIdleAgents, pagination: { offset, count, diff --git a/apps/meteor/client/components/AutoCompleteAgent.tsx b/apps/meteor/client/components/AutoCompleteAgent.tsx index b4e287bcc4ae7..f2cbebe469202 100644 --- a/apps/meteor/client/components/AutoCompleteAgent.tsx +++ b/apps/meteor/client/components/AutoCompleteAgent.tsx @@ -11,18 +11,26 @@ type AutoCompleteAgentProps = { value: string; error?: string; placeholder?: string; - onChange: (value: string) => void; haveAll?: boolean; haveNoAgentsSelectedOption?: boolean; + excludeId?: string; + showIdleAgents?: boolean; + onlyAvailable?: boolean; + withTitle?: boolean; + onChange: (value: string) => void; }; const AutoCompleteAgent = ({ value, error, placeholder, - onChange, haveAll = false, haveNoAgentsSelectedOption = false, + excludeId, + showIdleAgents = false, + onlyAvailable = false, + withTitle = false, + onChange, }: AutoCompleteAgentProps): ReactElement => { const [agentsFilter, setAgentsFilter] = useState(''); @@ -30,26 +38,16 @@ const AutoCompleteAgent = ({ const { itemsList: AgentsList, loadMoreItems: loadMoreAgents } = useAgentsList( useMemo( - () => ({ text: debouncedAgentsFilter, haveAll, haveNoAgentsSelectedOption }), - [debouncedAgentsFilter, haveAll, haveNoAgentsSelectedOption], + () => ({ text: debouncedAgentsFilter, onlyAvailable, haveAll, haveNoAgentsSelectedOption, excludeId, showIdleAgents }), + [debouncedAgentsFilter, excludeId, haveAll, haveNoAgentsSelectedOption, onlyAvailable, showIdleAgents], ), ); const { phase: agentsPhase, itemCount: agentsTotal, items: agentsItems } = useRecordList(AgentsList); - const sortedByName = agentsItems.sort((a, b) => { - if (a.label > b.label) { - return 1; - } - if (a.label < b.label) { - return -1; - } - - return 0; - }); - return ( void} - options={sortedByName} + options={agentsItems} data-qa='autocomplete-agent' endReached={ agentsPhase === AsyncStatePhase.LOADING ? (): void => undefined : (start): void => loadMoreAgents(start, Math.min(50, agentsTotal)) diff --git a/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts b/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts index b854866184f74..e2f6f80f23556 100644 --- a/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts +++ b/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts @@ -9,6 +9,9 @@ type AgentsListOptions = { text: string; haveAll: boolean; haveNoAgentsSelectedOption: boolean; + excludeId?: string; + showIdleAgents?: boolean; + onlyAvailable?: boolean; }; type AgentOption = { value: string; label: string; _updatedAt: Date; _id: string }; @@ -26,6 +29,7 @@ export const useAgentsList = ( const reload = useCallback(() => setItemsList(new RecordList()), []); const getAgents = useEndpoint('GET', '/v1/livechat/users/agent'); + const { text, onlyAvailable = false, showIdleAgents = false, excludeId, haveAll, haveNoAgentsSelectedOption } = options; useComponentDidUpdate(() => { options && reload(); @@ -34,7 +38,10 @@ export const useAgentsList = ( const fetchData = useCallback( async (start, end) => { const { users: agents, total } = await getAgents({ - ...(options.text && { text: options.text }), + ...(text && { text }), + ...(excludeId && { excludeId }), + showIdleAgents, + onlyAvailable, offset: start, count: end + start, sort: `{ "name": 1 }`, @@ -43,14 +50,14 @@ export const useAgentsList = ( const items = agents.map((agent) => { const agentOption = { _updatedAt: new Date(agent._updatedAt), - label: agent.username || agent._id, + label: `${agent.name || agent._id} (@${agent.username})`, value: agent._id, _id: agent._id, }; return agentOption; }); - options.haveAll && + haveAll && items.unshift({ label: t('All'), value: 'all', @@ -58,7 +65,7 @@ export const useAgentsList = ( _id: 'all', }); - options.haveNoAgentsSelectedOption && + haveNoAgentsSelectedOption && items.unshift({ label: t('Empty_no_agent_selected'), value: 'no-agent-selected', @@ -71,7 +78,7 @@ export const useAgentsList = ( itemCount: total + 1, }; }, - [getAgents, options.haveAll, options.haveNoAgentsSelectedOption, options.text, t], + [excludeId, getAgents, haveAll, haveNoAgentsSelectedOption, onlyAvailable, showIdleAgents, t, text], ); const { loadMoreItems, initialItemCount } = useScrollableRecordList(itemsList, fetchData, 25); diff --git a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx index bdbde6b05acd2..a4d095fdb90d8 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx @@ -19,7 +19,7 @@ import { useForm } from 'react-hook-form'; import { useRecordList } from '../../../hooks/lists/useRecordList'; import { AsyncStatePhase } from '../../../hooks/useAsyncState'; -import UserAutoComplete from '../../UserAutoComplete'; +import AutoCompleteAgent from '../../AutoCompleteAgent'; import { useDepartmentsList } from '../hooks/useDepartmentsList'; const ForwardChatModal = ({ @@ -53,28 +53,6 @@ const ForwardChatModal = ({ ); const { phase: departmentsPhase, items: departments, itemCount: departmentsTotal } = useRecordList(departmentsList); - const _id = { $ne: room.servedBy?._id }; - const conditions = { - _id, - ...(!idleAgentsAllowedForForwarding && { - $or: [ - { - status: { - $exists: true, - $ne: 'offline', - }, - roles: { - $ne: 'bot', - }, - }, - { - roles: 'bot', - }, - ], - }), - statusLivechat: 'available', - }; - const endReached = useCallback( (start) => { if (departmentsPhase !== AsyncStatePhase.LOADING) { @@ -134,13 +112,16 @@ const ForwardChatModal = ({ {t('Forward_to_user')} - { setValue('username', value); }} - value={getValues().username} /> diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 46805a1f0e3e7..d260037d2aab2 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -5453,6 +5453,7 @@ "Username_title": "Register username", "Username_has_been_updated": "Username has been updated", "Username_wants_to_start_otr_Do_you_want_to_accept": "{{username}} wants to start OTR. Do you want to accept?", + "Username_name_email": "Username, name or e-mail", "Users": "Users", "Users must use Two Factor Authentication": "Users must use Two Factor Authentication", "Users_added": "The users have been added", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 8da04e7c4e67d..b3aa28f89fb88 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -4575,6 +4575,7 @@ "Username_Placeholder": "Digite os nomes de usuário...", "Username_title": "Cadastre um nome de usuário", "Username_wants_to_start_otr_Do_you_want_to_accept": "{{username}} quer começar OTR. Você aceita?", + "Username_name_email": "Nome de usuário, nome ou e-mail", "Users": "Usuários", "Users must use Two Factor Authentication": "Os usuários devem usar autenticação de dois fatores", "Users_added": "Os usuários foram adicionados", diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts index 3c74065a9a84e..dc31f54be934d 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts @@ -65,6 +65,7 @@ test.describe('omnichannel-transfer-to-another-agent', () => { await agent2.poHomeOmnichannel.sidenav.switchStatus('offline'); await agent1.poHomeOmnichannel.content.btnForwardChat.click(); + await agent1.poHomeOmnichannel.content.inputModalAgentUserName.click(); await agent1.poHomeOmnichannel.content.inputModalAgentUserName.type('user2'); await expect(agent1.page.locator('text=Empty')).toBeVisible(); @@ -76,8 +77,9 @@ test.describe('omnichannel-transfer-to-another-agent', () => { await agent1.poHomeOmnichannel.sidenav.getSidebarItemByName(newVisitor.name).click(); await agent1.poHomeOmnichannel.content.btnForwardChat.click(); + await agent1.poHomeOmnichannel.content.inputModalAgentUserName.click(); await agent1.poHomeOmnichannel.content.inputModalAgentUserName.type('user2'); - await agent1.page.locator('.rcx-option .rcx-option__wrapper >> text="user2"').click(); + await agent1.page.locator('.rcx-option .rcx-option__wrapper >> text="user2 (@user2)"').click(); await agent1.poHomeOmnichannel.content.inputModalAgentForwardComment.type('any_comment'); await agent1.poHomeOmnichannel.content.btnModalConfirm.click(); await expect(agent1.poHomeOmnichannel.toastSuccess).toBeVisible(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index cb8c8b089095b..2ba8cd6428d94 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -197,7 +197,7 @@ export class HomeContent { } get inputModalAgentUserName(): Locator { - return this.page.locator('#modal-root input:nth-child(1)'); + return this.page.locator('#modal-root input[placeholder="Username, name or e-mail"]'); } get inputModalAgentForwardComment(): Locator { @@ -237,16 +237,8 @@ export class HomeContent { async openLastThreadMessageMenu(): Promise { await this.page.locator('//main//aside >> [data-qa-type="message"]').last().hover(); - await this.page - .locator('//main//aside >> [data-qa-type="message"]') - .last() - .locator('role=button[name="More"]') - .waitFor(); - await this.page - .locator('//main//aside >> [data-qa-type="message"]') - .last() - .locator('role=button[name="More"]') - .click(); + await this.page.locator('//main//aside >> [data-qa-type="message"]').last().locator('role=button[name="More"]').waitFor(); + await this.page.locator('//main//aside >> [data-qa-type="message"]').last().locator('role=button[name="More"]').click(); } async toggleAlsoSendThreadToChannel(isChecked: boolean): Promise { diff --git a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts index 83efe2c96aa2a..6f9120ea90949 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts @@ -94,6 +94,26 @@ describe('LIVECHAT - Agents', function () { expect(agentRecentlyCreated?._id).to.be.equal(agent._id); }); }); + it('should return an array of available agents', async () => { + await updatePermission('edit-omnichannel-contact', ['admin']); + await updatePermission('transfer-livechat-guest', ['admin']); + await updatePermission('manage-livechat-agents', ['admin']); + + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ onlyAvailable: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.users.every((u: { statusLivechat: string }) => u.statusLivechat === 'available')).to.be.true; + }); + }); it('should return an array of managers', async () => { await updatePermission('view-livechat-manager', ['admin']); await updatePermission('manage-livechat-agents', ['admin']); diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index bebea2856861f..3baeae111202a 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -749,7 +749,13 @@ const LivechatDepartmentsByUnitIdSchema = { export const isLivechatDepartmentsByUnitIdProps = ajv.compile(LivechatDepartmentsByUnitIdSchema); -type LivechatUsersManagerGETProps = PaginatedRequest<{ text?: string; fields?: string }>; +type LivechatUsersManagerGETProps = PaginatedRequest<{ + text?: string; + fields?: string; + onlyAvailable?: boolean; + excludeId?: string; + showIdleAgents?: boolean; +}>; const LivechatUsersManagerGETSchema = { type: 'object', @@ -758,6 +764,18 @@ const LivechatUsersManagerGETSchema = { type: 'string', nullable: true, }, + onlyAvailable: { + type: 'string', + nullable: true, + }, + excludeId: { + type: 'string', + nullable: true, + }, + showIdleAgents: { + type: 'boolean', + nullable: true, + }, count: { type: 'number', nullable: true, @@ -3386,7 +3404,9 @@ export type OmnichannelEndpoints = { }; '/v1/livechat/users/agent': { - GET: (params: PaginatedRequest<{ text?: string }>) => PaginatedResult<{ + GET: ( + params: PaginatedRequest<{ text?: string; onlyAvailable?: boolean; excludeId?: string; showIdleAgents?: boolean }>, + ) => PaginatedResult<{ users: (ILivechatAgent & { departments: string[] })[]; }>; POST: (params: LivechatUsersManagerPOSTProps) => { success: boolean };