snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -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(<AutoCompleteDepartmentAgent value='' onChange={jest.fn()} placeholder='Select an agent' agents={[]} />);
expect(screen.getByPlaceholderText('Select an agent')).toBeInTheDocument();
rerender(<AutoCompleteDepartmentAgent value='agent1' onChange={jest.fn()} placeholder='Select an agent' agents={[]} />);
expect(screen.queryByPlaceholderText('Select an agent')).not.toBeInTheDocument();
});

View File

@@ -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 } from 'react';
import { useMemo, useState } from 'react';
export type AutoCompleteDepartmentAgentProps = Omit<AllHTMLAttributes<HTMLInputElement>, 'onChange'> & {
error?: boolean;
value: string;
onChange(value: string): void;
agents?: Serialized<ILivechatDepartmentAgents>[];
};
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 (
<AutoComplete
{...props}
placeholder={!value ? placeholder : undefined}
filter={filter}
setFilter={setFilter}
value={value}
onChange={onChange as (value: string | string[]) => void}
options={options}
renderSelected={({ selected: { value, label }, ...props }) => {
return (
<Chip {...props} height='x20' value={value} onClick={() => onChange('')} marginInlineEnd={4}>
<UserAvatar size='x20' username={label} />
<Box is='span' margin='none' marginInlineStart={4}>
{label}
</Box>
</Chip>
);
}}
renderItem={({ value, label, ...props }) => (
<Option key={value} {...props}>
<OptionAvatar>
<UserAvatar username={label} size='x20' />
</OptionAvatar>
<OptionContent>{label}</OptionContent>
</Option>
)}
/>
);
};
export default AutoCompleteDepartmentAgent;

View File

@@ -0,0 +1,62 @@
import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings';
import { Option, OptionDescription, PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import type { ComponentProps } 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';
export type AutoCompleteOutboundProviderProps = Omit<
ComponentProps<typeof PaginatedSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem'
> & {
contact?: Serialized<Omit<ILivechatContact, 'contactManager'>> | null;
value: string;
onChange: (value: string) => void;
};
const AutoCompleteOutboundProvider = ({ contact, disabled, value, placeholder, onChange, ...props }: AutoCompleteOutboundProviderProps) => {
const [channelsFilter, setChannelsFilter] = useState<string>('');
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 (
<PaginatedSelectFiltered
{...props}
aria-busy={isPending}
placeholder={isPending ? t('Loading...') : placeholder}
aria-disabled={isPending || disabled}
disabled={isPending || disabled}
value={value}
flexShrink={0}
filter={channelsFilter}
setFilter={setChannelsFilter as (value: string | number | undefined) => void}
options={options}
onChange={onChange}
renderItem={({ label, value, ...props }) => {
const lastChat = findLastChatFromChannel(contact?.channels, value);
return (
<Option {...props} label={label} value={value}>
{lastChat ? (
<OptionDescription>{t('Last_message_received__time__', { time: getTimeFromNow(lastChat) })}</OptionDescription>
) : null}
</Option>
);
}}
/>
);
};
export default AutoCompleteOutboundProvider;

View File

@@ -0,0 +1,108 @@
import type {
ILivechatAgent,
ILivechatDepartment,
IOutboundProviderMetadata,
IOutboundProviderTemplate,
ILivechatContact,
} from '@rocket.chat/core-typings';
import { Box, Margins } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PreviewItem from './PreviewItem';
import { formatPhoneNumber } from '../../../../../../lib/formatPhoneNumber';
import type { TemplateParameters } from '../../types/template';
import TemplatePreview from '../TemplatePreview';
export type OutboundMessagePreviewProps = ComponentProps<typeof Box> & {
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,
...props
}: 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 (
<Box {...props} is='section'>
<ul>
<Margins blockStart={24}>
<Box is='li' marginBlockStart={0}>
<PreviewItem icon='doc' label={t('Template')}>
{template?.name}
</PreviewItem>
</Box>
<Box is='li'>
<PreviewItem icon='user' label={t('To')}>
{contactName && `${contactName} ${recipient ? `(${recipient})` : ''}`}
</PreviewItem>
</Box>
<Box is='li'>
<PreviewItem icon='send' label={t('From')}>
{providerName && `${providerName} ${sender ? `(${sender})` : ''}`}
</PreviewItem>
</Box>
<Box is='li'>
<PreviewItem icon='reply' label={t('Replies')}>
{replies}
</PreviewItem>
</Box>
</Margins>
</ul>
{template ? (
<Box marginBlockStart={30}>
<TemplatePreview template={template} parameters={templateParameters} />
</Box>
) : null}
</Box>
);
};
export default OutboundMessagePreview;

View File

@@ -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';
export type PreviewItemProps = {
label: string;
icon: Keys;
children: ReactNode;
};
const PreviewItem = ({ icon, label, children }: PreviewItemProps) => {
const { t } = useTranslation();
const id = useId();
return (
<Box display='flex'>
<Icon size={18} name={icon} />
<Box marginInlineStart={6}>
<Box is='span' id={id} fontScale='p2m'>
{label}
</Box>
<div aria-labelledby={id}>
{children ?? (
<Box fontScale='p2' color='disabled'>
{t('None')}
</Box>
)}
</div>
</Box>
</Box>
);
};
export default PreviewItem;

View File

@@ -0,0 +1 @@
export { default } from './OutboundMessagePreview';

View File

@@ -0,0 +1,148 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { StepsLinkedList, WizardContext } from '@rocket.chat/ui-client';
import { act, render, screen, waitFor } from '@testing-library/react';
import OutboundMessageWizard from './OutboundMessageWizard';
import { createFakeLicenseInfo } from '../../../../../../../tests/mocks/data';
import { createFakeProvider } from '../../../../../../../tests/mocks/data/outbound-message';
import type { OmnichannelContextValue } from '../../../../../../contexts/OmnichannelContext';
import { OmnichannelContext } from '../../../../../../contexts/OmnichannelContext';
const openUpsellModal = jest.fn();
jest.mock('../../modals', () => ({
useOutboundMessageUpsellModal: () => ({
open: openUpsellModal,
close: jest.fn(),
}),
}));
jest.mock('tinykeys', () => ({
__esModule: true,
default: jest.fn().mockReturnValue(() => () => undefined),
}));
jest.mock('../../../../../../../client/lib/getURL', () => ({
getURL: (url: string) => url,
}));
let currentOnSubmit: (payload: Record<string, unknown>) => void = () => undefined;
jest.mock('./steps', () => ({
...jest.requireActual('./steps'),
RecipientStep: jest.fn().mockImplementation((props) => {
currentOnSubmit = props.onSubmit;
return <div data-testid='recipient-form'>form</div>;
}),
}));
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 = (omnichannelEnabled = true) =>
mockAppRoot()
.withJohnDoe()
.withSetting('Livechat_enabled', omnichannelEnabled)
.withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => getProvidersMock())
.withEndpoint('GET', '/v1/licenses.info', () => getLicenseMock())
.wrap((children) => (
<OmnichannelContext.Provider value={{ enabled: omnichannelEnabled } as OmnichannelContextValue}>
<WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>
</OmnichannelContext.Provider>
));
describe('OutboundMessageWizard', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('error and loading states', () => {
it('should render loading state', async () => {
getProvidersMock.mockImplementationOnce(() => new Promise(() => undefined));
render(<OutboundMessageWizard />, { wrapper: appRoot().withPermission('outbound.send-messages').build() });
expect(await screen.findByRole('status')).toHaveAttribute('aria-busy', 'true');
});
it('should render unauthorized when user has no permission', async () => {
render(<OutboundMessageWizard />, { wrapper: appRoot().build() });
expect(await screen.findByText('You_are_not_authorized_to_access_this_feature')).toBeInTheDocument();
});
it('should render error state when omnichannel is disabled', async () => {
render(<OutboundMessageWizard />, { wrapper: appRoot(false).build() });
expect(await screen.findByText('Omnichannel_is_not_enabled')).toBeInTheDocument();
});
});
describe('upsell flow', () => {
it('should display upsell modal if module is not present', async () => {
getLicenseMock.mockResolvedValueOnce({ license: createFakeLicenseInfo({ activeModules: [] }) });
getProvidersMock.mockResolvedValueOnce({ providers: [] });
render(<OutboundMessageWizard />, { 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(<OutboundMessageWizard />, { 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: ['livechat-enterprise', 'outbound-messaging'] }),
});
getProvidersMock.mockResolvedValueOnce({ providers: [] });
render(<OutboundMessageWizard />, { 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: ['livechat-enterprise', 'outbound-messaging'] }),
});
render(<OutboundMessageWizard />, { wrapper: appRoot().build() });
await waitFor(() => expect(openUpsellModal).not.toHaveBeenCalled());
await act(() => currentOnSubmit({}));
await waitFor(() => expect(openUpsellModal).not.toHaveBeenCalled());
});
});
});

View File

@@ -0,0 +1,95 @@
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 = {
component: OutboundMessageWizard,
subcomponents: {
RecipientStep,
MessageStep,
RepliesStep,
ReviewStep,
},
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: [
(Story) => (
<AppRoot>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</AppRoot>
),
],
} satisfies Meta<typeof OutboundMessageWizard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
defaultValues: {},
},
};
export const WithDefaultValues: Story = {
args: {
defaultValues: {
contactId: contactMock._id,
providerId: providerMock.providerId,
},
},
};

View File

@@ -0,0 +1,207 @@
import { Box } from '@rocket.chat/fuselage';
import { useStableCallback } 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 { useQueryClient } from '@tanstack/react-query';
import { useEffect, useLayoutEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import OutboundMessageWizardErrorState from './components/OutboundMessageWizardErrorState';
import OutboubdMessageWizardSkeleton from './components/OutboundMessageWizardSkeleton';
import type { SubmitPayload } from './forms';
import { ReviewStep, MessageStep, RecipientStep, RepliesStep } from './steps';
import GenericError from '../../../../../../components/GenericError';
import { useEndpointMutation } from '../../../../../../hooks/useEndpointMutation';
import { useHasLicenseModule } from '../../../../../../hooks/useHasLicenseModule';
import { formatPhoneNumber } from '../../../../../../lib/formatPhoneNumber';
import { omnichannelQueryKeys } from '../../../../../../lib/queryKeys';
import { useOmnichannelEnabled } from '../../../../hooks/useOmnichannelEnabled';
import useOutboundProvidersList from '../../hooks/useOutboundProvidersList';
import { useOutboundMessageUpsellModal } from '../../modals';
import { formatOutboundMessagePayload, isMessageStepValid, isRecipientStepValid, isRepliesStepValid } from '../../utils/outbound-message';
export type OutboundMessageWizardProps = {
defaultValues?: Partial<Pick<SubmitPayload, 'contactId' | 'providerId' | 'recipient' | 'sender'>>;
onSuccess?(): void;
onError?(): void;
};
const OutboundMessageWizard = ({ defaultValues = {}, onSuccess, onError }: OutboundMessageWizardProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const dispatchToastMessage = useToastBarDispatch();
const [state, setState] = useState<Partial<SubmitPayload>>(defaultValues);
const { contact, sender, provider, department, agent, template, templateParameters, recipient } = state;
const templates = sender ? provider?.templates[sender] : [];
const upsellModal = useOutboundMessageUpsellModal();
const isOmnichannelEnabled = useOmnichannelEnabled();
const hasOmnichannelModule = useHasLicenseModule('livechat-enterprise');
const hasOutboundModule = useHasLicenseModule('outbound-messaging');
const hasOutboundPermission = usePermission('outbound.send-messages');
const isLoadingModule = hasOutboundModule.isPending || hasOmnichannelModule.isPending;
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<boolean>({
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(
() => () => {
// Clear cached providers and metadata on unmount to avoid stale data
void queryClient.removeQueries({ queryKey: omnichannelQueryKeys.outboundProviders() });
},
[queryClient],
);
useLayoutEffect(() => {
if (isLoadingModule || isLoadingProviders) {
return;
}
if (!hasOmnichannelModule.data || !hasOutboundModule.data || !hasProviders) {
upsellModal.open();
}
}, [hasOmnichannelModule.data, hasOutboundModule.data, hasProviders, isLoadingModule, isLoadingProviders, upsellModal]);
const handleSubmit = useStableCallback((values: SubmitPayload) => {
if (!hasOutboundModule.data) {
upsellModal.open();
return;
}
setState((state) => ({ ...state, ...values }));
});
const handleSend = useStableCallback(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 = useStableCallback(() => {
wizardApi.resetNextSteps();
});
if (!isOmnichannelEnabled) {
return <OutboundMessageWizardErrorState title={t('error-not-authorized')} description={t('Omnichannel_is_not_enabled')} />;
}
if (!hasOutboundPermission) {
return (
<OutboundMessageWizardErrorState title={t('error-not-authorized')} description={t('You_are_not_authorized_to_access_this_feature')} />
);
}
if (isLoadingModule || isLoadingProviders) {
return <OutboubdMessageWizardSkeleton />;
}
if (isErrorProviders) {
return <OutboundMessageWizardErrorState onRetry={refetchProviders} />;
}
return (
<ErrorBoundary fallbackRender={() => <GenericError icon='circle-exclamation' />}>
<Wizard api={wizardApi} display='flex' flexDirection='column' height='100%'>
<WizardTabs />
<Box marginBlockStart={16} minHeight={0} flexGrow={1}>
<WizardContent id='recipient'>
<RecipientStep defaultValues={state} onDirty={handleDirtyStep} onSubmit={handleSubmit} />
</WizardContent>
<WizardContent id='message'>
<MessageStep defaultValues={state} contact={contact} templates={templates} onSubmit={handleSubmit} />
</WizardContent>
<WizardContent id='replies'>
<RepliesStep defaultValues={state} onSubmit={handleSubmit} />
</WizardContent>
<WizardContent id='review'>
<ReviewStep
sender={sender}
recipient={recipient}
contactName={contact?.name}
departmentName={department?.name}
providerType={provider?.providerType}
providerName={provider?.providerName}
agentUsername={agent?.username}
template={template}
templateParameters={templateParameters}
onSend={handleSend}
/>
</WizardContent>
</Box>
</Wizard>
</ErrorBoundary>
);
};
export default OutboundMessageWizard;

View File

@@ -0,0 +1,12 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export type OutboundMessageFormProps = ComponentProps<typeof Box> & {
onSubmit?: () => void;
};
const OutboundMessageForm = ({ onSubmit, ...props }: OutboundMessageFormProps) => (
<Box is='form' display='flex' flexDirection='column' height='100%' flexGrow={1} flexShrink={0} onSubmit={onSubmit} {...props} />
);
export default OutboundMessageForm;

View File

@@ -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 (
<States width='100%' height='100%'>
<StatesIcon name='circle-exclamation' />
<StatesTitle>{title ?? t('Something_went_wrong')}</StatesTitle>
{description ? <StatesSubtitle>{description}</StatesSubtitle> : null}
{onRetry ? (
<StatesActions>
<StatesAction onClick={onRetry}>{t('Retry')}</StatesAction>
</StatesActions>
) : null}
</States>
);
};
export default OutboundMessageWizardErrorState;

View File

@@ -0,0 +1,36 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
const OutboubdMessageWizardSkeleton = () => {
return (
<Box role='status' aria-busy='true'>
<Box display='flex'>
<Skeleton width={75} height={40} />
<Skeleton marginInlineStart={8} width={100} height={50} />
<Skeleton marginInlineStart={8} width={100} height={50} />
<Skeleton marginInlineStart={8} width={100} height={50} />
</Box>
<Box marginBlockStart={10}>
<Skeleton width={80} height={30} />
<Skeleton width='100%' height={50} />
</Box>
<Box marginBlockStart={10}>
<Skeleton width={80} height={30} />
<Skeleton width='100%' height={50} />
</Box>
<Box marginBlockStart={10}>
<Skeleton width={80} height={30} />
<Skeleton width='100%' height={50} />
</Box>
<Box marginBlockStart={10}>
<Skeleton width={80} height={30} />
<Skeleton width='100%' height={50} />
</Box>
<Box marginBlockStart={10} display='flex' justifyContent='end'>
<Skeleton width={85} height={60} />
</Box>
</Box>
);
};
export default OutboubdMessageWizardSkeleton;

View File

@@ -0,0 +1,37 @@
import { css } from '@rocket.chat/css-in-js';
import { IconButton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export 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 (
<IconButton
mini
danger
secondary
className={btnStyle}
disabled={loading}
rcx-button--loading={loading}
icon={loading ? 'loading' : 'reload'}
title={loading ? t('Loading...') : t('Retry')}
marginInlineStart={4}
onClick={() => onClick()}
/>
);
};
export default RetryButton;

View File

@@ -0,0 +1,183 @@
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';
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(<MessageForm {...defaultProps} />, { wrapper: appRoot.build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render correctly', async () => {
render(<MessageForm {...defaultProps} />, { wrapper: appRoot.build() });
expect(screen.getByLabelText('Template*')).toBeInTheDocument();
});
it('should render with default values', async () => {
const defaultValues = { templateId: 'template-1' };
render(<MessageForm {...defaultProps} defaultValues={defaultValues} />, { wrapper: appRoot.build() });
expect(screen.getByLabelText('Template*')).toHaveTextContent('Template One');
});
it('should show TemplateEditor when a template is selected', async () => {
render(<MessageForm {...defaultProps} />, { 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(<MessageForm {...defaultProps} onSubmit={handleSubmit} />, { 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(<MessageForm {...defaultProps} onSubmit={handleSubmit} />, { 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(<MessageForm {...defaultProps} templates={[]} onSubmit={handleSubmit} />, { 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(<MessageForm {...defaultProps} templates={[template2]} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
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(<MessageForm {...defaultProps} onSubmit={handleSubmit} defaultValues={defaultValues} />, { 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 }) => <button disabled={isSubmitting}>Custom Action</button>);
render(<MessageForm {...defaultProps} renderActions={renderActions} />, { 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(<MessageForm {...defaultProps} templates={[template1, template2]} defaultValues={defaultValues} onSubmit={jest.fn()} />, {
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('');
});
});

View File

@@ -0,0 +1,107 @@
import type { IOutboundProviderTemplate, Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { Box, Button, FieldGroup, Scrollable } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar';
import type { ReactNode } from 'react';
import { useId, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import TemplateField from './components/TemplateField';
import TemplatePlaceholderField from './components/TemplatePlaceholderField';
import TemplatePreviewForm from './components/TemplatePreviewField';
import type { TemplateParameters } from '../../../../types/template';
import { extractParameterMetadata } from '../../../../utils/template';
import Form from '../../components/OutboundMessageForm';
import { FormFetchError } from '../../utils/errors';
export type MessageFormData = {
templateId: string;
templateParameters: TemplateParameters;
};
export type MessageFormSubmitPayload = {
templateId: string;
template: IOutboundProviderTemplate;
templateParameters: TemplateParameters;
};
export type MessageFormProps = {
contact?: Omit<Serialized<ILivechatContact>, '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: { isSubmitting },
handleSubmit,
setValue,
} = useForm<MessageFormData>({
mode: 'onChange',
defaultValues: {
templateParameters: defaultValues?.templateParameters ?? {},
templateId: defaultValues?.templateId ?? '',
},
});
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 submit = useStableCallback(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') });
}
});
return (
<Form id={messageFormId} onSubmit={handleSubmit(submit)} noValidate>
<Scrollable vertical>
<FieldGroup justifyContent='start' paddingInline={2}>
<TemplateField control={control} templates={templates} onChange={() => setValue('templateParameters', {})} />
{parametersMetadata.map((metadata) => (
<TemplatePlaceholderField key={metadata.id} control={control} metadata={metadata} contact={contact} />
))}
{template ? <TemplatePreviewForm control={control} template={template} /> : null}
</FieldGroup>
</Scrollable>
{customActions ?? (
<Box marginBlockStart={24} display='flex' justifyContent='end'>
<Button type='submit' primary loading={isSubmitting}>
{t('Submit')}
</Button>
</Box>
)}
</Form>
);
};
MessageForm.displayName = 'MessageForm';
export default MessageForm;

View File

@@ -0,0 +1,83 @@
import type { IOutboundProviderTemplate, Serialized } from '@rocket.chat/core-typings';
import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { useId } from 'react';
import type { Control } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { OUTBOUND_DOCS_LINK } from '../../../../../constants';
import TemplateSelect from '../../../../TemplateSelect';
import { cxp } from '../../../utils/cx';
import type { MessageFormData } from '../MessageForm';
export type TemplateFieldProps = ComponentProps<typeof Field> & {
control: Control<MessageFormData>;
templates: Serialized<IOutboundProviderTemplate>[] | undefined;
onChange?: (templateId: string) => void;
};
const TemplateField = ({ control, templates, onChange: onChangeExternal, ...props }: TemplateFieldProps) => {
const { t } = useTranslation();
const templateFieldId = useId();
const {
field: templateField,
fieldState: { error: templateFieldError },
} = useController({
control,
name: 'templateId',
rules: {
validate: {
// NOTE: The order of these validations matters
templatesNotFound: () => (!templates?.length ? t('No_templates_available') : true),
templateNotFound: (templateId) =>
templateId && !templates?.some((template) => template.id === templateId)
? t('Error_loading__name__information', { name: t('template') })
: true,
required: (value) => (!value?.trim() ? t('Required_field', { field: t('Template_message') }) : true),
},
},
});
const handleTemplateChange = useStableCallback((value: string) => {
onChangeExternal?.(value);
templateField.onChange(value);
});
return (
<Field {...props}>
<FieldLabel is='span' required id={templateFieldId}>
{t('Template')}
</FieldLabel>
<FieldRow>
<TemplateSelect
aria-labelledby={templateFieldId}
aria-invalid={!!templateFieldError}
aria-describedby={cxp(templateFieldId, {
error: !!templateFieldError,
hint: true,
})}
placeholder={t('Select_template')}
error={templateFieldError?.message}
templates={templates || []}
value={templateField.value}
onChange={handleTemplateChange}
/>
</FieldRow>
{templateFieldError && (
<FieldError aria-live='assertive' id={`${templateFieldId}-error`}>
{templateFieldError.message}
</FieldError>
)}
<FieldHint id={`${templateFieldId}-hint`}>
<a href={OUTBOUND_DOCS_LINK} target='_blank' rel='noopener noreferrer'>
{t('Learn_more')}
</a>
</FieldHint>
</Field>
);
};
export default TemplateField;

View File

@@ -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 '../../../../../types/template';
import TemplatePlaceholderInput from '../../../../TemplatePlaceholderSelector';
import type { MessageFormData } from '../MessageForm';
export type TemplatePlaceholderFieldProps = ComponentProps<typeof Field> & {
control: Control<MessageFormData>;
metadata: TemplateParameterMetadata;
contact?: Omit<Serialized<ILivechatContact>, '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 (
<Field key={id} {...props}>
<FieldLabel required htmlFor={id}>
{fieldLabel}
</FieldLabel>
<FieldRow>
<TemplatePlaceholderInput
id={id}
type={type}
name={field.name}
contact={contact}
aria-describedby={error ? `${id}-error` : undefined}
value={field.value?.value ?? ''}
error={error?.message}
onChange={(value) => field.onChange({ type, value, format })}
/>
</FieldRow>
{error ? (
<FieldError aria-live='assertive' id={`${id}-error`}>
{error.message}
</FieldError>
) : null}
</Field>
);
};
export default TemplatePlaceholderField;

View File

@@ -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';
export type TemplatePreviewFieldProps = {
control: Control<MessageFormData>;
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 <TemplatePreview template={template} parameters={templateParameters} />;
};
export default TemplatePreviewField;

View File

@@ -0,0 +1,2 @@
export { default } from './MessageForm';
export * from './MessageForm';

View File

@@ -0,0 +1,484 @@
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';
import type { OmnichannelContextValue } from '../../../../../../../../contexts/OmnichannelContext';
import { OmnichannelContext } from '../../../../../../../../contexts/OmnichannelContext';
const recipientOnePhoneNumber = '+12125554567';
const recipientTwoPhoneNumber = '+12125557788';
const senderOnePhoneNumber = '+12127774567';
const senderTwoPhoneNumber = '+12127778877';
const contactOneMock = createFakeContactWithManagerData({
_id: 'contact-1',
name: 'Contact 1',
phones: [{ phoneNumber: recipientOnePhoneNumber }],
unknown: false,
channels: [
createFakeContactChannel({
name: 'provider-1',
lastChat: { _id: '', ts: new Date().toISOString() },
}),
],
});
const contactTwoMock = createFakeContactWithManagerData({
_id: 'contact-2',
name: 'Contact 2',
phones: [{ phoneNumber: recipientTwoPhoneNumber }],
unknown: false,
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</1>',
No_phone_number_available_for_selected_channel: 'No phone number available for the selected channel',
Submit: 'Submit',
})
.wrap((children) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 28 }}>
<OmnichannelContext.Provider value={{ enabled: true } as OmnichannelContextValue}>{children}</OmnichannelContext.Provider>
</VirtuosoMockContext.Provider>
));
describe('RecipientForm', () => {
const defaultProps = {
onDirty: jest.fn(),
onSubmit: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly with all required fields', async () => {
render(<RecipientForm {...defaultProps} />, { 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(<RecipientForm {...defaultProps} />, { 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(<RecipientForm defaultValues={defaultValues} {...defaultProps} />, { 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(<RecipientForm {...defaultProps} />, { 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(<RecipientForm {...defaultProps} defaultValues={defaultValues} />, { 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(<RecipientForm {...defaultProps} defaultValues={defaultValues} />, { 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(<RecipientForm defaultValues={{ contactId: 'contact-1' }} {...defaultProps} />, { 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 show retry button when contact is unknown', async () => {
getContactMock.mockImplementationOnce(() => ({ contact: createFakeContactWithManagerData({ _id: 'contact-1', unknown: true }) }));
render(<RecipientForm defaultValues={{ contactId: 'contact-1' }} {...defaultProps} />, { 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(<RecipientForm defaultValues={defaultValues} {...defaultProps} />, { 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(<RecipientForm defaultValues={{ contactId: 'contact-1', providerId: 'provider-1' }} {...defaultProps} />, {
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(<RecipientForm defaultValues={{ contactId: 'contact-1', providerId: 'provider-1' }} {...defaultProps} />, {
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(<RecipientForm defaultValues={{ contactId: 'contact-1' }} {...defaultProps} />, {
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(<RecipientForm defaultValues={{ contactId: 'contact-1', providerId: 'provider-1' }} {...defaultProps} />, {
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(<RecipientForm defaultValues={{ contactId: 'contact-1', providerId: 'provider-1' }} {...defaultProps} />, {
wrapper: appRoot.build(),
});
await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveAccessibleDescription('Last contact less than a minute 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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
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(<RecipientForm {...defaultProps} onSubmit={handleSubmit} />, {
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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onDirty={mockOnDirty} />, {
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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={jest.fn()} />, {
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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={jest.fn()} />, {
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(<RecipientForm {...defaultProps} onSubmit={handleSubmit} />, {
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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
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(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
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());
});
});

View File

@@ -0,0 +1,229 @@
import type { IOutboundProviderMetadata, Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { Box, Button, FieldGroup, Scrollable } from '@rocket.chat/fuselage';
import { useStableCallback } 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 Form from '../../components/OutboundMessageForm';
import { ContactNotFoundError, ProviderNotFoundError } from '../../utils/errors';
export type RecipientFormData = {
contactId: string;
providerId: string;
recipient: string;
sender: string;
};
export type RecipientFormSubmitPayload = {
contactId: string;
contact: Serialized<ILivechatContact>;
providerId: string;
provider: IOutboundProviderMetadata;
recipient: string;
sender: string;
};
export type RecipientFormProps = {
defaultValues?: Partial<RecipientFormData>;
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<RecipientFormData>({
mode: '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: async () => {
const data = await getContact({ contactId });
// TODO: Can be safely removed once unknown contacts handling is added to the endpoint
if (data?.contact && data.contact.unknown) {
throw new ContactNotFoundError();
}
return data;
},
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 = useStableCallback((shouldValidate = false) => {
trigger('contactId');
setValue('recipient', '', { shouldValidate });
});
const validateProviderField = useStableCallback((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 = useStableCallback(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') });
}
});
return (
<Form id={recipientFormId} onSubmit={handleSubmit(submit)} noValidate>
<Scrollable vertical>
<FieldGroup justifyContent='start' paddingInline={2}>
<ContactField
control={control}
isError={isErrorContact || isContactNotFound}
isFetching={isFetchingContact}
onRetry={refetchContact}
/>
<ChannelField
control={control}
contact={contact}
disabled={!contactId}
isFetching={isFetchingProvider}
isError={isErrorProvider || isProviderNotFound}
onRetry={refetchProvider}
/>
<RecipientField
control={control}
contact={contact}
type={provider?.providerType || 'phone'}
disabled={!providerId || !contact}
isLoading={isFetchingContact}
/>
<SenderField control={control} provider={provider} disabled={!recipient || !provider} isLoading={isFetchingProvider} />
</FieldGroup>
</Scrollable>
{customActions ?? (
<Box marginBlockStart={24} display='flex' justifyContent='end' flexGrow={0} flexShrink={0}>
<Button type='submit' primary loading={isSubmitting}>
{t('Submit')}
</Button>
</Box>
)}
</Form>
);
};
export default RecipientForm;

View File

@@ -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';
export type ProviderFieldProps = ComponentProps<typeof Field> & {
control: Control<RecipientFormData>;
contact?: Omit<Serialized<ILivechatContact>, '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 (
<Field {...props}>
<FieldLabel is='span' required id={channelFieldId}>
{t('Channel')}
</FieldLabel>
<FieldRow>
<AutoCompleteOutboundProvider
contact={contact}
aria-labelledby={channelFieldId}
aria-invalid={!!providerFieldError}
aria-describedby={cxp(channelFieldId, {
error: !!providerFieldError,
hint: !!providerLastChat,
})}
error={providerFieldError?.message}
disabled={disabled}
placeholder={t('Select_channel')}
value={providerField.value}
onChange={providerField.onChange}
/>
</FieldRow>
{providerFieldError && (
<FieldError aria-live='assertive' id={`${channelFieldId}-error`} display='flex' alignItems='center'>
{providerFieldError.message}
{isError && <RetryButton loading={isFetching} onClick={onRetry} />}
</FieldError>
)}
{providerLastChat && (
<FieldHint id={`${channelFieldId}-hint`}>{t('Last_contact__time__', { time: getTimeFromNow(providerLastChat) })}</FieldHint>
)}
</Field>
);
};
export default ProviderField;

View File

@@ -0,0 +1,85 @@
import { Box, Field, FieldError, FieldLabel, FieldRow, Option, OptionContent, OptionDescription } from '@rocket.chat/fuselage';
import { useStableCallback } 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';
export type ContactFieldProps = ComponentProps<typeof Field> & {
control: Control<RecipientFormData>;
isError: boolean;
isFetching: boolean;
onRetry: () => void;
};
type RenderFnType = Required<ComponentProps<typeof AutoCompleteContact>>['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 = useStableCallback<RenderFnType>(({ label, ...props }, { phones }) => {
const phoneList = phones?.map((p) => formatPhoneNumber(p.phoneNumber)).join(', ');
return (
<Option {...props} avatar={<UserAvatar title={label} username={label} size='x20' />}>
<Box is={OptionContent} marginInlineEnd='auto' flexGrow={0} flexShrink={0} flexBasis={0}>
{label}
</Box>
{phones?.length ? (
<Box withTruncatedText flexGrow={0} flexShrink={1} title={phoneList}>
<OptionDescription>{`(${phoneList})`}</OptionDescription>
</Box>
) : null}
</Option>
);
});
return (
<Field {...props}>
<FieldLabel is='span' required id={contactFieldId}>
{t('Contact')}
</FieldLabel>
<FieldRow>
<AutoCompleteContact
aria-labelledby={contactFieldId}
aria-describedby={contactFieldError && `${contactFieldId}-error`}
aria-invalid={!!contactFieldError}
placeholder={t('Select_recipient')}
value={contactField.value}
onChange={contactField.onChange}
error={contactFieldError?.message}
renderItem={renderContactOption}
/>
</FieldRow>
{contactFieldError && (
<FieldError aria-live='assertive' id={`${contactFieldId}-error`} display='flex' alignItems='center'>
{contactFieldError.message}
{isError && <RetryButton loading={isFetching} onClick={onRetry} />}
</FieldError>
)}
</Field>
);
};
export default ContactField;

View File

@@ -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';
export type RecipientFieldProps = ComponentProps<typeof Field> & {
control: Control<RecipientFormData>;
contact?: Omit<Serialized<ILivechatContact>, '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 (
<Field {...props}>
<FieldLabel is='span' required id={`${recipientFieldId}`}>
{t('To')}
</FieldLabel>
<FieldRow>
<RecipientSelect
contact={contact}
type={type}
aria-busy={isLoading}
aria-labelledby={recipientFieldId}
aria-describedby={recipientFieldError && `${recipientFieldId}-error`}
aria-invalid={!!recipientFieldError}
error={recipientFieldError && 'error'}
placeholder={isLoading ? t('Loading...') : t('Contact_detail')}
value={recipientField.value}
disabled={disabled || isLoading}
onChange={recipientField.onChange}
/>
</FieldRow>
{recipientFieldError?.type === 'required' && (
<FieldError aria-live='assertive' id={`${recipientFieldId}-error`}>
{recipientFieldError.message}
</FieldError>
)}
{recipientFieldError?.type === 'noPhoneNumber' && (
<FieldError aria-live='assertive' id={`${recipientFieldId}-error`}>
<Trans i18nKey='No_phone_number_yet_edit_contact'>
No phone number yet <a href={`/omnichannel-directory/contacts/edit/${contact?._id}`}>Edit contact</a>
</Trans>
</FieldError>
)}
</Field>
);
};
export default RecipientField;

View File

@@ -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';
export type SenderFieldProps = ComponentProps<typeof Field> & {
control: Control<RecipientFormData>;
provider?: Serialized<IOutboundProviderMetadata>;
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 (
<Field {...props}>
<FieldLabel is='span' required id={senderFieldId}>
{t('From')}
</FieldLabel>
<FieldRow>
<SenderSelect
provider={provider}
aria-busy={isLoading}
aria-labelledby={senderFieldId}
aria-describedby={senderFieldError && `${senderFieldId}-error`}
aria-invalid={!!senderFieldError}
error={senderFieldError?.message}
disabled={disabled || isLoading}
placeholder={isLoading ? t('Loading...') : t('Workspace_detail')}
value={senderField.value}
onChange={senderField.onChange}
/>
</FieldRow>
{senderFieldError && (
<FieldError aria-live='assertive' id={`${senderFieldId}-error`}>
{senderFieldError.message}
</FieldError>
)}
</Field>
);
};
export default SenderField;

View File

@@ -0,0 +1,2 @@
export { default } from './RecipientForm';
export * from './RecipientForm';

View File

@@ -0,0 +1,268 @@
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';
const mockDepartment = createFakeDepartment({
_id: 'department-1',
name: 'Department 1',
});
const mockUser = createFakeUser({
_id: 'agent-1',
username: 'agent.one',
name: 'Agent One',
roles: ['livechat-agent'],
});
const mockAgentOne: Serialized<ILivechatAgent> = {
_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<ILivechatAgent> = {
_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) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 28 }}>{children}</VirtuosoMockContext.Provider>
));
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(<RepliesForm onSubmit={jest.fn()} />, { wrapper: appRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders correctly with all fields', async () => {
render(<RepliesForm onSubmit={jest.fn()} />, { 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(<RepliesForm defaultValues={defaultValues} onSubmit={jest.fn()} />, {
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(<RepliesForm onSubmit={jest.fn()} />, { 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(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={jest.fn()} />, {
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(<RepliesForm defaultValues={defaultValues} onSubmit={handleSubmit} />, {
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(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={handleSubmit} />, { 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(<RepliesForm defaultValues={{ departmentId: 'department-1', agentId: 'agent-1' }} onSubmit={handleSubmit} />, {
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(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={jest.fn()} />, {
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(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={jest.fn()} />, {
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(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={jest.fn()} />, {
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();
});
});

View File

@@ -0,0 +1,154 @@
import type { Serialized, ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings';
import { Box, Button, FieldGroup, Scrollable } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar';
import { useEndpoint, usePermission, useUser } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEffect, useId, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import AgentField from './components/AgentField';
import DepartmentField from './components/DepartmentField';
import { useAllowedAgents } from './hooks/useAllowedAgents';
import { omnichannelQueryKeys } from '../../../../../../../../lib/queryKeys';
import Form from '../../components/OutboundMessageForm';
import { FormFetchError } from '../../utils/errors';
export type RepliesFormData = {
departmentId: string;
agentId: string;
};
export type RepliesFormSubmitPayload = {
departmentId?: string;
department?: Serialized<ILivechatDepartment>;
agentId?: string;
agent?: Serialized<ILivechatDepartmentAgents>;
};
export type RepliesFormRef = {
submit: () => Promise<RepliesFormSubmitPayload>;
};
export type RepliesFormProps = {
defaultValues?: Partial<RepliesFormData>;
renderActions?(props: { isSubmitting: boolean }): ReactNode;
onSubmit: (data: RepliesFormSubmitPayload) => void;
};
const RepliesForm = (props: RepliesFormProps) => {
const { defaultValues, renderActions, onSubmit } = props;
const dispatchToastMessage = useToastBarDispatch();
const { t } = useTranslation();
const repliesFormId = useId();
const user = useUser();
const canAssignAllDepartments = usePermission('outbound.can-assign-queues');
const canAssignSelfOnlyAgent = usePermission('outbound.can-assign-self-only');
const canAssignAnyAgent = usePermission('outbound.can-assign-any-agent');
const {
control,
formState: { isSubmitting },
trigger,
clearErrors,
handleSubmit,
setValue,
} = useForm<RepliesFormData>({ defaultValues });
const customActions = useMemo(() => renderActions?.({ isSubmitting }), [isSubmitting, renderActions]);
const departmentId = useWatch({ control, name: 'departmentId' });
const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: departmentId ?? '' });
const {
data: { department, agents: queryAgents = [] } = {},
isError: isErrorDepartment,
isFetching: isFetchingDepartment,
refetch: refetchDepartment,
} = useQuery({
queryKey: omnichannelQueryKeys.department(departmentId),
queryFn: () => getDepartment({ onlyMyDepartments: !canAssignAllDepartments ? 'true' : 'false' }),
enabled: !!departmentId,
});
const agents = useAllowedAgents({
user,
queryAgents,
departmentId: department?._id,
canAssignSelfOnlyAgent,
canAssignAnyAgent,
});
useEffect(() => {
isErrorDepartment && trigger('departmentId');
return () => clearErrors('departmentId');
}, [clearErrors, isErrorDepartment, trigger]);
const submit = useStableCallback(async ({ agentId, departmentId }: RepliesFormData) => {
try {
const agent = agents?.find((agent) => agent.agentId === agentId);
// Wait if department or agent is still being fetched in background
const updatedDepartment =
departmentId && isFetchingDepartment ? await refetchDepartment().then((r) => r.data?.department) : department;
if (departmentId && !updatedDepartment) {
throw new FormFetchError('error-department-not-found');
}
if (agentId && !agent) {
throw new FormFetchError('error-agent-not-found');
}
onSubmit({ departmentId, department: updatedDepartment, agentId, agent });
} catch (error) {
if (error instanceof FormFetchError) {
trigger();
return;
}
dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') });
}
});
return (
<Form id={repliesFormId} onSubmit={handleSubmit(submit)} noValidate>
<Scrollable vertical>
<FieldGroup justifyContent='start' paddingInline={2}>
<DepartmentField
control={control}
onlyMyDepartments={!canAssignAllDepartments}
isError={isErrorDepartment}
isFetching={isFetchingDepartment}
onRefetch={refetchDepartment}
onChange={() => setValue('agentId', '')}
/>
<AgentField
control={control}
agents={agents}
canAssignAgent={canAssignAnyAgent || canAssignSelfOnlyAgent}
disabled={!departmentId}
isLoading={isFetchingDepartment}
/>
</FieldGroup>
</Scrollable>
{customActions ?? (
<Box marginBlockStart={24} display='flex' justifyContent='end'>
<Button type='submit' primary loading={isSubmitting}>
{t('Submit')}
</Button>
</Box>
)}
</Form>
);
};
RepliesForm.displayName = 'RepliesForm';
export default RepliesForm;

View File

@@ -0,0 +1,66 @@
import type { ILivechatDepartmentAgents, Serialized } from '@rocket.chat/core-typings';
import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useId } from 'react';
import type { Control } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import AutoCompleteAgent from '../../../../AutoCompleteDepartmentAgent';
import { cxp } from '../../../utils/cx';
import type { RepliesFormData } from '../RepliesForm';
export type AgentFieldProps = ComponentProps<typeof Field> & {
control: Control<RepliesFormData>;
agents: Serialized<ILivechatDepartmentAgents>[];
canAssignAgent?: boolean;
disabled?: boolean;
isLoading?: boolean;
};
const AgentField = ({ control, agents, canAssignAgent, disabled = false, isLoading = false, ...props }: AgentFieldProps) => {
const { t } = useTranslation();
const agentFieldId = useId();
const {
field: agentField,
fieldState: { error: agentFieldError },
} = useController({
control,
name: 'agentId',
});
return (
<Field {...props}>
<FieldLabel htmlFor={agentFieldId}>{`${t('Agent')} (${t('optional')})`}</FieldLabel>
<FieldRow>
<AutoCompleteAgent
name={agentField.name}
aria-busy={isLoading}
aria-invalid={!!agentFieldError}
aria-describedby={cxp(agentFieldId, {
error: !!agentFieldError,
hint: true,
})}
error={!!agentFieldError}
id={agentFieldId}
agents={agents}
placeholder={isLoading ? t('Loading...') : t('Select_agent')}
disabled={disabled || isLoading || !canAssignAgent}
value={agentField.value}
onChange={agentField.onChange}
/>
</FieldRow>
{agentFieldError && (
<FieldError aria-live='assertive' id={`${agentFieldId}-error`} display='flex' alignItems='center'>
{agentFieldError.message}
</FieldError>
)}
<FieldHint id={`${agentFieldId}-hint`}>
{canAssignAgent ? t('Outbound_message_agent_hint') : t('Outbound_message_agent_hint_no_permission')}
</FieldHint>
</Field>
);
};
export default AgentField;

View File

@@ -0,0 +1,83 @@
import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { useId } from 'react';
import type { Control } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import AutoCompleteDepartment from '../../../../../../AutoCompleteDepartment';
import RetryButton from '../../../components/RetryButton';
import { cxp } from '../../../utils/cx';
import type { RepliesFormData } from '../RepliesForm';
export type DepartmentFieldProps = ComponentProps<typeof Field> & {
control: Control<RepliesFormData>;
onlyMyDepartments?: boolean;
isError: boolean;
isFetching: boolean;
onRefetch: () => void;
onChange: () => void;
};
const DepartmentField = ({
control,
onlyMyDepartments,
isError,
isFetching,
onRefetch,
onChange: onChangeExternal,
...props
}: DepartmentFieldProps) => {
const { t } = useTranslation();
const departmentFieldId = useId();
const {
field: departmentField,
fieldState: { error: departmentFieldError },
} = useController({
control,
name: 'departmentId',
rules: {
validate: () => (isError ? t('Error_loading__name__information', { name: t('department') }) : true),
},
});
const handleDepartmentChange = useStableCallback((onChange: (value: string) => void) => {
return (value: string) => {
onChangeExternal();
onChange(value);
};
});
return (
<Field {...props}>
<FieldLabel is='span' id={departmentFieldId}>{`${t('Department')} (${t('optional')})`}</FieldLabel>
<FieldRow>
<AutoCompleteDepartment
name={departmentField.name}
aria-invalid={!!departmentFieldError}
aria-labelledby={departmentFieldId}
aria-describedby={cxp(departmentFieldId, {
error: !!departmentFieldError,
hint: true,
})}
error={departmentFieldError?.message}
placeholder={t('Select_department')}
onlyMyDepartments={onlyMyDepartments}
value={departmentField.value}
onChange={handleDepartmentChange(departmentField.onChange)}
/>
</FieldRow>
{departmentFieldError && (
<FieldError aria-live='assertive' id={`${departmentFieldId}-error`} display='flex' alignItems='center'>
{departmentFieldError.message}
{isError && <RetryButton loading={isFetching} onClick={onRefetch} />}
</FieldError>
)}
<FieldHint id={`${departmentFieldId}-hint`}>{t('Outbound_message_department_hint')}</FieldHint>
</Field>
);
};
export default DepartmentField;

View File

@@ -0,0 +1,169 @@
import type { ILivechatDepartmentAgents, IUser, Serialized } from '@rocket.chat/core-typings';
import { renderHook } from '@testing-library/react';
import { useAllowedAgents } from './useAllowedAgents';
import { createFakeUser } from '../../../../../../../../../../tests/mocks/data';
import { getAgentDerivedFromUser } from '../utils/getAgentDerivedFromUser';
jest.mock('../utils/getAgentDerivedFromUser', () => ({
getAgentDerivedFromUser: jest.fn(),
}));
const getAgentDerivedFromUserMocked = jest.mocked(getAgentDerivedFromUser);
const mockUser: IUser = createFakeUser({
_id: 'test-user-id',
username: 'testuser',
roles: ['livechat-agent'],
});
const mockQueryAgents: Serialized<ILivechatDepartmentAgents>[] = [
{
agentId: 'agent1',
username: 'agent.one',
count: 1,
order: 1,
_id: 'agent1',
_updatedAt: new Date().toISOString(),
departmentEnabled: true,
departmentId: 'dept1',
},
{
agentId: 'agent2',
username: 'agent.two',
count: 2,
order: 2,
_id: 'agent2',
_updatedAt: new Date().toISOString(),
departmentEnabled: true,
departmentId: 'dept1',
},
];
const mockDerivedAgent: Serialized<ILivechatDepartmentAgents> = {
agentId: 'test-user-id',
username: 'testuser',
_id: 'test-user-id',
_updatedAt: new Date().toISOString(),
departmentId: 'department-1',
departmentEnabled: true,
count: 0,
order: 0,
};
describe('useAllowedAgents', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return an empty array if no departmentId is provided', () => {
const { result } = renderHook(() =>
useAllowedAgents({
user: mockUser,
departmentId: undefined,
canAssignSelfOnlyAgent: true,
canAssignAnyAgent: true,
queryAgents: mockQueryAgents,
}),
);
expect(result.current).toEqual([]);
});
it('should return an empty array if user has no assignment permissions', () => {
const { result } = renderHook(() =>
useAllowedAgents({
user: mockUser,
departmentId: 'department-1',
canAssignSelfOnlyAgent: false,
canAssignAnyAgent: false,
queryAgents: mockQueryAgents,
}),
);
expect(result.current).toEqual([]);
});
it('should return queryAgents if canAssignAnyAgent is true and queryAgents has items', () => {
const { result } = renderHook(() =>
useAllowedAgents({
user: mockUser,
departmentId: 'department-1',
canAssignSelfOnlyAgent: false,
canAssignAnyAgent: true,
queryAgents: mockQueryAgents,
}),
);
expect(result.current).toEqual(mockQueryAgents);
});
it('should return derived agent if canAssignSelfOnlyAgent is true', () => {
getAgentDerivedFromUserMocked.mockReturnValue(mockDerivedAgent);
const { result } = renderHook(() =>
useAllowedAgents({
user: mockUser,
departmentId: 'department-1',
canAssignSelfOnlyAgent: true,
canAssignAnyAgent: false,
queryAgents: mockQueryAgents,
}),
);
expect(getAgentDerivedFromUserMocked).toHaveBeenCalledWith(mockUser, 'department-1');
expect(result.current).toEqual([mockDerivedAgent]);
});
it('should return derived agent if canAssignAnyAgent is true but queryAgents is empty', () => {
getAgentDerivedFromUserMocked.mockReturnValue(mockDerivedAgent);
const { result } = renderHook(() =>
useAllowedAgents({
user: mockUser,
departmentId: 'department-1',
canAssignSelfOnlyAgent: false,
canAssignAnyAgent: true,
queryAgents: [],
}),
);
expect(getAgentDerivedFromUserMocked).toHaveBeenCalledWith(mockUser, 'department-1');
expect(result.current).toEqual([mockDerivedAgent]);
});
it('should return derived agent if canAssignAnyAgent is true but queryAgents is undefined', () => {
getAgentDerivedFromUserMocked.mockReturnValue(mockDerivedAgent);
const { result } = renderHook(() =>
useAllowedAgents({
user: mockUser,
departmentId: 'department-1',
canAssignSelfOnlyAgent: false,
canAssignAnyAgent: true,
queryAgents: undefined,
}),
);
expect(getAgentDerivedFromUserMocked).toHaveBeenCalledWith(mockUser, 'department-1');
expect(result.current).toEqual([mockDerivedAgent]);
});
it('should return an empty array if getAgentDerivedFromUser throws an error', () => {
getAgentDerivedFromUserMocked.mockImplementation(() => {
throw new Error('User not found');
});
const { result } = renderHook(() =>
useAllowedAgents({
user: null,
departmentId: 'department-1',
canAssignSelfOnlyAgent: true,
canAssignAnyAgent: false,
queryAgents: [],
}),
);
expect(result.current).toEqual([]);
});
});

View File

@@ -0,0 +1,37 @@
import type { Serialized, ILivechatDepartmentAgents, IUser } from '@rocket.chat/core-typings';
import { useMemo } from 'react';
import { getAgentDerivedFromUser } from '../utils/getAgentDerivedFromUser';
type UseAllowedAgentsProps = {
user: IUser | null;
departmentId: string | undefined;
canAssignSelfOnlyAgent: boolean;
canAssignAnyAgent: boolean;
queryAgents: Serialized<ILivechatDepartmentAgents>[] | undefined;
};
export const useAllowedAgents = ({ user, departmentId, queryAgents, canAssignSelfOnlyAgent, canAssignAnyAgent }: UseAllowedAgentsProps) =>
useMemo(() => {
// no department selected, no agents
if (!departmentId) {
return [];
}
// no permission to assign any agents, no agents
if (!canAssignSelfOnlyAgent && !canAssignAnyAgent) {
return [];
}
// can assign any agent, return all agents from query (if any)
if (canAssignAnyAgent && queryAgents?.length) {
return queryAgents;
}
try {
// all other cases, attempt to derive agent from user
return [getAgentDerivedFromUser(user, departmentId)];
} catch {
return [];
}
}, [canAssignAnyAgent, canAssignSelfOnlyAgent, user, departmentId, queryAgents]);

View File

@@ -0,0 +1,2 @@
export { default } from './RepliesForm';
export * from './RepliesForm';

View File

@@ -0,0 +1,39 @@
import type { IUser } from '@rocket.chat/core-typings';
import { getAgentDerivedFromUser } from './getAgentDerivedFromUser';
import { createFakeUser } from '../../../../../../../../../../tests/mocks/data';
describe('getAgentDerivedFromUser', () => {
it('should throw an error if the user is null', () => {
expect(() => getAgentDerivedFromUser(null, 'any-department-id')).toThrow('User is not a livechat agent');
});
it('should throw an error if the user is not a livechat agent', () => {
const user: IUser = createFakeUser({ roles: ['user'] });
expect(() => getAgentDerivedFromUser(user, 'any-department-id')).toThrow('User is not a livechat agent');
});
it('should return a valid agent object if the user is a livechat agent', () => {
const user: IUser = createFakeUser({
_id: 'agentId123',
username: 'agentusername',
roles: ['livechat-agent'],
});
const departmentId = 'department123';
const agent = getAgentDerivedFromUser(user, departmentId);
expect(agent).toEqual({
agentId: user._id,
username: user.username,
_id: user._id,
_updatedAt: expect.any(String),
departmentId,
departmentEnabled: true,
count: 0,
order: 0,
});
});
});

View File

@@ -0,0 +1,20 @@
import type { ILivechatDepartmentAgents, Serialized, IUser } from '@rocket.chat/core-typings';
const isOmnichannelAgent = (user: IUser | null): user is IUser => (user ? user.roles.includes('livechat-agent') : false);
export const getAgentDerivedFromUser = (user: IUser | null, departmentId: string): Serialized<ILivechatDepartmentAgents> => {
if (!isOmnichannelAgent(user)) {
throw new Error('User is not a livechat agent');
}
return {
agentId: user._id,
username: user.username || '',
_id: user._id,
_updatedAt: new Date().toISOString(),
departmentId,
departmentEnabled: true,
count: 0,
order: 0,
};
};

View File

@@ -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';

View File

@@ -0,0 +1 @@
export { default } from './OutboundMessageWizard';

View File

@@ -0,0 +1,129 @@
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 <div data-testid='message-form'>{props.renderActions?.({ isSubmitting })}</div>;
});
jest.mock('../forms/MessageForm', () => ({
__esModule: true,
default: (props: ComponentProps<typeof MessageForm>) => 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 <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
});
describe('MessageStep', () => {
beforeEach(() => {
currentOnSubmit = () => undefined;
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { 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(<MessageStep contact={contact} templates={templates} defaultValues={defaultValues} onSubmit={jest.fn()} />, {
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(<MessageStep onSubmit={jest.fn()} />, { 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(<MessageStep onSubmit={handleSubmit} />, { 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(<MessageStep onSubmit={onSubmit} />, { wrapper: appRoot.build() });
act(() => currentOnSubmit(expectedPayload));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expectedPayload));
await waitFor(() => expect(mockWizardApi.next).toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,72 @@
import { Box } from '@rocket.chat/fuselage';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
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 = {
component: MessageStep,
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: (Story) => (
<WizardContext.Provider value={mockWizardApi}>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</WizardContext.Provider>
),
} satisfies Meta<typeof MessageStep>;
export default meta;
type Story = StoryObj<typeof meta>;
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' },
],
},
},
},
};

View File

@@ -0,0 +1,36 @@
import { useStableCallback } 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';
export type MessageStepProps = Omit<ComponentProps<typeof MessageForm>, 'onSubmit'> & {
onSubmit(values: MessageFormSubmitPayload): void;
};
const MessageStep = ({ contact, templates, defaultValues, onSubmit }: MessageStepProps) => {
const { next } = useWizardContext();
const handleSubmit = useStableCallback(async (values: MessageFormSubmitPayload) => {
onSubmit(values);
next();
});
return (
<MessageForm
contact={contact}
templates={templates}
defaultValues={defaultValues}
onSubmit={handleSubmit}
renderActions={({ isSubmitting }) => (
<WizardActions>
<WizardBackButton />
<WizardNextButton manual type='submit' loading={isSubmitting} />
</WizardActions>
)}
/>
);
};
export default MessageStep;

View File

@@ -0,0 +1,107 @@
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 <div data-testid='recipient-form'>{props.renderActions?.({ isSubmitting })}</div>;
});
jest.mock('../forms/RecipientForm', () => ({
__esModule: true,
default: (props: ComponentProps<typeof RecipientForm>) => 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 <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
});
describe('RecipientStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { 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(<RecipientStep onSubmit={jest.fn()} />, { 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(<RecipientStep onSubmit={jest.fn()} />);
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(<RecipientStep onSubmit={onSubmit} />, { wrapper: appRoot.build() });
act(() => currentOnSubmit(expectedPayload));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expectedPayload));
});
});

View File

@@ -0,0 +1,97 @@
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
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 = {
component: RecipientStep,
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: [
(Story) => (
<AppRoot>
<WizardContext.Provider value={mockWizardApi}>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</WizardContext.Provider>
</AppRoot>
),
],
} satisfies Meta<typeof RecipientStep>;
export default meta;
type Story = StoryObj<typeof meta>;
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,
},
},
};

View File

@@ -0,0 +1,35 @@
import { useStableCallback } 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';
export type RecipientStepProps = {
defaultValues?: Partial<RecipientFormData>;
onDirty?: () => void;
onSubmit(values: RecipientFormSubmitPayload): void;
};
const RecipientStep = ({ defaultValues, onDirty, onSubmit }: RecipientStepProps) => {
const { next } = useWizardContext();
const handleSubmit = useStableCallback((values: RecipientFormSubmitPayload) => {
onSubmit(values);
next();
});
return (
<RecipientForm
defaultValues={defaultValues}
onDirty={onDirty}
onSubmit={handleSubmit}
renderActions={({ isSubmitting }) => (
<WizardActions>
<WizardNextButton manual type='submit' loading={isSubmitting} />
</WizardActions>
)}
/>
);
};
export default RecipientStep;

View File

@@ -0,0 +1,123 @@
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<string, unknown>) => void = () => undefined;
const mockRepliesForm = jest.fn().mockImplementation((props) => {
currentOnSubmit = props.onSubmit;
return <div data-testid='replies-form'>{props.renderActions?.({ isSubmitting })}</div>;
});
jest.mock('../forms/RepliesForm', () => ({
__esModule: true,
default: (props: ComponentProps<typeof RepliesForm>) => 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 <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
});
describe('RepliesStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { 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(<RepliesStep defaultValues={defaultValues} onSubmit={jest.fn()} />, {
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(<RepliesStep defaultValues={defaultValues} onSubmit={jest.fn()} />, {
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(<RepliesStep onSubmit={onSubmit} />, { 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(<RepliesStep onSubmit={jest.fn()} />, { 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(<RepliesStep onSubmit={jest.fn()} />, { wrapper: appRoot.build() });
const nextButton = screen.getByRole('button', { name: 'Next' });
await userEvent.click(nextButton);
expect(nextButton).toBeDisabled();
isSubmitting = false;
rerender(<RepliesStep onSubmit={jest.fn()} />);
await waitFor(() => expect(nextButton).not.toBeDisabled());
});
});

View File

@@ -0,0 +1,83 @@
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 type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
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 = {
component: RepliesStep,
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: [
(Story) => (
<AppRoot>
<WizardContext.Provider value={mockWizardApi}>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</WizardContext.Provider>
</AppRoot>
),
],
} satisfies Meta<typeof RepliesStep>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
defaultValues: {},
onSubmit: action('onSubmit'),
},
};
export const WithDefaultValues: Story = {
args: {
onSubmit: action('onSubmit'),
defaultValues: {
departmentId: mockDepartment._id,
agentId: mockAgent._id,
},
},
};

View File

@@ -0,0 +1,34 @@
import { useStableCallback } 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';
export type RepliesStepProps = {
defaultValues?: Partial<RepliesFormData>;
onSubmit(values: RepliesFormSubmitPayload): void;
};
const RepliesStep = ({ defaultValues, onSubmit }: RepliesStepProps) => {
const { next } = useWizardContext();
const handleSubmit = useStableCallback(async (values: RepliesFormSubmitPayload) => {
onSubmit(values);
next();
});
return (
<RepliesForm
defaultValues={defaultValues}
onSubmit={handleSubmit}
renderActions={({ isSubmitting }) => (
<WizardActions>
<WizardBackButton />
<WizardNextButton manual type='submit' loading={isSubmitting} />
</WizardActions>
)}
/>
);
};
export default RepliesStep;

View File

@@ -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) => <div data-testid='outbound-message-preview' {...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()
.withTranslations('en', 'core', {
Send: 'Send',
})
.wrap((children) => {
return <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
})
.build();
describe('ReviewStep', () => {
const onSendMock = jest.fn();
const defaultProps: ComponentProps<typeof ReviewStep> = {
onSend: onSendMock,
};
beforeEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders correctly with OutboundMessagePreview and a send button', () => {
render(<ReviewStep {...defaultProps} />, { 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(<ReviewStep {...defaultProps} />, { wrapper: appRoot });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should call previous step when back button is clicked', async () => {
render(<ReviewStep {...defaultProps} />, { 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(<ReviewStep {...defaultProps} />, { 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(<ReviewStep {...defaultProps} />, { 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(<ReviewStep {...defaultProps} />, { 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());
});
});

View File

@@ -0,0 +1,67 @@
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 = {
component: ReviewStep,
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: [
(Story) => (
<WizardContext.Provider value={mockWizardApi}>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</WizardContext.Provider>
),
],
} satisfies Meta<typeof ReviewStep>;
export default meta;
type Story = StoryObj<typeof meta>;
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(),
},
};

View File

@@ -0,0 +1,35 @@
import { Box, Button, Scrollable } 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';
export type ReviewStepProps = ComponentProps<typeof OutboundMessagePreview> & {
onSend(): Promise<void>;
};
const ReviewStep = ({ onSend, ...props }: ReviewStepProps) => {
const { t } = useTranslation();
const sendMutation = useMutation({ mutationFn: onSend });
return (
<Box display='flex' flexDirection='column' height='100%'>
<Scrollable vertical>
<OutboundMessagePreview {...props} maxHeight={500} />
</Scrollable>
<WizardActions annotation={t('Messages_cannot_be_unsent')}>
<WizardBackButton />
<Button primary icon='send' loading={sendMutation.isPending} onClick={() => sendMutation.mutate()}>
{t('Send')}
</Button>
</WizardActions>
</Box>
);
};
export default ReviewStep;

View File

@@ -0,0 +1,89 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`MessageStep renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-162rsse"
>
<div
data-testid="message-form"
>
<div
class="rcx-box rcx-box--full steps-wizard-footer rcx-css-7vikfg"
>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Back
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="submit"
>
<span
class="rcx-button--content"
>
Next
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</body>
`;
exports[`MessageStep renders WithDefaultValues without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-162rsse"
>
<div
data-testid="message-form"
>
<div
class="rcx-box rcx-box--full steps-wizard-footer rcx-css-7vikfg"
>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Back
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="submit"
>
<span
class="rcx-button--content"
>
Next
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,69 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RecipientStep renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-162rsse"
>
<div
data-testid="recipient-form"
>
<div
class="rcx-box rcx-box--full steps-wizard-footer rcx-css-7vikfg"
>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="submit"
>
<span
class="rcx-button--content"
>
Next
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</body>
`;
exports[`RecipientStep renders WithDefaultValues without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-162rsse"
>
<div
data-testid="recipient-form"
>
<div
class="rcx-box rcx-box--full steps-wizard-footer rcx-css-7vikfg"
>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="submit"
>
<span
class="rcx-button--content"
>
Next
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,89 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RepliesStep renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-162rsse"
>
<div
data-testid="replies-form"
>
<div
class="rcx-box rcx-box--full steps-wizard-footer rcx-css-7vikfg"
>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Back
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="submit"
>
<span
class="rcx-button--content"
>
Next
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</body>
`;
exports[`RepliesStep renders WithDefaultValues without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-162rsse"
>
<div
data-testid="replies-form"
>
<div
class="rcx-box rcx-box--full steps-wizard-footer rcx-css-7vikfg"
>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Back
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="submit"
>
<span
class="rcx-button--content"
>
Next
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,70 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ReviewStep renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-162rsse"
>
<div
class="rcx-box rcx-box--full rcx-css-ogq2n4"
>
<div
agentname="John Doe"
agentusername="johndoe"
contactname="Jane Smith"
data-testid="outbound-message-preview"
departmentname="Support"
maxheight="500"
providername="Rocket.Chat"
providertype="phone"
recipient="+0987654321"
sender="+1234567890"
template="[object Object]"
templateparameters="[object Object]"
/>
<div
class="rcx-box rcx-box--full steps-wizard-footer rcx-css-7vikfg"
>
<div
class="rcx-box rcx-box--full rcx-css-17mntgq"
>
Messages_cannot_be_unsent
</div>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Back
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-send rcx-icon rcx-css-1hdf9ok"
>
〈
</i>
Send
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</body>
`;

View File

@@ -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';

View File

@@ -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, boolean | undefined | null>): 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, boolean | undefined | null>): string => {
return Object.entries(conditionals)
.filter(([_, value]) => Boolean(value))
.map(([key]) => `${prefix}-${key}`)
.join(' ');
};

View File

@@ -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');
}
}

View File

@@ -0,0 +1,31 @@
import type { Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { Select } from '@rocket.chat/fuselage';
import type { ComponentProps, Key } from 'react';
import { useMemo } from 'react';
import { formatPhoneNumber } from '../../../../../lib/formatPhoneNumber';
export type RecipientSelectProps = Omit<ComponentProps<typeof Select>, 'options' | 'onChange' | 'value'> & {
type: 'phone' | 'email';
contact: Serialized<ILivechatContact> | undefined;
value: string;
onChange: (value: Key) => void;
};
const RecipientSelect = ({ contact, type, value, disabled, onChange, ...props }: RecipientSelectProps) => {
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 <Select {...props} aria-disabled={disabled} disabled={disabled} value={value} options={options} onChange={onChange} />;
};
export default RecipientSelect;

View File

@@ -0,0 +1,25 @@
import type { IOutboundProviderMetadata } from '@rocket.chat/core-typings';
import { Select } from '@rocket.chat/fuselage';
import { useMemo, type ComponentProps } from 'react';
import { formatPhoneNumber } from '../../../../../lib/formatPhoneNumber';
export type SenderSelectProps = Omit<ComponentProps<typeof Select>, 'options'> & {
provider: IOutboundProviderMetadata | undefined;
};
const SenderSelect = ({ provider, value, onChange, ...props }: SenderSelectProps) => {
const options = useMemo<[string, string][]>(() => {
if (!provider) {
return [];
}
return Object.keys(provider.templates).map((sender) => {
return provider.providerType === 'phone' ? [sender, formatPhoneNumber(sender)] : [sender, sender];
});
}, [provider]);
return <Select {...props} value={value} options={options} onChange={onChange} />;
};
export default SenderSelect;

View File

@@ -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';
export type TemplatePlaceholderButtonProps = Omit<ComponentProps<typeof IconButton>, 'color' | 'icon'> & {
icon?: ComponentProps<typeof IconButton>['icon'];
};
const TemplatePlaceholderButton = forwardRef<HTMLButtonElement, TemplatePlaceholderButtonProps>(
({ icon: _icon, pressed: _pressed, small: _small, size: _size, ...props }, ref) => {
const { t } = useTranslation();
return (
<Button ref={ref} {...props}>
{t('Placeholder')}
</Button>
);
},
);
TemplatePlaceholderButton.displayName = 'TemplatePlaceholderButton';
export default TemplatePlaceholderButton;

View File

@@ -0,0 +1,52 @@
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';
import type TemplatePlaceholderSelector from './TemplatePlaceholderSelector';
const appRoot = mockAppRoot().build();
jest.mock('./TemplatePlaceholderSelector', () => ({ onSelect, onOpenChange }: ComponentProps<typeof TemplatePlaceholderSelector>) => (
<div>
<button type='button' onClick={() => onSelect('Selected value')}>
Placeholder
</button>
<button type='button' onClick={() => onOpenChange?.(false)}>
Close
</button>
</div>
));
it('should call onChange when value changes', async () => {
const onChange = jest.fn();
render(<TemplatePlaceholderInput value='' onChange={onChange} />, { 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(<TemplatePlaceholderInput value='' onChange={onChange} />, { 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(<TemplatePlaceholderInput value='' onChange={jest.fn()} />, { 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();
});

View File

@@ -0,0 +1,44 @@
import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings';
import { Box, Icon, TextInput } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRef, type ChangeEventHandler, type ChangeEvent, type ComponentProps } from 'react';
import PlaceholderSelector from './TemplatePlaceholderSelector';
import type { TemplateParameter } from '../../types/template';
export type TemplatePlaceholderInputProps = Omit<ComponentProps<typeof TextInput>, 'value' | 'onChange'> & {
type?: TemplateParameter['type'];
value: string;
contact?: Serialized<ILivechatContact>;
onChange(value: string): void;
};
const TemplatePlaceholderInput = ({ contact, value = '', type, onChange, ...props }: TemplatePlaceholderInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (event: ChangeEvent<HTMLInputElement> | string) => {
onChange(typeof event === 'string' ? event : event.currentTarget.value);
};
const addon = type === 'media' ? <Icon name='link' /> : undefined;
const handleOpenToggle = useStableCallback((isOpen: boolean) => {
if (!isOpen) inputRef.current?.focus();
});
return (
<Box display='flex' width='100%'>
<TextInput {...props} ref={inputRef} value={value} endAddon={addon} onChange={handleChange as ChangeEventHandler<HTMLInputElement>} />
<PlaceholderSelector
disabled={type !== 'text'}
marginInlineStart={12}
contact={contact}
onSelect={handleChange}
onOpenChange={handleOpenToggle}
/>
</Box>
);
};
export default TemplatePlaceholderInput;

View File

@@ -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<ComponentProps<typeof PlaceholderButton>, 'marginInlineStart' | 'disabled'> & {
contact?: Serialized<ILivechatContact>;
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 (
<GenericMenu
button={<PlaceholderButton {...props} />}
onOpenChange={onOpenChange}
title=''
disabled={disabled}
maxWidth='100%'
sections={[...contactSection, ...customFieldsSection, ...agentSection]}
placement='bottom-end'
/>
);
};
export default TemplatePlaceholderSelector;

View File

@@ -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) => (
<p>
{index ? `${label} (${index})` : label} <span>{value}</span>
</p>
);
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]);
};

View File

@@ -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<ILivechatContact>;
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) => (
<p>
{index ? `${label} (${index})` : label} <span>{value}</span>
</p>
);
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]);
};

View File

@@ -0,0 +1,37 @@
import { Box } from '@rocket.chat/fuselage';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type UseCustomFieldsSectionProps = {
customFields?: Record<string, unknown>;
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: (
<p>
<Box is='span' fontScale='p2m'>
{label}
</Box>{' '}
<Box is='span' fontScale='p2'>
{String(value)}
</Box>
</p>
),
}));
return [{ title: t('Custom_fields'), items }];
}, [customFields, onSelect, t]);
};

View File

@@ -0,0 +1 @@
export { default } from './TemplatePlaceholderInput';

View File

@@ -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 '../../../../../components/MarkdownText';
import type { TemplateParameters, ComponentType } from '../types/template';
import { processTemplatePreviewText } from '../utils/template';
export type TemplatePreviewProps = {
template: IOutboundProviderTemplate;
parameters?: TemplateParameters;
};
const TemplatePreview = ({ template, parameters = {} }: TemplatePreviewProps) => {
const { t } = useTranslation();
const previewId = useId();
const { components } = template;
const content = useMemo<Partial<Record<ComponentType, string>>>(() => {
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 (
<Box marginBlockStart={12}>
<Box is='p' fontScale='p2m' marginBlockEnd={2} id={previewId}>
{t('Message_preview')}
</Box>
<Callout type='info' icon='balloon-text' title={content.header || ''} aria-labelledby={previewId}>
<MarkdownText parseEmoji content={content.body || ''} />
<MarkdownText parseEmoji color='annotation' content={content.footer || ''} />
</Callout>
</Box>
);
};
export default TemplatePreview;

View File

@@ -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(<TemplateSelect templates={mockTemplates} placeholder='Select template' value='' onChange={jest.fn()} />, { wrapper: appRoot });
expect(screen.getByPlaceholderText('Select template')).toBeInTheDocument();
});
it('should display the correct template options with language descriptions', async () => {
render(<TemplateSelect templates={mockTemplates} placeholder='Select template' value='' onChange={jest.fn()} />, { 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(<TemplateSelect templates={mockTemplates} placeholder='Select template' value='' onChange={jest.fn()} />, { 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)');
});
});

View File

@@ -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';
export type TemplateSelectProps = Omit<ComponentProps<typeof SelectFiltered>, '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<string, IOutboundProviderTemplate>();
const templateOptions: SelectOption[] = [];
for (const template of templates) {
templateMap.set(template.id, template);
templateOptions.push([template.id, template.name]);
}
return [templateOptions, templateMap];
}, [templates]);
return (
<SelectFiltered
{...props}
value={value}
options={options}
onChange={onChange}
renderItem={({ label, value: templateId, ...props }) => {
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 (
<Option {...props} label={label}>
{language ? <OptionDescription>{language.name}</OptionDescription> : null}
</Option>
);
}}
/>
);
};
export default TemplateSelect;

View File

@@ -0,0 +1,4 @@
import { links } from '../../../../lib/links';
export const OUTBOUND_DOCS_LINK = links.outboundDocs;
export const CONTACT_SALES_LINK = links.go.contactSales;

View File

@@ -0,0 +1 @@
export * from './useOutboundMessageAccess';

View File

@@ -0,0 +1,112 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react';
import { useOutboundMessageAccess } from './useOutboundMessageAccess';
import { useHasLicenseModule } from '../../../../../hooks/useHasLicenseModule';
import { useOmnichannelEnabled } from '../../../hooks/useOmnichannelEnabled';
jest.mock('@rocket.chat/ui-contexts', () => ({
usePermission: jest.fn(),
}));
jest.mock('../../../hooks/useOmnichannelEnabled', () => ({
useOmnichannelEnabled: jest.fn(),
}));
jest.mock('../../../../../hooks/useHasLicenseModule', () => ({
useHasLicenseModule: jest.fn(),
}));
const usePermissionMock = jest.mocked(usePermission);
const useOmnichannelEnabledMock = jest.mocked(useOmnichannelEnabled);
const useHasLicenseModuleMock = jest.mocked(useHasLicenseModule);
// TODO: do not mock the hook itself, mock only its dependencies
const createSuccessfulQueryResult = (data: boolean): UseQueryResult<boolean, Error> => ({
data,
error: null,
isPending: false,
isError: false,
isLoading: false,
isLoadingError: false,
isRefetchError: false,
isSuccess: true,
isPlaceholderData: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isRefetching: false,
isStale: false,
status: 'success',
dataUpdatedAt: Date.now(),
errorUpdatedAt: Date.now(),
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
refetch: () => Promise.resolve(createSuccessfulQueryResult(data)),
fetchStatus: 'idle',
promise: Promise.resolve(data),
});
describe('useOutboundMessageAccess', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return false if omnichannel is not enabled', () => {
useOmnichannelEnabledMock.mockReturnValue(false);
useHasLicenseModuleMock.mockReturnValue(createSuccessfulQueryResult(true));
usePermissionMock.mockReturnValue(true);
const { result } = renderHook(() => useOutboundMessageAccess());
expect(result.current).toBe(false);
});
it('should return true if omnichannel module is missing (upsell)', () => {
useOmnichannelEnabledMock.mockReturnValue(true);
useHasLicenseModuleMock.mockImplementation((module) => createSuccessfulQueryResult(module !== 'livechat-enterprise'));
usePermissionMock.mockReturnValue(true);
const { result } = renderHook(() => useOutboundMessageAccess());
expect(result.current).toBe(true);
});
it('should return true if outbound module is missing (upsell)', () => {
useOmnichannelEnabledMock.mockReturnValue(true);
useHasLicenseModuleMock.mockImplementation((module) => createSuccessfulQueryResult(module !== 'outbound-messaging'));
usePermissionMock.mockReturnValue(true);
const { result } = renderHook(() => useOutboundMessageAccess());
expect(result.current).toBe(true);
});
it('should return true if both modules are missing (upsell)', () => {
useOmnichannelEnabledMock.mockReturnValue(true);
useHasLicenseModuleMock.mockReturnValue(createSuccessfulQueryResult(false));
usePermissionMock.mockReturnValue(true);
const { result } = renderHook(() => useOutboundMessageAccess());
expect(result.current).toBe(true);
});
it('should return true if all conditions are met and user has permission', () => {
useOmnichannelEnabledMock.mockReturnValue(true);
useHasLicenseModuleMock.mockReturnValue(createSuccessfulQueryResult(true));
usePermissionMock.mockReturnValue(true);
const { result } = renderHook(() => useOutboundMessageAccess());
expect(result.current).toBe(true);
});
it('should return false if all conditions are met but user does not have permission', () => {
useOmnichannelEnabledMock.mockReturnValue(true);
useHasLicenseModuleMock.mockReturnValue(createSuccessfulQueryResult(true));
usePermissionMock.mockReturnValue(false);
const { result } = renderHook(() => useOutboundMessageAccess());
expect(result.current).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import { useHasLicenseModule } from '../../../../../hooks/useHasLicenseModule';
import { useOmnichannelEnabled } from '../../../hooks/useOmnichannelEnabled';
export const useOutboundMessageAccess = (): boolean => {
const isOmnichannelEnabled = useOmnichannelEnabled();
const { data: hasOmnichannelModule = false } = useHasLicenseModule('livechat-enterprise');
const { data: hasOutboundModule = false } = useHasLicenseModule('outbound-messaging');
const hasPermission = usePermission('outbound.send-messages');
if (!isOmnichannelEnabled) {
return false;
}
if (!hasOmnichannelModule || !hasOutboundModule) {
return true;
}
return hasPermission;
};

View File

@@ -0,0 +1,35 @@
import type { IOutboundProvider, Serialized } from '@rocket.chat/core-typings';
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint, usePermission } 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';
import { useOmnichannelEnterpriseEnabled } from '../../../hooks/useOmnichannelEnterpriseEnabled';
type OutboundProvidersResponse = Serialized<OperationResult<'GET', '/v1/omnichannel/outbound/providers'>>;
type UseOutboundProvidersListProps<TData> = Omit<UseQueryOptions<OutboundProvidersResponse, Error, TData>, 'queryKey' | 'queryFn'> & {
type?: IOutboundProvider['providerType'];
};
const useOutboundProvidersList = <TData = OutboundProvidersResponse>(options?: UseOutboundProvidersListProps<TData>) => {
const { type = 'phone', enabled = true, staleTime = 5 * 60 * 1000, ...queryOptions } = options || {};
const getProviders = useEndpoint('GET', '/v1/omnichannel/outbound/providers');
const isOmnichannelEnabled = useOmnichannelEnterpriseEnabled();
const { data: hasOutboundModule = false } = useHasLicenseModule('outbound-messaging');
const canSendOutboundMessages = usePermission('outbound.send-messages');
return useQuery<OutboundProvidersResponse, Error, TData>({
queryKey: omnichannelQueryKeys.outboundProviders({ type }),
queryFn: () => getProviders({ type }),
retry: 3,
enabled: isOmnichannelEnabled && hasOutboundModule && canSendOutboundMessages && enabled,
staleTime,
...queryOptions,
});
};
export default useOutboundProvidersList;

View File

@@ -0,0 +1,56 @@
import {
Button,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterAnnotation,
ModalFooterControllers,
ModalHeader,
ModalTitle,
} from '@rocket.chat/fuselage';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
export type OutboundMessageCloseConfirmationModalProps = {
onConfirm(): void;
onCancel(): void;
};
const OutboundMessageCloseConfirmationModal = ({ onConfirm, onCancel }: OutboundMessageCloseConfirmationModalProps) => {
const { t } = useTranslation();
const modalId = useId();
return (
<Modal aria-labelledby={`${modalId}-title`} aria-describedby={`${modalId}-description`} open>
<ModalHeader>
<ModalTitle id={`${modalId}-title`}>{t('Discard_message')}</ModalTitle>
<ModalClose aria-label={t('Close')} onClick={onCancel} />
</ModalHeader>
<ModalContent>
<p aria-live='assertive' id={`${modalId}-description`}>
{t('Are_you_sure_you_want_to_discard_this_outbound_message')}
</p>
</ModalContent>
<ModalFooter justifyContent='space-between'>
<ModalFooterAnnotation>{t('This_action_cannot_be_undone')}</ModalFooterAnnotation>
<ModalFooterControllers>
<Button
ref={(el) => {
el?.focus();
}}
secondary
onClick={onCancel}
>
{t('Keep_editing')}
</Button>
<Button danger onClick={onConfirm}>
{t('Discard')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default OutboundMessageCloseConfirmationModal;

View File

@@ -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: () => <div>Outbound message Wizard</div>,
}));
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(<OutboundMessageModal onClose={onClose} />, { 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(<OutboundMessageModal onClose={onClose} />, { 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();
});

View File

@@ -0,0 +1,70 @@
import { Modal, ModalBackdrop, ModalClose, ModalContent, ModalHeader, ModalTitle } from '@rocket.chat/fuselage';
import { useStableCallback } 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<typeof OutboundMessageWizard>['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 = useStableCallback((e: KeyboardEvent<HTMLDivElement>): void => {
if (e.key !== 'Escape') {
return;
}
e.stopPropagation();
e.preventDefault();
// Toggle confirmation visibility on Esc.
setClosingConfirmation(!isClosing);
});
return (
<ModalBackdrop
backgroundColor='transparent'
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<Modal aria-labelledby={modalId} display={isClosing ? 'none' : undefined}>
<ModalHeader>
<ModalTitle id={modalId}>{t('Outbound_message')}</ModalTitle>
<ModalClose aria-label={t('Close')} onClick={() => setClosingConfirmation(true)} />
</ModalHeader>
<ModalContent paddingBlockEnd={16} height='100%'>
<OutboundMessageWizard defaultValues={defaultValues} onSuccess={onClose} onError={onClose} />
</ModalContent>
</Modal>
{isClosing ? <OutboundMessageCloseConfirmationModal onConfirm={onClose} onCancel={() => setClosingConfirmation(false)} /> : null}
</ModalBackdrop>
);
};
export default OutboundMessageModal;

View File

@@ -0,0 +1,3 @@
export { default } from './OutboundMessageModal';
export * from './OutboundMessageModal';
export * from './useOutboundMessageModal';

View File

@@ -0,0 +1,18 @@
import { useStableCallback } 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 = useStableCallback((): void => setModal(null));
const open = useStableCallback((defaultValues?: ComponentProps<typeof OutboundMessageModal>['defaultValues']) => {
setModal(<OutboundMessageModal defaultValues={defaultValues} onClose={close} />);
});
return useMemo(() => ({ open, close }), [open, close]);
};

View File

@@ -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('../../../../../../../client/lib/getURL', () => ({
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(<OutboundMessageUpsellModal onClose={onClose} />, { 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(<OutboundMessageUpsellModal onClose={onClose} />, { 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(<OutboundMessageUpsellModal onClose={onClose} />, { 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(<OutboundMessageUpsellModal isAdmin onClose={onClose} />, { 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(<OutboundMessageUpsellModal isAdmin isCommunity onClose={onClose} />, { 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(<OutboundMessageUpsellModal isAdmin onClose={onClose} />, { 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(<OutboundMessageUpsellModal isAdmin onClose={onClose} />, { 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(<OutboundMessageUpsellModal isAdmin onClose={onClose} />, { 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(<OutboundMessageUpsellModal hasModule onClose={onClose} />, { 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(<OutboundMessageUpsellModal hasModule onClose={onClose} />, { 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(<OutboundMessageUpsellModal hasModule onClose={onClose} />, { 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(<OutboundMessageUpsellModal hasModule isAdmin onClose={onClose} />, { 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(<OutboundMessageUpsellModal hasModule isAdmin onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.queryByText('Outbound_message_upsell_annotation')).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
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 {
component: OutboundMessageUpsellModal,
args: {
onClose: action('onClose'),
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof OutboundMessageUpsellModal>;
type Story = StoryObj<typeof OutboundMessageUpsellModal>;
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,
},
};

View File

@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import GenericUpsellModal from '../../../../../../components/GenericUpsellModal';
import { useExternalLink } from '../../../../../../hooks/useExternalLink';
import { getURL } from '../../../../../../lib/getURL';
import { CONTACT_SALES_LINK, OUTBOUND_DOCS_LINK } from '../../constants';
export 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 (
<GenericUpsellModal
{...props}
title={t('Outbound_message_upsell_title')}
description={t('Outbound_message_upsell_description')}
img={getURL('images/outbound-message-upsell.svg')}
imgAlt={t('Outbound_message_upsell_image_alt')}
imgHeight={255}
/>
);
};
export default OutboundMessageUpsellModal;

View File

@@ -0,0 +1,2 @@
export { default } from './OutboundMessageUpsellModal';
export * from './useOutboundMessageUpsellModal';

View File

@@ -0,0 +1,21 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useLicense } from '@rocket.chat/ui-client';
import { useRole, useSetModal } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import OutboundMessageUpsellModal from './OutboundMessageUpsellModal';
import { useHasLicenseModule } from '../../../../../../hooks/useHasLicenseModule';
export const useOutboundMessageUpsellModal = () => {
const setModal = useSetModal();
const isAdmin = useRole('admin');
const license = useLicense();
const { data: hasModule = false } = useHasLicenseModule('outbound-messaging');
const close = useStableCallback(() => setModal(null));
const open = useStableCallback(() =>
setModal(<OutboundMessageUpsellModal isCommunity={!license.data?.license} isAdmin={isAdmin} hasModule={hasModule} onClose={close} />),
);
return useMemo(() => ({ open, close }), [open, close]);
};

View File

@@ -0,0 +1,2 @@
export * from './OutboundMessageUpsellModal';
export * from './OutboundMessageModal';

View File

@@ -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<Record<ComponentType, TemplateParameter[]>>;
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<T extends TemplateParameter> = Omit<T, 'value'> & {
componentType: ComponentType;
id: string;
index: number;
name: string;
placeholder: string;
};
export type TemplateParameterMetadata = WithMetadata<TemplateTextParameter> | WithMetadata<TemplateMediaParameter>;

View File

@@ -0,0 +1,6 @@
import type { ILivechatContactChannel, Serialized } from '@rocket.chat/core-typings';
export const findLastChatFromChannel = (channels: Serialized<ILivechatContactChannel>[] = [], providerId: string) => {
const channel = channels.find((channel) => channel.name === providerId);
return channel?.lastChat?.ts;
};

View File

@@ -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/RecipientForm';
import type { RepliesFormSubmitPayload } from '../components/OutboundMessageWizard/forms/RepliesForm';
import type { TemplateParameter, TemplateParameters } from '../types/template';
export const isRecipientStepValid = (data: Partial<SubmitPayload>): data is Required<RecipientFormSubmitPayload> => {
return !!(data.contactId && data.contact && data.provider && data.providerId && data.recipient && data.sender);
};
export const isMessageStepValid = (data: Partial<SubmitPayload>): data is Required<MessageFormSubmitPayload> => {
return !!(data.templateId && data.template && data.templateParameters);
};
export const isRepliesStepValid = (data: Partial<SubmitPayload>): 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),
},
};
};

View File

@@ -0,0 +1,63 @@
import type { IOutboundProviderTemplate } from '@rocket.chat/apps-engine/definition/outboundCommunication';
import { capitalize } from '@rocket.chat/tools';
import { extractParameterMetadata, processTemplatePreviewText } from './template';
import type { TemplateMediaParameter, TemplateComponent, TemplateParameterMetadata } from '../types/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('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}}');
});
});

View File

@@ -0,0 +1,86 @@
import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings';
import { capitalize } from '@rocket.chat/tools';
import type { ComponentType, TemplateParameterMetadata, TemplateParameter } from '../types/template';
const placeholderPattern = new RegExp('{{(.*?)}}', 'g'); // e.g {{1}} or {{text}}
export const extractParameterMetadata = (template: Pick<IOutboundProviderTemplate, 'id' | 'components'>) => {
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;
});
};