diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.spec.tsx index 6c44141f40702..8de27054f5040 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.spec.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.spec.tsx @@ -18,6 +18,7 @@ const mockUser = createFakeUser({ _id: 'agent-1', username: 'agent.one', name: 'Agent One', + roles: ['livechat-agent'], }); const mockAgentOne: Serialized = { @@ -218,7 +219,7 @@ describe('RepliesForm', () => { getDepartmentMock.mockResolvedValue({ department: mockDepartment, agents: [] }); const handleSubmit = jest.fn(); render(, { - wrapper: appRoot().build(), + wrapper: appRoot([]).build(), }); await userEvent.click(screen.getByRole('button', { name: 'Submit' })); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.tsx index 482fa8e52a14e..f7e6acc2f13e5 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.tsx @@ -2,7 +2,7 @@ import type { Serialized, ILivechatDepartment, ILivechatDepartmentAgents } from import { Box, Button, FieldGroup } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; -import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; +import { useEndpoint, usePermission, useUser } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { useEffect, useId, useMemo } from 'react'; @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'; import AgentField from './components/AgentField'; import DepartmentField from './components/DepartmentField'; +import { useAllowedAgents } from './hooks/useAllowedAgents'; import { omnichannelQueryKeys } from '../../../../../../../lib/queryKeys'; import { FormFetchError } from '../../utils/errors'; @@ -41,6 +42,7 @@ const RepliesForm = (props: RepliesFormProps) => { const dispatchToastMessage = useToastBarDispatch(); const { t } = useTranslation(); const repliesFormId = useId(); + const user = useUser(); const canAssignAllDepartments = usePermission('outbound.can-assign-queues'); const canAssignSelfOnlyAgent = usePermission('outbound.can-assign-self-only'); @@ -62,7 +64,7 @@ const RepliesForm = (props: RepliesFormProps) => { const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: departmentId ?? '' }); const { - data: { department, agents = [] } = {}, + data: { department, agents: queryAgents = [] } = {}, isError: isErrorDepartment, isFetching: isFetchingDepartment, refetch: refetchDepartment, @@ -72,6 +74,14 @@ const RepliesForm = (props: RepliesFormProps) => { enabled: !!departmentId, }); + const agents = useAllowedAgents({ + user, + queryAgents, + departmentId: department?._id, + canAssignSelfOnlyAgent, + canAssignAnyAgent, + }); + useEffect(() => { isErrorDepartment && trigger('departmentId'); return () => clearErrors('departmentId'); @@ -119,8 +129,7 @@ const RepliesForm = (props: RepliesFormProps) => { diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/AgentField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/AgentField.tsx index f2d006f085e7b..424587969176e 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/AgentField.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/AgentField.tsx @@ -1,6 +1,5 @@ import type { ILivechatDepartmentAgents, Serialized } from '@rocket.chat/core-typings'; import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; -import { useUserId } from '@rocket.chat/ui-contexts'; import { useId } from 'react'; import type { Control } from 'react-hook-form'; import { useController } from 'react-hook-form'; @@ -13,21 +12,15 @@ import type { RepliesFormData } from '../RepliesForm'; type AgentFieldProps = { control: Control; agents: Serialized[]; - canAssignAny?: boolean; - canAssignSelfOnly?: boolean; + canAssignAgent?: boolean; disabled?: boolean; isLoading?: boolean; }; -const AgentField = ({ control, agents, canAssignAny, canAssignSelfOnly, disabled = false, isLoading = false }: AgentFieldProps) => { +const AgentField = ({ control, agents, canAssignAgent, disabled = false, isLoading = false }: AgentFieldProps) => { const { t } = useTranslation(); const agentFieldId = useId(); - const userId = useUserId(); - const canAssignAgent = canAssignSelfOnly || canAssignAny; - - const allowedAgents = canAssignAny ? agents : agents.filter((agent) => agent.agentId === userId); - const { field: agentField, fieldState: { error: agentFieldError }, @@ -50,7 +43,7 @@ const AgentField = ({ control, agents, canAssignAny, canAssignSelfOnly, disabled })} error={!!agentFieldError} id={agentFieldId} - agents={allowedAgents} + agents={agents} placeholder={isLoading ? t('Loading...') : t('Select_agent')} disabled={disabled || isLoading || !canAssignAgent} value={agentField.value} diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/hooks/useAllowedAgents.spec.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/hooks/useAllowedAgents.spec.ts new file mode 100644 index 0000000000000..a816b040b8cf4 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/hooks/useAllowedAgents.spec.ts @@ -0,0 +1,169 @@ +import type { ILivechatDepartmentAgents, IUser, Serialized } from '@rocket.chat/core-typings'; +import { renderHook } from '@testing-library/react'; + +import { useAllowedAgents } from './useAllowedAgents'; +import { createFakeUser } from '../../../../../../../../../tests/mocks/data'; +import { getAgentDerivedFromUser } from '../utils/getAgentDerivedFromUser'; + +jest.mock('../utils/getAgentDerivedFromUser', () => ({ + getAgentDerivedFromUser: jest.fn(), +})); + +const getAgentDerivedFromUserMocked = jest.mocked(getAgentDerivedFromUser); + +const mockUser: IUser = createFakeUser({ + _id: 'test-user-id', + username: 'testuser', + roles: ['livechat-agent'], +}); + +const mockQueryAgents: Serialized[] = [ + { + agentId: 'agent1', + username: 'agent.one', + count: 1, + order: 1, + _id: 'agent1', + _updatedAt: new Date().toISOString(), + departmentEnabled: true, + departmentId: 'dept1', + }, + { + agentId: 'agent2', + username: 'agent.two', + count: 2, + order: 2, + _id: 'agent2', + _updatedAt: new Date().toISOString(), + departmentEnabled: true, + departmentId: 'dept1', + }, +]; + +const mockDerivedAgent: Serialized = { + agentId: 'test-user-id', + username: 'testuser', + _id: 'test-user-id', + _updatedAt: new Date().toISOString(), + departmentId: 'department-1', + departmentEnabled: true, + count: 0, + order: 0, +}; + +describe('useAllowedAgents', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return an empty array if no departmentId is provided', () => { + const { result } = renderHook(() => + useAllowedAgents({ + user: mockUser, + departmentId: undefined, + canAssignSelfOnlyAgent: true, + canAssignAnyAgent: true, + queryAgents: mockQueryAgents, + }), + ); + + expect(result.current).toEqual([]); + }); + + it('should return an empty array if user has no assignment permissions', () => { + const { result } = renderHook(() => + useAllowedAgents({ + user: mockUser, + departmentId: 'department-1', + canAssignSelfOnlyAgent: false, + canAssignAnyAgent: false, + queryAgents: mockQueryAgents, + }), + ); + + expect(result.current).toEqual([]); + }); + + it('should return queryAgents if canAssignAnyAgent is true and queryAgents has items', () => { + const { result } = renderHook(() => + useAllowedAgents({ + user: mockUser, + departmentId: 'department-1', + canAssignSelfOnlyAgent: false, + canAssignAnyAgent: true, + queryAgents: mockQueryAgents, + }), + ); + + expect(result.current).toEqual(mockQueryAgents); + }); + + it('should return derived agent if canAssignSelfOnlyAgent is true', () => { + getAgentDerivedFromUserMocked.mockReturnValue(mockDerivedAgent); + + const { result } = renderHook(() => + useAllowedAgents({ + user: mockUser, + departmentId: 'department-1', + canAssignSelfOnlyAgent: true, + canAssignAnyAgent: false, + queryAgents: mockQueryAgents, + }), + ); + + expect(getAgentDerivedFromUserMocked).toHaveBeenCalledWith(mockUser, 'department-1'); + expect(result.current).toEqual([mockDerivedAgent]); + }); + + it('should return derived agent if canAssignAnyAgent is true but queryAgents is empty', () => { + getAgentDerivedFromUserMocked.mockReturnValue(mockDerivedAgent); + + const { result } = renderHook(() => + useAllowedAgents({ + user: mockUser, + departmentId: 'department-1', + canAssignSelfOnlyAgent: false, + canAssignAnyAgent: true, + queryAgents: [], + }), + ); + + expect(getAgentDerivedFromUserMocked).toHaveBeenCalledWith(mockUser, 'department-1'); + expect(result.current).toEqual([mockDerivedAgent]); + }); + + it('should return derived agent if canAssignAnyAgent is true but queryAgents is undefined', () => { + getAgentDerivedFromUserMocked.mockReturnValue(mockDerivedAgent); + + const { result } = renderHook(() => + useAllowedAgents({ + user: mockUser, + departmentId: 'department-1', + canAssignSelfOnlyAgent: false, + canAssignAnyAgent: true, + queryAgents: undefined, + }), + ); + + expect(getAgentDerivedFromUserMocked).toHaveBeenCalledWith(mockUser, 'department-1'); + expect(result.current).toEqual([mockDerivedAgent]); + }); + + it('should return an empty array if getAgentDerivedFromUser throws an error', () => { + getAgentDerivedFromUserMocked.mockImplementation(() => { + throw new Error('User not found'); + }); + + const { result } = renderHook(() => + useAllowedAgents({ + user: null, + departmentId: 'department-1', + canAssignSelfOnlyAgent: true, + canAssignAnyAgent: false, + queryAgents: [], + }), + ); + + expect(result.current).toEqual([]); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/hooks/useAllowedAgents.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/hooks/useAllowedAgents.ts new file mode 100644 index 0000000000000..a7b397146738a --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/hooks/useAllowedAgents.ts @@ -0,0 +1,37 @@ +import type { Serialized, ILivechatDepartmentAgents, IUser } from '@rocket.chat/core-typings'; +import { useMemo } from 'react'; + +import { getAgentDerivedFromUser } from '../utils/getAgentDerivedFromUser'; + +type UseAllowedAgentsProps = { + user: IUser | null; + departmentId: string | undefined; + canAssignSelfOnlyAgent: boolean; + canAssignAnyAgent: boolean; + queryAgents: Serialized[] | undefined; +}; + +export const useAllowedAgents = ({ user, departmentId, queryAgents, canAssignSelfOnlyAgent, canAssignAnyAgent }: UseAllowedAgentsProps) => + useMemo(() => { + // no department selected, no agents + if (!departmentId) { + return []; + } + + // no permission to assign any agents, no agents + if (!canAssignSelfOnlyAgent && !canAssignAnyAgent) { + return []; + } + + // can assign any agent, return all agents from query (if any) + if (canAssignAnyAgent && queryAgents?.length) { + return queryAgents; + } + + try { + // all other cases, attempt to derive agent from user + return [getAgentDerivedFromUser(user, departmentId)]; + } catch { + return []; + } + }, [canAssignAnyAgent, canAssignSelfOnlyAgent, user, departmentId, queryAgents]); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/utils/getAgentDerivedFromUser.spec.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/utils/getAgentDerivedFromUser.spec.ts new file mode 100644 index 0000000000000..f5f7eb295da1a --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/utils/getAgentDerivedFromUser.spec.ts @@ -0,0 +1,39 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import { getAgentDerivedFromUser } from './getAgentDerivedFromUser'; +import { createFakeUser } from '../../../../../../../../../tests/mocks/data'; + +describe('getAgentDerivedFromUser', () => { + it('should throw an error if the user is null', () => { + expect(() => getAgentDerivedFromUser(null, 'any-department-id')).toThrow('User is not a livechat agent'); + }); + + it('should throw an error if the user is not a livechat agent', () => { + const user: IUser = createFakeUser({ roles: ['user'] }); + + expect(() => getAgentDerivedFromUser(user, 'any-department-id')).toThrow('User is not a livechat agent'); + }); + + it('should return a valid agent object if the user is a livechat agent', () => { + const user: IUser = createFakeUser({ + _id: 'agentId123', + username: 'agentusername', + roles: ['livechat-agent'], + }); + + const departmentId = 'department123'; + + const agent = getAgentDerivedFromUser(user, departmentId); + + expect(agent).toEqual({ + agentId: user._id, + username: user.username, + _id: user._id, + _updatedAt: expect.any(String), + departmentId, + departmentEnabled: true, + count: 0, + order: 0, + }); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/utils/getAgentDerivedFromUser.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/utils/getAgentDerivedFromUser.ts new file mode 100644 index 0000000000000..54c44e327d650 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/utils/getAgentDerivedFromUser.ts @@ -0,0 +1,20 @@ +import type { ILivechatDepartmentAgents, Serialized, IUser } from '@rocket.chat/core-typings'; + +const isOmnichannelAgent = (user: IUser | null): user is IUser => (user ? user.roles.includes('livechat-agent') : false); + +export const getAgentDerivedFromUser = (user: IUser | null, departmentId: string): Serialized => { + if (!isOmnichannelAgent(user)) { + throw new Error('User is not a livechat agent'); + } + + return { + agentId: user._id, + username: user.username || '', + _id: user._id, + _updatedAt: new Date().toISOString(), + departmentId, + departmentEnabled: true, + count: 0, + order: 0, + }; +};