From 31506bd3e2ccd8adf87864155209c2e1fe132eb9 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 17 Sep 2025 16:02:16 -0300 Subject: [PATCH 1/6] refactor: extract fields from RepliesForm --- .../forms/RepliesForm.tsx | 211 ------------------ .../{ => RepliesForm}/RepliesForm.spec.tsx | 2 +- .../forms/RepliesForm/RepliesForm.tsx | 145 ++++++++++++ .../RepliesForm/components/AgentField.tsx | 72 ++++++ .../components/DepartmentField.tsx | 81 +++++++ .../forms/RepliesForm/index.ts | 2 + 6 files changed, 301 insertions(+), 212 deletions(-) delete mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.tsx rename apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/{ => RepliesForm}/RepliesForm.spec.tsx (99%) create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.tsx create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/AgentField.tsx create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/DepartmentField.tsx create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/index.ts diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.tsx deleted file mode 100644 index 8dcd6a9d59983..0000000000000 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import type { Serialized, ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; -import { Box, Button, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; -import { useEndpoint, usePermission, useUserId } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; -import type { ReactNode } from 'react'; -import { useEffect, useId, useMemo } from 'react'; -import { Controller, useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; - -import { omnichannelQueryKeys } from '../../../../../../lib/queryKeys'; -import AutoCompleteDepartment from '../../../../../AutoCompleteDepartment'; -import AutoCompleteAgent from '../../AutoCompleteDepartmentAgent'; -import RetryButton from '../components/RetryButton'; -import { useFormKeyboardSubmit } from '../hooks/useFormKeyboardSubmit'; -import { cxp } from '../utils/cx'; -import { FormFetchError } from '../utils/errors'; - -export type RepliesFormData = { - departmentId: string; - agentId: string; -}; - -export type RepliesFormSubmitPayload = { - departmentId?: string; - department?: Serialized; - agentId?: string; - agent?: Serialized; -}; - -export type RepliesFormRef = { - submit: () => Promise; -}; - -type RepliesFormProps = { - defaultValues?: Partial; - renderActions?(props: { isSubmitting: boolean }): ReactNode; - onSubmit: (data: RepliesFormSubmitPayload) => void; -}; - -const RepliesForm = (props: RepliesFormProps) => { - const { defaultValues, renderActions, onSubmit } = props; - const dispatchToastMessage = useToastBarDispatch(); - const { t } = useTranslation(); - const repliesFormId = useId(); - const userId = useUserId(); - - const canAssignAllDepartments = usePermission('outbound.can-assign-queues'); - const canAssignAgentSelfOnly = usePermission('outbound.can-assign-self-only'); - const canAssignAgentAny = usePermission('outbound.can-assign-any-agent'); - const canAssignAgent = canAssignAgentSelfOnly || canAssignAgentAny; - - const { - control, - formState: { errors, isSubmitting }, - trigger, - clearErrors, - handleSubmit, - setValue, - } = useForm({ defaultValues }); - - const customActions = useMemo(() => renderActions?.({ isSubmitting }), [isSubmitting, renderActions]); - - const departmentId = useWatch({ control, name: 'departmentId' }); - - const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: departmentId ?? '' }); - - const { - data: { department, agents = [] } = {}, - isError: isErrorDepartment, - isFetching: isFetchingDepartment, - refetch: refetchDepartment, - } = useQuery({ - queryKey: omnichannelQueryKeys.department(departmentId), - queryFn: () => getDepartment({ onlyMyDepartments: !canAssignAllDepartments ? 'true' : 'false' }), - enabled: !!departmentId, - }); - - useEffect(() => { - isErrorDepartment && trigger('departmentId'); - return () => clearErrors('departmentId'); - }, [clearErrors, isErrorDepartment, trigger]); - - const allowedAgents = canAssignAgentAny ? agents : agents.filter((agent) => agent.agentId === userId); - - const handleDepartmentChange = useEffectEvent((onChange: (value: string) => void) => { - return (value: string) => { - setValue('agentId', ''); - onChange(value); - }; - }); - - const submit = useEffectEvent(async ({ agentId, departmentId }: RepliesFormData) => { - try { - const agent = agents?.find((agent) => agent.agentId === agentId); - - // Wait if department or agent is still being fetched in background - const updatedDepartment = - departmentId && isFetchingDepartment ? await refetchDepartment().then((r) => r.data?.department) : department; - - if (departmentId && !updatedDepartment) { - throw new FormFetchError('error-department-not-found'); - } - - if (agentId && !agent) { - throw new FormFetchError('error-agent-not-found'); - } - - onSubmit({ departmentId, department: updatedDepartment, agentId, agent }); - } catch (error) { - if (error instanceof FormFetchError) { - trigger(); - return; - } - - dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') }); - } - }); - - const formRef = useFormKeyboardSubmit(() => handleSubmit(submit)(), [submit, handleSubmit]); - - return ( -
- - - {`${t('Department')} (${t('optional')})`} - - (isErrorDepartment ? t('Error_loading__name__information', { name: t('department') }) : true), - }} - render={({ field }) => ( - - )} - /> - - {errors.departmentId && ( - - {errors.departmentId.message} - {isErrorDepartment && } - - )} - {t('Outbound_message_department_hint')} - - - {`${t('Agent')} (${t('optional')})`} - - ( - - )} - /> - - {errors.agentId && ( - - {errors.agentId.message} - - )} - - {canAssignAgent ? t('Outbound_message_agent_hint') : t('Outbound_message_agent_hint_no_permission')} - - - - - {customActions ?? ( - - - - )} -
- ); -}; - -RepliesForm.displayName = 'RepliesForm'; - -export default RepliesForm; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.spec.tsx similarity index 99% rename from apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.spec.tsx rename to apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.spec.tsx index b61f304a68d7e..9e264fa31a5c9 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.spec.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.spec.tsx @@ -7,7 +7,7 @@ import { axe } from 'jest-axe'; import { VirtuosoMockContext } from 'react-virtuoso'; import RepliesForm from './RepliesForm'; -import { createFakeDepartment, createFakeUser } from '../../../../../../../tests/mocks/data'; +import { createFakeDepartment, createFakeUser } from '../../../../../../../../tests/mocks/data'; jest.mock('tinykeys', () => ({ __esModule: true, 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 new file mode 100644 index 0000000000000..01c43ffa1681b --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.tsx @@ -0,0 +1,145 @@ +import type { Serialized, ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +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 { useQuery } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useEffect, useId, useMemo } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import AgentField from './components/AgentField'; +import DepartmentField from './components/DepartmentField'; +import { omnichannelQueryKeys } from '../../../../../../../lib/queryKeys'; +import { useFormKeyboardSubmit } from '../../hooks/useFormKeyboardSubmit'; +import { FormFetchError } from '../../utils/errors'; + +export type RepliesFormData = { + departmentId: string; + agentId: string; +}; + +export type RepliesFormSubmitPayload = { + departmentId?: string; + department?: Serialized; + agentId?: string; + agent?: Serialized; +}; + +export type RepliesFormRef = { + submit: () => Promise; +}; + +type RepliesFormProps = { + defaultValues?: Partial; + renderActions?(props: { isSubmitting: boolean }): ReactNode; + onSubmit: (data: RepliesFormSubmitPayload) => void; +}; + +const RepliesForm = (props: RepliesFormProps) => { + const { defaultValues, renderActions, onSubmit } = props; + const dispatchToastMessage = useToastBarDispatch(); + const { t } = useTranslation(); + const repliesFormId = useId(); + + const canAssignAllDepartments = usePermission('outbound.can-assign-queues'); + const canAssignSelfOnlyAgent = usePermission('outbound.can-assign-self-only'); + const canAssignAnyAgent = usePermission('outbound.can-assign-any-agent'); + + const { + control, + formState: { isSubmitting }, + trigger, + clearErrors, + handleSubmit, + setValue, + } = useForm({ defaultValues }); + + const customActions = useMemo(() => renderActions?.({ isSubmitting }), [isSubmitting, renderActions]); + + const departmentId = useWatch({ control, name: 'departmentId' }); + + const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: departmentId ?? '' }); + + const { + data: { department, agents = [] } = {}, + isError: isErrorDepartment, + isFetching: isFetchingDepartment, + refetch: refetchDepartment, + } = useQuery({ + queryKey: omnichannelQueryKeys.department(departmentId), + queryFn: () => getDepartment({ onlyMyDepartments: !canAssignAllDepartments ? 'true' : 'false' }), + enabled: !!departmentId, + }); + + useEffect(() => { + isErrorDepartment && trigger('departmentId'); + return () => clearErrors('departmentId'); + }, [clearErrors, isErrorDepartment, trigger]); + + const submit = useEffectEvent(async ({ agentId, departmentId }: RepliesFormData) => { + try { + const agent = agents?.find((agent) => agent.agentId === agentId); + + // Wait if department or agent is still being fetched in background + const updatedDepartment = + departmentId && isFetchingDepartment ? await refetchDepartment().then((r) => r.data?.department) : department; + + if (departmentId && !updatedDepartment) { + throw new FormFetchError('error-department-not-found'); + } + + if (agentId && !agent) { + throw new FormFetchError('error-agent-not-found'); + } + + onSubmit({ departmentId, department: updatedDepartment, agentId, agent }); + } catch (error) { + if (error instanceof FormFetchError) { + trigger(); + return; + } + + dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') }); + } + }); + + const formRef = useFormKeyboardSubmit(() => handleSubmit(submit)(), [submit, handleSubmit]); + + return ( +
+ + setValue('agentId', '')} + /> + + + + + {customActions ?? ( + + + + )} +
+ ); +}; + +RepliesForm.displayName = 'RepliesForm'; + +export default RepliesForm; 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 new file mode 100644 index 0000000000000..f2d006f085e7b --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/AgentField.tsx @@ -0,0 +1,72 @@ +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'; +import { useTranslation } from 'react-i18next'; + +import AutoCompleteAgent from '../../../../AutoCompleteDepartmentAgent'; +import { cxp } from '../../../utils/cx'; +import type { RepliesFormData } from '../RepliesForm'; + +type AgentFieldProps = { + control: Control; + agents: Serialized[]; + canAssignAny?: boolean; + canAssignSelfOnly?: boolean; + disabled?: boolean; + isLoading?: boolean; +}; + +const AgentField = ({ control, agents, canAssignAny, canAssignSelfOnly, 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 }, + } = useController({ + control, + name: 'agentId', + }); + + return ( + + {`${t('Agent')} (${t('optional')})`} + + + + {agentFieldError && ( + + {agentFieldError.message} + + )} + + {canAssignAgent ? t('Outbound_message_agent_hint') : t('Outbound_message_agent_hint_no_permission')} + + + ); +}; + +export default AgentField; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/DepartmentField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/DepartmentField.tsx new file mode 100644 index 0000000000000..a0095174d7661 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/DepartmentField.tsx @@ -0,0 +1,81 @@ +import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useId } from 'react'; +import type { Control } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import AutoCompleteDepartment from '../../../../../../../AutoCompleteDepartment'; +import RetryButton from '../../../components/RetryButton'; +import { cxp } from '../../../utils/cx'; +import type { RepliesFormData } from '../RepliesForm'; + +type DepartmentFieldProps = { + control: Control; + onlyMyDepartments?: boolean; + isError: boolean; + isFetching: boolean; + onRefetch: () => void; + onChange: () => void; +}; + +const DepartmentField = ({ + control, + onlyMyDepartments, + isError, + isFetching, + onRefetch, + onChange: onChangeExternal, +}: DepartmentFieldProps) => { + const { t } = useTranslation(); + const departmentFieldId = useId(); + + const { + field: departmentField, + fieldState: { error: departmentFieldError }, + } = useController({ + control, + name: 'departmentId', + rules: { + validate: () => (isError ? t('Error_loading__name__information', { name: t('department') }) : true), + }, + }); + + const handleDepartmentChange = useEffectEvent((onChange: (value: string) => void) => { + return (value: string) => { + onChangeExternal(); + onChange(value); + }; + }); + + return ( + + {`${t('Department')} (${t('optional')})`} + + + + {departmentFieldError && ( + + {departmentFieldError.message} + {isError && } + + )} + {t('Outbound_message_department_hint')} + + ); +}; + +export default DepartmentField; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/index.ts new file mode 100644 index 0000000000000..b99db595bc968 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/index.ts @@ -0,0 +1,2 @@ +export { default } from './RepliesForm'; +export * from './RepliesForm'; From 7c2ab6dc2babd27148f46d500032515e394952aa Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 17 Sep 2025 16:04:22 -0300 Subject: [PATCH 2/6] refactor: moved recipient form unit tests to the correct folder --- .../forms/{ => RecipientForm}/RecipientForm.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/{ => RecipientForm}/RecipientForm.spec.tsx (99%) diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.spec.tsx similarity index 99% rename from apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm.spec.tsx rename to apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.spec.tsx index 071cafd91fa37..00e484b75e876 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm.spec.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.spec.tsx @@ -5,8 +5,8 @@ import { axe } from 'jest-axe'; import { VirtuosoMockContext } from 'react-virtuoso'; import RecipientForm from './RecipientForm'; -import { createFakeContactChannel, createFakeContactWithManagerData } from '../../../../../../../tests/mocks/data'; -import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../../tests/mocks/data/outbound-message'; +import { createFakeContactChannel, createFakeContactWithManagerData } from '../../../../../../../../tests/mocks/data'; +import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../../../tests/mocks/data/outbound-message'; // NOTE: Mocking tinykeys to avoid conflicts with esm/cjs imports in Jest // Can be safely removed once cause is found and fixed From ea04a450678d6dcd4b38c3335f59c15aa1d4200e Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 17 Sep 2025 16:10:18 -0300 Subject: [PATCH 3/6] chore: yarn lock --- yarn.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/yarn.lock b/yarn.lock index 210f430751330..26f191e3c15a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7390,7 +7390,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -7452,9 +7452,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/fuselage@npm:^0.66.3": - version: 0.66.3 - resolution: "@rocket.chat/fuselage@npm:0.66.3" +"@rocket.chat/fuselage@npm:^0.66.4": + version: 0.66.4 + resolution: "@rocket.chat/fuselage@npm:0.66.4" dependencies: "@rocket.chat/css-in-js": "npm:^0.31.25" "@rocket.chat/css-supports": "npm:^0.31.25" @@ -7472,7 +7472,7 @@ __metadata: react: "*" react-dom: "*" react-virtuoso: "*" - checksum: 10/874f7d4158a1780fac526b855ccedaca7fec76c0ab08e8c3a902b298bd4b26a30ee918b765493ba2438defd50798bfe030702662644e7985d2a8f44d1c5e63f4 + checksum: 10/b8ffe08d01d7a3e548e1bb91dfc9f4eeffaf1158eaa86cbfeb7a8ed0ca763541ef3db7d0ee53037b0be73b71b232f2f1146aac56a2adecfda0a6dfb03bba6137 languageName: node linkType: hard @@ -7484,7 +7484,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -7920,7 +7920,7 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/freeswitch": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-forms": "npm:^0.1.0" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" @@ -8838,7 +8838,7 @@ __metadata: dependencies: "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:~" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -8974,7 +8974,7 @@ __metadata: "@babel/core": "npm:~7.26.10" "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9007,7 +9007,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9061,7 +9061,7 @@ __metadata: "@react-aria/toolbar": "npm:^3.0.0-nightly.5042" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9167,7 +9167,7 @@ __metadata: dependencies: "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9203,7 +9203,7 @@ __metadata: "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9261,7 +9261,7 @@ __metadata: "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9331,7 +9331,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-toastbar": "npm:^0.35.0" @@ -9381,7 +9381,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" From 00c3f34c152bde7b7a1d0abc95fb7eb3acb611c8 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Thu, 18 Sep 2025 11:17:57 -0300 Subject: [PATCH 4/6] refactor: extracted template field from message form --- .../forms/MessageForm/MessageForm.tsx | 60 ++------------ .../MessageForm/components/TemplateField.tsx | 82 +++++++++++++++++++ 2 files changed, 87 insertions(+), 55 deletions(-) create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplateField.tsx diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx index 6d0c2e9c37e78..eea51f3bbf679 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx @@ -1,20 +1,18 @@ import type { IOutboundProviderTemplate, Serialized, ILivechatContact } from '@rocket.chat/core-typings'; -import { Box, Button, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { Box, Button, FieldGroup } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; import type { ReactNode } from 'react'; import { useId, useMemo } from 'react'; -import { useController, useForm, useWatch } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import TemplateField from './components/TemplateField'; import TemplatePlaceholderField from './components/TemplatePlaceholderField'; import TemplatePreviewForm from './components/TemplatePreviewField'; -import { OUTBOUND_DOCS_LINK } from '../../../../constants'; import type { TemplateParameters } from '../../../../definitions/template'; import { extractParameterMetadata } from '../../../../utils/template'; -import TemplateSelect from '../../../TemplateSelect'; import { useFormKeyboardSubmit } from '../../hooks/useFormKeyboardSubmit'; -import { cxp } from '../../utils/cx'; import { FormFetchError } from '../../utils/errors'; export type MessageFormData = { @@ -47,7 +45,7 @@ const MessageForm = (props: MessageFormProps) => { const { control, - formState: { errors, isSubmitting }, + formState: { isSubmitting }, handleSubmit, setValue, } = useForm({ @@ -59,29 +57,11 @@ const MessageForm = (props: MessageFormProps) => { }, }); - const { field: templateIdField } = useController({ - control, - name: 'templateId', - rules: { - validate: { - // NOTE: The order of these validations matters - templatesNotFound: () => (!templates?.length ? t('No_templates_available') : true), - templateNotFound: () => (templateId && !template ? t('Error_loading__name__information', { name: t('template') }) : true), - required: (value) => (!value?.trim() ? t('Required_field', { field: t('Template_message') }) : true), - }, - }, - }); - const templateId = useWatch({ control, name: 'templateId' }); const template = useMemo(() => templates?.find((template) => template.id === templateId), [templates, templateId]); const parametersMetadata = useMemo(() => (template ? extractParameterMetadata(template) : []), [template]); const customActions = useMemo(() => renderActions?.({ isSubmitting }), [isSubmitting, renderActions]); - const handleTemplateChange = useEffectEvent((value: string) => { - setValue('templateParameters', {}); - templateIdField.onChange(value); - }); - const submit = useEffectEvent(async (values: MessageFormData) => { try { const { templateId, templateParameters } = values; @@ -103,37 +83,7 @@ const MessageForm = (props: MessageFormProps) => { return (
- - - {t('Template')} - - - - - {errors.templateId && ( - - {errors.templateId.message} - - )} - - {/* TODO: Change to the correct address */} - - {t('Learn_more')} - - - + setValue('templateParameters', {})} /> {parametersMetadata.map((metadata) => ( diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplateField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplateField.tsx new file mode 100644 index 0000000000000..0c9ef49dbd258 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplateField.tsx @@ -0,0 +1,82 @@ +import type { IOutboundProviderTemplate, Serialized } from '@rocket.chat/core-typings'; +import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useId } from 'react'; +import type { Control } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { OUTBOUND_DOCS_LINK } from '../../../../../constants'; +import TemplateSelect from '../../../../TemplateSelect'; +import { cxp } from '../../../utils/cx'; +import type { MessageFormData } from '../MessageForm'; + +type TemplateFieldProps = { + control: Control; + templates: Serialized[] | undefined; + onChange?: (templateId: string) => void; +}; + +const TemplateField = ({ control, templates, onChange: onChangeExternal }: TemplateFieldProps) => { + const { t } = useTranslation(); + const templateFieldId = useId(); + + const { + field: templateField, + fieldState: { error: templateFieldError }, + } = useController({ + control, + name: 'templateId', + rules: { + validate: { + // NOTE: The order of these validations matters + templatesNotFound: () => (!templates?.length ? t('No_templates_available') : true), + templateNotFound: (templateId) => + templateId && !templates?.some((template) => template.id === templateId) + ? t('Error_loading__name__information', { name: t('template') }) + : true, + required: (value) => (!value?.trim() ? t('Required_field', { field: t('Template_message') }) : true), + }, + }, + }); + + const handleTemplateChange = useEffectEvent((value: string) => { + onChangeExternal?.(value); + templateField.onChange(value); + }); + + return ( + + + {t('Template')} + + + + + {templateFieldError && ( + + {templateFieldError.message} + + )} + + + {t('Learn_more')} + + + + ); +}; + +export default TemplateField; From 13923a966f51447450880e6efe5e4b771e0334bc Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Thu, 18 Sep 2025 14:27:18 -0300 Subject: [PATCH 5/6] refactor: removed redundant keyboard shortcut --- .../forms/MessageForm/MessageForm.spec.tsx | 5 ---- .../forms/MessageForm/MessageForm.tsx | 5 +--- .../RecipientForm/RecipientForm.spec.tsx | 7 ------ .../forms/RecipientForm/RecipientForm.tsx | 5 +--- .../forms/RepliesForm/RepliesForm.spec.tsx | 5 ---- .../forms/RepliesForm/RepliesForm.tsx | 5 +--- .../hooks/useFormKeyboardSubmit.tsx | 24 ------------------- 7 files changed, 3 insertions(+), 53 deletions(-) delete mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/hooks/useFormKeyboardSubmit.tsx diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.spec.tsx index 249b351981625..9150066ef8379 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.spec.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.spec.tsx @@ -7,11 +7,6 @@ import MessageForm from './MessageForm'; import { createFakeContactWithManagerData } from '../../../../../../../../tests/mocks/data'; import { createFakeOutboundTemplate } from '../../../../../../../../tests/mocks/data/outbound-message'; -jest.mock('tinykeys', () => ({ - __esModule: true, - default: jest.fn().mockReturnValue(() => () => undefined), -})); - const component = { header: { type: 'header', text: 'New {{1}} appointment' }, body: { type: 'body', text: 'Hello {{1}}' }, diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx index eea51f3bbf679..1d1dde6475fac 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx @@ -12,7 +12,6 @@ import TemplatePlaceholderField from './components/TemplatePlaceholderField'; import TemplatePreviewForm from './components/TemplatePreviewField'; import type { TemplateParameters } from '../../../../definitions/template'; import { extractParameterMetadata } from '../../../../utils/template'; -import { useFormKeyboardSubmit } from '../../hooks/useFormKeyboardSubmit'; import { FormFetchError } from '../../utils/errors'; export type MessageFormData = { @@ -78,10 +77,8 @@ const MessageForm = (props: MessageFormProps) => { } }); - const formRef = useFormKeyboardSubmit(() => handleSubmit(submit)(), [submit, handleSubmit]); - return ( - + setValue('templateParameters', {})} /> diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.spec.tsx index 00e484b75e876..7f654b54693e6 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.spec.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.spec.tsx @@ -8,13 +8,6 @@ import RecipientForm from './RecipientForm'; import { createFakeContactChannel, createFakeContactWithManagerData } from '../../../../../../../../tests/mocks/data'; import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../../../tests/mocks/data/outbound-message'; -// NOTE: Mocking tinykeys to avoid conflicts with esm/cjs imports in Jest -// Can be safely removed once cause is found and fixed -jest.mock('tinykeys', () => ({ - __esModule: true, - default: jest.fn().mockReturnValue(() => () => undefined), -})); - const recipientOnePhoneNumber = '+12125554567'; const recipientTwoPhoneNumber = '+12125557788'; const senderOnePhoneNumber = '+12127774567'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.tsx index 7923e86e949d6..3e2c5f6e18eed 100644 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.tsx +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.tsx @@ -14,7 +14,6 @@ import ContactField from './components/ContactField'; import RecipientField from './components/RecipientField'; import SenderField from './components/SenderField'; import { omnichannelQueryKeys } from '../../../../../../../lib/queryKeys'; -import { useFormKeyboardSubmit } from '../../hooks/useFormKeyboardSubmit'; import { ContactNotFoundError, ProviderNotFoundError } from '../../utils/errors'; export type RecipientFormData = { @@ -175,10 +174,8 @@ const RecipientForm = (props: RecipientFormProps) => { } }); - const formRef = useFormKeyboardSubmit(() => handleSubmit(submit)(), [submit, handleSubmit]); - return ( - + ({ - __esModule: true, - default: jest.fn().mockReturnValue(() => () => undefined), -})); - const mockDepartment = createFakeDepartment({ _id: 'department-1', name: 'Department 1', 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 01c43ffa1681b..482fa8e52a14e 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 @@ -12,7 +12,6 @@ import { useTranslation } from 'react-i18next'; import AgentField from './components/AgentField'; import DepartmentField from './components/DepartmentField'; import { omnichannelQueryKeys } from '../../../../../../../lib/queryKeys'; -import { useFormKeyboardSubmit } from '../../hooks/useFormKeyboardSubmit'; import { FormFetchError } from '../../utils/errors'; export type RepliesFormData = { @@ -105,10 +104,8 @@ const RepliesForm = (props: RepliesFormProps) => { } }); - const formRef = useFormKeyboardSubmit(() => handleSubmit(submit)(), [submit, handleSubmit]); - return ( - + void, deps: DependencyList): RefCallback => { - return useSafeRefCallback( - useCallback((formRef: HTMLFormElement | null) => { - if (!formRef) { - return; - } - - return tinykeys(formRef, { '$mod+Enter': callback }); - }, deps), - ); -}; From d5532433c2cc8519da6e7f09649caadda93882d4 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Fri, 19 Sep 2025 01:50:52 -0300 Subject: [PATCH 6/6] fix: Agents unable to assign outbound message replies to themselves (#36989) Co-authored-by: Tasso --- .../forms/RepliesForm/RepliesForm.spec.tsx | 3 +- .../forms/RepliesForm/RepliesForm.tsx | 17 +- .../RepliesForm/components/AgentField.tsx | 13 +- .../hooks/useAllowedAgents.spec.ts | 169 ++++++++++++++++++ .../RepliesForm/hooks/useAllowedAgents.ts | 37 ++++ .../utils/getAgentDerivedFromUser.spec.ts | 39 ++++ .../utils/getAgentDerivedFromUser.ts | 20 +++ 7 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/hooks/useAllowedAgents.spec.ts create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/hooks/useAllowedAgents.ts create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/utils/getAgentDerivedFromUser.spec.ts create mode 100644 apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/utils/getAgentDerivedFromUser.ts 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, + }; +};