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 6d0c2e9c37e78..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 @@ -1,20 +1,17 @@ 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 +44,7 @@ const MessageForm = (props: MessageFormProps) => { const { control, - formState: { errors, isSubmitting }, + formState: { isSubmitting }, handleSubmit, setValue, } = useForm({ @@ -59,29 +56,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; @@ -98,42 +77,10 @@ const MessageForm = (props: MessageFormProps) => { } }); - const formRef = useFormKeyboardSubmit(() => handleSubmit(submit)(), [submit, handleSubmit]); - 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; 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 97% 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..7f654b54693e6 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,15 +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'; - -// 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), -})); +import { createFakeContactChannel, createFakeContactWithManagerData } from '../../../../../../../../tests/mocks/data'; +import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../../../tests/mocks/data/outbound-message'; const recipientOnePhoneNumber = '+12125554567'; const recipientTwoPhoneNumber = '+12125557788'; 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 ( - + ; - 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 98% 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..8de27054f5040 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,12 +7,7 @@ import { axe } from 'jest-axe'; import { VirtuosoMockContext } from 'react-virtuoso'; import RepliesForm from './RepliesForm'; -import { createFakeDepartment, createFakeUser } from '../../../../../../../tests/mocks/data'; - -jest.mock('tinykeys', () => ({ - __esModule: true, - default: jest.fn().mockReturnValue(() => () => undefined), -})); +import { createFakeDepartment, createFakeUser } from '../../../../../../../../tests/mocks/data'; const mockDepartment = createFakeDepartment({ _id: 'department-1', @@ -23,6 +18,7 @@ const mockUser = createFakeUser({ _id: 'agent-1', username: 'agent.one', name: 'Agent One', + roles: ['livechat-agent'], }); const mockAgentOne: Serialized = { @@ -223,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 new file mode 100644 index 0000000000000..f7e6acc2f13e5 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/RepliesForm.tsx @@ -0,0 +1,151 @@ +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, useUser } 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 { useAllowedAgents } from './hooks/useAllowedAgents'; +import { omnichannelQueryKeys } from '../../../../../../../lib/queryKeys'; +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 user = useUser(); + + 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: queryAgents = [] } = {}, + isError: isErrorDepartment, + isFetching: isFetchingDepartment, + refetch: refetchDepartment, + } = useQuery({ + queryKey: omnichannelQueryKeys.department(departmentId), + queryFn: () => getDepartment({ onlyMyDepartments: !canAssignAllDepartments ? 'true' : 'false' }), + enabled: !!departmentId, + }); + + const agents = useAllowedAgents({ + user, + queryAgents, + departmentId: department?._id, + canAssignSelfOnlyAgent, + canAssignAnyAgent, + }); + + 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') }); + } + }); + + 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..424587969176e --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm/components/AgentField.tsx @@ -0,0 +1,65 @@ +import type { ILivechatDepartmentAgents, Serialized } from '@rocket.chat/core-typings'; +import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +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[]; + canAssignAgent?: boolean; + disabled?: boolean; + isLoading?: boolean; +}; + +const AgentField = ({ control, agents, canAssignAgent, disabled = false, isLoading = false }: AgentFieldProps) => { + const { t } = useTranslation(); + const agentFieldId = useId(); + + 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/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/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'; 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, + }; +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/hooks/useFormKeyboardSubmit.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/hooks/useFormKeyboardSubmit.tsx deleted file mode 100644 index 40c9167690adb..0000000000000 --- a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/hooks/useFormKeyboardSubmit.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { useSafeRefCallback } from '@rocket.chat/ui-client'; -import type { DependencyList, RefCallback } from 'react'; -import { useCallback } from 'react'; -import tinykeys from 'tinykeys'; - -/** - * A hook to enable form submission via keyboard shortcut ($mod+Enter). - * - * @param callback - The function to execute when the keyboard shortcut is pressed. - * @param deps - The dependency array for the callback memoization. - * @returns A ref callback to be attached to the form element. - */ -export const useFormKeyboardSubmit = (callback: (event: KeyboardEvent) => void, deps: DependencyList): RefCallback => { - return useSafeRefCallback( - useCallback((formRef: HTMLFormElement | null) => { - if (!formRef) { - return; - } - - return tinykeys(formRef, { '$mod+Enter': callback }); - }, deps), - ); -}; 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"