diff --git a/.changeset/rich-rules-sleep.md b/.changeset/rich-rules-sleep.md new file mode 100644 index 0000000000000..d96fda4156168 --- /dev/null +++ b/.changeset/rich-rules-sleep.md @@ -0,0 +1,20 @@ +--- +'@rocket.chat/web-ui-registration': patch +'@rocket.chat/storybook-config': patch +'@rocket.chat/fuselage-ui-kit': patch +'@rocket.chat/ui-theming': patch +'@rocket.chat/ui-video-conf': patch +'@rocket.chat/uikit-playground': patch +'@rocket.chat/ui-composer': patch +'@rocket.chat/gazzodown': patch +'@rocket.chat/ui-avatar': patch +'@rocket.chat/ui-client': patch +'@rocket.chat/ui-voip': patch +'@rocket.chat/core-typings': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/license': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx index 9bbc20a0fd0f9..a1c2f56676aae 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx @@ -1,8 +1,9 @@ import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; -import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts'; +import { useTranslation, useSetting, useAtLeastOnePermission, usePermission } from '@rocket.chat/ui-contexts'; import { useCreateRoomModal } from './useCreateRoomModal'; import CreateDiscussion from '../../../components/CreateDiscussion'; +import { useOutboundMessageModal } from '../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal'; import CreateChannelModal from '../actions/CreateChannelModal'; import CreateDirectMessage from '../actions/CreateDirectMessage'; import CreateTeamModal from '../actions/CreateTeamModal'; @@ -20,11 +21,13 @@ export const useCreateNewItems = (): GenericMenuItemProps[] => { const canCreateTeam = useAtLeastOnePermission(CREATE_TEAM_PERMISSIONS); const canCreateDirectMessages = useAtLeastOnePermission(CREATE_DIRECT_PERMISSIONS); const canCreateDiscussion = useAtLeastOnePermission(CREATE_DISCUSSION_PERMISSIONS); + const canSendOutboundMessage = usePermission('outbound.send-messages'); const createChannel = useCreateRoomModal(CreateChannelModal); const createTeam = useCreateRoomModal(CreateTeamModal); const createDiscussion = useCreateRoomModal(CreateDiscussion); const createDirectMessage = useCreateRoomModal(CreateDirectMessage); + const outboundMessageModal = useOutboundMessageModal(); const createChannelItem: GenericMenuItemProps = { id: 'channel', @@ -58,11 +61,18 @@ export const useCreateNewItems = (): GenericMenuItemProps[] => { createDiscussion(); }, }; + const createOutboundMessageItem: GenericMenuItemProps = { + id: 'outbound-message', + content: t('Outbound_message'), + icon: 'send', + onClick: () => outboundMessageModal.open(), + }; return [ ...(canCreateDirectMessages ? [createDirectMessageItem] : []), ...(canCreateDiscussion && discussionEnabled ? [createDiscussionItem] : []), ...(canCreateChannel ? [createChannelItem] : []), ...(canCreateTeam && canCreateChannel ? [createTeamItem] : []), + ...(canSendOutboundMessage ? [createOutboundMessageItem] : []), ]; }; diff --git a/apps/meteor/client/components/AutoCompleteContact/AutoCompleteContact.tsx b/apps/meteor/client/components/AutoCompleteContact/AutoCompleteContact.tsx new file mode 100644 index 0000000000000..5c594c60cd158 --- /dev/null +++ b/apps/meteor/client/components/AutoCompleteContact/AutoCompleteContact.tsx @@ -0,0 +1,63 @@ +import type { Serialized } from '@rocket.chat/core-typings'; +import { PaginatedSelectFiltered } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings'; +import type { ComponentProps, ReactElement, SyntheticEvent } from 'react'; +import { memo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useContactsList } from './useContactsList'; + +type OptionProps = { + role: 'option'; + title?: string; + index: number; + label: string; + value: string; + selected: boolean; + focus: boolean; + onMouseDown(event: SyntheticEvent): void; +}; + +type AutoCompleteContactProps = Omit< + ComponentProps, + 'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem' | 'value' | 'onChange' +> & { + value: string; + onChange: (value: string) => void; + renderItem?: (props: OptionProps, contact: Serialized) => ReactElement; +}; + +const AutoCompleteContact = ({ value, placeholder, disabled, renderItem, onChange, ...props }: AutoCompleteContactProps): ReactElement => { + const { t } = useTranslation(); + const [contactsFilter, setContactFilter] = useState(''); + const debouncedContactFilter = useDebouncedValue(contactsFilter, 500); + + const { + data: contactsItems = [], + fetchNextPage, + isPending, + } = useContactsList({ + filter: debouncedContactFilter, + }); + + return ( + void} + options={contactsItems} + onChange={onChange} + endReached={() => fetchNextPage()} + renderItem={renderItem ? (props: OptionProps) => renderItem(props, contactsItems[props.index]) : undefined} + /> + ); +}; + +export default memo(AutoCompleteContact); diff --git a/apps/meteor/client/components/AutoCompleteContact/index.ts b/apps/meteor/client/components/AutoCompleteContact/index.ts new file mode 100644 index 0000000000000..142320280e800 --- /dev/null +++ b/apps/meteor/client/components/AutoCompleteContact/index.ts @@ -0,0 +1 @@ +export { default } from './AutoCompleteContact'; diff --git a/apps/meteor/client/components/AutoCompleteContact/useContactsList.tsx b/apps/meteor/client/components/AutoCompleteContact/useContactsList.tsx new file mode 100644 index 0000000000000..9927fea251e62 --- /dev/null +++ b/apps/meteor/client/components/AutoCompleteContact/useContactsList.tsx @@ -0,0 +1,52 @@ +import type { Serialized } from '@rocket.chat/core-typings'; +import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { omnichannelQueryKeys } from '../../lib/queryKeys'; + +export type ContactOption = Serialized & { + value: string; + label: string; +}; + +type ContactOptions = { + filter: string; + limit?: number; +}; + +const DEFAULT_QUERY_LIMIT = 25; + +const formatContactItem = (contact: Serialized): ContactOption => ({ + ...contact, + label: contact.name || contact._id, + value: contact._id, +}); + +export const useContactsList = (options: ContactOptions) => { + const getContacts = useEndpoint('GET', '/v1/omnichannel/contacts.search'); + const { filter, limit = DEFAULT_QUERY_LIMIT } = options; + + return useInfiniteQuery({ + queryKey: omnichannelQueryKeys.contacts({ filter, limit }), + queryFn: async ({ pageParam: offset = 0 }) => { + const { contacts, ...data } = await getContacts({ + searchText: filter, + // sort: `{ name: -1 }`, + ...(limit && { count: limit }), + ...(offset && { offset }), + }); + + return { + ...data, + contacts: contacts.map(formatContactItem), + }; + }, + select: (data) => data.pages.flatMap((page) => page.contacts), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + const offset = lastPage.offset + lastPage.count; + return offset < lastPage.total ? offset : undefined; + }, + }); +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteDepartmentAgent.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteDepartmentAgent.spec.tsx new file mode 100644 index 0000000000000..59f9dee9ffc33 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteDepartmentAgent.spec.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react'; + +import AutoCompleteDepartmentAgent from './AutoCompleteDepartmentAgent'; + +it('should not display the placeholder when there is a value', () => { + const { rerender } = render(); + + expect(screen.getByPlaceholderText('Select an agent')).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByPlaceholderText('Select an agent')).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteDepartmentAgent.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteDepartmentAgent.tsx new file mode 100644 index 0000000000000..eeee2ff378ab9 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteDepartmentAgent.tsx @@ -0,0 +1,64 @@ +import type { ILivechatDepartmentAgents, Serialized } from '@rocket.chat/core-typings'; +import { AutoComplete, Box, Chip, Option, OptionAvatar, OptionContent } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import type { AllHTMLAttributes, ReactElement } from 'react'; +import { useMemo, useState } from 'react'; + +type AutoCompleteDepartmentAgentProps = Omit, 'onChange'> & { + error?: boolean; + value: string; + onChange(value: string): void; + agents?: Serialized[]; +}; + +const AutoCompleteDepartmentAgent = ({ value, onChange, agents, placeholder, ...props }: AutoCompleteDepartmentAgentProps) => { + const [filter, setFilter] = useState(''); + const debouncedFilter = useDebouncedValue(filter, 1000); + + const options = useMemo(() => { + if (!agents) { + return []; + } + + return agents + .filter((agent) => agent.username?.includes(debouncedFilter)) + .sort((a, b) => a.username.localeCompare(b.username)) + .map((agent) => ({ + value: agent.agentId, + label: agent.username, + })); + }, [agents, debouncedFilter]); + + return ( + void} + options={options} + renderSelected={({ selected: { value, label }, ...props }): ReactElement => { + return ( + onChange('')} mie={4}> + + + {label} + + + ); + }} + renderItem={({ value, label, ...props }): ReactElement => ( + + )} + /> + ); +}; + +export default AutoCompleteDepartmentAgent; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteOutboundProvider.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteOutboundProvider.tsx new file mode 100644 index 0000000000000..2b69afe479055 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/AutoCompleteOutboundProvider.tsx @@ -0,0 +1,69 @@ +import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings'; +import { Option, OptionDescription, PaginatedSelectFiltered } from '@rocket.chat/fuselage'; +import type { ComponentProps, ReactElement } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useTimeFromNow } from '../../../../hooks/useTimeFromNow'; +import useOutboundProvidersList from '../hooks/useOutboundProvidersList'; +import { findLastChatFromChannel } from '../utils/findLastChatFromChannel'; + +type AutoCompleteOutboundProviderProps = Omit< + ComponentProps, + 'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem' +> & { + contact?: Serialized> | null; + value: string; + onChange: (value: string) => void; +}; + +const AutoCompleteOutboundProvider = ({ + contact, + disabled, + value, + placeholder, + onChange, + ...props +}: AutoCompleteOutboundProviderProps): ReactElement => { + const [channelsFilter, setChannelsFilter] = useState(''); + const { t } = useTranslation(); + const getTimeFromNow = useTimeFromNow(true); + + const { data: options = [], isPending } = useOutboundProvidersList({ + select: ({ providers = [] }) => { + return providers.map((prov) => ({ + label: prov.providerName, + value: prov.providerId, + })); + }, + }); + + return ( + void} + options={options} + onChange={onChange} + renderItem={({ label, value, ...props }) => { + const lastChat = findLastChatFromChannel(contact?.channels, value); + + return ( + + ); + }} + /> + ); +}; + +export default AutoCompleteOutboundProvider; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/OutboundMessagePreview.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/OutboundMessagePreview.tsx new file mode 100644 index 0000000000000..5230cc9060af7 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/OutboundMessagePreview.tsx @@ -0,0 +1,106 @@ +import type { + ILivechatAgent, + ILivechatDepartment, + IOutboundProviderMetadata, + IOutboundProviderTemplate, + ILivechatContact, +} from '@rocket.chat/core-typings'; +import { Box, Margins } from '@rocket.chat/fuselage'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import PreviewItem from './PreviewItem'; +import { formatPhoneNumber } from '../../../../../lib/formatPhoneNumber'; +import type { TemplateParameters } from '../../definitions/template'; +import TemplatePreview from '../TemplatePreview'; + +type OutboundMessagePreviewProps = { + template?: IOutboundProviderTemplate; + contactName?: ILivechatContact['name']; + providerName?: IOutboundProviderMetadata['providerName']; + providerType?: IOutboundProviderMetadata['providerType']; + departmentName?: ILivechatDepartment['name']; + agentName?: ILivechatAgent['name']; + agentUsername?: ILivechatAgent['username']; + sender?: string; + recipient?: string; + templateParameters?: TemplateParameters; +}; + +const formatContact = (rawValue?: string, providerType?: IOutboundProviderMetadata['providerType']) => { + if (!rawValue) { + return undefined; + } + + if (providerType === 'phone') { + return formatPhoneNumber(rawValue); + } + + return rawValue; +}; + +const OutboundMessagePreview = ({ + template, + contactName, + providerName, + providerType, + departmentName, + agentName, + agentUsername, + sender: rawSender, + recipient: rawRecipient, + templateParameters, +}: OutboundMessagePreviewProps) => { + const { t } = useTranslation(); + + const recipient = useMemo(() => formatContact(rawRecipient, providerType), [providerType, rawRecipient]); + const sender = useMemo(() => formatContact(rawSender, providerType), [providerType, rawSender]); + const replies = useMemo(() => { + if (agentName) { + return `${agentName} ${agentUsername ? `(${agentUsername})` : ''} ${departmentName ? `at ${departmentName}` : ''}`; + } + + if (agentUsername) { + return `@${agentUsername} ${departmentName ? `at ${departmentName}` : ''}`; + } + + return departmentName; + }, [agentName, agentUsername, departmentName]); + + return ( +
+
    + + + + {template?.name} + + + + + {contactName && `${contactName} ${recipient ? `(${recipient})` : ''}`} + + + + + {providerName && `${providerName} ${sender ? `(${sender})` : ''}`} + + + + + {replies} + + + +
+ + {template ? ( + + + + ) : null} +
+ ); +}; + +export default OutboundMessagePreview; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/PreviewItem.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/PreviewItem.tsx new file mode 100644 index 0000000000000..847e2d4b6f7b6 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/PreviewItem.tsx @@ -0,0 +1,36 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { Keys } from '@rocket.chat/icons'; +import type { ReactNode } from 'react'; +import { useId } from 'react'; +import { useTranslation } from 'react-i18next'; + +type PreviewItemProps = { + label: string; + icon: Keys; + children: ReactNode; +}; + +const PreviewItem = ({ icon, label, children }: PreviewItemProps) => { + const { t } = useTranslation(); + const id = useId(); + + return ( + + + + + {label} + +
+ {children ?? ( + + {t('None')} + + )} +
+
+
+ ); +}; + +export default PreviewItem; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/index.ts new file mode 100644 index 0000000000000..78ac50f76c018 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessagePreview/index.ts @@ -0,0 +1 @@ +export { default } from './OutboundMessagePreview'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.spec.tsx new file mode 100644 index 0000000000000..8af73ccd37dd1 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.spec.tsx @@ -0,0 +1,119 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { StepsLinkedList, WizardContext } from '@rocket.chat/ui-client'; +import { act, render, waitFor } from '@testing-library/react'; + +import OutboundMessageWizard from './OutboundMessageWizard'; +import { createFakeLicenseInfo } from '../../../../../../tests/mocks/data'; +import { createFakeProvider } from '../../../../../../tests/mocks/data/outbound-message'; +import { useOutboundMessageUpsellModal } from '../../modals'; + +const openUpsellModal = jest.fn(); +jest.mock('../../modals', () => ({ + useOutboundMessageUpsellModal: () => ({ + open: openUpsellModal, + close: jest.fn(), + }), +})); + +useOutboundMessageUpsellModal; + +jest.mock('tinykeys', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue(() => () => undefined), +})); + +jest.mock('../../../../../../app/utils/client', () => ({ + getURL: (url: string) => url, +})); + +let currentOnSubmit: (payload: Record) => void = () => undefined; +jest.mock('./steps', () => ({ + ...jest.requireActual('./steps'), + RecipientStep: jest.fn().mockImplementation((props) => { + currentOnSubmit = props.onSubmit; + return
form
; + }), +})); + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head?.next ?? null, + next: jest.fn(), + previous: jest.fn(), + register: jest.fn(), + goTo: jest.fn(), + resetNextSteps: jest.fn(), +}; + +const getProvidersMock = jest.fn().mockImplementation(() => ({ providers: [] })); +const getLicenseMock = jest.fn().mockImplementation(() => ({ + license: createFakeLicenseInfo({ + activeModules: ['livechat-enterprise'], + }), +})); + +const appRoot = mockAppRoot() + .withJohnDoe() + .withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => getProvidersMock()) + .withEndpoint('GET', '/v1/licenses.info', () => getLicenseMock()) + .wrap((children) => { + return {children}; + }); + +describe('OutboundMessageWizard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('upsell flow', () => { + it('should display upsell modal if module is not present', async () => { + getLicenseMock.mockResolvedValueOnce({ license: createFakeLicenseInfo({ activeModules: [] }) }); + getProvidersMock.mockResolvedValueOnce({ providers: [] }); + + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(openUpsellModal).toHaveBeenCalled()); + }); + + it('should display upsell modal if module is present but theres no providers', async () => { + getLicenseMock.mockResolvedValueOnce({ license: createFakeLicenseInfo({ activeModules: [] }) }); + getProvidersMock.mockResolvedValueOnce({ providers: [createFakeProvider()] }); + + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(openUpsellModal).toHaveBeenCalled()); + }); + + it('should display upsell modal on submit when module is present but provider is not', async () => { + getLicenseMock.mockResolvedValueOnce({ license: createFakeLicenseInfo({ activeModules: ['outbound-messaging'] }) }); + getProvidersMock.mockResolvedValueOnce({ providers: [] }); + + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(openUpsellModal).not.toHaveBeenCalled()); + + await act(() => currentOnSubmit({})); + + await waitFor(() => expect(openUpsellModal).toHaveBeenCalled()); + }); + + it('should not display upsell modal when module and provider is present', async () => { + getProvidersMock.mockResolvedValueOnce({ providers: [createFakeProvider()] }); + getLicenseMock.mockResolvedValueOnce({ license: createFakeLicenseInfo({ activeModules: ['outbound-messaging'] }) }); + + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(openUpsellModal).not.toHaveBeenCalled()); + + await act(() => currentOnSubmit({})); + + await waitFor(() => expect(openUpsellModal).not.toHaveBeenCalled()); + }); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.stories.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.stories.tsx new file mode 100644 index 0000000000000..bb3df4f99cec3 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.stories.tsx @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { faker } from '@faker-js/faker/locale/af_ZA'; +import { Box } from '@rocket.chat/fuselage'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import OutboundMessageWizard from './OutboundMessageWizard'; +import { MessageStep, ReviewStep, RecipientStep, RepliesStep } from './steps'; +import { + createFakeAgent, + createFakeContact, + createFakeContactWithManagerData, + createFakeDepartment, +} from '../../../../../../tests/mocks/data'; +import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../tests/mocks/data/outbound-message'; + +const mockDepartment = createFakeDepartment({ name: `${faker.commerce.department()} Department` }); + +const contactWithManagerMock = createFakeContactWithManagerData({ + _id: 'contact-1', + name: 'John Doe', + phones: [{ phoneNumber: '+12125554567' }], +}); + +const { contactManager: _, ...contactNormal } = contactWithManagerMock; +const contactMock = createFakeContact(contactNormal); + +const providerMock = createFakeProviderMetadata({ + providerId: 'provider-1', + providerName: 'WhatsApp', + templates: { + '+12127774567': [createFakeOutboundTemplate({ phoneNumber: '+12127774567' })], + }, +}); + +const mockAgent = createFakeAgent(); +const mockDepartmentAgent = { + ...mockAgent, + username: mockAgent.username || '', + agentId: mockAgent._id, + departmentId: mockDepartment._id, + departmentEnabled: true, + count: 0, + order: 0, +}; + +const AppRoot = mockAppRoot() + .withEndpoint('GET', '/v1/livechat/department', () => ({ departments: [mockDepartment], count: 1, offset: 0, total: 1 })) + .withEndpoint('GET', '/v1/livechat/users/agent', () => ({ users: [{ ...mockAgent, departments: [] }], count: 1, offset: 0, total: 1 })) + .withEndpoint('GET', '/v1/livechat/department/:_id', () => ({ department: mockDepartment, agents: [mockDepartmentAgent] })) + .withEndpoint('GET', '/v1/livechat/users/agent/:_id', () => ({ user: mockAgent })) + .withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => ({ providers: [providerMock] })) + .withEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', () => ({ metadata: providerMock })) + .withEndpoint('GET', '/v1/omnichannel/contacts.get', () => ({ contact: contactMock })) + .withEndpoint('GET', '/v1/omnichannel/contacts.search', () => ({ contacts: [contactWithManagerMock], count: 1, offset: 0, total: 1 })) + .build(); + +const meta = { + title: 'Components/OutboundMessage/OutboundMessageWizard', + component: OutboundMessageWizard, + subcomponents: { + RecipientStep, + MessageStep, + RepliesStep, + ReviewStep, + }, + parameters: { + controls: { hideNoControlsWarning: true }, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultValues: {}, + }, +}; + +export const WithDefaultValues: Story = { + args: { + defaultValues: { + contactId: contactMock._id, + providerId: providerMock.providerId, + }, + }, +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.tsx new file mode 100644 index 0000000000000..859a0994a123e --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/OutboundMessageWizard.tsx @@ -0,0 +1,186 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; +import { Wizard, useWizard, WizardContent, WizardTabs } from '@rocket.chat/ui-client'; +import { usePermission } from '@rocket.chat/ui-contexts'; +import { useEffect, useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useTranslation } from 'react-i18next'; + +import OutboundMessageWizardErrorState from './components/OutboundMessageWizardErrorState'; +import type { SubmitPayload } from './forms'; +import { ReviewStep, MessageStep, RecipientStep, RepliesStep } from './steps'; +import { useHasLicenseModule } from '../../../../../hooks/useHasLicenseModule'; +import { formatPhoneNumber } from '../../../../../lib/formatPhoneNumber'; +import GenericError from '../../../../GenericError'; +import useOutboundProvidersList from '../../hooks/useOutboundProvidersList'; +import { useOutboundMessageUpsellModal } from '../../modals'; +import OutboubdMessageWizardSkeleton from './components/OutboundMessageWizardSkeleton'; +import { useEndpointMutation } from '../../../../../hooks/useEndpointMutation'; +import { formatOutboundMessagePayload, isMessageStepValid, isRecipientStepValid, isRepliesStepValid } from '../../utils/outbound-message'; + +type OutboundMessageWizardProps = { + defaultValues?: Partial>; + onSuccess?(): void; + onError?(): void; +}; + +const OutboundMessageWizard = ({ defaultValues = {}, onSuccess, onError }: OutboundMessageWizardProps) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastBarDispatch(); + const [state, setState] = useState>(defaultValues); + const { contact, sender, provider, department, agent, template, templateParameters, recipient } = state; + + const templates = sender ? provider?.templates[sender] : []; + const upsellModal = useOutboundMessageUpsellModal(); + + const hasOmnichannelModule = useHasLicenseModule('livechat-enterprise'); + const hasOutboundModule = useHasLicenseModule('outbound-messaging'); + const hasOutboundPermission = usePermission('outbound.send-messages'); + + const isLoadingModule = hasOutboundModule === 'loading' || hasOmnichannelModule === 'loading'; + + const sendOutboundMessage = useEndpointMutation('POST', '/v1/omnichannel/outbound/providers/:id/message', { + keys: { id: provider?.providerId || '' }, + onError: () => undefined, // error being handled in handleSend + }); + + const { + data: hasProviders = false, + isLoading: isLoadingProviders, + isError: isErrorProviders, + refetch: refetchProviders, + } = useOutboundProvidersList({ + select: ({ providers = [] }) => providers.length > 0, + }); + + const wizardApi = useWizard({ + steps: [ + { id: 'recipient', title: t('Recipient') }, + { id: 'message', title: t('Message') }, + { id: 'replies', title: t('Replies') }, + { id: 'review', title: t('Review') }, + ], + }); + + useEffect(() => { + if (!isLoadingProviders && !isLoadingModule && (!hasOutboundModule || !hasProviders)) { + upsellModal.open(); + } + }, [hasOutboundModule, hasProviders, isLoadingModule, isLoadingProviders, upsellModal]); + + const handleSubmit = useEffectEvent((values: SubmitPayload) => { + if (!hasOutboundModule) { + upsellModal.open(); + return; + } + + setState((state) => ({ ...state, ...values })); + }); + + const handleSend = useEffectEvent(async () => { + try { + if (!isRecipientStepValid(state)) { + throw new Error('error-invalid-recipient-step'); + } + + if (!isMessageStepValid(state)) { + throw new Error('error-invalid-message-step'); + } + + if (!isRepliesStepValid(state)) { + throw new Error('error-invalid-replies-step'); + } + + const { template, sender, recipient, templateParameters, departmentId, agentId } = state; + + const payload = formatOutboundMessagePayload({ + type: 'template', + template, + sender, + recipient, + templateParameters, + departmentId, + agentId, + }); + + await sendOutboundMessage.mutateAsync(payload); + + dispatchToastMessage({ + type: 'success', + message: t('Outbound_message_sent_to__name__', { name: contact?.name || formatPhoneNumber(recipient) }), + }); + + onSuccess?.(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: t('Outbound_message_not_sent') }); + + // only console.error when in debug mode + const urlParams = new URLSearchParams(window.location.search); + const debug = urlParams.get('debug') === 'true'; + + if (debug) { + console.error(error); + } + + onError?.(); + } + }); + + const handleDirtyStep = useEffectEvent(() => { + wizardApi.resetNextSteps(); + }); + + if (!hasOutboundPermission) { + return ( + + ); + } + + if (isLoadingModule || isLoadingProviders) { + return ; + } + + if (isErrorProviders) { + return ; + } + + return ( + }> + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default OutboundMessageWizard; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/OutboundMessageWizardErrorState.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/OutboundMessageWizardErrorState.tsx new file mode 100644 index 0000000000000..12545da673fc2 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/OutboundMessageWizardErrorState.tsx @@ -0,0 +1,26 @@ +import { States, StatesIcon, StatesTitle, StatesActions, StatesAction, StatesSubtitle } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +type Props = { + title?: string; + description?: string; + onRetry?(): void; +}; + +const OutboundMessageWizardErrorState = ({ title, description, onRetry }: Props) => { + const { t } = useTranslation(); + return ( + + + {title ?? t('Something_went_wrong')} + {description ? {description} : null} + {onRetry ? ( + + {t('Retry')} + + ) : null} + + ); +}; + +export default OutboundMessageWizardErrorState; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/OutboundMessageWizardSkeleton.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/OutboundMessageWizardSkeleton.tsx new file mode 100644 index 0000000000000..65b2da290f57f --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/OutboundMessageWizardSkeleton.tsx @@ -0,0 +1,36 @@ +import { Box, Skeleton } from '@rocket.chat/fuselage'; + +const OutboubdMessageWizardSkeleton = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default OutboubdMessageWizardSkeleton; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/RetryButton.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/RetryButton.tsx new file mode 100644 index 0000000000000..84355605ac4e9 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/components/RetryButton.tsx @@ -0,0 +1,37 @@ +import { css } from '@rocket.chat/css-in-js'; +import { IconButton } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +type RetryButtonProps = { + loading: boolean; + onClick(): void; +}; + +/* NOTE: Necessary hack due to Field styles interfering with icons */ +const btnStyle = css` + i { + font-family: 'RocketChat'; + font-style: normal; + } +`; + +const RetryButton = ({ loading, onClick }: RetryButtonProps) => { + const { t } = useTranslation(); + + return ( + onClick()} + /> + ); +}; + +export default RetryButton; 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 new file mode 100644 index 0000000000000..249b351981625 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.spec.tsx @@ -0,0 +1,188 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; + +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}}' }, +} as const; + +const template1 = createFakeOutboundTemplate({ + id: 'template-1', + name: 'Template One', + components: [component.body], +}); + +export const template2 = createFakeOutboundTemplate({ + id: 'template-2', + name: 'Template Two', + components: [component.header, component.body], +}); + +const mockTemplates = [template1, template2]; +const mockContact = createFakeContactWithManagerData({ _id: 'contact-1', name: 'John Doe' }); + +const appRoot = mockAppRoot().withTranslations('en', 'core', { + Template: 'Template', + template: 'template', + Select_template: 'Select template', + Required_field: '{{field}} is required', + Template_message: 'Template message', + No_templates_available: 'No templates available', + Error_loading__name__information: 'Error loading {{name}} information', + Submit: 'Submit', +}); + +describe('MessageForm', () => { + const defaultProps = { + templates: mockTemplates, + contact: mockContact, + onSubmit: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should pass accessibility tests', async () => { + const { container } = render(, { wrapper: appRoot.build() }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should render correctly', async () => { + render(, { wrapper: appRoot.build() }); + + expect(screen.getByLabelText('Template*')).toBeInTheDocument(); + }); + + it('should render with default values', async () => { + const defaultValues = { templateId: 'template-1' }; + render(, { wrapper: appRoot.build() }); + + expect(screen.getByLabelText('Template*')).toHaveTextContent('Template One'); + }); + + it('should show TemplateEditor when a template is selected', async () => { + render(, { wrapper: appRoot.build() }); + + expect(screen.queryByText(component.body.text)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Template*')); + await userEvent.click(await screen.findByRole('option', { name: /Template One/ })); + + expect(screen.getByLabelText('Template*')).toHaveTextContent('Template One'); + + expect(await screen.findByText(component.body.text)).toBeInTheDocument(); + }); + + it('should call onSubmit with correct data on successful submission', async () => { + const handleSubmit = jest.fn(); + render(, { wrapper: appRoot.build() }); + + await userEvent.click(screen.getByLabelText('Template*')); + await userEvent.click(await screen.findByRole('option', { name: /Template One/ })); + + await userEvent.type(screen.getByLabelText('Body {{1}}*'), 'Hello World'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: 'template-1', + template: template1, + templateParameters: { body: [{ type: 'text', format: 'text', value: 'Hello World' }] }, + }), + ); + }); + }); + + it('should show required error when submitting without a template', async () => { + const handleSubmit = jest.fn(); + render(, { wrapper: appRoot.build() }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(await screen.findByText('Template message is required')).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); + }); + + it('should show "no templates available" error if templates array is empty', async () => { + const handleSubmit = jest.fn(); + render(, { wrapper: appRoot.build() }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(await screen.findByText('No templates available')).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); + }); + + it('should throw an error if the selected template is not found on submit', async () => { + const handleSubmit = jest.fn(); + const defaultValues = { templateId: 'template-1' }; + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(screen.getByLabelText('Template*')).not.toHaveAccessibleDescription('Error loading template information'); + + expect(handleSubmit).not.toHaveBeenCalled(); + }); + + it('should show parameter is required when submitting without filling all parameters', async () => { + const handleSubmit = jest.fn(); + const defaultValues = { templateId: 'template-1' }; + render(, { wrapper: appRoot.build() }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(screen.getByLabelText('Body {{1}}*')).toHaveAccessibleDescription('Body {{1}} is required'); + + expect(handleSubmit).not.toHaveBeenCalled(); + + await userEvent.type(screen.getByLabelText('Body {{1}}*'), 'Hello World'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(screen.getByLabelText('Body {{1}}*')).not.toHaveAccessibleDescription('Body {{1}} is required'); + + expect(handleSubmit).toHaveBeenCalled(); + }); + + it('should render custom actions via renderActions prop', async () => { + const renderActions = jest.fn(({ isSubmitting }) => ); + render(, { wrapper: appRoot.build() }); + + expect(screen.getByRole('button', { name: 'Custom Action' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument(); + expect(renderActions).toHaveBeenCalledWith({ isSubmitting: false }); + }); + + it('should clear parameters field when template changes', async () => { + const defaultValues = { templateId: 'template-1' }; + + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.type(screen.getByLabelText('Body {{1}}*'), 'Hello World'); + + await userEvent.click(screen.getByLabelText('Template*')); + await userEvent.click(await screen.findByRole('option', { name: /Template Two/ })); + + expect(screen.getByLabelText('Body {{1}}*')).toHaveValue(''); + }); +}); 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 new file mode 100644 index 0000000000000..6d0c2e9c37e78 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx @@ -0,0 +1,158 @@ +import type { IOutboundProviderTemplate, Serialized, ILivechatContact } 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 type { ReactNode } from 'react'; +import { useId, useMemo } from 'react'; +import { useController, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +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 = { + templateId: string; + templateParameters: TemplateParameters; +}; + +export type MessageFormSubmitPayload = { + templateId: string; + template: IOutboundProviderTemplate; + templateParameters: TemplateParameters; +}; + +type MessageFormProps = { + contact?: Omit, 'contactManager'>; + templates?: IOutboundProviderTemplate[]; + onSubmit(values: MessageFormSubmitPayload): void; + renderActions?(state: { isSubmitting: boolean }): ReactNode; + defaultValues?: { + templateId?: string; + templateParameters?: TemplateParameters; + }; +}; + +const MessageForm = (props: MessageFormProps) => { + const { defaultValues, templates, contact, renderActions, onSubmit } = props; + const dispatchToastMessage = useToastBarDispatch(); + const { t } = useTranslation(); + const messageFormId = useId(); + + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + setValue, + } = useForm({ + mode: 'onChange', + reValidateMode: 'onChange', + defaultValues: { + templateParameters: defaultValues?.templateParameters ?? {}, + templateId: defaultValues?.templateId ?? '', + }, + }); + + 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; + + // It shouldn't be possible to get here without a template due to form validations. + // Adding this to be safe and ts compliant + if (!template) { + throw new FormFetchError('error-template-not-found'); + } + + onSubmit({ templateId, templateParameters, template }); + } catch { + dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') }); + } + }); + + const formRef = useFormKeyboardSubmit(() => handleSubmit(submit)(), [submit, handleSubmit]); + + return ( +
+ + + + {t('Template')} + + + + + {errors.templateId && ( + + {errors.templateId.message} + + )} + + {/* TODO: Change to the correct address */} + + {t('Learn_more')} + + + + + {parametersMetadata.map((metadata) => ( + + ))} + + {template ? : null} + + + {customActions ?? ( + + + + )} +
+ ); +}; + +MessageForm.displayName = 'MessageForm'; + +export default MessageForm; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplatePlaceholderField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplatePlaceholderField.tsx new file mode 100644 index 0000000000000..5604df16d3322 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplatePlaceholderField.tsx @@ -0,0 +1,61 @@ +import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings'; +import { Field, FieldError, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; +import type { Control } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { TemplateParameter, TemplateParameterMetadata } from '../../../../../definitions/template'; +import TemplatePlaceholderInput from '../../../../TemplatePlaceholderSelector'; +import type { MessageFormData } from '../MessageForm'; + +type TemplatePlaceholderFieldProps = ComponentProps & { + control: Control; + metadata: TemplateParameterMetadata; + contact?: Omit, 'contactManager'>; +}; + +const TemplatePlaceholderField = ({ control, metadata, contact, ...props }: TemplatePlaceholderFieldProps) => { + const { t } = useTranslation(); + + const { id, type, name, placeholder, format, componentType, index } = metadata; + const fieldLabel = `${t(name)} ${placeholder}`; + + const { + field, + fieldState: { error }, + } = useController({ + control, + name: `templateParameters.${componentType}.${index}` as const, + defaultValue: { type, value: '', format } as TemplateParameter, + rules: { validate: (param) => (!param?.value?.trim() ? t('Required_field', { field: fieldLabel }) : true) }, + shouldUnregister: true, + }); + + return ( + + + {fieldLabel} + + + field.onChange({ type, value, format })} + /> + + {error ? ( + + {error.message} + + ) : null} + + ); +}; + +export default TemplatePlaceholderField; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplatePreviewField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplatePreviewField.tsx new file mode 100644 index 0000000000000..2bbebb589d3da --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/components/TemplatePreviewField.tsx @@ -0,0 +1,22 @@ +import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings'; +import type { Control } from 'react-hook-form'; +import { useWatch } from 'react-hook-form'; + +import TemplatePreview from '../../../../TemplatePreview'; +import type { MessageFormData } from '../MessageForm'; + +type TemplatePreviewFieldProps = { + control: Control; + template: IOutboundProviderTemplate; +}; + +/** + * This component exists to isolate the re-rendering that happens whenever templateParameters changes. + */ +const TemplatePreviewField = ({ control, template }: TemplatePreviewFieldProps) => { + const templateParameters = useWatch({ control, name: 'templateParameters' }); + + return ; +}; + +export default TemplatePreviewField; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/index.ts new file mode 100644 index 0000000000000..bc3005d9bc9f0 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/MessageForm/index.ts @@ -0,0 +1,2 @@ +export { default } from './MessageForm'; +export * from './MessageForm'; 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.spec.tsx new file mode 100644 index 0000000000000..071cafd91fa37 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm.spec.tsx @@ -0,0 +1,474 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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), +})); + +const recipientOnePhoneNumber = '+12125554567'; +const recipientTwoPhoneNumber = '+12125557788'; +const senderOnePhoneNumber = '+12127774567'; +const senderTwoPhoneNumber = '+12127778877'; + +const contactOneMock = createFakeContactWithManagerData({ + _id: 'contact-1', + name: 'Contact 1', + phones: [{ phoneNumber: recipientOnePhoneNumber }], + channels: [ + createFakeContactChannel({ + name: 'provider-1', + lastChat: { _id: '', ts: new Date().toISOString() }, + }), + ], +}); + +const contactTwoMock = createFakeContactWithManagerData({ + _id: 'contact-2', + name: 'Contact 2', + phones: [{ phoneNumber: recipientTwoPhoneNumber }], + channels: [ + createFakeContactChannel({ + name: 'provider-2', + lastChat: { _id: '', ts: new Date().toISOString() }, + }), + ], +}); + +const getContactMock = jest.fn().mockImplementation(() => ({ contact: contactOneMock })); +const getContactsMock = jest.fn().mockImplementation(() => ({ + contacts: [contactOneMock, contactTwoMock], + count: 2, + offset: 0, + total: 2, + success: true, +})); + +const providerOneMock = createFakeProviderMetadata({ + providerId: 'provider-1', + providerName: 'Provider 1', + templates: { + [senderOnePhoneNumber]: [createFakeOutboundTemplate({ phoneNumber: senderOnePhoneNumber })], + }, +}); + +const providerTwoMock = createFakeProviderMetadata({ + providerId: 'provider-2', + providerName: 'Provider 2', + templates: { + [senderTwoPhoneNumber]: [createFakeOutboundTemplate({ phoneNumber: senderTwoPhoneNumber })], + }, +}); + +const getProvidersMock = jest.fn().mockImplementation(() => ({ providers: [providerOneMock, providerTwoMock] })); +const getProviderMock = jest.fn().mockImplementation(() => ({ metadata: providerOneMock })); + +const appRoot = mockAppRoot() + .withJohnDoe() + .withEndpoint('GET', '/v1/omnichannel/contacts.get', () => getContactMock()) + .withEndpoint('GET', '/v1/omnichannel/contacts.search', () => getContactsMock()) + .withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => getProvidersMock()) + .withEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', () => getProviderMock()) + .withTranslations('en', 'core', { + Error_loading__name__information: 'Error loading {{name}} information', + Last_contact__time__: 'Last contact {{time, relativeTime}}', + No_phone_number_yet_edit_contact: 'No phone number yet <1>Edit contact', + No_phone_number_available_for_selected_channel: 'No phone number available for the selected channel', + Submit: 'Submit', + }) + .wrap((children) => ( + {children} + )); + +describe('RecipientForm', () => { + const defaultProps = { + onDirty: jest.fn(), + onSubmit: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly with all required fields', async () => { + render(, { wrapper: appRoot.build() }); + + expect(screen.getByLabelText('Contact*')).toBeInTheDocument(); + expect(screen.getByLabelText('Channel*')).toBeInTheDocument(); + expect(screen.getByLabelText('To*')).toBeInTheDocument(); + expect(screen.getByLabelText('From*')).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByLabelText('Contact*')).not.toHaveAttribute('aria-disabled')); + await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveAttribute('aria-disabled', 'true')); + expect(screen.getByLabelText('To*')).toHaveClass('disabled'); + expect(screen.getByLabelText('From*')).toHaveClass('disabled'); + }); + + it('should pass accessibility tests', async () => { + const { container } = render(, { wrapper: appRoot.build() }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should render with default values', async () => { + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + recipient: recipientOnePhoneNumber, + sender: senderOnePhoneNumber, + }; + + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(screen.getByLabelText('Contact*')).toHaveTextContent('Contact 1')); + await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveTextContent('Provider 1')); + expect(screen.getByLabelText('To*')).toHaveTextContent(/\+1 212-555-4567/); + expect(screen.getByLabelText('From*')).toHaveTextContent(/\+1 212-777-4567/); + }); + + it('should disable provider selection when no contact is selected', async () => { + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(screen.getByLabelText('Contact*')).not.toHaveAttribute('aria-disabled')); + expect(screen.getByLabelText('Channel*')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should disable recipient selection when no provider is selected', async () => { + const defaultValues = { contactId: 'contact-1' }; + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveAttribute('aria-disabled', 'false')); + expect(screen.getByLabelText('To*')).toHaveClass('disabled'); + }); + + it('should disable sender selection when no recipient is selected', async () => { + const defaultValues = { contactId: 'contact-1', providerId: 'provider-1' }; + + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(screen.getByLabelText('To*')).not.toHaveClass('disabled')); + expect(screen.getByLabelText('From*')).toHaveClass('disabled'); + }); + + it('should show retry button when contact fetch fails', async () => { + getContactMock.mockImplementationOnce(() => Promise.reject()); + + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(screen.getByLabelText('Contact*')).toHaveAccessibleDescription('Error loading contact information')); + + const retryButton = screen.getByRole('button', { name: 'Retry' }); + expect(retryButton).toBeInTheDocument(); + }); + + it('should disable recipient field when contact fetch fails', async () => { + getContactMock.mockImplementationOnce(() => Promise.reject()); + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + }; + + render(, { wrapper: appRoot.build() }); + + await waitFor(() => expect(screen.getByLabelText('To*')).not.toHaveTextContent('Loading...')); + expect(screen.getByLabelText('To*')).toHaveClass('disabled'); + }); + + it('should show retry button when provider fetch fails', async () => { + getProviderMock.mockImplementationOnce(() => Promise.reject()); + + render(, { + wrapper: appRoot.build(), + }); + + const channelErrorMessage = await screen.findByText('Error loading channel information'); + expect(channelErrorMessage).toBeInTheDocument(); + + const retryButton = screen.getByRole('button', { name: 'Retry' }); + expect(retryButton).toBeInTheDocument(); + }); + + it('should disable sender when provider fetch fails', async () => { + getProviderMock.mockImplementationOnce(() => Promise.reject()); + + render(, { + wrapper: appRoot.build(), + }); + + await waitFor(() => expect(screen.getByLabelText('From*')).not.toHaveTextContent('Loading...')); + expect(screen.getByLabelText('From*')).toHaveClass('disabled'); + }); + + it('should call retry contact fetch when retry button is clicked', async () => { + getContactMock.mockImplementationOnce(() => Promise.reject()); + + render(, { + wrapper: appRoot.build(), + }); + + expect(await screen.findByText('Error loading contact information')).toBeInTheDocument(); + + const retryButton = screen.getByRole('button', { name: 'Retry' }); + await userEvent.click(retryButton); + + await waitFor(() => expect(screen.queryByText('Error loading contact information')).not.toBeInTheDocument()); + }); + + it('should call retry channel fetch when retry button is clicked', async () => { + getProviderMock.mockImplementationOnce(() => Promise.reject()); + + render(, { + wrapper: appRoot.build(), + }); + + expect(await screen.findByText('Error loading channel information')).toBeInTheDocument(); + + const retryButton = screen.getByRole('button', { name: 'Retry' }); + await userEvent.click(retryButton); + + await waitFor(() => expect(screen.queryByText('Error loading channel information')).not.toBeInTheDocument()); + }); + + it('should display channel hint when provider has lastChat', async () => { + render(, { + wrapper: appRoot.build(), + }); + + await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveAccessibleDescription('Last contact a few seconds ago')); + }); + + it('should call onSubmit with correct values when form is submitted', async () => { + const handleSubmit = jest.fn(); + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + recipient: recipientOnePhoneNumber, + sender: senderOnePhoneNumber, + }; + + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(handleSubmit).toHaveBeenCalledWith({ + contactId: 'contact-1', + providerId: 'provider-1', + recipient: recipientOnePhoneNumber, + sender: senderOnePhoneNumber, + contact: contactOneMock, + provider: providerOneMock, + }), + ); + }); + + it('should not call onSubmit when form is invalid', async () => { + const handleSubmit = jest.fn(); + + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled()); + }); + + it('should display no phone number error when contact has no phones', async () => { + const handleSubmit = jest.fn(); + getContactMock.mockImplementationOnce(() => ({ + contact: createFakeContactWithManagerData({ ...contactOneMock, phones: undefined }), + })); + + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + }; + + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + const errorMessage = await screen.findByText(/No phone number yet/); + expect(errorMessage).toBeInTheDocument(); + + const editLink = screen.getByText(/edit contact/i); + expect(editLink).toBeInTheDocument(); + }); + + it('should call onDirty when form becomes dirty', async () => { + const mockOnDirty = jest.fn(); + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + recipient: recipientOnePhoneNumber, + }; + + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByLabelText('From*')); + + const phoneOption = await screen.findByRole('option', { name: '+1 212-777-4567' }); + expect(phoneOption).toBeInTheDocument(); + + await userEvent.click(phoneOption); + + expect(mockOnDirty).toHaveBeenCalledTimes(1); + }); + + it('should clear recipient field when contact changes', async () => { + getContactMock.mockImplementationOnce(() => ({ contact: contactOneMock })); + getContactMock.mockImplementationOnce(() => ({ contact: contactTwoMock })); + + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + recipient: recipientOnePhoneNumber, + }; + + render(, { + wrapper: appRoot.build(), + }); + + await waitFor(() => expect(screen.getByLabelText('To*')).toHaveTextContent(/\+1 212-555-4567/)); + + await userEvent.click(screen.getByLabelText('Contact*')); + await userEvent.click(await screen.findByRole('option', { name: /Contact 2/ })); + + await waitFor(() => expect(screen.getByLabelText('To*')).toHaveTextContent('Contact_detail')); + }); + + it('should validate sender when no phone numbers available for selected channel', async () => { + const handleSubmit = jest.fn(); + getProviderMock.mockImplementationOnce(() => ({ + metadata: createFakeProviderMetadata({ ...providerOneMock, templates: {} }), + })); + + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + recipient: recipientOnePhoneNumber, + }; + + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(screen.getByLabelText('From*')).toHaveAccessibleDescription('No phone number available for the selected channel'); + }); + + it('should clear sender field when provider changes', async () => { + getProviderMock.mockImplementationOnce(() => ({ metadata: providerOneMock })); + getProviderMock.mockImplementationOnce(() => ({ metadata: providerTwoMock })); + + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + recipient: recipientOnePhoneNumber, + sender: senderOnePhoneNumber, + }; + + render(, { + wrapper: appRoot.build(), + }); + + await waitFor(() => expect(screen.getByLabelText('From*')).toHaveTextContent(/\+1 212-777-4567/)); + + await userEvent.click(screen.getByLabelText('Channel*')); + await userEvent.click(await screen.findByRole('option', { name: /Provider 2/ })); + + await waitFor(() => expect(screen.getByLabelText('From*')).toHaveTextContent('Workspace_detail')); + }); + + it('should validate contact field is required', async () => { + const handleSubmit = jest.fn(); + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByLabelText('Contact*')).toHaveAccessibleDescription('Required_field'); + + await userEvent.click(screen.getByLabelText('Contact*')); + await userEvent.click(await screen.findByRole('option', { name: /Contact 1/ })); + + expect(screen.getByLabelText('Contact*')).not.toHaveAccessibleDescription('Required_field'); + }); + + it('should validate channel field is required', async () => { + const handleSubmit = jest.fn(); + const defaultValues = { + contactId: 'contact-1', + }; + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByLabelText('Channel*')).toHaveAccessibleDescription('Required_field'); + + await userEvent.click(screen.getByLabelText('Channel*')); + await userEvent.click(await screen.findByRole('option', { name: /Provider 1/ })); + + expect(screen.getByLabelText('Channel*')).not.toHaveAccessibleDescription('Required_field'); + await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled()); + }); + + it('should validate recipient field is required', async () => { + const handleSubmit = jest.fn(); + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + }; + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByLabelText('To*')).toHaveAccessibleDescription('Required_field'); + + await userEvent.click(screen.getByLabelText('To*')); + await userEvent.click(await screen.findByRole('option', { name: /\+1 212-555-4567/ })); + + expect(screen.getByLabelText('To*')).not.toHaveAccessibleDescription('Required_field'); + await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled()); + }); + + it('should validate sender field is required', async () => { + const handleSubmit = jest.fn(); + const defaultValues = { + contactId: 'contact-1', + providerId: 'provider-1', + recipient: recipientOnePhoneNumber, + }; + render(, { + wrapper: appRoot.build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByLabelText('From*')).toHaveAccessibleDescription('Required_field'); + + await userEvent.click(screen.getByLabelText('From*')); + await userEvent.click(await screen.findByRole('option', { name: /\+1 212-777-4567/ })); + + expect(screen.getByLabelText('From*')).not.toHaveAccessibleDescription('Required_field'); + await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled()); + }); +}); 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 new file mode 100644 index 0000000000000..7923e86e949d6 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.tsx @@ -0,0 +1,223 @@ +import type { IOutboundProviderMetadata, Serialized, ILivechatContact } 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 } 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 ChannelField from './components/ChannelField'; +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 = { + contactId: string; + providerId: string; + recipient: string; + sender: string; +}; + +export type RecipientFormSubmitPayload = { + contactId: string; + contact: Serialized; + providerId: string; + provider: IOutboundProviderMetadata; + recipient: string; + sender: string; +}; + +type RecipientFormProps = { + defaultValues?: Partial; + onDirty?(): void; + onSubmit(values: RecipientFormSubmitPayload): void; + renderActions?(state: { isSubmitting: boolean }): ReactNode; +}; + +const RecipientForm = (props: RecipientFormProps) => { + const { defaultValues, renderActions, onDirty, onSubmit } = props; + const dispatchToastMessage = useToastBarDispatch(); + + const { trigger, control, handleSubmit, formState, clearErrors, setValue } = useForm({ + mode: 'onChange', + reValidateMode: 'onChange', + defaultValues: { + contactId: defaultValues?.contactId ?? '', + providerId: defaultValues?.providerId ?? '', + recipient: defaultValues?.recipient ?? '', + sender: defaultValues?.sender ?? '', + }, + }); + + const { isDirty, isSubmitting } = formState; + + const recipientFormId = useId(); + + const [contactId, providerId, recipient] = useWatch({ + name: ['contactId', 'providerId', 'recipient'], + control, + }); + const { t } = useTranslation(); + + const getContact = useEndpoint('GET', '/v1/omnichannel/contacts.get'); + const getProvider = useEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', { id: providerId }); + + const customActions = useMemo(() => renderActions?.({ isSubmitting }), [isSubmitting, renderActions]); + + const { + data: contact, + isError: isErrorContact, + isSuccess: isSuccessContact, + isFetching: isFetchingContact, + refetch: refetchContact, + } = useQuery({ + queryKey: omnichannelQueryKeys.contact(contactId), + queryFn: () => getContact({ contactId }), + staleTime: 5 * 60 * 1000, + select: (data) => data?.contact || undefined, + enabled: !!contactId, + }); + + const { + data: provider, + isError: isErrorProvider, + isSuccess: isSuccessProvider, + isFetching: isFetchingProvider, + refetch: refetchProvider, + } = useQuery({ + queryKey: omnichannelQueryKeys.outboundProviderMetadata(providerId), + queryFn: () => getProvider(), + select: (data) => data?.metadata, + staleTime: 5 * 60 * 1000, + enabled: !!providerId, + }); + + const hasRecipientOptions = !!contact && !!contact.phones?.length; + const hasProviderOptions = !!provider && !!Object.keys(provider.templates).length; + const isContactNotFound = isSuccessContact && !contact; + const isProviderNotFound = isSuccessProvider && !provider; + + const validateContactField = useEffectEvent((shouldValidate = false) => { + trigger('contactId'); + setValue('recipient', '', { shouldValidate }); + }); + + const validateProviderField = useEffectEvent((shouldValidate = false) => { + trigger('providerId'); + setValue('sender', '', { shouldValidate }); + }); + + useEffect(() => { + if (isSuccessContact && !hasRecipientOptions) { + trigger('recipient'); + } + + return () => clearErrors('recipient'); + }, [clearErrors, trigger, hasRecipientOptions, isSuccessContact]); + + useEffect(() => { + if (isSuccessProvider && !hasProviderOptions) { + trigger('sender'); + } + + return () => clearErrors('sender'); + }, [clearErrors, trigger, hasProviderOptions, isSuccessProvider]); + + useEffect(() => { + isErrorContact && validateContactField(); + return () => clearErrors('contactId'); + }, [clearErrors, isErrorContact, validateContactField]); + + useEffect(() => { + isErrorProvider && validateProviderField(); + return () => clearErrors('providerId'); + }, [clearErrors, isErrorProvider, validateProviderField]); + + useEffect(() => { + isDirty && onDirty && onDirty(); + }, [isDirty, onDirty]); + + const submit = useEffectEvent(async (values: RecipientFormData) => { + try { + // Wait if contact or provider is still being fetched in background + const [updatedContact, updatedProvider] = await Promise.all([ + isFetchingContact ? refetchContact().then((r) => r.data) : Promise.resolve(contact), + isFetchingProvider ? refetchProvider().then((r) => r.data) : Promise.resolve(provider), + ]); + + if (!updatedContact) { + throw new ContactNotFoundError(); + } + + if (!updatedProvider) { + throw new ProviderNotFoundError(); + } + + onSubmit({ ...values, provider: updatedProvider, contact: updatedContact }); + } catch (error) { + if (error instanceof ContactNotFoundError) { + validateContactField(true); + return; + } + + if (error instanceof ProviderNotFoundError) { + validateProviderField(true); + return; + } + + dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') }); + } + }); + + const formRef = useFormKeyboardSubmit(() => handleSubmit(submit)(), [submit, handleSubmit]); + + return ( +
+ + + + + + + + + + + {customActions ?? ( + + + + )} +
+ ); +}; + +RecipientForm.displayName = 'RecipientForm'; + +export default RecipientForm; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/ChannelField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/ChannelField.tsx new file mode 100644 index 0000000000000..0aeac5b4a3f1d --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/ChannelField.tsx @@ -0,0 +1,90 @@ +import type { Serialized, ILivechatContact } from '@rocket.chat/core-typings'; +import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { useId, useMemo } from 'react'; +import type { ComponentProps } from 'react'; +import type { Control } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { useTimeFromNow } from '../../../../../../../../hooks/useTimeFromNow'; +import { findLastChatFromChannel } from '../../../../../utils/findLastChatFromChannel'; +import AutoCompleteOutboundProvider from '../../../../AutoCompleteOutboundProvider'; +import RetryButton from '../../../components/RetryButton'; +import { cxp } from '../../../utils/cx'; +import type { RecipientFormData } from '../RecipientForm'; + +type ProviderFieldProps = ComponentProps & { + control: Control; + contact?: Omit, 'contactManager'>; + disabled?: boolean; + isError: boolean; + isFetching: boolean; + onRetry: () => void; +}; + +const ProviderField = ({ + control, + contact, + disabled = false, + isError = false, + isFetching = false, + onRetry, + ...props +}: ProviderFieldProps) => { + const { t } = useTranslation(); + const channelFieldId = useId(); + const getTimeFromNow = useTimeFromNow(true); + + const { + field: providerField, + fieldState: { error: providerFieldError }, + } = useController({ + control, + name: 'providerId', + rules: { + validate: { + fetchError: () => (isError ? t('Error_loading__name__information', { name: t('channel') }) : true), + required: (value) => (!value ? t('Required_field', { field: t('Channel') }) : true), + }, + }, + }); + + const providerLastChat = useMemo(() => { + return findLastChatFromChannel(contact?.channels, providerField.value); + }, [contact?.channels, providerField.value]); + + return ( + + + {t('Channel')} + + + + + {providerFieldError && ( + + {providerFieldError.message} + {isError && } + + )} + {providerLastChat && ( + {t('Last_contact__time__', { time: getTimeFromNow(providerLastChat) })} + )} + + ); +}; + +export default ProviderField; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/ContactField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/ContactField.tsx new file mode 100644 index 0000000000000..e33b0c6255152 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/ContactField.tsx @@ -0,0 +1,85 @@ +import { Box, Field, FieldError, FieldLabel, FieldRow, Option, OptionContent, OptionDescription } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useId } from 'react'; +import type { ComponentProps } from 'react'; +import { useController, type Control } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { formatPhoneNumber } from '../../../../../../../../lib/formatPhoneNumber'; +import AutoCompleteContact from '../../../../../../../AutoCompleteContact'; +import RetryButton from '../../../components/RetryButton'; +import type { RecipientFormData } from '../RecipientForm'; + +type ContactFieldProps = ComponentProps & { + control: Control; + isError: boolean; + isFetching: boolean; + onRetry: () => void; +}; + +type RenderFnType = Required>['renderItem']; + +const ContactField = ({ control, isError = false, isFetching = false, onRetry, ...props }: ContactFieldProps) => { + const { t } = useTranslation(); + const contactFieldId = useId(); + + const { + field: contactField, + fieldState: { error: contactFieldError }, + } = useController({ + control, + name: 'contactId', + rules: { + validate: { + fetchError: () => (isError ? t('Error_loading__name__information', { name: t('contact') }) : true), + required: (value) => (!value ? t('Required_field', { field: t('Contact') }) : true), + }, + }, + }); + + const renderContactOption = useEffectEvent(({ label, ...props }, { phones }) => { + const phoneList = phones?.map((p) => formatPhoneNumber(p.phoneNumber)).join(', '); + + return ( + + ); + }); + + return ( + + + {t('Contact')} + + + + + {contactFieldError && ( + + {contactFieldError.message} + {isError && } + + )} + + ); +}; + +export default ContactField; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/RecipientField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/RecipientField.tsx new file mode 100644 index 0000000000000..203e043deb32d --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/RecipientField.tsx @@ -0,0 +1,73 @@ +import type { Serialized, ILivechatContact } from '@rocket.chat/core-typings'; +import { Field, FieldError, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { useId } from 'react'; +import type { ComponentProps } from 'react'; +import { useController, type Control } from 'react-hook-form'; +import { Trans, useTranslation } from 'react-i18next'; + +import RecipientSelect from '../../../../RecipientSelect'; +import type { RecipientFormData } from '../RecipientForm'; + +type RecipientFieldProps = ComponentProps & { + control: Control; + contact?: Omit, 'contactManager'>; + type: 'phone' | 'email'; + disabled?: boolean; + isLoading: boolean; +}; + +const RecipientField = ({ control, contact, type, disabled = false, isLoading = false, ...props }: RecipientFieldProps) => { + const { t } = useTranslation(); + const recipientFieldId = useId(); + + const { + field: recipientField, + fieldState: { error: recipientFieldError }, + } = useController({ + control, + name: 'recipient', + rules: { + validate: { + noPhoneNumber: () => (type === 'phone' && contact ? !!contact.phones?.length : true), + required: (value) => (!value ? t('Required_field', { field: t('To') }) : true), + }, + }, + }); + + return ( + + + {t('To')} + + + + + {recipientFieldError?.type === 'required' && ( + + {recipientFieldError.message} + + )} + {recipientFieldError?.type === 'noPhoneNumber' && ( + + + No phone number yet Edit contact + + + )} + + ); +}; + +export default RecipientField; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/SenderField.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/SenderField.tsx new file mode 100644 index 0000000000000..51b45f3825c71 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/components/SenderField.tsx @@ -0,0 +1,66 @@ +import type { Serialized, IOutboundProviderMetadata } from '@rocket.chat/core-typings'; +import { Field, FieldError, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { useId } from 'react'; +import type { ComponentProps } from 'react'; +import { useController, type Control } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import SenderSelect from '../../../../SenderSelect'; +import type { RecipientFormData } from '../RecipientForm'; + +type SenderFieldProps = ComponentProps & { + control: Control; + provider?: Serialized; + disabled?: boolean; + isLoading: boolean; +}; + +const SenderField = ({ control, provider, disabled = false, isLoading = false, ...props }: SenderFieldProps) => { + const { t } = useTranslation(); + const senderFieldId = useId(); + + const hasProviderOptions = !!provider && !!Object.keys(provider.templates).length; + + const { + field: senderField, + fieldState: { error: senderFieldError }, + } = useController({ + control, + name: 'sender', + rules: { + validate: { + noPhoneNumber: () => hasProviderOptions || t('No_phone_number_available_for_selected_channel'), + required: (value) => (!value ? t('Required_field', { field: t('From') }) : true), + }, + }, + }); + + return ( + + + {t('From')} + + + + + {senderFieldError && ( + + {senderFieldError.message} + + )} + + ); +}; + +export default SenderField; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/index.ts new file mode 100644 index 0000000000000..670e7af03abc5 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RecipientForm/index.ts @@ -0,0 +1,2 @@ +export { default } from './RecipientForm'; +export * from './RecipientForm'; 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.spec.tsx new file mode 100644 index 0000000000000..b61f304a68d7e --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.spec.tsx @@ -0,0 +1,272 @@ +import type { ILivechatAgent, Serialized } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, UserStatus } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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), +})); + +const mockDepartment = createFakeDepartment({ + _id: 'department-1', + name: 'Department 1', +}); + +const mockUser = createFakeUser({ + _id: 'agent-1', + username: 'agent.one', + name: 'Agent One', +}); + +const mockAgentOne: Serialized = { + _id: 'agent-1', + username: 'agent.one', + name: 'Agent 1', + status: UserStatus.ONLINE, + statusLivechat: ILivechatAgentStatus.AVAILABLE, + emails: [{ address: 'a1@test.com', verified: true }], + _updatedAt: '', + createdAt: '', + active: true, + lastRoutingTime: '', + livechatCount: 1, + roles: [], + type: '', +}; + +const mockAgentTwo: Serialized = { + _id: 'agent-2', + username: 'agent.two', + name: 'Agent 2', + status: UserStatus.ONLINE, + statusLivechat: ILivechatAgentStatus.AVAILABLE, + emails: [{ address: 'a2@test.com', verified: true }], + _updatedAt: '', + createdAt: '', + active: true, + lastRoutingTime: '', + livechatCount: 1, + roles: [], + type: '', +}; + +const mockDepartmentAgentOne = { + ...mockAgentOne, + username: mockAgentOne.username || '', + agentId: mockUser._id, + departmentId: mockDepartment._id, + departmentEnabled: true, + count: 0, + order: 0, +}; + +const mockDepartmentAgentTwo = { + ...mockAgentTwo, + username: mockAgentTwo.username || '', + agentId: mockAgentTwo._id, + departmentId: mockDepartment._id, + departmentEnabled: true, + count: 0, + order: 0, +}; + +const getDepartmentMock = jest.fn(); + +const getDepartmentsAutocompleteMock = jest.fn().mockImplementation(() => ({ + departments: [mockDepartment], + count: 1, + total: 1, + offset: 0, +})); + +const appRoot = (permissions = ['outbound.can-assign-self-only', 'outbound.can-assign-any-agent']) => { + const root = mockAppRoot() + .withUser(mockUser) + .withEndpoint('GET', '/v1/livechat/department/:_id', () => getDepartmentMock()) + .withEndpoint('GET', '/v1/livechat/department', () => getDepartmentsAutocompleteMock()) + .withTranslations('en', 'core', { + Department: 'Department', + Agent: 'Agent', + optional: 'optional', + Select_department: 'Select department', + Select_agent: 'Select agent', + Error_loading__name__information: 'Error loading {{name}} information', + Retry: 'Retry', + Outbound_message_agent_hint: 'Leave empty so any agent from the designated department can manage the replies.', + Outbound_message_agent_hint_no_permission: + "You don't have permission to assign an agent. The reply will be assigned to the department.", + }) + .wrap((children) => ( + {children} + )); + + permissions.forEach((permission) => { + root.withPermission(permission); + }); + + return root; +}; + +describe('RepliesForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + + getDepartmentMock.mockImplementation(() => ({ + department: mockDepartment, + agents: [mockDepartmentAgentOne, mockDepartmentAgentTwo], + })); + }); + + it('should pass accessibility tests', async () => { + const { container } = render(, { wrapper: appRoot().build() }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('renders correctly with all fields', async () => { + render(, { wrapper: appRoot().build() }); + + expect(screen.getByLabelText('Department (optional)')).toBeInTheDocument(); + expect(screen.getByLabelText('Agent (optional)')).toBeInTheDocument(); + + expect(screen.getByLabelText('Department (optional)')).not.toHaveAttribute('aria-disabled'); + expect(screen.getByLabelText('Agent (optional)')).toBeDisabled(); + }); + + xit('should render with default values', async () => { + const defaultValues = { + departmentId: 'department-1', + agentId: 'agent-1', + }; + + render(, { + wrapper: appRoot().build(), + }); + + await waitFor(() => expect(screen.getByLabelText('Department (optional)')).toHaveTextContent('Department 1')); + await waitFor(() => expect(screen.getByLabelText('Agent (optional)')).toHaveTextContent('agent.one')); + }); + + it('should enable agent selection when a department is selected', async () => { + render(, { wrapper: appRoot().build() }); + + const departmentInput = screen.getByLabelText('Department (optional)'); + const agentInput = screen.getByLabelText('Agent (optional)'); + + await waitFor(() => expect(departmentInput).not.toHaveAttribute('aria-busy', 'true')); + expect(agentInput).toBeDisabled(); + + await userEvent.click(departmentInput); + await userEvent.click(await screen.findByRole('option', { name: 'Department 1' })); + + await waitFor(() => expect(agentInput).toBeEnabled()); + }); + + it('should show retry button when department fetch fails and retry when button is clicked', async () => { + getDepartmentMock + .mockRejectedValueOnce(new Error('API Error')) // useDepartmentList call + .mockRejectedValueOnce(new Error('API Error')); // RepliesForm call + + render(, { + wrapper: appRoot().build(), + }); + + const departmentErrorMessage = await screen.findByText('Error loading department information'); + expect(departmentErrorMessage).toBeInTheDocument(); + + const retryButton = screen.getByRole('button', { name: 'Retry' }); + await userEvent.click(retryButton); + + await waitFor(() => expect(screen.queryByText('Error loading department information')).not.toBeInTheDocument()); + }); + + xit('should call submit with correct values when form is submitted', async () => { + const handleSubmit = jest.fn(); + const defaultValues = { + departmentId: 'department-1', + agentId: 'agent-1', + }; + + render(, { + wrapper: appRoot().build(), + }); + + await waitFor(() => + expect(handleSubmit).toHaveBeenCalledWith({ + departmentId: 'department-1', + department: mockDepartment, + agentId: 'agent-1', + agent: mockAgentOne, + }), + ); + }); + + it('should not submit if department is not found', async () => { + const handleSubmit = jest.fn(); + getDepartmentMock.mockResolvedValue({ department: null, agents: [] }); + + render(, { wrapper: appRoot().build() }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled()); + }); + + it('should not submit if agent is not found', async () => { + getDepartmentMock.mockResolvedValue({ department: mockDepartment, agents: [] }); + const handleSubmit = jest.fn(); + render(, { + wrapper: appRoot().build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled()); + }); + + it('should not enable "Agent" field if the user doesnt have assign agent permissions', async () => { + render(, { + wrapper: appRoot([]).build(), + }); + + expect(screen.getByLabelText('Agent (optional)')).toBeDisabled(); + await expect(screen.getByLabelText('Agent (optional)')).toHaveAccessibleDescription( + `You don't have permission to assign an agent. The reply will be assigned to the department.`, + ); + }); + + it('should display only self when user doesnt have assign any permission', async () => { + render(, { + wrapper: appRoot(['outbound.can-assign-self-only']).build(), + }); + + await userEvent.click(screen.getByLabelText('Agent (optional)')); + + const agentOneOption = await screen.findByRole('option', { name: 'agent.one' }); + const agentTwoOption = screen.queryByRole('option', { name: 'agent.two' }); + + expect(agentOneOption).toBeInTheDocument(); + expect(agentTwoOption).not.toBeInTheDocument(); + }); + + it('should display all agents when user has assign any permission', async () => { + render(, { + wrapper: appRoot(['outbound.can-assign-any-agent']).build(), + }); + + await userEvent.click(screen.getByLabelText('Agent (optional)')); + + const agentOneOption = await screen.findByRole('option', { name: 'agent.one' }); + const agentTwoOption = await screen.findByRole('option', { name: 'agent.two' }); + + expect(agentOneOption).toBeInTheDocument(); + expect(agentTwoOption).toBeInTheDocument(); + }); +}); 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 new file mode 100644 index 0000000000000..8dcd6a9d59983 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/RepliesForm.tsx @@ -0,0 +1,211 @@ +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/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/index.ts new file mode 100644 index 0000000000000..c9651cc980f1f --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/forms/index.ts @@ -0,0 +1,9 @@ +import type { MessageFormSubmitPayload } from './MessageForm'; +import type { RecipientFormSubmitPayload } from './RecipientForm'; +import type { RepliesFormSubmitPayload } from './RepliesForm'; + +export type SubmitPayload = RecipientFormSubmitPayload & MessageFormSubmitPayload & RepliesFormSubmitPayload; + +export { default as RecipientForm } from './RecipientForm'; +export { default as MessageForm } from './MessageForm'; +export { default as RepliesForm } from './RepliesForm'; 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 new file mode 100644 index 0000000000000..40c9167690adb --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/hooks/useFormKeyboardSubmit.tsx @@ -0,0 +1,24 @@ +/* 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/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/index.ts new file mode 100644 index 0000000000000..a81ca167cf871 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/index.ts @@ -0,0 +1 @@ +export { default } from './OutboundMessageWizard'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.spec.tsx new file mode 100644 index 0000000000000..efa52dda2942f --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.spec.tsx @@ -0,0 +1,130 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client'; +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { act, type ComponentProps } from 'react'; + +import MessageStep from './MessageStep'; +import * as stories from './MessageStep.stories'; +import { createFakeContact } from '../../../../../../../tests/mocks/data'; +import { createFakeOutboundTemplate } from '../../../../../../../tests/mocks/data/outbound-message'; +import type { MessageFormSubmitPayload } from '../forms/MessageForm'; +import type MessageForm from '../forms/MessageForm'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +jest.mock('tinykeys', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue(() => () => undefined), +})); + +let isSubmitting = false; +let currentOnSubmit: (payload: MessageFormSubmitPayload) => void = () => undefined; +const mockMessageForm = jest.fn().mockImplementation((props) => { + currentOnSubmit = props.onSubmit; + return
{props.renderActions?.({ isSubmitting })}
; +}); + +jest.mock('../forms/MessageForm', () => ({ + __esModule: true, + default: (props: ComponentProps) => mockMessageForm(props), +})); + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head?.next ?? null, + next: jest.fn(), + previous: jest.fn(), + register: jest.fn(), + goTo: jest.fn(), + resetNextSteps: jest.fn(), +}; + +const appRoot = mockAppRoot() + .withJohnDoe() + .wrap((children) => { + return {children}; + }); + +describe('MessageStep', () => { + beforeEach(() => { + currentOnSubmit = () => undefined; + jest.clearAllMocks(); + }); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(, { wrapper: mockAppRoot().build() }); + expect(view.baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: mockAppRoot().build() }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should render message form with correct props', () => { + const defaultValues = { templateId: 'test-template-id' }; + const contact = createFakeContact(); + const templates = [createFakeOutboundTemplate()]; + + render(, { + wrapper: appRoot.build(), + }); + + expect(screen.getByTestId('message-form')).toBeInTheDocument(); + expect(mockMessageForm).toHaveBeenCalledWith( + expect.objectContaining({ + defaultValues, + contact, + templates, + }), + ); + }); + + it('should call previous step when back button is clicked', async () => { + render(, { wrapper: appRoot.build() }); + const backButton = screen.getByRole('button', { name: 'Back' }); + await userEvent.click(backButton); + + await waitFor(() => expect(mockWizardApi.previous).toHaveBeenCalled()); + }); + + it('shows a loading state on the button while submit is pending', async () => { + isSubmitting = true; + const handleSubmit = jest.fn(); + render(, { wrapper: appRoot.build() }); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + await userEvent.click(nextButton); + + expect(nextButton).toBeDisabled(); + expect(handleSubmit).not.toHaveBeenCalled(); + }); + + it('should call onSubmit with form values when form submits successfully', async () => { + const expectedPayload = { + templateId: 'test-template-id', + template: createFakeOutboundTemplate({ id: 'test-template-id' }), + templateParameters: {}, + }; + const onSubmit = jest.fn(); + + render(, { wrapper: appRoot.build() }); + + act(() => currentOnSubmit(expectedPayload)); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expectedPayload)); + await waitFor(() => expect(mockWizardApi.next).toHaveBeenCalled()); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.stories.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.stories.tsx new file mode 100644 index 0000000000000..e5874530a8c94 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.stories.tsx @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Box } from '@rocket.chat/fuselage'; +import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import MessageStep from './MessageStep'; +import { createFakeContact } from '../../../../../../../tests/mocks/data'; +import { createFakeOutboundTemplate } from '../../../../../../../tests/mocks/data/outbound-message'; + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head?.next ?? null, + next: () => undefined, + previous: () => undefined, + register: () => () => undefined, + goTo: () => undefined, + resetNextSteps: () => undefined, +}; + +const meta = { + title: 'Components/OutboundMessage/OutboundMessageWizard/Steps/MessageStep', + component: MessageStep, + parameters: { + controls: { hideNoControlsWarning: true }, + }, + decorators: (Story) => ( + + + + + + ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultValues: {}, + templates: [createFakeOutboundTemplate({ id: 'template-1' })], + contact: createFakeContact(), + onSubmit: action('onSubmit'), + }, +}; + +export const WithDefaultValues: Story = { + args: { + onSubmit: action('onSubmit'), + contact: createFakeContact(), + templates: [createFakeOutboundTemplate({ id: 'template-1' })], + defaultValues: { + templateId: 'template-1', + templateParameters: { + header: [{ type: 'text', format: 'text', value: 'Dentist' }], + body: [ + { type: 'text', format: 'text', value: 'John Doe' }, + { type: 'text', format: 'text', value: 'tomorrow' }, + { type: 'text', format: 'text', value: '10:00 AM' }, + { type: 'text', format: 'text', value: '14:00 PM' }, + { type: 'text', format: 'text', value: 'slot' }, + { type: 'text', format: 'text', value: 'John Doe' }, + ], + }, + }, + }, +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.tsx new file mode 100644 index 0000000000000..bb5ce6aa063b0 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/MessageStep.tsx @@ -0,0 +1,38 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useWizardContext, WizardActions, WizardBackButton, WizardNextButton } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; + +import type { MessageFormSubmitPayload } from '../forms/MessageForm'; +import MessageForm from '../forms/MessageForm'; + +type MessageStepProps = Omit, 'onSubmit'> & { + onSubmit(values: MessageFormSubmitPayload): void; +}; + +const MessageStep = ({ contact, templates, defaultValues, onSubmit }: MessageStepProps) => { + const { next } = useWizardContext(); + + const handleSubmit = useEffectEvent(async (values: MessageFormSubmitPayload) => { + onSubmit(values); + next(); + }); + + return ( +
+ ( + + + + + )} + /> +
+ ); +}; + +export default MessageStep; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.spec.tsx new file mode 100644 index 0000000000000..c8b2b2337f752 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.spec.tsx @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client'; +import { composeStories } from '@storybook/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import type { ComponentProps } from 'react'; + +import RecipientStep from './RecipientStep'; +import * as stories from './RecipientStep.stories'; +import { createFakeContact } from '../../../../../../../tests/mocks/data'; +import { createFakeProviderMetadata } from '../../../../../../../tests/mocks/data/outbound-message'; +import type RecipientForm from '../forms/RecipientForm'; +import type { RecipientFormSubmitPayload } from '../forms/RecipientForm'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +jest.mock('tinykeys', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue(() => () => undefined), +})); + +let isSubmitting = false; +let currentOnSubmit: (payload: RecipientFormSubmitPayload) => void = () => undefined; +const mockRecipientForm = jest.fn().mockImplementation((props) => { + currentOnSubmit = props.onSubmit; + return
{props.renderActions?.({ isSubmitting })}
; +}); + +jest.mock('../forms/RecipientForm', () => ({ + __esModule: true, + default: (props: ComponentProps) => mockRecipientForm(props), +})); + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head, + next: jest.fn(), + previous: jest.fn(), + register: jest.fn(), + goTo: jest.fn(), + resetNextSteps: jest.fn(), +}; + +const appRoot = mockAppRoot() + .withJohnDoe() + .wrap((children) => { + return {children}; + }); + +describe('RecipientStep', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(, { wrapper: mockAppRoot().build() }); + expect(view.baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: mockAppRoot().build() }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('shows a loading state on the button while submit is pending', async () => { + isSubmitting = true; + const { rerender } = render(, { wrapper: appRoot.build() }); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + await userEvent.click(nextButton); + + expect(nextButton).toBeDisabled(); + expect(mockWizardApi.next).not.toHaveBeenCalled(); + + isSubmitting = false; + rerender(); + + await waitFor(() => expect(nextButton).not.toBeDisabled()); + }); + + it('should call onSubmit with form values when form submits successfully', async () => { + const expectedPayload: RecipientFormSubmitPayload = { + contact: createFakeContact({ _id: 'contact-id' }), + contactId: 'contact-id', + providerId: 'provider-id', + provider: createFakeProviderMetadata({ providerId: 'provider-id' }), + recipient: '', + sender: '', + }; + + const onSubmit = jest.fn(); + + render(, { wrapper: appRoot.build() }); + + act(() => currentOnSubmit(expectedPayload)); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expectedPayload)); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.stories.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.stories.tsx new file mode 100644 index 0000000000000..7d4ae6077ae41 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.stories.tsx @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Box } from '@rocket.chat/fuselage'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import RecipientStep from './RecipientStep'; +import { createFakeContact, createFakeContactChannel, createFakeContactWithManagerData } from '../../../../../../../tests/mocks/data'; +import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../../tests/mocks/data/outbound-message'; + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head?.next ?? null, + next: () => undefined, + previous: () => undefined, + register: () => () => undefined, + goTo: () => undefined, + resetNextSteps: () => undefined, +}; + +const recipientPhone = { raw: '+12127774567', formatted: '+1 212-777-4567' }; +const senderPhone = { raw: '+12125554567', formatted: '+1 212-555-4567' }; +const contactWithManagerMock = createFakeContactWithManagerData({ + _id: 'contact-1', + name: 'John Doe', + phones: [{ phoneNumber: recipientPhone.raw }], + channels: [ + createFakeContactChannel({ + name: 'provider-1', + lastChat: { _id: '', ts: new Date().toISOString() }, + }), + ], +}); + +const { contactManager: _, ...contactNormal } = contactWithManagerMock; +const contactMock = createFakeContact(contactNormal); + +const providerMock = createFakeProviderMetadata({ + providerId: 'provider-1', + providerName: 'WhatsApp', + templates: { + [senderPhone.raw]: [createFakeOutboundTemplate({ phoneNumber: senderPhone.raw })], + }, +}); + +const AppRoot = mockAppRoot() + .withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => ({ providers: [providerMock] })) + .withEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', () => ({ metadata: providerMock })) + .withEndpoint('GET', '/v1/omnichannel/contacts.get', () => ({ contact: contactMock })) + .withEndpoint('GET', '/v1/omnichannel/contacts.search', () => ({ contacts: [contactWithManagerMock], count: 1, offset: 0, total: 1 })) + .build(); + +const meta = { + title: 'Components/OutboundMessage/OutboundMessageWizard/Steps/RecipientStep', + component: RecipientStep, + parameters: { + controls: { hideNoControlsWarning: true }, + }, + decorators: [ + (Story) => ( + + + + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultValues: {}, + onSubmit: action('onSubmit'), + }, +}; + +export const WithDefaultValues: Story = { + args: { + onSubmit: action('onSubmit'), + defaultValues: { + contactId: contactWithManagerMock._id, + providerId: providerMock.providerId, + recipient: recipientPhone.raw, + sender: senderPhone.raw, + }, + }, +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.tsx new file mode 100644 index 0000000000000..689a6f8648a9c --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RecipientStep.tsx @@ -0,0 +1,37 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useWizardContext, WizardActions, WizardNextButton } from '@rocket.chat/ui-client'; + +import type { RecipientFormData, RecipientFormSubmitPayload } from '../forms/RecipientForm'; +import RecipientForm from '../forms/RecipientForm'; + +type RecipientStepProps = { + defaultValues?: Partial; + onDirty?: () => void; + onSubmit(values: RecipientFormSubmitPayload): void; +}; + +const RecipientStep = ({ defaultValues, onDirty, onSubmit }: RecipientStepProps) => { + const { next } = useWizardContext(); + + const handleSubmit = useEffectEvent((values: RecipientFormSubmitPayload) => { + onSubmit(values); + next(); + }); + + return ( +
+ ( + + + + )} + /> +
+ ); +}; + +export default RecipientStep; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.spec.tsx new file mode 100644 index 0000000000000..382b766579ad1 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.spec.tsx @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client'; +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { act, type ComponentProps } from 'react'; + +import RepliesStep from './RepliesStep'; +import * as stories from './RepliesStep.stories'; +import type RepliesForm from '../forms/RepliesForm'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +let isSubmitting = false; +let currentOnSubmit: (payload: Record) => void = () => undefined; +const mockRepliesForm = jest.fn().mockImplementation((props) => { + currentOnSubmit = props.onSubmit; + return
{props.renderActions?.({ isSubmitting })}
; +}); +jest.mock('../forms/RepliesForm', () => ({ + __esModule: true, + default: (props: ComponentProps) => mockRepliesForm(props), +})); + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head?.next ?? null, + next: jest.fn(), + previous: jest.fn(), + register: jest.fn(), + goTo: jest.fn(), + resetNextSteps: jest.fn(), +}; + +const appRoot = mockAppRoot() + .withJohnDoe() + .wrap((children) => { + return {children}; + }); + +describe('RepliesStep', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(, { wrapper: mockAppRoot().build() }); + expect(view.baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: mockAppRoot().build() }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should pass accessibility tests', async () => { + const defaultValues = { departmentId: 'test-department-id', agentId: 'test-agent-id' }; + + const { container } = render(, { + wrapper: appRoot.build(), + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should render message form with correct props', () => { + const defaultValues = { departmentId: 'test-department-id', agentId: 'test-agent-id' }; + + render(, { + wrapper: appRoot.build(), + }); + + expect(screen.getByTestId('replies-form')).toBeInTheDocument(); + expect(mockRepliesForm).toHaveBeenCalledWith(expect.objectContaining({ defaultValues })); + }); + + it('should call onSubmit with form values when form submits successfully', async () => { + const expectedPayload = { + departmentId: 'test-department-id', + agentId: 'test-agent-id', + }; + const onSubmit = jest.fn(); + + render(, { wrapper: appRoot.build() }); + + await act(() => currentOnSubmit(expectedPayload)); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expectedPayload)); + }); + + it('should call previous step when back button is clicked', async () => { + render(, { wrapper: appRoot.build() }); + + const backButton = screen.getByRole('button', { name: 'Back' }); + await userEvent.click(backButton); + + await waitFor(() => expect(mockWizardApi.previous).toHaveBeenCalled()); + }); + + it('shows a loading state on the button while submit is pending', async () => { + isSubmitting = true; + const { rerender } = render(, { wrapper: appRoot.build() }); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + await userEvent.click(nextButton); + + expect(nextButton).toBeDisabled(); + + isSubmitting = false; + rerender(); + + await waitFor(() => expect(nextButton).not.toBeDisabled()); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.stories.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.stories.tsx new file mode 100644 index 0000000000000..a1741338f7397 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.stories.tsx @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { faker } from '@faker-js/faker'; +import { Box } from '@rocket.chat/fuselage'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import RepliesStep from './RepliesStep'; +import { createFakeAgent, createFakeDepartment } from '../../../../../../../tests/mocks/data'; + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head?.next ?? null, + next: () => undefined, + previous: () => undefined, + register: () => () => undefined, + goTo: () => undefined, + resetNextSteps: () => undefined, +}; + +const mockDepartment = createFakeDepartment({ name: `${faker.commerce.department()} Department` }); + +const mockAgent = createFakeAgent({ _id: 'agent-1' }); +const mockDepartmentAgent = { + ...mockAgent, + username: mockAgent.username || '', + agentId: mockAgent._id, + departmentId: mockDepartment._id, + departmentEnabled: true, + count: 0, + order: 0, +}; + +const AppRoot = mockAppRoot() + .withEndpoint('GET', '/v1/livechat/department', () => ({ departments: [mockDepartment], count: 1, offset: 0, total: 1 })) + .withEndpoint('GET', '/v1/livechat/users/agent', () => ({ users: [{ ...mockAgent, departments: [] }], count: 1, offset: 0, total: 1 })) + .withEndpoint('GET', '/v1/livechat/department/:_id', () => ({ department: mockDepartment, agents: [mockDepartmentAgent] })) + .withEndpoint('GET', '/v1/livechat/users/agent/:_id', () => ({ user: mockAgent })) + .build(); + +const meta = { + title: 'Components/OutboundMessage/OutboundMessageWizard/Steps/RepliesStep', + component: RepliesStep, + parameters: { + controls: { hideNoControlsWarning: true }, + }, + decorators: [ + (Story) => ( + + + + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultValues: {}, + onSubmit: action('onSubmit'), + }, +}; + +export const WithDefaultValues: Story = { + args: { + onSubmit: action('onSubmit'), + defaultValues: { + departmentId: mockDepartment._id, + agentId: mockAgent._id, + }, + }, +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.tsx new file mode 100644 index 0000000000000..7c569115c4a92 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/RepliesStep.tsx @@ -0,0 +1,36 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useWizardContext, WizardActions, WizardBackButton, WizardNextButton } from '@rocket.chat/ui-client'; + +import type { RepliesFormData, RepliesFormSubmitPayload } from '../forms/RepliesForm'; +import RepliesForm from '../forms/RepliesForm'; + +type RepliesStepProps = { + defaultValues?: Partial; + onSubmit(values: RepliesFormSubmitPayload): void; +}; + +const RepliesStep = ({ defaultValues, onSubmit }: RepliesStepProps) => { + const { next } = useWizardContext(); + + const handleSubmit = useEffectEvent(async (values: RepliesFormSubmitPayload) => { + onSubmit(values); + next(); + }); + + return ( +
+ ( + + + + + )} + /> +
+ ); +}; + +export default RepliesStep; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.spec.tsx new file mode 100644 index 0000000000000..381206112d27e --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.spec.tsx @@ -0,0 +1,138 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client'; +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import type { ComponentProps } from 'react'; + +import ReviewStep from './ReviewStep'; +import * as stories from './ReviewStep.stories'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +jest.mock('../../OutboundMessagePreview', () => ({ + __esModule: true, + default: (props: any) =>
, +})); + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head?.next ?? null, + next: jest.fn(), + previous: jest.fn(), + register: jest.fn(), + goTo: jest.fn(), + resetNextSteps: jest.fn(), +}; + +const appRoot = mockAppRoot() + .withJohnDoe() + .withTranslations('en', 'core', { + Send: 'Send', + }) + .wrap((children) => { + return {children}; + }) + .build(); + +describe('ReviewStep', () => { + const onSendMock = jest.fn(); + + const defaultProps: ComponentProps = { + onSend: onSendMock, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(, { wrapper: mockAppRoot().build() }); + expect(view.baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: mockAppRoot().build() }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('renders correctly with OutboundMessagePreview and a send button', () => { + render(, { wrapper: appRoot }); + + expect(screen.getByTestId('outbound-message-preview')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument(); + }); + + it('should pass accessibility tests', async () => { + const { container } = render(, { wrapper: appRoot }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should call previous step when back button is clicked', async () => { + render(, { wrapper: appRoot }); + const backButton = screen.getByRole('button', { name: 'Back' }); + await userEvent.click(backButton); + + await waitFor(() => expect(mockWizardApi.previous).toHaveBeenCalled()); + }); + + it('calls onSend when the send button is clicked', async () => { + onSendMock.mockResolvedValue(undefined); + render(, { wrapper: appRoot }); + + const sendButton = screen.getByRole('button', { name: 'Send' }); + await userEvent.click(sendButton); + + expect(onSendMock).toHaveBeenCalledTimes(1); + }); + + it('shows a loading state on the button while onSend is pending', async () => { + let resolvePromise: (value: unknown) => void = jest.fn(); + onSendMock.mockReturnValue( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + render(, { wrapper: appRoot }); + + const sendButton = screen.getByRole('button', { name: 'Send' }); + await userEvent.click(sendButton); + + await waitFor(() => expect(sendButton).toBeDisabled()); + + resolvePromise(undefined); + + await waitFor(() => expect(sendButton).not.toBeDisabled()); + }); + + it('removes loading state if onSend rejects', async () => { + let rejectPromise: (reason?: any) => void = jest.fn(); + onSendMock.mockReturnValue( + new Promise((_, reject) => { + rejectPromise = reject; + }), + ); + + render(, { wrapper: appRoot }); + + const sendButton = screen.getByRole('button', { name: 'Send' }); + await userEvent.click(sendButton); + + await waitFor(() => expect(sendButton).toBeDisabled()); + + rejectPromise(new Error('Failed to send')); + + await waitFor(() => expect(sendButton).not.toBeDisabled()); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.stories.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.stories.tsx new file mode 100644 index 0000000000000..d915c78f13063 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.stories.tsx @@ -0,0 +1,68 @@ +import { Box } from '@rocket.chat/fuselage'; +import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewStep from './ReviewStep'; +import { createFakeOutboundTemplate } from '../../../../../../../tests/mocks/data/outbound-message'; + +const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, +]); + +const mockWizardApi = { + steps, + currentStep: steps.head?.next ?? null, + next: () => undefined, + previous: () => undefined, + register: () => () => undefined, + goTo: () => undefined, + resetNextSteps: () => undefined, +}; + +const meta = { + title: 'Components/OutboundMessage/OutboundMessageWizard/Steps/ReviewStep', + component: ReviewStep, + parameters: { + controls: { hideNoControlsWarning: true }, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + agentName: 'John Doe', + contactName: 'Jane Smith', + agentUsername: 'johndoe', + departmentName: 'Support', + providerName: 'Rocket.Chat', + providerType: 'phone', + sender: '+1234567890', + recipient: '+0987654321', + templateParameters: { + header: [{ type: 'text', format: 'text', value: 'Dentist' }], + body: [ + { type: 'text', format: 'text', value: 'John Doe' }, + { type: 'text', format: 'text', value: 'tomorrow' }, + { type: 'text', format: 'text', value: '10:00 AM' }, + { type: 'text', format: 'text', value: '14:00 PM' }, + { type: 'text', format: 'text', value: 'slot' }, + { type: 'text', format: 'text', value: 'John Doe' }, + ], + }, + template: createFakeOutboundTemplate(), + onSend: () => Promise.resolve(), + }, +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.tsx new file mode 100644 index 0000000000000..b6cf83197bb02 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/ReviewStep.tsx @@ -0,0 +1,35 @@ +import { Box, Button } from '@rocket.chat/fuselage'; +import { WizardActions, WizardBackButton } from '@rocket.chat/ui-client'; +import { useMutation } from '@tanstack/react-query'; +import type { ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +import OutboundMessagePreview from '../../OutboundMessagePreview'; + +type ReviewStepProps = ComponentProps & { + onSend(): Promise; +}; + +const ReviewStep = ({ onSend, ...props }: ReviewStepProps) => { + const { t } = useTranslation(); + + const sendMutation = useMutation({ mutationFn: onSend }); + + return ( +
+ + + + + + + + + +
+ ); +}; + +export default ReviewStep; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/MessageStep.spec.tsx.snap b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/MessageStep.spec.tsx.snap new file mode 100644 index 0000000000000..4e25cd5e1229c --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/MessageStep.spec.tsx.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`MessageStep renders Default without crashing 1`] = ` + +
+
+
+
+ +
+
+
+
+ +`; + +exports[`MessageStep renders WithDefaultValues without crashing 1`] = ` + +
+
+
+
+ +
+
+
+
+ +`; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/RecipientStep.spec.tsx.snap b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/RecipientStep.spec.tsx.snap new file mode 100644 index 0000000000000..7a42710074c6c --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/RecipientStep.spec.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RecipientStep renders Default without crashing 1`] = ` + +
+
+
+
+ +
+
+
+
+ +`; + +exports[`RecipientStep renders WithDefaultValues without crashing 1`] = ` + +
+
+
+
+ +
+
+
+
+ +`; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/RepliesStep.spec.tsx.snap b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/RepliesStep.spec.tsx.snap new file mode 100644 index 0000000000000..a23b2bbfa7f1e --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/RepliesStep.spec.tsx.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RepliesStep renders Default without crashing 1`] = ` + +
+
+
+
+ +
+
+
+
+ +`; + +exports[`RepliesStep renders WithDefaultValues without crashing 1`] = ` + +
+
+
+
+ +
+
+
+
+ +`; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/ReviewStep.spec.tsx.snap b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/ReviewStep.spec.tsx.snap new file mode 100644 index 0000000000000..b065e27062f08 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/__snapshots__/ReviewStep.spec.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`ReviewStep renders Default without crashing 1`] = ` + +
+
+
+
+
+
+ +
+
+
+ +`; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/index.ts new file mode 100644 index 0000000000000..4c7d2df2eecf0 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/steps/index.ts @@ -0,0 +1,4 @@ +export { default as RecipientStep } from './RecipientStep'; +export { default as MessageStep } from './MessageStep'; +export { default as RepliesStep } from './RepliesStep'; +export { default as ReviewStep } from './ReviewStep'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/utils/cx.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/utils/cx.ts new file mode 100644 index 0000000000000..4d14d9677fa93 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/utils/cx.ts @@ -0,0 +1,34 @@ +/** + * A utility function that creates a string from an object of conditional values + * + * @param conditionals An object where keys are strings and values are booleans + * @returns A string of space-separated entries from keys with truthy values + * + * @example + * cx({ 'btn': true, 'btn-primary': true, 'disabled': false }) + * // returns 'btn btn-primary' + */ +export const cx = (conditionals: Record): string => { + return Object.entries(conditionals) + .filter(([_, value]) => Boolean(value)) + .map(([key]) => key) + .join(' '); +}; + +/** + * A utility function that creates a string with prefixed keys from an object of conditional values + * + * @param prefix String to prepend to each key + * @param conditionals An object where keys are strings and values are booleans + * @returns A string of space-separated entries from prefixed keys with truthy values + * + * @example + * cxp('form-field', { 'error': true, 'hint': false }) + * // returns 'form-field-error' + */ +export const cxp = (prefix: string, conditionals: Record): string => { + return Object.entries(conditionals) + .filter(([_, value]) => Boolean(value)) + .map(([key]) => `${prefix}-${key}`) + .join(' '); +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/utils/errors.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/utils/errors.ts new file mode 100644 index 0000000000000..d845ed4e61be6 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/OutboundMessageWizard/utils/errors.ts @@ -0,0 +1,23 @@ +export class FormValidationError extends Error { + constructor(message: string) { + super(message); + } +} + +export class FormFetchError extends Error { + constructor(message: string) { + super(message); + } +} + +export class ContactNotFoundError extends FormFetchError { + constructor() { + super('error-contact-not-found'); + } +} + +export class ProviderNotFoundError extends FormFetchError { + constructor() { + super('error-provider-not-found'); + } +} diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/RecipientSelect.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/RecipientSelect.tsx new file mode 100644 index 0000000000000..562e8e4e55690 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/RecipientSelect.tsx @@ -0,0 +1,31 @@ +import type { Serialized, ILivechatContact } from '@rocket.chat/core-typings'; +import { Select } from '@rocket.chat/fuselage'; +import type { ComponentProps, Key, ReactElement } from 'react'; +import { useMemo } from 'react'; + +import { formatPhoneNumber } from '../../../../lib/formatPhoneNumber'; + +type RecipientSelectProps = Omit, 'options' | 'onChange' | 'value'> & { + type: 'phone' | 'email'; + contact: Serialized | undefined; + value: string; + onChange: (value: Key) => void; +}; + +const RecipientSelect = ({ contact, type, value, disabled, onChange, ...props }: RecipientSelectProps): ReactElement => { + const options = useMemo<[string, string][]>(() => { + if (!contact) { + return []; + } + + if (type === 'phone') { + return contact.phones?.map((item) => [item.phoneNumber, formatPhoneNumber(item.phoneNumber)]) ?? []; + } + + return contact.emails?.map((item) => [item.address, item.address]) ?? []; + }, [contact, type]); + + return ; +}; + +export default SenderSelect; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderButton.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderButton.tsx new file mode 100644 index 0000000000000..2fa847ebd19c2 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderButton.tsx @@ -0,0 +1,23 @@ +import type { IconButton } from '@rocket.chat/fuselage'; +import { Button } from '@rocket.chat/fuselage'; +import { forwardRef, type ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +type TemplatePlaceholderButtonProps = Omit, 'color' | 'icon'> & { + icon?: ComponentProps['icon']; +}; + +const TemplatePlaceholderButton = forwardRef( + ({ icon: _icon, pressed: _pressed, small: _small, ...props }, ref) => { + const { t } = useTranslation(); + return ( + + ); + }, +); + +TemplatePlaceholderButton.displayName = 'TemplatePlaceholderButton'; + +export default TemplatePlaceholderButton; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderInput.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderInput.spec.tsx new file mode 100644 index 0000000000000..b959c6673ca7c --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderInput.spec.tsx @@ -0,0 +1,53 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps } from 'react'; + +import TemplatePlaceholderInput from './TemplatePlaceholderInput'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import TemplatePlaceholderSelector from './TemplatePlaceholderSelector'; + +const appRoot = mockAppRoot().build(); + +jest.mock('./TemplatePlaceholderSelector', () => ({ onSelect, onOpenChange }: ComponentProps) => ( +
+ + +
+)); + +it('should call onChange when value changes', async () => { + const onChange = jest.fn(); + render(, { wrapper: appRoot }); + + await userEvent.type(screen.getByRole('textbox'), 'Hi'); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledWith('H'); + expect(onChange).toHaveBeenCalledWith('i'); +}); + +it('should call onChange when a placeholder is selected', async () => { + const onChange = jest.fn(); + render(, { wrapper: appRoot }); + + await userEvent.click(screen.getByRole('button', { name: 'Placeholder' })); + + expect(onChange).toHaveBeenCalledWith('Selected value'); +}); + +it('should focus the input when the placeholder menu is closed', async () => { + render(, { wrapper: appRoot }); + + await userEvent.click(screen.getByRole('button', { name: 'Placeholder' })); + + expect(screen.getByRole('textbox')).not.toHaveFocus(); + + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + + expect(screen.getByRole('textbox')).toHaveFocus(); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderInput.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderInput.tsx new file mode 100644 index 0000000000000..e7116f9ca1e4c --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderInput.tsx @@ -0,0 +1,38 @@ +import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings'; +import { Box, Icon, TextInput } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useRef, type ComponentProps, type FormEvent, type FormEventHandler } from 'react'; + +import PlaceholderSelector from './TemplatePlaceholderSelector'; +import type { TemplateParameter } from '../../definitions/template'; + +type TemplatePlaceholderInputProps = Omit, 'value' | 'onChange'> & { + type?: TemplateParameter['type']; + value: string; + contact?: Serialized; + onChange(value: string): void; +}; + +const TemplatePlaceholderInput = ({ contact, value = '', type, onChange, ...props }: TemplatePlaceholderInputProps) => { + const inputRef = useRef(null); + + const handleChange = (event: FormEvent | string) => { + onChange(typeof event === 'string' ? event : event.currentTarget.value); + }; + + const addon = type === 'media' ? : undefined; + + const handleOpenToggle = useEffectEvent((isOpen: boolean) => { + if (!isOpen) inputRef.current?.focus(); + }); + + return ( + + } /> + + + + ); +}; + +export default TemplatePlaceholderInput; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderSelector.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderSelector.tsx new file mode 100644 index 0000000000000..cb0953fe1d11a --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/TemplatePlaceholderSelector.tsx @@ -0,0 +1,34 @@ +import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; + +import PlaceholderButton from './TemplatePlaceholderButton'; +import { useAgentSection } from './hooks/useAgentSection'; +import { useContactSection } from './hooks/useContactSection'; +import { useCustomFieldsSection } from './hooks/useCustomFieldsSection'; + +type PlaceholderSelectorProps = Pick, 'mis' | 'disabled'> & { + contact?: Serialized; + onSelect(value: string): void; + onOpenChange?(isOpen: boolean): void; +}; + +const TemplatePlaceholderSelector = ({ contact, disabled, onSelect, onOpenChange, ...props }: PlaceholderSelectorProps) => { + const contactSection = useContactSection({ contact, onSelect }); + const customFieldsSection = useCustomFieldsSection({ customFields: contact?.customFields, onSelect }); + const agentSection = useAgentSection({ onSelect }); + + return ( + } + onOpenChange={onOpenChange} + title='' + disabled={disabled} + maxWidth='100%' + sections={[...contactSection, ...customFieldsSection, ...agentSection]} + placement='bottom-end' + /> + ); +}; + +export default TemplatePlaceholderSelector; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useAgentSection.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useAgentSection.tsx new file mode 100644 index 0000000000000..cfa6ef90861a9 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useAgentSection.tsx @@ -0,0 +1,47 @@ +import { useUser } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { formatPhoneNumber } from '../../../../../../lib/formatPhoneNumber'; + +type UseAgentSectionProps = { + onSelect(value: string): void; +}; + +export const useAgentSection = ({ onSelect }: UseAgentSectionProps) => { + const { t } = useTranslation(); + const user = useUser(); + + return useMemo(() => { + if (!user) { + return [{ title: t('Agent'), items: [{ id: 'no-contact-data', content: t('No_data_found') }] }]; + } + + const renderItem = (label: string, value: string, index?: number) => ( +

+ {index ? `${label} (${index})` : label} {value} +

+ ); + + const nameItem = { + id: `${user._id}.name`, + content: renderItem(t('Name'), user.name || user.username || t('Unnamed')), + onClick: () => onSelect(user.name || user._id), + }; + + const phoneItem = { + id: `${user._id}.phone`, + content: renderItem(t('Phone_number'), user.phone ? formatPhoneNumber(user.phone) : t('None')), + onClick: () => onSelect(user.phone || ''), + }; + + const emails = + user.emails?.map((item, index) => ({ + id: `${user._id}.email.${index}`, + content: renderItem(t('Email'), item.address, index + 1), + onClick: () => onSelect(item.address), + })) || []; + + return [{ title: t('Agent'), items: [nameItem, phoneItem, ...emails] }]; + }, [user, onSelect, t]); +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useContactSection.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useContactSection.tsx new file mode 100644 index 0000000000000..ed4081457d6f9 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useContactSection.tsx @@ -0,0 +1,46 @@ +import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type UseContactSectionProps = { + contact?: Serialized; + onSelect(value: string): void; +}; + +export const useContactSection = ({ contact, onSelect }: UseContactSectionProps) => { + const { t } = useTranslation(); + + return useMemo(() => { + if (!contact) { + return [{ title: t('Contact'), items: [{ id: 'no-contact-data', content: t('No_data_found') }] }]; + } + + const renderItem = (label: string, value: string, index?: number) => ( +

+ {index ? `${label} (${index})` : label} {value} +

+ ); + + const nameItem = { + id: `${contact._id}.name`, + content: renderItem(t('Name'), contact.name), + onClick: () => onSelect(contact.name || contact._id), + }; + + const phones = + contact.phones?.map((item, index) => ({ + id: `${contact._id}.phone.${index}`, + content: renderItem(t('Phone_number'), item.phoneNumber, index + 1), + onClick: () => onSelect(item.phoneNumber), + })) || []; + + const emails = + contact.emails?.map((item, index) => ({ + id: `${contact._id}.email.${index}`, + content: renderItem(t('Email'), item.address, index + 1), + onClick: () => onSelect(item.address), + })) || []; + + return [{ title: t('Contact'), items: [nameItem, ...phones, ...emails] }]; + }, [contact, onSelect, t]); +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useCustomFieldsSection.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useCustomFieldsSection.tsx new file mode 100644 index 0000000000000..34672575556fa --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/hooks/useCustomFieldsSection.tsx @@ -0,0 +1,37 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type UseCustomFieldsSectionProps = { + customFields?: Record; + onSelect(value: string): void; +}; + +export const useCustomFieldsSection = ({ customFields, onSelect }: UseCustomFieldsSectionProps) => { + const { t } = useTranslation(); + + return useMemo(() => { + const entries = customFields ? Object.entries(customFields) : []; + + if (!entries.length) { + return [{ title: t('Custom_fields'), items: [{ id: 'no-custom-fields-data', content: t('No_data_found') }] }]; + } + + const items = entries.map(([label, value]) => ({ + id: label, + onClick: () => onSelect(String(value)), + content: ( +

+ + {label} + {' '} + + {String(value)} + +

+ ), + })); + + return [{ title: t('Custom_fields'), items }]; + }, [customFields, onSelect, t]); +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/index.ts new file mode 100644 index 0000000000000..d31ea2afd6148 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePlaceholderSelector/index.ts @@ -0,0 +1 @@ +export { default } from './TemplatePlaceholderInput'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePreview.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePreview.tsx new file mode 100644 index 0000000000000..3bcda82cde73f --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplatePreview.tsx @@ -0,0 +1,49 @@ +import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings'; +import { Box, Callout } from '@rocket.chat/fuselage'; +import { useId, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import MarkdownText from '../../../MarkdownText'; +import type { TemplateParameters, ComponentType } from '../definitions/template'; +import { processTemplatePreviewText } from '../utils/template'; + +type TemplatePreviewProps = { + template: IOutboundProviderTemplate; + parameters?: TemplateParameters; +}; + +const TemplatePreview = ({ template, parameters = {} }: TemplatePreviewProps) => { + const { t } = useTranslation(); + const previewId = useId(); + + const { components } = template; + + const content = useMemo>>(() => { + return Object.fromEntries( + components.map((component) => { + if (component.type === 'header' && component.format !== 'text') { + return [component.type, `[${t('Media')}]`]; + } + + const values = parameters[component.type]; + const processedText = component.text ? processTemplatePreviewText(component.text, values) : ''; + + return [component.type, processedText] as const; + }), + ); + }, [components, parameters, t]); + + return ( + + + {t('Message_preview')} + + + + + + + ); +}; + +export default TemplatePreview; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplateSelect.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplateSelect.spec.tsx new file mode 100644 index 0000000000000..08563808d019a --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplateSelect.spec.tsx @@ -0,0 +1,60 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import TemplateSelect from './TemplateSelect'; +import { createFakeOutboundTemplate } from '../../../../../tests/mocks/data/outbound-message'; + +const component = { + header: { type: 'header', text: 'New {{1}} appointment' }, + body: { type: 'body', text: 'Hello {{1}}' }, +} as const; + +const template1 = createFakeOutboundTemplate({ + id: 'template-1', + name: 'Template One', + language: 'en_US', + components: [component.body], +}); + +export const template2 = createFakeOutboundTemplate({ + id: 'template-2', + name: 'Template Two', + language: 'pt_BR', + components: [component.header, component.body], +}); + +const mockTemplates = [template1, template2]; + +const appRoot = mockAppRoot().build(); + +describe('TemplateSelect', () => { + it('should render correctly', () => { + render(, { wrapper: appRoot }); + expect(screen.getByPlaceholderText('Select template')).toBeInTheDocument(); + }); + + it('should display the correct template options with language descriptions', async () => { + render(, { wrapper: appRoot }); + + await userEvent.click(screen.getByPlaceholderText('Select template')); + + const optionOne = await screen.findByRole('option', { name: /Template One/ }); + const optionTwo = await screen.findByRole('option', { name: /Template Two/ }); + + expect(optionOne).toBeInTheDocument(); + expect(optionTwo).toBeInTheDocument(); + }); + + it('should display the template language as the option description', async () => { + render(, { wrapper: appRoot }); + + await userEvent.click(screen.getByPlaceholderText('Select template')); + + const optionOne = await screen.findByRole('option', { name: /Template One/ }); + const optionTwo = await screen.findByRole('option', { name: /Template Two/ }); + + expect(optionOne).toHaveTextContent('English'); + expect(optionTwo).toHaveTextContent('português (Brasil)'); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplateSelect.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplateSelect.tsx new file mode 100644 index 0000000000000..adff266e07192 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/components/TemplateSelect.tsx @@ -0,0 +1,50 @@ +import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings'; +import type { SelectOption } from '@rocket.chat/fuselage'; +import { Option, OptionDescription, SelectFiltered } from '@rocket.chat/fuselage'; +import { useLanguages } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import type { Key, ComponentProps } from 'react'; + +type TemplateSelectProps = Omit, 'value' | 'onChange' | 'options'> & { + templates: IOutboundProviderTemplate[]; + value: string; + onChange(value: Key): void; +}; + +const TemplateSelect = ({ templates, value, onChange, ...props }: TemplateSelectProps) => { + const languages = useLanguages(); + + const [options, templateMap] = useMemo(() => { + const templateMap = new Map(); + const templateOptions: SelectOption[] = []; + + for (const template of templates) { + templateMap.set(template.id, template); + templateOptions.push([template.id, template.name]); + } + + return [templateOptions, templateMap]; + }, [templates]); + + return ( + { + const { language: templateLanguage = '' } = templateMap.get(templateId) || {}; + const normalizedTemplateLanguage = templateLanguage.replace(/_/g, '-').replace(/en-US/g, 'en'); + const language = languages.find((lang) => lang.key === normalizedTemplateLanguage); + + return ( + + ); + }} + /> + ); +}; + +export default TemplateSelect; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/constants.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/constants.ts new file mode 100644 index 0000000000000..1dfa9c744b550 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/constants.ts @@ -0,0 +1,2 @@ +export const OUTBOUND_DOCS_LINK = 'https://docs.rocket.chat/docs/p2p-outbound-messaging'; +export const CONTACT_SALES_LINK = 'https://go.rocket.chat/i/contact-sales'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/definitions/template.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/definitions/template.ts new file mode 100644 index 0000000000000..045c0cc829c12 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/definitions/template.ts @@ -0,0 +1,34 @@ +import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings'; + +export type ComponentType = IOutboundProviderTemplate['components'][0]['type']; + +export type TemplateComponent = { + type: 'header' | 'body' | 'footer'; + parameters: TemplateParameter[]; +}; + +export type TemplateParameters = Partial>; + +export type TemplateTextParameter = { + type: 'text'; + value: string; + format: 'text'; +}; + +export type TemplateMediaParameter = { + type: 'media'; + value: string; + format: 'image' | 'video' | 'document'; +}; + +export type TemplateParameter = TemplateTextParameter | TemplateMediaParameter; + +type WithMetadata = Omit & { + componentType: ComponentType; + id: string; + index: number; + name: string; + placeholder: string; +}; + +export type TemplateParameterMetadata = WithMetadata | WithMetadata; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/hooks/useOutboundProvidersList.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/hooks/useOutboundProvidersList.ts new file mode 100644 index 0000000000000..79e121709331c --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/hooks/useOutboundProvidersList.ts @@ -0,0 +1,31 @@ +import type { IOutboundProvider, Serialized } from '@rocket.chat/core-typings'; +import type { OperationResult } from '@rocket.chat/rest-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; + +import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; +import { omnichannelQueryKeys } from '../../../../lib/queryKeys'; + +type OutboundProvidersResponse = Serialized>; + +type UseOutboundProvidersListProps = Omit, 'queryKey' | 'queryFn'> & { + type?: IOutboundProvider['providerType']; +}; + +const useOutboundProvidersList = (options?: UseOutboundProvidersListProps) => { + const { type = 'phone', enabled = true, staleTime, ...queryOptions } = options || {}; + const getProviders = useEndpoint('GET', '/v1/omnichannel/outbound/providers'); + const hasModule = useHasLicenseModule('outbound-messaging'); + + return useQuery({ + queryKey: omnichannelQueryKeys.outboundProviders({ type }), + queryFn: () => getProviders({ type }), + retry: 3, + enabled: hasModule && enabled, + staleTime, + ...queryOptions, + }); +}; + +export default useOutboundProvidersList; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageCloseConfirmationModal.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageCloseConfirmationModal.tsx new file mode 100644 index 0000000000000..d824025a1acdd --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageCloseConfirmationModal.tsx @@ -0,0 +1,50 @@ +import { + Button, + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalFooterAnnotation, + ModalFooterControllers, + ModalHeader, + ModalTitle, +} from '@rocket.chat/fuselage'; +import { useId } from 'react'; +import { useTranslation } from 'react-i18next'; + +type OutboundMessageCloseConfirmationModalProps = { + onConfirm(): void; + onCancel(): void; +}; + +const OutboundMessageCloseConfirmationModal = ({ onConfirm, onCancel }: OutboundMessageCloseConfirmationModalProps) => { + const { t } = useTranslation(); + const modalId = useId(); + + return ( + + + {t('Discard_message')} + + + +

+ {t('Are_you_sure_you_want_to_discard_this_outbound_message')} +

+
+ + {t('This_action_cannot_be_undone')} + + + + + +
+ ); +}; + +export default OutboundMessageCloseConfirmationModal; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageModal.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageModal.spec.tsx new file mode 100644 index 0000000000000..0d6cbb2168d58 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageModal.spec.tsx @@ -0,0 +1,58 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import OutboundMessageModal from './OutboundMessageModal'; + +jest.mock('../../components/OutboundMessageWizard', () => ({ + __esModule: true, + default: () =>
Outbound message Wizard
, +})); + +const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Close: 'Close', + Discard: 'Discard', + Discard_message: 'Discard message', + Outbound_message: 'Outbound message', + This_action_cannot_be_undone: 'This action cannot be undone', + Keep_editing: 'Keep editing', + Are_you_sure_you_want_to_discard_this_outbound_message: 'Are you sure you want to discard this outbound message?', + }) + .build(); + +it('should display confirmation before closing the modal', async () => { + const onClose = jest.fn(); + render(, { wrapper: appRoot }); + + expect(screen.getByRole('dialog', { name: 'Outbound message' })).toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: 'Discard message' })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + + expect(screen.getByRole('dialog', { name: 'Discard message' })).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Discard message' })).toHaveAccessibleDescription( + 'Are you sure you want to discard this outbound message?', + ); + + await userEvent.click(screen.getByRole('button', { name: 'Discard' })); + expect(onClose).toHaveBeenCalled(); +}); + +it('should close confirmation and leave modal open when cancel is clicked', async () => { + const onClose = jest.fn(); + render(, { wrapper: appRoot }); + + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + + expect(screen.getByRole('dialog', { name: 'Discard message' })).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Discard message' })).toHaveAccessibleDescription( + 'Are you sure you want to discard this outbound message?', + ); + + await userEvent.click(screen.getByRole('button', { name: 'Keep editing' })); + + expect(screen.queryByRole('dialog', { name: 'Discard message' })).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Outbound message' })).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageModal.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageModal.tsx new file mode 100644 index 0000000000000..7ca5d4af946dc --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/OutboundMessageModal.tsx @@ -0,0 +1,65 @@ +import { Modal, ModalBackdrop, ModalClose, ModalContent, ModalHeader, ModalTitle } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useRouter } from '@rocket.chat/ui-contexts'; +import { useEffect, useId, useState } from 'react'; +import type { KeyboardEvent, ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +import OutboundMessageCloseConfirmationModal from './OutboundMessageCloseConfirmationModal'; +import OutboundMessageWizard from '../../components/OutboundMessageWizard'; + +export type OutboundMessageModalProps = { + defaultValues?: ComponentProps['defaultValues']; + onClose: () => void; +}; + +const OutboundMessageModal = ({ defaultValues, onClose }: OutboundMessageModalProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const [initialRoute] = useState(router.getLocationPathname()); + const [isClosing, setClosingConfirmation] = useState(false); + const modalId = useId(); + + useEffect(() => { + // NOTE: close the modal when the route changes. + // This is necessary to ensure that the modal closes when navigating the user to the edit contact page or other relevant routes. + return router.subscribeToRouteChange(() => { + if (initialRoute === router.getLocationPathname()) { + return; + } + + onClose(); + }); + }, [initialRoute, onClose, router]); + + const handleKeyDown = useEffectEvent((e: KeyboardEvent): void => { + if (e.key !== 'Escape') { + return; + } + + e.stopPropagation(); + e.preventDefault(); + + // Toggle confirmation visibility on Esc. + setClosingConfirmation(!isClosing); + }); + + return ( + e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onKeyDown={handleKeyDown}> + + + {t('Outbound_message')} + setClosingConfirmation(true)} /> + + + + + + + + {isClosing ? setClosingConfirmation(false)} /> : null} + + ); +}; + +export default OutboundMessageModal; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/index.ts new file mode 100644 index 0000000000000..b7831407e4ed1 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/index.ts @@ -0,0 +1,3 @@ +export { default } from './OutboundMessageModal'; +export * from './OutboundMessageModal'; +export * from './useOutboundMessageModal'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/useOutboundMessageModal.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/useOutboundMessageModal.tsx new file mode 100644 index 0000000000000..b9f8a28f819e8 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageModal/useOutboundMessageModal.tsx @@ -0,0 +1,18 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; +import { useMemo } from 'react'; + +import OutboundMessageModal from './OutboundMessageModal'; + +export const useOutboundMessageModal = () => { + const setModal = useSetModal(); + + const close = useEffectEvent((): void => setModal(null)); + + const open = useEffectEvent((defaultValues?: ComponentProps['defaultValues']) => { + setModal(); + }); + + return useMemo(() => ({ open, close }), [open, close]); +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.spec.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.spec.tsx new file mode 100644 index 0000000000000..640c8042e5b5b --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.spec.tsx @@ -0,0 +1,116 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import OutboundMessageUpsellModal from './OutboundMessageUpsellModal'; +import { CONTACT_SALES_LINK, OUTBOUND_DOCS_LINK } from '../../constants'; + +const openExternalLink = jest.fn(); +jest.mock('../../../../../hooks/useExternalLink', () => ({ + useExternalLink: jest.fn(() => openExternalLink), +})); + +jest.mock('../../../../../../app/utils/client', () => ({ + getURL: (url: string) => url, +})); + +const appRoot = mockAppRoot().withJohnDoe().withTranslations('en', 'core', { + Learn_more: 'Learn more', + Contact_sales: 'Contact sales', + Outbound_message_upsell_annotation: 'Outbound_message_upsell_annotation', + No_phone_number_available_for_selected_channel: 'No phone number available for the selected channel', +}); + +describe('OutboundMessageUpsellModal', () => { + const onClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('hasModule is false', () => { + describe('user is not admin', () => { + it('should render only "Learn more" button', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.getByRole('button', { name: 'Learn more' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Contact sales' })).not.toBeInTheDocument(); + }); + + it('should render the annotation', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.getByText('Outbound_message_upsell_annotation')).toBeInTheDocument(); + }); + + it('should call openExternalLink with docs link when "Learn more" is clicked', async () => { + render(, { wrapper: appRoot.build() }); + await userEvent.click(screen.getByRole('button', { name: 'Learn more' })); + expect(openExternalLink).toHaveBeenCalledWith(OUTBOUND_DOCS_LINK); + }); + }); + + describe('user is admin', () => { + it('should render "Learn more" and "Contact sales" buttons', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.getByRole('button', { name: 'Learn more' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Contact sales' })).toBeInTheDocument(); + }); + + it('should render "Upgrade" button when isCommunity is true', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Contact sales' })).not.toBeInTheDocument(); + }); + + it('should call openExternalLink with docs link when "Learn more" is clicked', async () => { + render(, { wrapper: appRoot.build() }); + await userEvent.click(screen.getByRole('button', { name: 'Learn more' })); + expect(openExternalLink).toHaveBeenCalledWith(OUTBOUND_DOCS_LINK); + }); + + it('should call openExternalLink with sales link when "Contact sales" is clicked', async () => { + render(, { wrapper: appRoot.build() }); + await userEvent.click(screen.getByRole('button', { name: 'Contact sales' })); + expect(openExternalLink).toHaveBeenCalledWith(CONTACT_SALES_LINK); + }); + + it('should not render the annotation', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.queryByText('Outbound_message_upsell_annotation')).not.toBeInTheDocument(); + }); + }); + }); + + describe('hasModule is true', () => { + describe('user is not admin', () => { + it('should render only "Learn more" button', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.getByRole('button', { name: 'Learn more' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Contact sales' })).not.toBeInTheDocument(); + }); + + it('should render the annotation', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.getByText('Outbound_message_upsell_annotation')).toBeInTheDocument(); + }); + + it('should call openExternalLink with docs link when "Learn more" is clicked', async () => { + render(, { wrapper: appRoot.build() }); + await userEvent.click(screen.getByRole('button', { name: 'Learn more' })); + expect(openExternalLink).toHaveBeenCalledWith(OUTBOUND_DOCS_LINK); + }); + }); + + describe('user is admin', () => { + it('should render only "Learn more" button', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.getByRole('button', { name: 'Learn more' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Contact sales' })).not.toBeInTheDocument(); + }); + + it('should not render the annotation', () => { + render(, { wrapper: appRoot.build() }); + expect(screen.queryByText('Outbound_message_upsell_annotation')).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.stories.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.stories.tsx new file mode 100644 index 0000000000000..9dd8c4bc3a4dc --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.stories.tsx @@ -0,0 +1,47 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import OutboundMessageUpsellModal from './OutboundMessageUpsellModal'; + +const g = globalThis as typeof globalThis & { + __meteor_runtime_config__?: { ROOT_URL_PATH_PREFIX?: string }; +}; + +g.__meteor_runtime_config__ = { + ...(g.__meteor_runtime_config__ ?? {}), + ROOT_URL_PATH_PREFIX: '', +}; + +export default { + title: 'Outbound Message/OutboundMessageUpsellModal', + component: OutboundMessageUpsellModal, + args: { + onClose: action('onClose'), + }, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + hasModule: false, + isAdmin: false, + }, +}; + +export const WithModuleForAdmin: Story = { + args: { + hasModule: true, + isAdmin: true, + }, +}; + +export const WithModuleForNonAdmin: Story = { + args: { + hasModule: true, + isAdmin: false, + }, +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.tsx new file mode 100644 index 0000000000000..ea2cc582be839 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/OutboundMessageUpsellModal.tsx @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { getURL } from '../../../../../../app/utils/client'; +import { useExternalLink } from '../../../../../hooks/useExternalLink'; +import GenericUpsellModal from '../../../../GenericUpsellModal'; +import { CONTACT_SALES_LINK, OUTBOUND_DOCS_LINK } from '../../constants'; + +type OutboundMessageUpsellModalProps = { + hasModule?: boolean; + isAdmin?: boolean; + isCommunity?: boolean; + onClose: () => void; +}; + +const OutboundMessageUpsellModal = ({ isCommunity, hasModule, isAdmin, onClose }: OutboundMessageUpsellModalProps) => { + const { t } = useTranslation(); + + const openExternalLink = useExternalLink(); + + const props = useMemo(() => { + if (isAdmin && !hasModule) { + return { + cancelText: t('Learn_more'), + onCancel: () => openExternalLink(OUTBOUND_DOCS_LINK), + confirmText: isCommunity ? t('Upgrade') : t('Contact_sales'), + onConfirm: () => openExternalLink(CONTACT_SALES_LINK), + onClose, + }; + } + + return { + cancelText: t('Learn_more'), + annotation: !isAdmin ? t('Outbound_message_upsell_annotation') : undefined, + onCancel: () => openExternalLink(OUTBOUND_DOCS_LINK), + onClose, + }; + }, [hasModule, isAdmin, isCommunity, onClose, openExternalLink, t]); + + return ( + + ); +}; + +export default OutboundMessageUpsellModal; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/index.ts new file mode 100644 index 0000000000000..67101e79b08b7 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/index.ts @@ -0,0 +1,2 @@ +export { default } from './OutboundMessageUpsellModal'; +export * from './useOutboundMessageUpsellModal'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/useOutboundMessageUpsellModal.tsx b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/useOutboundMessageUpsellModal.tsx new file mode 100644 index 0000000000000..0524517cfe217 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/OutboundMessageUpsellModal/useOutboundMessageUpsellModal.tsx @@ -0,0 +1,21 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useRole, useSetModal } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import OutboundMessageUpsellModal from './OutboundMessageUpsellModal'; +import { useHasLicenseModule } from '../../../../../hooks/useHasLicenseModule'; +import { useLicense } from '../../../../../hooks/useLicense'; + +export const useOutboundMessageUpsellModal = () => { + const setModal = useSetModal(); + const isAdmin = useRole('admin'); + const license = useLicense(); + const hasModule = useHasLicenseModule('outbound-messaging') === true; + + const close = useEffectEvent(() => setModal(null)); + const open = useEffectEvent(() => + setModal(), + ); + + return useMemo(() => ({ open, close }), [open, close]); +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/index.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/index.ts new file mode 100644 index 0000000000000..0edfe259f2378 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/modals/index.ts @@ -0,0 +1 @@ +export * from './OutboundMessageUpsellModal'; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/findLastChatFromChannel.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/findLastChatFromChannel.ts new file mode 100644 index 0000000000000..5a82fc0ed94af --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/findLastChatFromChannel.ts @@ -0,0 +1,6 @@ +import type { ILivechatContactChannel, Serialized } from '@rocket.chat/core-typings'; + +export const findLastChatFromChannel = (channels: Serialized[] = [], providerId: string) => { + const channel = channels.find((channel) => channel.name === providerId); + return channel?.lastChat?.ts; +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/outbound-message.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/outbound-message.ts new file mode 100644 index 0000000000000..c99c8c20ca92a --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/outbound-message.ts @@ -0,0 +1,79 @@ +import type { + IOutboundMessage, + IOutboundProviderTemplate, + TemplateComponent, + TemplateParameter as CoreTemplateParameter, +} from '@rocket.chat/core-typings'; + +import type { SubmitPayload } from '../components/OutboundMessageWizard/forms'; +import type { MessageFormSubmitPayload } from '../components/OutboundMessageWizard/forms/MessageForm'; +import type { RecipientFormSubmitPayload } from '../components/OutboundMessageWizard/forms/RecipientForm'; +import type { RepliesFormSubmitPayload } from '../components/OutboundMessageWizard/forms/RepliesForm'; +import type { TemplateParameter, TemplateParameters } from '../definitions/template'; + +export const isRecipientStepValid = (data: Partial): data is Required => { + return !!(data.contactId && data.contact && data.provider && data.providerId && data.recipient && data.sender); +}; + +export const isMessageStepValid = (data: Partial): data is Required => { + return !!(data.templateId && data.template && data.templateParameters); +}; + +export const isRepliesStepValid = (data: Partial): data is RepliesFormSubmitPayload => { + return (!data.departmentId || !!data.department) && (!data.agentId || (!!data.agent && !!data.departmentId)); +}; + +const formatParameterForOutboundMessage = (parameter: TemplateParameter): CoreTemplateParameter => { + switch (parameter.type) { + case 'media': + return { type: 'media', link: parameter.value, format: parameter.format }; + default: + return { type: 'text', text: parameter.value }; + } +}; + +const formatOutboundMessageComponents = ( + components: IOutboundProviderTemplate['components'], + parameters: TemplateParameters, +): TemplateComponent[] => { + return components.map((component) => { + const values = parameters[component.type] || []; + return { + type: component.type, + parameters: values.map(formatParameterForOutboundMessage), + }; + }); +}; + +export const formatOutboundMessagePayload = ({ + recipient, + sender, + template, + type = 'template', + templateParameters, + departmentId, + agentId, +}: { + type: IOutboundMessage['type']; + recipient: string; + sender: string; + template: IOutboundProviderTemplate; + templateParameters: TemplateParameters; + departmentId?: string; + agentId?: string; +}): IOutboundMessage => { + return { + to: recipient, + type, + templateProviderPhoneNumber: sender, + departmentId, + agentId, + template: { + name: template.name, + language: { + code: template.language, + }, + components: formatOutboundMessageComponents(template.components, templateParameters), + }, + }; +}; diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/template.spec.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/template.spec.ts new file mode 100644 index 0000000000000..1de3aa77b7da6 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/template.spec.ts @@ -0,0 +1,63 @@ +import type { IOutboundProviderTemplate } from '@rocket.chat/apps-engine/definition/outboundComunication'; +import { capitalize } from '@rocket.chat/string-helpers'; + +import { extractParameterMetadata, processTemplatePreviewText } from './template'; +import type { TemplateMediaParameter, TemplateComponent, TemplateParameterMetadata } from '../definitions/template'; + +const createMockTextMetadata = (templateId: string, type: TemplateComponent['type'], index = 0): TemplateParameterMetadata => { + const placeholder = `{{${index + 1}}}`; + return { + id: `${templateId}.${type}.${placeholder}`, + type: 'text', + placeholder, + name: capitalize(type), + index, + componentType: type, + format: 'text', + }; +}; + +const createMockMediaMetadata = ( + templateId: string, + type: TemplateComponent['type'], + format: TemplateMediaParameter['format'], +): TemplateParameterMetadata => ({ + id: `${templateId}.${type}.mediaUrl`, + type: 'media', + placeholder: '', + name: 'Media_URL', + index: 0, + componentType: type, + format, +}); + +const variations: [IOutboundProviderTemplate['components'], TemplateParameterMetadata[]][] = [ + [[{ type: 'header', text: 'Parameter {{1}}', format: 'image' }], [createMockMediaMetadata('template-1', 'header', 'image')]], + [ + [{ type: 'body', text: 'Parameter {{1}} and {{2}}' }], + [createMockTextMetadata('template-1', 'body', 0), createMockTextMetadata('template-1', 'body', 1)], + ], + [[{ type: 'footer', text: 'Parameter {{1}}' }], [createMockTextMetadata('template-1', 'footer', 0)]], +]; + +describe('extractParameterMetadata', () => { + test.each(variations)('should return the parameters metadata for "%i" component type', (components, expected) => { + const parametersMetadata = extractParameterMetadata({ id: 'template-1', components }); + + expect(parametersMetadata).toStrictEqual(expected); + }); +}); + +describe('processTemplatePreviewText', () => { + it('should replate placeholder with the parameter value', () => { + const text = processTemplatePreviewText('Hello {{1}}', [{ type: 'text', value: 'World', format: 'text' }]); + + expect(text).toBe('Hello World'); + }); + + it('it should keep the placeholder in case the parameter is an empty string', () => { + const text = processTemplatePreviewText('Hello {{1}}', [{ type: 'text', value: '', format: 'text' }]); + + expect(text).toBe('Hello {{1}}'); + }); +}); diff --git a/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/template.ts b/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/template.ts new file mode 100644 index 0000000000000..e4415b3c32683 --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/OutboundMessage/utils/template.ts @@ -0,0 +1,86 @@ +import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings'; +import { capitalize } from '@rocket.chat/string-helpers'; + +import type { ComponentType, TemplateParameterMetadata, TemplateParameter } from '../definitions/template'; + +const placeholderPattern = new RegExp('{{(.*?)}}', 'g'); // e.g {{1}} or {{text}} + +export const extractParameterMetadata = (template: Pick) => { + if (!template.components?.length) { + return []; + } + + return template.components.flatMap((component) => { + const format = component.type === 'header' ? component.format : 'text'; + return parseComponentText(template.id, component.type, component.text, format); + }); +}; + +export const parseComponentText = ( + templateId: string, + componentType: ComponentType, + text: string | undefined, + format: TemplateParameter['format'] = 'text', +): TemplateParameterMetadata[] => { + if (format !== 'text') { + return [ + { + id: `${templateId}.${componentType}.mediaUrl`, + placeholder: '', + name: 'Media_URL', + type: 'media', + componentType, + format, + index: 0, + }, + ]; + } + + if (!text) { + return []; + } + + const matches = text.match(placeholderPattern) || []; + const placeholders = new Set(matches); + + return Array.from(placeholders).map((placeholder, index) => ({ + id: `${templateId}.${componentType}.${placeholder}`, + placeholder, + name: capitalize(componentType), + type: 'text', + componentType, + format, + index, + })); +}; + +export const replacePlaceholders = (text = '', replacer: (substring: string, captured: number) => string) => { + return text.replace(placeholderPattern, (match, captured) => replacer(match, captured)); +}; + +const replaceLineBreaks = (text: string) => { + return text.replace(/([^\n])\n(?!\n)/g, '$1 \n'); +}; + +export const processTemplatePreviewText = (text: string, parameters: TemplateParameter[] = []): string => { + if (!text) { + return text; + } + + const processedText = replaceLineBreaks(text); + + if (!parameters?.length) { + return processedText; + } + + return replacePlaceholders(processedText, (placeholder, captured) => { + const index = Number(captured) - 1; + + if (!Number.isFinite(index) || index < 0) { + return placeholder; + } + + const parameter = parameters[index]; + return parameter?.value || placeholder; + }); +}; diff --git a/apps/meteor/client/lib/formatPhoneNumber.spec.ts b/apps/meteor/client/lib/formatPhoneNumber.spec.ts new file mode 100644 index 0000000000000..9e51b4df5a762 --- /dev/null +++ b/apps/meteor/client/lib/formatPhoneNumber.spec.ts @@ -0,0 +1,132 @@ +import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber'; + +import { formatPhoneNumber } from './formatPhoneNumber'; + +jest.mock('google-libphonenumber', () => { + const mockFormat = jest.fn(); + const mockIsValidNumber = jest.fn(); + const mockParseAndKeepRawInput = jest.fn(); + + return { + PhoneNumberFormat: { + INTERNATIONAL: 1, + }, + PhoneNumberUtil: { + getInstance: jest.fn().mockReturnValue({ + parseAndKeepRawInput: mockParseAndKeepRawInput, + isValidNumber: mockIsValidNumber, + format: mockFormat, + }), + }, + }; +}); + +describe('formatPhoneNumber', () => { + const phoneUtil = PhoneNumberUtil.getInstance() as unknown as { + parseAndKeepRawInput: jest.Mock; + isValidNumber: jest.Mock; + format: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return an empty string when input is empty', () => { + const rawNumber = ''; + phoneUtil.parseAndKeepRawInput.mockImplementationOnce(() => { + throw new Error('Parsing error'); + }); + + const result = formatPhoneNumber(rawNumber); + + expect(result).toBe(''); + }); + + it('should format a valid phone number to international format', () => { + const rawNumber = '1234567890'; + const parsedNumber = { mock: 'parsedNumber' }; + const formattedNumber = '+1 234 567 890'; + + phoneUtil.parseAndKeepRawInput.mockReturnValueOnce(parsedNumber); + phoneUtil.isValidNumber.mockReturnValueOnce(true); + phoneUtil.format.mockReturnValueOnce(formattedNumber); + + const result = formatPhoneNumber(rawNumber); + + expect(phoneUtil.parseAndKeepRawInput).toHaveBeenCalledWith(rawNumber, 'US'); + expect(phoneUtil.isValidNumber).toHaveBeenCalledWith(parsedNumber); + expect(phoneUtil.format).toHaveBeenCalledWith(parsedNumber, PhoneNumberFormat.INTERNATIONAL); + expect(result).toBe(formattedNumber); + }); + + it('should use the provided default region', () => { + const rawNumber = '1234567890'; + const region = 'GB'; + const parsedNumber = { mock: 'parsedNumber' }; + + phoneUtil.parseAndKeepRawInput.mockReturnValueOnce(parsedNumber); + phoneUtil.isValidNumber.mockReturnValueOnce(true); + + formatPhoneNumber(rawNumber, region); + + expect(phoneUtil.parseAndKeepRawInput).toHaveBeenCalledWith(rawNumber, region); + }); + + it('should return the raw input if the number is invalid', () => { + const rawNumber = 'invalid-number'; + const parsedNumber = { mock: 'parsedNumber' }; + + phoneUtil.parseAndKeepRawInput.mockReturnValueOnce(parsedNumber); + phoneUtil.isValidNumber.mockReturnValueOnce(false); + + const result = formatPhoneNumber(rawNumber); + + expect(result).toBe(rawNumber); + expect(phoneUtil.format).not.toHaveBeenCalled(); + }); + + it('should return the raw input if parsing throws an error', () => { + const rawNumber = 'error-number'; + + phoneUtil.parseAndKeepRawInput.mockImplementationOnce(() => { + throw new Error('Parsing error'); + }); + + const result = formatPhoneNumber(rawNumber); + + expect(result).toBe(rawNumber); + expect(phoneUtil.isValidNumber).not.toHaveBeenCalled(); + expect(phoneUtil.format).not.toHaveBeenCalled(); + }); + + it('should format a valid Brazilian number when the region is provided', () => { + const rawNumber = '11987654321'; + const region = 'BR'; + const parsedNumber = { mock: 'parsedNumber' }; + const formattedNumber = '+55 11 98765-4321'; + + phoneUtil.parseAndKeepRawInput.mockReturnValueOnce(parsedNumber); + phoneUtil.isValidNumber.mockReturnValueOnce(true); + phoneUtil.format.mockReturnValueOnce(formattedNumber); + + const result = formatPhoneNumber(rawNumber, region); + + expect(phoneUtil.parseAndKeepRawInput).toHaveBeenCalledWith(rawNumber, region); + expect(result).toBe(formattedNumber); + }); + + it('should handle a number already in international format', () => { + const rawNumber = '+1 202-555-0173'; + const parsedNumber = { mock: 'parsedNumber' }; + const formattedNumber = '+1 202-555-0173'; + + phoneUtil.parseAndKeepRawInput.mockReturnValueOnce(parsedNumber); + phoneUtil.isValidNumber.mockReturnValueOnce(true); + phoneUtil.format.mockReturnValueOnce(formattedNumber); + + const result = formatPhoneNumber(rawNumber); + + expect(result).toBe(formattedNumber); + }); +}); diff --git a/apps/meteor/client/lib/formatPhoneNumber.ts b/apps/meteor/client/lib/formatPhoneNumber.ts new file mode 100644 index 0000000000000..53c1f16b79078 --- /dev/null +++ b/apps/meteor/client/lib/formatPhoneNumber.ts @@ -0,0 +1,36 @@ +import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber'; + +const phoneUtil = PhoneNumberUtil.getInstance(); + +/** + * Formats a phone number string to international format using Google's libphonenumber. + * + * @param {string} rawNumber - The raw phone number string to format + * @param {string} [defaultRegion='US'] - The default region (country code) to use for parsing + * @returns {string} The formatted phone number in international format if valid, or the original input if invalid + * + * @example + * // Returns "+1 555-123-4567" + * formatPhoneNumber("5551234567"); + * + * @example + * // Returns "+44 20 7123 4567" + * formatPhoneNumber("2071234567", "GB"); + * + * @example + * // Returns "invalid-number" (original input) + * formatPhoneNumber("invalid-number"); + */ +export function formatPhoneNumber(rawNumber: string, defaultRegion = 'US') { + try { + const parsedNumber = phoneUtil.parseAndKeepRawInput(rawNumber, defaultRegion); + + if (phoneUtil.isValidNumber(parsedNumber)) { + return phoneUtil.format(parsedNumber, PhoneNumberFormat.INTERNATIONAL); + } + + return rawNumber; + } catch { + return rawNumber; + } +} diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index 7d7db6163f833..0dcdf5bd2162d 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -1,4 +1,4 @@ -import type { ILivechatDepartment, IMessage, IRoom, ITeam, IUser, ILivechatAgent } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, IMessage, IRoom, ITeam, IUser, ILivechatAgent, IOutboundProvider } from '@rocket.chat/core-typings'; import type { PaginatedRequest } from '@rocket.chat/rest-typings'; export const roomsQueryKeys = { @@ -73,6 +73,12 @@ export const omnichannelQueryKeys = { productivityTotals: (departmentId: ILivechatDepartment['_id'], dateRange: { start: string; end: string }) => [...omnichannelQueryKeys.analytics.all(departmentId), 'productivity-totals', dateRange] as const, }, + contacts: (query?: { filter: string; limit?: number }) => + !query ? [...omnichannelQueryKeys.all, 'contacts'] : ([...omnichannelQueryKeys.all, 'contacts', query] as const), + contact: (contactId?: string) => [...omnichannelQueryKeys.contacts(), contactId] as const, + outboundProviders: ({ type }: { type: IOutboundProvider['providerType'] }) => + [...omnichannelQueryKeys.all, 'outbound', 'providers', { type }] as const, + outboundProviderMetadata: (providerId: string) => [...omnichannelQueryKeys.all, 'outbound', 'provider', 'metadata', providerId] as const, }; export const deviceManagementQueryKeys = { diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx index 8092e0639cb28..d71438fffc6e5 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx @@ -1,7 +1,8 @@ import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; -import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts'; +import { useTranslation, useSetting, useAtLeastOnePermission, usePermission } from '@rocket.chat/ui-contexts'; import CreateDiscussion from '../../../../components/CreateDiscussion'; +import { useOutboundMessageModal } from '../../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal'; import CreateChannelWithData from '../../CreateChannel'; import CreateDirectMessage from '../../CreateDirectMessage'; import CreateTeam from '../../CreateTeam'; @@ -20,11 +21,13 @@ export const useCreateRoomItems = (): GenericMenuItemProps[] => { const canCreateTeam = useAtLeastOnePermission(CREATE_TEAM_PERMISSIONS); const canCreateDirectMessages = useAtLeastOnePermission(CREATE_DIRECT_PERMISSIONS); const canCreateDiscussion = useAtLeastOnePermission(CREATE_DISCUSSION_PERMISSIONS); + const canSendOutboundMessage = usePermission('outbound.send-messages'); const createChannel = useCreateRoomModal(CreateChannelWithData); const createTeam = useCreateRoomModal(CreateTeam); const createDiscussion = useCreateRoomModal(CreateDiscussion); const createDirectMessage = useCreateRoomModal(CreateDirectMessage); + const outboundMessageModal = useOutboundMessageModal(); const createChannelItem: GenericMenuItemProps = { id: 'channel', @@ -59,10 +62,18 @@ export const useCreateRoomItems = (): GenericMenuItemProps[] => { }, }; + const createOutboundMessageItem: GenericMenuItemProps = { + id: 'outbound-message', + content: t('Outbound_message'), + icon: 'send', + onClick: () => outboundMessageModal.open(), + }; + return [ ...(canCreateDirectMessages ? [createDirectMessageItem] : []), ...(canCreateDiscussion && discussionEnabled ? [createDiscussionItem] : []), ...(canCreateChannel ? [createChannelItem] : []), ...(canCreateTeam && canCreateChannel ? [createTeamItem] : []), + ...(canSendOutboundMessage ? [createOutboundMessageItem] : []), ]; }; diff --git a/apps/meteor/client/views/omnichannel/components/Info.tsx b/apps/meteor/client/views/omnichannel/components/Info.tsx index b478c14d04145..72c1aa16364c2 100644 --- a/apps/meteor/client/views/omnichannel/components/Info.tsx +++ b/apps/meteor/client/views/omnichannel/components/Info.tsx @@ -1,6 +1,6 @@ import type { cssFn } from '@rocket.chat/css-in-js'; import { css } from '@rocket.chat/css-in-js'; -import type { CSSProperties, ReactNode } from 'react'; +import type { ComponentProps, CSSProperties, ReactNode } from 'react'; import { UserCardInfo } from '../../../components/UserCard'; @@ -8,7 +8,7 @@ const wordBreak = css` word-break: break-word; `; -type InfoProps = { +type InfoProps = Omit, 'className' | 'style' | 'children'> & { className?: string | cssFn; style?: CSSProperties; children?: ReactNode; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx index a68e4ee48949e..ebebee15060cb 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx @@ -34,7 +34,17 @@ const ContactInfo = ({ contact, onClose }: ContactInfoProps) => { const formatDate = useFormatDate(); const canEditContact = usePermission('edit-omnichannel-contact'); - const { name, emails, phones, conflictingFields, createdAt, lastChat, contactManager, customFields: userCustomFields } = contact; + const { + _id: contactId, + name, + emails, + phones, + conflictingFields, + createdAt, + lastChat, + contactManager, + customFields: userCustomFields, + } = contact; const hasConflicts = conflictingFields && conflictingFields?.length > 0; const customFieldEntries = useValidCustomFields(userCustomFields); @@ -96,6 +106,7 @@ const ContactInfo = ({ contact, onClose }: ContactInfoProps) => { {context === 'details' && ( phoneNumber)} diff --git a/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx b/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx index 832fe99864204..87e6a5bae9187 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx @@ -2,6 +2,7 @@ import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings'; import { Field, FieldLabel, FieldRow, FieldError, TextInput, ButtonGroup, Button, IconButton, Divider } from '@rocket.chat/fuselage'; import { CustomFieldsForm } from '@rocket.chat/ui-client'; import { useEndpoint, useSetModal } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import { Fragment, useId } from 'react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; @@ -23,6 +24,7 @@ import { ContextualbarSkeleton, } from '../../../components/Contextualbar'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import { omnichannelQueryKeys } from '../../../lib/queryKeys'; import { ContactManagerInput } from '../additionalForms'; import { useCustomFieldsMetadata } from '../directory/hooks/useCustomFieldsMetadata'; @@ -76,7 +78,7 @@ const EditContactInfo = ({ contactData, onClose, onCancel }: ContactNewEditProps const editContact = useEditContact(['current-contacts']); const createContact = useCreateContact(['current-contacts']); const checkExistenceEndpoint = useEndpoint('GET', '/v1/omnichannel/contacts.checkExistence'); - + const queryClient = useQueryClient(); const handleOpenUpSellModal = () => setModal( setModal(null)} />); const { data: customFieldsMetadata = [], isLoading: isLoadingCustomFields } = useCustomFieldsMetadata({ @@ -175,10 +177,13 @@ const EditContactInfo = ({ contactData, onClose, onCancel }: ContactNewEditProps }; if (contactData) { - return editContact.mutate({ contactId: contactData?._id, ...payload }); + await editContact.mutateAsync({ contactId: contactData?._id, ...payload }); + await queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.contacts() }); + return; } - return createContact.mutate(payload); + await createContact.mutateAsync(payload); + await queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.contacts() }); }; const formId = useId(); diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannels.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannels.tsx index 49b3be9a15b18..ac9468eb8d082 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannels.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannels.tsx @@ -8,6 +8,7 @@ import { Virtuoso } from 'react-virtuoso'; import ContactInfoChannelsItem from './ContactInfoChannelsItem'; import { ContextualbarContent, ContextualbarEmptyContent } from '../../../../../components/Contextualbar'; import { VirtualizedScrollbars } from '../../../../../components/CustomScrollbars'; +import useOutboundProvidersList from '../../../../../components/Omnichannel/OutboundMessage/hooks/useOutboundProvidersList'; type ContactInfoChannelsProps = { contactId: ILivechatContact['_id']; @@ -22,6 +23,10 @@ const ContactInfoChannels = ({ contactId }: ContactInfoChannelsProps) => { queryFn: () => getContactChannels({ contactId }), }); + const { data: providers = [] } = useOutboundProvidersList({ + select: (data) => data.providers.map((provider) => provider.providerId), + }); + if (isPending) { return ( @@ -59,7 +64,14 @@ const ContactInfoChannels = ({ contactId }: ContactInfoChannelsProps) => { totalCount={data.channels.length} overscan={25} data={data?.channels} - itemContent={(index, data) => } + itemContent={(index, data) => ( + + )} /> diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx index fb1354204a6b4..246e85a1a6f8c 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx @@ -3,23 +3,35 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Palette } from '@rocket.chat/fuselage'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { GenericMenu } from '@rocket.chat/ui-client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useBlockChannel } from './useBlockChannel'; +import { useOutboundMessageModal } from '../../../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal'; import { OmnichannelRoomIcon } from '../../../../../components/RoomIcon/OmnichannelRoomIcon'; import { useTimeFromNow } from '../../../../../hooks/useTimeFromNow'; import { useOmnichannelSource } from '../../../hooks/useOmnichannelSource'; -type ContactInfoChannelsItemProps = Serialized; +type ContactInfoChannelsItemProps = Serialized & { + contactId?: string; + canSendOutboundMessage?: boolean; +}; -const ContactInfoChannelsItem = ({ visitor, details, blocked, lastChat }: ContactInfoChannelsItemProps) => { +const ContactInfoChannelsItem = ({ + contactId, + visitor, + details, + blocked, + lastChat, + canSendOutboundMessage, +}: ContactInfoChannelsItemProps) => { const { t } = useTranslation(); const { getSourceLabel, getSourceName } = useOmnichannelSource(); const getTimeFromNow = useTimeFromNow(true); const [showButton, setShowButton] = useState(false); const handleBlockContact = useBlockChannel({ association: visitor, blocked }); + const outboundMessageModal = useOutboundMessageModal(); const customClass = css` &:hover, @@ -28,15 +40,28 @@ const ContactInfoChannelsItem = ({ visitor, details, blocked, lastChat }: Contac } `; - const menuItems: GenericMenuItemProps[] = [ - { - id: 'block', - icon: 'ban', - content: blocked ? t('Unblock') : t('Block'), - variant: 'danger', - onClick: handleBlockContact, - }, - ]; + const menuItems = useMemo(() => { + const items: GenericMenuItemProps[] = [ + { + id: 'block', + icon: 'ban', + content: blocked ? t('Unblock') : t('Block'), + variant: 'danger', + onClick: handleBlockContact, + }, + ]; + + if (canSendOutboundMessage) { + items.unshift({ + id: 'outbound-message', + icon: 'send', + content: t('Outbound_message'), + onClick: () => outboundMessageModal.open({ contactId, providerId: details.id }), + }); + } + + return items; + }, [blocked, canSendOutboundMessage, contactId, details.id, handleBlockContact, outboundMessageModal, t]); return ( { +const ContactInfoDetails = ({ contactId, emails, phones, createdAt, customFieldEntries, contactManager }: ContactInfoDetailsProps) => { const { t } = useTranslation(); const formatDate = useFormatDate(); return ( - {emails?.length ? : null} - {phones?.length ? : null} - {contactManager && } + {emails?.length ? ( + + +
    + {emails.map((email) => ( + + ))} +
+
+ ) : null} + + {phones?.length ? ( + + +
    + {phones.map((phone) => ( + + ))} +
+
+ ) : null} + + {contactManager ? : null} + {createdAt && ( - - {formatDate(createdAt)} + + {formatDate(createdAt)} )} + {customFieldEntries.length > 0 && ( <> diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsEntry.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsEntry.tsx index a8e4aa320ce35..d07e2e600728f 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsEntry.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsEntry.tsx @@ -1,37 +1,25 @@ import type { IconProps } from '@rocket.chat/fuselage'; -import { Box, Icon, IconButton } from '@rocket.chat/fuselage'; -import { useTranslation } from 'react-i18next'; +import { Box, ButtonGroup, Icon } from '@rocket.chat/fuselage'; +import type { ComponentProps, ReactNode } from 'react'; -import ContactInfoCallButton from './ContactInfoCallButton'; -import { useIsCallReady } from '../../../../../contexts/CallContext'; -import useClipboardWithToast from '../../../../../hooks/useClipboardWithToast'; - -type ContactInfoDetailsEntryProps = { +type ContactInfoDetailsEntryProps = Pick, 'is' | 'aria-labelledby'> & { icon: IconProps['name']; - isPhone: boolean; value: string; + actions?: ReactNode; }; -const ContactInfoDetailsEntry = ({ icon, isPhone, value }: ContactInfoDetailsEntryProps) => { - const { t } = useTranslation(); - const { copy } = useClipboardWithToast(value); - - const isCallReady = useIsCallReady(); - - return ( - - - - - {value} - - - {isCallReady && isPhone && } - copy()} tiny title={t('Copy')} icon='copy' /> - +const ContactInfoDetailsEntry = ({ icon, value, actions, ...props }: ContactInfoDetailsEntryProps) => ( + + + + + {value} + + + {actions} - ); -}; + +); export default ContactInfoDetailsEntry; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsGroup.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsGroup.tsx deleted file mode 100644 index 841885ddbad35..0000000000000 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsGroup.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { ValidOutboundProvider } from '@rocket.chat/core-typings'; -import { Box } from '@rocket.chat/fuselage'; - -import ContactInfoDetailsEntry from './ContactInfoDetailsEntry'; -import { parseOutboundPhoneNumber } from '../../../../../lib/voip/parseOutboundPhoneNumber'; - -type ContactInfoDetailsGroupProps = { - type: ValidOutboundProvider; - label: string; - values: string[]; -}; - -const ContactInfoDetailsGroup = ({ type, label, values }: ContactInfoDetailsGroupProps) => { - return ( - - - {label} - - {values.map((value, index) => ( - - ))} - - ); -}; - -export default ContactInfoDetailsGroup; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoOutboundMessageButton.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoOutboundMessageButton.tsx new file mode 100644 index 0000000000000..36816f6bfc969 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoOutboundMessageButton.tsx @@ -0,0 +1,27 @@ +import { IconButton } from '@rocket.chat/fuselage'; +import { usePermission } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import type { OutboundMessageModalProps } from '../../../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal'; +import { useOutboundMessageModal } from '../../../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal'; +import { useHasLicenseModule } from '../../../../../hooks/useHasLicenseModule'; + +type ContactInfoOutboundMessageButtonProps = { + defaultValues?: OutboundMessageModalProps['defaultValues']; +}; + +const ContactInfoOutboundMessageButton = ({ defaultValues }: ContactInfoOutboundMessageButtonProps) => { + const { t } = useTranslation(); + const outboundMessageModal = useOutboundMessageModal(); + + const hasLicense = useHasLicenseModule('livechat-enterprise') === true; + const hasPermission = usePermission('outbound.send-messages'); + + if (!hasLicense || !hasPermission) { + return null; + } + + return outboundMessageModal.open(defaultValues)} tiny icon='send' title={t('Outbound_message')} />; +}; + +export default ContactInfoOutboundMessageButton; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoPhoneEntry.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoPhoneEntry.tsx new file mode 100644 index 0000000000000..876553967f20a --- /dev/null +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoPhoneEntry.tsx @@ -0,0 +1,35 @@ +import { IconButton } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +import ContactInfoCallButton from './ContactInfoCallButton'; +import ContactInfoDetailsEntry from './ContactInfoDetailsEntry'; +import ContactInfoOutboundMessageButton from './ContactInfoOutboundMessageButton'; +import { useIsCallReady } from '../../../../../contexts/CallContext'; +import useClipboardWithToast from '../../../../../hooks/useClipboardWithToast'; +import { parseOutboundPhoneNumber } from '../../../../../lib/voip/parseOutboundPhoneNumber'; + +type ContactInfoPhoneEntryProps = Omit, 'icon' | 'actions'> & { + contactId?: string; +}; + +const ContactInfoPhoneEntry = ({ contactId, value, ...props }: ContactInfoPhoneEntryProps) => { + const { t } = useTranslation(); + const isCallReady = useIsCallReady(); + const { copy } = useClipboardWithToast(value); + + return ( + copy()} tiny icon='copy' title={t('Copy')} />, + isCallReady ? : null, + , + ]} + /> + ); +}; + +export default ContactInfoPhoneEntry; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/outboundcomms/rest.ts b/apps/meteor/ee/app/livechat-enterprise/server/outboundcomms/rest.ts index 1b40a6584ee91..97af6a4bfe57e 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/outboundcomms/rest.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/outboundcomms/rest.ts @@ -164,6 +164,7 @@ const POSTOutboundMessageSchema = { properties: { type: { const: 'media' }, link: { type: 'string' }, + format: { type: 'string', enum: ['image', 'video', 'document'] }, }, additionalProperties: false, }, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index a1e42b9e2ea6f..e9458b88853e0 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -253,7 +253,7 @@ "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/freeswitch": "workspace:^", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-forms": "^0.1.0", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", diff --git a/apps/meteor/public/images/outbound-message-upsell.svg b/apps/meteor/public/images/outbound-message-upsell.svg new file mode 100644 index 0000000000000..76a473ffd5109 --- /dev/null +++ b/apps/meteor/public/images/outbound-message-upsell.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts index 2898008e6c8e6..04ea4ec697ae0 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts @@ -55,8 +55,12 @@ export class HomeOmnichannelContent extends HomeContent { return this.page.locator('[data-qa-id="ToolBoxAction-user"]'); } + get contactContextualBar() { + return this.page.getByRole('dialog', { name: 'Contact' }); + } + get infoContactEmail(): Locator { - return this.page.getByRole('dialog').locator('p[data-type="email"]'); + return this.contactContextualBar.getByRole('list', { name: 'Email' }).getByRole('listitem').first().locator('p'); } get btnReturn(): Locator { diff --git a/apps/meteor/tests/mocks/data.ts b/apps/meteor/tests/mocks/data.ts index 7ab4bd4c3541e..8e4083a1d3219 100644 --- a/apps/meteor/tests/mocks/data.ts +++ b/apps/meteor/tests/mocks/data.ts @@ -23,6 +23,7 @@ import type { ILivechatMonitor, } from '@rocket.chat/core-typings'; import { parse } from '@rocket.chat/message-parser'; +import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import type { MessageWithMdEnforced } from '../../client/lib/parseMessageTextToAstMarkdown'; @@ -348,6 +349,22 @@ export function createFakeContact(overrides?: Partial>, +): Serialized { + const { contactManager: contactManagerOverwrites, ...contactOverwrites } = overrides || {}; + const contact = createFakeContact(contactOverwrites); + return { + ...contact, + contactManager: { + _id: faker.string.uuid(), + name: faker.person.fullName(), + username: faker.internet.userName(), + ...contactManagerOverwrites, + }, + }; +} + export function createFakeAgent(overrides?: Partial>): Serialized { const email = faker.internet.email(); const firstName = faker.person.firstName(); diff --git a/apps/meteor/tests/mocks/data/outbound-message.ts b/apps/meteor/tests/mocks/data/outbound-message.ts new file mode 100644 index 0000000000000..2febe8cd92c88 --- /dev/null +++ b/apps/meteor/tests/mocks/data/outbound-message.ts @@ -0,0 +1,64 @@ +import { faker } from '@faker-js/faker'; +import type { IOutboundProvider, IOutboundProviderMetadata, IOutboundProviderTemplate } from '@rocket.chat/core-typings'; +import { capitalize } from '@rocket.chat/string-helpers'; + +export const createFakeProvider = (overrides: Partial = {}): IOutboundProvider => ({ + providerId: faker.string.uuid(), + providerName: faker.company.name(), + providerType: 'phone', + supportsTemplates: true, + ...overrides, +}); + +export const createFakeProviderMetadata = (overrides: Partial = {}): IOutboundProviderMetadata => { + const { templates, ...providerOverrides } = overrides; + const provider = createFakeProvider(providerOverrides); + const phoneNumber = faker.phone.number(); + return { + ...provider, + templates: templates || { + [phoneNumber]: [createFakeOutboundTemplate({ phoneNumber })], + }, + }; +}; + +export const createFakeOutboundTemplate = (overrides: Partial = {}): IOutboundProviderTemplate => ({ + id: faker.string.uuid(), + name: `${capitalize(faker.company.catchPhraseAdjective())} ${faker.company.buzzNoun()} Template`, + language: 'en', + type: 'whatsapp', + category: 'MARKETING', + status: 'APPROVED', + qualityScore: { + score: 'GREEN', + reasons: null, + }, + components: [ + { + type: 'header', + format: 'text', + text: 'New {{1}} appointment', + }, + { + type: 'body', + text: '**Hi {{1}}** Your _appointment_ for {{2}} is scheduled for {{3}} and can be rescheduled to {{4}} if {{5}} becomes available. {{6}} what do you choose?', + }, + { + type: 'footer', + text: 'Need to reschedule? Tap below to reply', + }, + ], + createdAt: new Date().toISOString(), // ISO 8601 timestamp + createdBy: '', + modifiedAt: new Date().toISOString(), // ISO 8601 timestamp + modifiedBy: '', + namespace: '', + wabaAccountId: '', + // This is the phone number that will be used to send the message. + phoneNumber: '+5547998461115', + partnerId: '', + externalId: '', + updatedExternal: new Date().toISOString(), // ISO 8601 timestamp + rejectedReason: '', + ...overrides, +}); diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index ac0e638b6a611..88707835e458b 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -18,7 +18,7 @@ "@lezer/highlight": "^1.2.1", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.35.0", diff --git a/ee/packages/license/src/v2/convertToV3.ts b/ee/packages/license/src/v2/convertToV3.ts index cf135452ed8c1..5f70e6d0cbfc7 100644 --- a/ee/packages/license/src/v2/convertToV3.ts +++ b/ee/packages/license/src/v2/convertToV3.ts @@ -50,7 +50,7 @@ export const convertToV3 = (v2: ILicenseV2): ILicenseV3 => { }, grantedModules: [ ...new Set( - ['teams-voip', 'contact-id-verification', 'hide-watermark', ...v2.modules] + ['outbound-messaging', 'teams-voip', 'contact-id-verification', 'hide-watermark', ...v2.modules] .map((licenseModule) => (isBundle(licenseModule) ? getBundleModules(licenseModule) : [licenseModule])) .reduce((prev, curr) => [...prev, ...curr], []) .map((licenseModule) => ({ module: licenseModule as InternalModuleName })), diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index b6a998a1f6ae6..dbbf2beb74361 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/emitter": "~0.31.25", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2", diff --git a/packages/apps-engine/src/definition/outboundComunication/IOutboundMessage.ts b/packages/apps-engine/src/definition/outboundComunication/IOutboundMessage.ts index 86b0a1fc7f06d..ed90a87162f8a 100644 --- a/packages/apps-engine/src/definition/outboundComunication/IOutboundMessage.ts +++ b/packages/apps-engine/src/definition/outboundComunication/IOutboundMessage.ts @@ -50,4 +50,24 @@ export type TemplateParameter = | { type: 'media'; link: string; + format: 'image' | 'document' | 'video'; + } + | { + type: 'document'; + document: { + link: string; + filename: string; + }; + } + | { + type: 'video'; + video: { + link: string; + }; + } + | { + type: 'image'; + image: { + link: string; + }; }; diff --git a/packages/core-typings/src/omnichannel/outbound.ts b/packages/core-typings/src/omnichannel/outbound.ts index ad303e43d227b..b985694f3d1d9 100644 --- a/packages/core-typings/src/omnichannel/outbound.ts +++ b/packages/core-typings/src/omnichannel/outbound.ts @@ -109,6 +109,26 @@ export type TemplateParameter = | { type: 'media'; link: string; + format: 'image' | 'document' | 'video'; + } + | { + type: 'document'; + document: { + link: string; + filename: string; + }; + } + | { + type: 'video'; + video: { + link: string; + }; + } + | { + type: 'image'; + image: { + link: string; + }; }; export type IOutboundProvider = { diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 338bde7e0a392..9b22774528f59 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -53,7 +53,7 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2", diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index 82bf3c13f67ed..8944b9ac2f647 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -32,7 +32,7 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/emitter": "~0.31.25", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 4c5357e834d07..0a653c6fc7f57 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -396,6 +396,7 @@ "After_OAuth2_authentication_users_will_be_redirected_to_this_URL": "After OAuth2 authentication, users will be redirected to an URL on this list. You can add one URL per line.", "After_guest_registration": "After guest registration", "Agent": "Agent", + "agent": "agent", "Agent_Info": "Agent Info", "Agent_Name": "Agent Name", "Agent_Name_Placeholder": "Please enter an agent name...", @@ -668,6 +669,7 @@ "Are_you_sure_you_want_to_pin_this_message": "Are you sure you want to pin this message?", "Are_you_sure_you_want_to_reset_the_name_of_all_priorities": "Are you sure you want to reset the name of all priorities?", "Are_you_sure_delete_contact": "Are you sure you want to delete {{contactName}} and all {{channelsCount}} of their conversation history? To confirm, type '{{confirmationText}}' in the field below.", + "Are_you_sure_you_want_to_discard_this_outbound_message": "Are you sure you want to discard this outbound message?", "Ask_enable_advanced_contact_profile": "Ask your workspace admin to enable advanced contact profile", "Asset_preview": "Asset preview", "Assets": "Assets", @@ -1149,8 +1151,10 @@ "Consulting": "Consulting", "Consumer_Packaged_Goods": "Consumer Packaged Goods", "Contact": "Contact", + "contact": "contact", "Contact_Center": "Contact Center", "Contact_Chat_History": "Contact Chat History", + "Contact_detail": "Contact detail", "Contact_Info": "Contact Information", "Contact_Manager": "Contact Manager", "Contact_Name": "Contact Name", @@ -1507,6 +1511,7 @@ "Custom_Field_Not_Found": "Custom Field not found", "Custom_Field_Removed": "Custom Field Removed", "Custom_Fields": "Custom Fields", + "Custom_fields": "Custom fields", "Custom_Integration": "Custom Integration", "Custom_OAuth_has_been_added": "Custom OAuth has been added", "Custom_OAuth_has_been_removed": "Custom OAuth has been removed", @@ -1618,6 +1623,7 @@ "Deleted_user": "Deleted user", "Deleting": "Deleting", "Department": "Department", + "department": "department", "Department_Removal_Disabled": "Delete option disabled by admin", "Department_archived": "Department archived", "Department_name": "Department name", @@ -1709,6 +1715,7 @@ "Disallow_reacting": "Disallow Reacting", "Disallow_reacting_Description": "Disallows reacting", "Discard": "Discard", + "Discard_message": "Discard message", "Disconnect": "Disconnect", "Disconnect_workspace": "Disconnect workspace", "Disconnected": "Disconnected", @@ -1990,6 +1997,7 @@ "Error_sending_livechat_offline_message": "Error sending Omnichannel offline message", "Error_sending_livechat_transcript": "Error sending Omnichannel transcript", "Error_something_went_wrong": "Oops! Something went wrong. Please reload the page or contact an administrator.", + "Error_loading__name__information": "Error loading {{name}} information", "Errors_and_Warnings": "Errors and Warnings", "Esc_to": "Esc to", "Estimated_due_time": "Estimated due time", @@ -2678,6 +2686,7 @@ "Katex_Parenthesis_Syntax": "Allow Parenthesis Syntax", "Katex_Parenthesis_Syntax_Description": "Allow using \\[katex block\\] and \\(inline katex\\) syntaxes", "Keep_default_user_settings": "Keep the default settings", + "Keep_editing": "Keep editing", "Keyboard_Shortcuts_Edit_Previous_Message": "Edit previous message", "Keyboard_Shortcuts_Keys_1": "Command (or Ctrl) + p OR Command (or Ctrl) + k", "Keyboard_Shortcuts_Keys_2": "Up Arrow", @@ -2911,6 +2920,8 @@ "Last_message__date__": "Last message: {{date}}", "Last_seen": "Last seen", "Last_token_part": "Last token part", + "Last_contact__time__": "Last contact {{time}}", + "Last_message_received__time__": "Last message received {{time}}", "Latest": "Latest", "Launched_successfully": "Launched successfully", "Layout": "Layout", @@ -3238,6 +3249,7 @@ "Maximum_number_of_guests_reached": "Maximum number of guests reached", "Me": "Me", "Media": "Media", + "Media_URL": "Media URL", "Medium": "Medium", "Members": "Members", "Members_List": "Members List", @@ -3350,6 +3362,7 @@ "Message_KeepHistory": "Keep Per Message Editing History", "Message_MaxAll": "Maximum Channel Size for ALL Message", "Message_MaxAllowedSize": "Maximum Allowed Characters Per Message", + "Message_not_sent_try_again": "Message not sent. \nPlease try again", "Message_QuoteChainLimit": "Maximum Number of Chained Quotes", "Message_Read_Receipt_Enabled": "Show Read Receipts", "Message_Read_Receipt_Store_Users": "Detailed Read Receipts", @@ -3399,6 +3412,7 @@ "Messages_exported_successfully": "Messages exported successfully", "Messages_sent": "Messages sent", "Messages_that_are_sent_to_the_Incoming_WebHook_will_be_posted_here": "Messages that are sent to the Incoming WebHook will be posted here.", + "Messages_cannot_be_unsent": "Messages cannot be unsent", "Meta": "Meta", "Meta_Description": "Set custom Meta properties.", "Meta_custom": "Custom Meta Tags", @@ -3662,6 +3676,8 @@ "No_pinned_messages": "No pinned messages", "No_previous_chat_found": "No previous chat found", "No_private_apps_installed": "No private apps installed", + "No_phone_number_yet_edit_contact": "No phone number yet <1>Edit contact", + "No_phone_number_available_for_selected_channel": "No phone number available for the selected channel", "No_release_information_provided": "No release information provided", "No_requested_apps": "No requested apps", "No_requests": "No requests", @@ -3888,6 +3904,12 @@ "Original": "Original", "Other": "Other", "Others": "Others", + "Outbound_message": "Outbound message", + "Outbound_message_sent_to__name__": "Outbound message sent to: {{name}}", + "Outbound_message_not_sent": "Outbound message not sent.", + "Outbound_message_department_hint": "Assign replies to a department.", + "Outbound_message_agent_hint": "Leave empty so any agent from the designated department can manage the replies.", + "Outbound_message_agent_hint_no_permission": "You don't have permission to assign an agent. The reply will be assigned to the department.", "Out_of_seats": "Out of Seats", "Outdated": "Outdated", "Outgoing": "Outgoing", @@ -3986,6 +4008,7 @@ "Placeholder_for_email_or_username_login_field": "Placeholder for Email or Username Login Field", "Placeholder_for_password_login_confirm_field": "Confirm Placeholder for Password Login Field", "Placeholder_for_password_login_field": "Placeholder for Password Login Field", + "Placeholder": "Placeholder", "Plan_limits_reached": "Plan limits reached", "Platform_Linux": "Linux", "Platform_Mac": "Mac", @@ -4188,6 +4211,7 @@ "Reconnecting": "Reconnecting", "Record": "Record", "Records": "Records", + "Recipient": "Recipient", "Redirect_URI": "Redirect URI", "Redirect_URL_does_not_match": "Redirect URL does not match", "Refresh": "Refresh", @@ -4390,10 +4414,12 @@ "Retention_policy_warning_callout": "Retention policy warning callout", "Retention_setting_changed_successfully": "Retention policy setting changed successfully", "Retry": "Retry", + "Retrying": "Retrying", "Retry_Count": "Retry Count", "Return_to_home": "Return to home", "Return_to_previous_page": "Return to previous page", "Return_to_the_queue": "Return back to the Queue", + "Review": "Review", "Review_contact": "Review contact", "Review_devices": "Review when and where devices are connecting from", "Right": "Right", @@ -4634,14 +4660,18 @@ "Select_at_least_one_user": "Select at least one user", "Select_at_least_two_users": "Select at least two users", "Select_atleast_one_channel_to_forward_the_messsage_to": "Select at least one channel to forward the message to", + "Select_agent": "Select agent", + "Select_channel": "Select channel", "Select_department": "Select a department", "Select_file": "Select file", "Select_messages_to_hide": "Select messages to hide", "Select_period": "Select period", + "Select_recipient": "Select recipient", "Select_role": "Select a Role", "Select_service_to_login": "Select a service to login to load your picture or upload one directly from your computer", "Select_someone_to_transfer_the_call_to": "Select someone to transfer the call to", "Select_tag": "Select a tag", + "Select_template": "Select template", "Select_the_channels_you_want_the_user_to_be_removed_from": "Select the channels you want the user to be removed from", "Select_the_teams_channels_you_would_like_to_delete": "Select the Team’s Channels you would like to delete, the ones you do not select will be moved to the Workspace.", "Select_user": "Select user", @@ -5061,6 +5091,9 @@ "Technology_Provider": "Technology Provider", "Technology_Services": "Technology Services", "Temporarily_unavailable": "Temporarily unavailable", + "Template": "Template", + "template": "template", + "Template_message": "Template message", "Terms": "Terms", "Terms_of_use": "Terms of use", "Test_Connection": "Test Connection", @@ -5741,6 +5774,7 @@ "Without_SLA": "Without SLA", "Without_priority": "Without priority", "Workspace": "Workspace", + "Workspace_detail": "Workspace detail", "Workspace_and_user_preferences": "Workspace and user preferences", "Workspace_exceeded_MAC_limit_disclaimer": "The workspace has exceeded the monthly limit of active contacts. Talk to your workspace admin to address this issue.", "Workspace_instance": "Workspace instance", @@ -5777,6 +5811,7 @@ "You_are_converting_team_to_channel": "You are converting this Team to a Channel.", "You_are_logged_in_as": "You are logged in as", "You_are_not_authorized_to_view_this_page": "You are not authorized to view this page.", + "You_are_not_authorized_to_access_this_feature": "You are not authorized to access this feature.", "You_can_change_a_different_avatar_too": "You can override the avatar used to post from this integration.", "You_can_close_this_window_now": "You can close this window now.", "You_can_do_from_account_preferences": "You can do this later from your account preferences", @@ -6408,6 +6443,7 @@ "message_pruned": "message pruned", "messages": "messages", "messages_pruned": "messages pruned", + "Message_preview": "Message preview", "meteor_status_connected": "Connected", "meteor_status_connecting": "Connecting...", "meteor_status_failed": "Connection attempt failed", @@ -6535,6 +6571,10 @@ "others": "others", "outbound-voip-calls": "Outbound Voip Calls", "outbound-voip-calls_description": "Permission to outbound voip calls", + "Outbound_message_upsell_title": "Take the first step in the conversation", + "Outbound_message_upsell_description": "Send personalized outbound messages on WhatsApp and other channels — ideal for reminders, alerts and follow-ups.", + "Outbound_message_upsell_annotation": "Contact your workspace admin to enable outbound chat", + "Outbound_message_upsell_alt": "Illustration of a smartphone receiving a new message notification.", "outbound.send-messages": "Send outbound messages", "outbound.send-messages_description": "Permission to send outbound messages", "outbound.can-assign-queues": "Can assign departments to receive outbound messages responses", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index 9dade4f4a462b..423f2af5292b1 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -668,6 +668,7 @@ "Are_you_sure_you_want_to_disable_Facebook_integration": "Tem certeza de que deseja desabilitar a integração do Facebook?", "Are_you_sure_you_want_to_pin_this_message": "Tem certeza de que deseja fixar esta mensagem?", "Are_you_sure_you_want_to_reset_the_name_of_all_priorities": "Tem certeza que deseja redefinir o nome de todas as prioridades?", + "Are_you_sure_you_want_to_discard_this_outbound_message": "Tem certeza de que deseja descartar esta mensagem ativa?", "Ask_enable_advanced_contact_profile": "Peça para o administrador do workspace habilitar perfil de contato avançado.", "Asset_preview": "Pré-visualização do recurso", "Assets": "Recursos", @@ -1141,8 +1142,10 @@ "Consulting": "Consultoria", "Consumer_Packaged_Goods": "Bens de consumo embalados", "Contact": "Contato", + "contact": "contato", "Contact_Center": "Central de contatos", "Contact_Chat_History": "Histórico de conversas do contato", + "Contact_detail": "Detalhes do contato", "Contact_Info": "Informações do contato", "Contact_Manager": "Gerente de contato", "Contact_Name": "Nome do contato", @@ -1697,6 +1700,7 @@ "Disallow_reacting": "Não permitir reagir", "Disallow_reacting_Description": "Não permite reagir", "Discard": "Descartar", + "Discard_message": "Descartar mensagem", "Disconnect": "Desconectar", "Disconnect_workspace": "Desconectar workspace", "Disconnected": "Desconectado", @@ -1970,6 +1974,7 @@ "Error_sending_livechat_offline_message": "Erro ao enviar mensagem Omnichannel offline", "Error_sending_livechat_transcript": "Erro ao enviar transcript do Omnichannel", "Error_something_went_wrong": "Ops! Algo deu errado. Recarregue a página ou entre em contato com um administrador.", + "Error_loading__name__information": "Erro ao carregar informações de {{name}}", "Errors_and_Warnings": "Erros e avisos", "Esc_to": "Esc para", "Estimated_due_time": "Tempo estimado(tempo em minutos)", @@ -2648,6 +2653,7 @@ "Katex_Parenthesis_Syntax": "Permitir sintaxe com parênteses", "Katex_Parenthesis_Syntax_Description": "Permitir o uso de sintaxes \\[bloco Katex\\] e \\(Katex em linha \\)", "Keep_default_user_settings": "Mantenha as configurações padrão", + "Keep_editing": "Continuar editando", "Keyboard_Shortcuts_Edit_Previous_Message": "Editar mensagem anterior", "Keyboard_Shortcuts_Keys_1": "Command (ou Ctrl) + p OU Command (ou Ctrl) + k", "Keyboard_Shortcuts_Keys_2": "Seta para cima", @@ -2868,6 +2874,7 @@ "Last_Heartbeat_Time": "Última atividade conectado", "Last_Message": "Última mensagem", "Last_Message_At": "Última mensagem em", + "Last_message_received__time__": "Última mensagem recebida {{time}}", "Last_Status": "Último status", "Last_Updated": "Ultima atualização", "Last_active": "Ativo pela última vez", @@ -3361,6 +3368,7 @@ "Messages_exported_successfully": "Mensagens exportadas com sucesso", "Messages_sent": "Mensagens enviadas", "Messages_that_are_sent_to_the_Incoming_WebHook_will_be_posted_here": "As mensagens que são enviadas para o WebHook de entrada serão publicadas aqui.", + "Messages_cannot_be_unsent": "O envio da mensagem não pode ser desfeito", "Meta": "Meta", "Meta_Description": "Definir propriedades Meta personalizadas.", "Meta_custom": "Meta tags personalizadas", @@ -3824,6 +3832,16 @@ "Other": "Outro", "Others": "Outros", "Out_of_seats": "Sem lugares", + "Outbound_message": "Mensagem ativa", + "Outbound_message_agent_hint": "Deixe em branco para que qualquer agente do departamento designado possa gerenciar as respostas.", + "Outbound_message_agent_hint_no_permission": "Você não tem permissão para atribuir um agente. A resposta será atribuída ao departamento.", + "Outbound_message_department_hint": "Atribuir respostas a um departamento.", + "Outbound_message_not_sent": "Mensagem ativa não enviada.", + "Outbound_message_sent_to__name__": "Mensagem ativa enviada para: {{name}}", + "Outbound_message_upsell_alt": "Ilustração de um smartphone recebendo uma nova notificação de mensagem.", + "Outbound_message_upsell_annotation": "Entre em contato com o admin do seu workspace para habilitar a mensagem ativa", + "Outbound_message_upsell_description": "Envie mensagens ativas personalizadas no WhatsApp e em outros canais - ideal para lembretes, alertas e acompanhamentos.", + "Outbound_message_upsell_title": "Dê o primeiro passo na conversa", "Outdated": "Desatualizado", "Outgoing": "Enviado", "Outgoing_WebHook": "WebHook de saída", @@ -4117,6 +4135,7 @@ "Receive_login_notifications": "Receber notificações de login", "Recent": "Recente", "Recent_Import_History": "Histórico de importações recentes", + "Recipient": "Destinatário", "Reconnecting": "Reconectando", "Record": "Gravar", "Records": "Registros", @@ -4323,6 +4342,7 @@ "Return_to_home": "Retornar para a página inicial", "Return_to_previous_page": "Retornar para a página anterior", "Return_to_the_queue": "Retornar para fila", + "Review": "Revisar", "Review_contact": "Revisar contato", "Review_devices": "Analisar quando e de onde os dispositivos estão se conectando", "Right": "Direita", @@ -4557,14 +4577,18 @@ "Select_at_least_one_user": "Selecione pelo menos um usuário", "Select_at_least_two_users": "Selecione pelo menos dois usuários", "Select_atleast_one_channel_to_forward_the_messsage_to": "Selecione pelo menos um canal para o qual encaminhar a mensagem", + "Select_agent": "Selecionar agente", + "Select_channel": "Selecionar canal", "Select_department": "Selecionar um departamento", "Select_file": "Selecionar arquivo", "Select_messages_to_hide": "Selecione as mensagens a serem ocultadas", "Select_period": "Selecionar período", + "Select_recipient": "Selecionar destinatário", "Select_role": "Selecione uma função", "Select_service_to_login": "Selecionar um serviço para iniciar sessão e carregar sua imagem ou fazer upload de um arquivo de seu computador", "Select_someone_to_transfer_the_call_to": "Selecione alguém para transferir a chamada", "Select_tag": "Selecione uma tag", + "Select_template": "Selecionar modelo", "Select_the_channels_you_want_the_user_to_be_removed_from": "Selecione os canais dos quais deseja remover o usuário", "Select_the_teams_channels_you_would_like_to_delete": "Selecione os Canais da equipe que deseja remover, os que você não selecionar serão movidos para o espaço de trabalho.", "Select_user": "Selecionar usuário", @@ -4979,6 +5003,9 @@ "Teams_removing_member": "Removendo membro", "Technology_Services": "Serviços tecnológicos", "Temporarily_unavailable": "Temporariamente indisponível", + "Template": "Modelo", + "template": "modelo", + "Template_message": "Mensagem modelo", "Terms": "Termos", "Terms_of_use": "Termos de uso", "Test_Connection": "Testar conexão", @@ -5653,6 +5680,7 @@ "Without_priority": "Sem prioridade", "Workspace": "Workspace", "Workspace_and_user_preferences": "Workspace e preferências do usuário", + "Workspace_detail": "Detalhes do workspace", "Workspace_exceeded_MAC_limit_disclaimer": "O workspace excedeu o limite mensal de contatos ativos. Fale com o administrador do workspace para resolver esse problema.", "Workspace_not_connected": "Workspace não conectado", "Workspace_not_registered": "Workspace não registrado", @@ -5687,6 +5715,7 @@ "You_are_converting_team_to_channel": "Você está convertendo esta equipe em um canal.", "You_are_logged_in_as": "Você está conectado como", "You_are_not_authorized_to_view_this_page": "Você não tem permissão para visualizar esta página.", + "You_are_not_authorized_to_access_this_feature": "Você não tem permissão para acessar esse recurso.", "You_can_change_a_different_avatar_too": "Você pode substituir o avatar usado para publicar a partir desta integração.", "You_can_close_this_window_now": "Você pode fechar esta janela agora.", "You_can_do_from_account_preferences": "Você pode fazer isso mais tarde nas preferências de sua conta", @@ -6314,6 +6343,7 @@ "message_pruned": "mensagem removida", "messages": "mensagens", "messages_pruned": "mensagens removidas", + "Message_preview": "Pré-visualização da mensagem", "meteor_status_connected": "Conectado", "meteor_status_connecting": "Conectando...", "meteor_status_failed": "A conexão com o servidor falhou", diff --git a/packages/storybook-config/package.json b/packages/storybook-config/package.json index 2d20f3b9ff0ce..0e8ca2db50311 100644 --- a/packages/storybook-config/package.json +++ b/packages/storybook-config/package.json @@ -22,7 +22,7 @@ }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:~", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/icons": "^0.43.0", "@rocket.chat/tsconfig": "workspace:*", "@storybook/react": "^8.6.14", diff --git a/packages/ui-avatar/package.json b/packages/ui-avatar/package.json index 4e748370dcef8..a622f21767e19 100644 --- a/packages/ui-avatar/package.json +++ b/packages/ui-avatar/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.26.10", "@rocket.chat/core-typings": "workspace:~", "@rocket.chat/emitter": "~0.31.25", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2", diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index 304085e08e716..d9833f2a8aad0 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -23,7 +23,7 @@ "@rocket.chat/core-typings": "workspace:~", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/emitter": "~0.31.25", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2", diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index 11a95813b9e54..7d24c1398466d 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -24,7 +24,7 @@ "@react-aria/toolbar": "^3.0.0-nightly.5042", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2", diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json index d3de60080f62c..3ee9a0c4637dc 100644 --- a/packages/ui-video-conf/package.json +++ b/packages/ui-video-conf/package.json @@ -24,7 +24,7 @@ "@babel/core": "~7.26.10", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2", diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index d19954461f971..633a51f1b0d63 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -31,7 +31,7 @@ "@react-spectrum/test-utils": "~1.0.0-alpha.8", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2", diff --git a/packages/web-ui-registration/package.json b/packages/web-ui-registration/package.json index c6a9d7b29b181..db4935bada880 100644 --- a/packages/web-ui-registration/package.json +++ b/packages/web-ui-registration/package.json @@ -24,7 +24,7 @@ "@rocket.chat/core-typings": "workspace:~", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/emitter": "~0.31.25", - "@rocket.chat/fuselage": "^0.66.3", + "@rocket.chat/fuselage": "^0.66.4", "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-tokens": "~0.33.2",