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
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:
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './OutboundMessagePreview';
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './MessageForm';
|
||||
export * from './MessageForm';
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './RecipientForm';
|
||||
export * from './RecipientForm';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './RepliesForm';
|
||||
export * from './RepliesForm';
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './OutboundMessageWizard';
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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';
|
||||
@@ -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(' ');
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './TemplatePlaceholderInput';
|
||||
@@ -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;
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { links } from '../../../../lib/links';
|
||||
|
||||
export const OUTBOUND_DOCS_LINK = links.outboundDocs;
|
||||
export const CONTACT_SALES_LINK = links.go.contactSales;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useOutboundMessageAccess';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './OutboundMessageModal';
|
||||
export * from './OutboundMessageModal';
|
||||
export * from './useOutboundMessageModal';
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './OutboundMessageUpsellModal';
|
||||
export * from './useOutboundMessageUpsellModal';
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './OutboundMessageUpsellModal';
|
||||
export * from './OutboundMessageModal';
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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}}');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user