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

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

View File

@@ -0,0 +1,37 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode, ComponentProps } from 'react';
import * as UserStatus from '../../../components/UserStatus';
export type AgentInfoDetailsProps = ComponentProps<typeof Box> & {
name: string | undefined;
status: ReactNode;
shortName?: string;
};
const AgentInfoDetails = ({ name, shortName, status = <UserStatus.Offline />, ...props }: AgentInfoDetailsProps) => (
<Box
display='flex'
title={name}
flexGrow={0}
flexShrink={1}
flexBasis={0}
alignItems='center'
fontScale='h4'
color='default'
withTruncatedText
{...props}
>
{status}{' '}
<Box marginInlineStart={8} flexGrow={1} withTruncatedText>
{name}
</Box>
{shortName && (
<Box display='flex' marginInlineStart={8} marginBlock={8} justifyContent='center' fontScale='c1'>
({shortName})
</Box>
)}
</Box>
);
export default AgentInfoDetails;

View File

@@ -0,0 +1,57 @@
import { PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useState } from 'react';
import { useAgentsList } from '../hooks/useAgentsList';
export type AutoCompleteAgentProps = Omit<
ComponentProps<typeof PaginatedSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem'
> & {
value: string;
haveAll?: boolean;
haveNoAgentsSelectedOption?: boolean;
excludeId?: string;
showIdleAgents?: boolean;
onlyAvailable?: boolean;
onChange: (value: string) => void;
};
const AutoCompleteAgent = ({
value,
haveAll = false,
haveNoAgentsSelectedOption = false,
excludeId,
showIdleAgents = true,
onlyAvailable = false,
onChange,
...props
}: AutoCompleteAgentProps) => {
const [agentsFilter, setAgentsFilter] = useState<string>('');
const debouncedAgentsFilter = useDebouncedValue(agentsFilter, 500);
const { data: agentsItems, fetchNextPage } = useAgentsList({
filter: debouncedAgentsFilter,
onlyAvailable,
haveAll,
haveNoAgentsSelectedOption,
excludeId,
showIdleAgents,
});
return (
<PaginatedSelectFiltered
{...props}
value={value}
flexShrink={0}
filter={agentsFilter}
setFilter={setAgentsFilter as (value: string | number | undefined) => void}
options={agentsItems}
onChange={onChange}
endReached={() => fetchNextPage()}
/>
);
};
export default memo(AutoCompleteAgent);

View File

@@ -0,0 +1,63 @@
import type { Serialized } from '@rocket.chat/core-typings';
import { PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import type { ComponentProps, ReactNode, SyntheticEvent } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useContactsList } from './useContactsList';
type OptionProps = {
role: 'option';
title?: string;
index: number;
label: string;
value: string;
selected: boolean;
focus: boolean;
onMouseDown(event: SyntheticEvent): void;
};
export type AutoCompleteContactProps = Omit<
ComponentProps<typeof PaginatedSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem' | 'value' | 'onChange'
> & {
value: string;
onChange: (value: string) => void;
renderItem?: (props: OptionProps, contact: Serialized<ILivechatContactWithManagerData>) => ReactNode;
};
const AutoCompleteContact = ({ value, placeholder, disabled, renderItem, onChange, ...props }: AutoCompleteContactProps) => {
const { t } = useTranslation();
const [contactsFilter, setContactFilter] = useState<string>('');
const debouncedContactFilter = useDebouncedValue(contactsFilter, 500);
const {
data: contactsItems = [],
fetchNextPage,
isPending,
} = useContactsList({
filter: debouncedContactFilter,
});
return (
<PaginatedSelectFiltered
{...props}
aria-busy={isPending}
placeholder={isPending ? t('Loading...') : placeholder}
aria-disabled={isPending || disabled}
disabled={isPending || disabled}
value={value}
flexShrink={0}
filter={contactsFilter}
setFilter={setContactFilter as (value: string | number | undefined) => void}
options={contactsItems}
onChange={onChange}
endReached={() => fetchNextPage()}
renderItem={renderItem ? (props: OptionProps) => renderItem(props, contactsItems[props.index]) : undefined}
/>
);
};
export default memo(AutoCompleteContact);

View File

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

View File

@@ -0,0 +1,52 @@
import type { Serialized } from '@rocket.chat/core-typings';
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
export type ContactOption = Serialized<ILivechatContactWithManagerData> & {
value: string;
label: string;
};
type ContactOptions = {
filter: string;
limit?: number;
};
const DEFAULT_QUERY_LIMIT = 25;
const formatContactItem = (contact: Serialized<ILivechatContactWithManagerData>): ContactOption => ({
...contact,
label: contact.name || contact._id,
value: contact._id,
});
export const useContactsList = (options: ContactOptions) => {
const getContacts = useEndpoint('GET', '/v1/omnichannel/contacts.search');
const { filter, limit = DEFAULT_QUERY_LIMIT } = options;
return useInfiniteQuery({
queryKey: omnichannelQueryKeys.contacts({ filter, limit }),
queryFn: async ({ pageParam: offset = 0 }) => {
const { contacts, ...data } = await getContacts({
searchText: filter,
// sort: `{ name: -1 }`,
...(limit && { count: limit }),
...(offset && { offset }),
});
return {
...data,
contacts: contacts.map(formatContactItem),
};
},
select: (data) => data.pages.flatMap<ContactOption>((page) => page.contacts),
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const offset = lastPage.offset + lastPage.count;
return offset < lastPage.total ? offset : undefined;
},
});
};

View File

@@ -0,0 +1,43 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import AutoCompleteDepartment from './AutoCompleteDepartment';
import { useDepartmentsList } from '../hooks/useDepartmentsList';
jest.mock('../hooks/useDepartmentsList');
const useDepartmentsListMocked = jest.mocked(useDepartmentsList);
const appRoot = mockAppRoot().build();
describe('AutoCompleteDepartment', () => {
beforeEach(() => {
useDepartmentsListMocked.mockClear();
});
it('should render loading state correctly', () => {
useDepartmentsListMocked.mockReturnValue({
data: [],
isPending: true,
fetchNextPage: jest.fn(),
} as unknown as ReturnType<typeof useDepartmentsList>);
const { rerender } = render(<AutoCompleteDepartment value='' onChange={jest.fn()} />, { wrapper: appRoot });
expect(screen.getByPlaceholderText('Loading...')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Select_an_option')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeDisabled();
useDepartmentsListMocked.mockReturnValue({
data: [],
isPending: false,
fetchNextPage: jest.fn(),
} as unknown as ReturnType<typeof useDepartmentsList>);
rerender(<AutoCompleteDepartment value='' onChange={jest.fn()} />);
expect(screen.getByPlaceholderText('Select_an_option')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Loading...')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeEnabled();
});
});

View File

@@ -0,0 +1,71 @@
import { Option, PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDepartmentsList } from '../hooks/useDepartmentsList';
export type AutoCompleteDepartmentProps = {
value?: string;
onChange: (value: string) => void;
excludeId?: string;
onlyMyDepartments?: boolean;
haveAll?: boolean;
haveNone?: boolean;
showArchived?: boolean;
unitId?: string;
} & Omit<ComponentProps<typeof PaginatedSelectFiltered>, 'options' | 'setFilter'>;
const AutoCompleteDepartment = ({
value,
excludeId,
onlyMyDepartments,
unitId,
onChange,
haveAll,
haveNone,
showArchived = false,
disabled,
...props
}: AutoCompleteDepartmentProps) => {
const { t } = useTranslation();
const [departmentsFilter, setDepartmentsFilter] = useState<string>('');
const debouncedDepartmentsFilter = useDebouncedValue(departmentsFilter, 500);
const {
data: departmentsItems,
isPending,
fetchNextPage,
} = useDepartmentsList({
filter: debouncedDepartmentsFilter,
onlyMyDepartments,
haveAll,
haveNone,
excludeId,
showArchived,
selectedDepartmentId: value,
unitId,
});
return (
<PaginatedSelectFiltered
withTitle
{...props}
value={value}
onChange={onChange}
filter={departmentsFilter}
disabled={isPending || disabled}
aria-busy={isPending}
aria-disabled={disabled}
setFilter={setDepartmentsFilter as (value?: string | number) => void}
options={departmentsItems}
placeholder={isPending ? t('Loading...') : t('Select_an_option')}
endReached={() => fetchNextPage()}
renderItem={({ label, ...props }) => <Option {...props} label={<span style={{ whiteSpace: 'normal' }}>{label}</span>} />}
/>
);
};
export default memo(AutoCompleteDepartment);

View File

@@ -0,0 +1,50 @@
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { VirtuosoMockContext } from 'react-virtuoso';
import AutoCompleteDepartmentMultiple from './AutoCompleteDepartmentMultiple';
import { createFakeDepartment } from '../../../../tests/mocks/data';
const mockGetDepartments = jest.fn();
const appRoot = new MockedAppRootBuilder()
.withEndpoint('GET', '/v1/livechat/department', mockGetDepartments)
.wrap((children) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 28 }}>{children}</VirtuosoMockContext.Provider>
));
it('should render autocomplete with checkbox', async () => {
mockGetDepartments.mockResolvedValueOnce({
departments: [createFakeDepartment({ name: 'Test Department' })],
count: 1,
offset: 0,
total: 1,
});
render(<AutoCompleteDepartmentMultiple withCheckbox value={undefined} onChange={jest.fn()} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('listbox'));
await waitFor(() => {
const checkbox = within(screen.getByRole('option', { name: 'Test Department' })).getByRole('checkbox');
expect(checkbox).toBeInTheDocument();
});
});
it('should render autocomplete without checkbox', async () => {
mockGetDepartments.mockResolvedValueOnce({
departments: [createFakeDepartment({ name: 'Test Department' })],
count: 1,
offset: 0,
total: 1,
});
render(<AutoCompleteDepartmentMultiple value={undefined} onChange={jest.fn()} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('listbox'));
await waitFor(() => {
const checkbox = within(screen.getByRole('option', { name: 'Test Department' })).queryByRole('checkbox');
expect(checkbox).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
import { CheckOption, Option, PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage';
import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDepartmentsList } from '../hooks/useDepartmentsList';
export type AutoCompleteDepartmentMultipleProps = Omit<
ComponentProps<typeof PaginatedMultiSelectFiltered>,
'options' | 'renderItem' | 'setFilter' | 'filter' | 'placeholder' | 'endReached'
> & {
value?: PaginatedMultiSelectOption[];
onChange: (value: PaginatedMultiSelectOption[]) => void;
onlyMyDepartments?: boolean;
showArchived?: boolean;
enabled?: boolean;
withCheckbox?: boolean;
excludeId?: string;
unitId?: string;
};
const AutoCompleteDepartmentMultiple = ({
value = [],
onlyMyDepartments = false,
showArchived = false,
enabled = false,
withCheckbox = false,
excludeId,
unitId,
onChange = () => undefined,
...props
}: AutoCompleteDepartmentMultipleProps) => {
const { t } = useTranslation();
const [departmentsFilter, setDepartmentsFilter] = useState('');
const debouncedDepartmentsFilter = useDebouncedValue(departmentsFilter, 500);
const { data: departmentsItems, fetchNextPage } = useDepartmentsList({
filter: debouncedDepartmentsFilter,
excludeId,
onlyMyDepartments,
showArchived,
enabled,
unitId,
});
const selectedValues = useMemo(() => new Set(value.map((item) => item.value)), [value]);
const departmentOptions = useMemo(() => {
const pending = value.filter(({ value }) => !departmentsItems.find((dep) => dep.value === value)) || [];
return [...departmentsItems, ...pending];
}, [departmentsItems, value]);
return (
<PaginatedMultiSelectFiltered
withTitle
{...props}
value={value}
onChange={onChange}
filter={departmentsFilter}
setFilter={setDepartmentsFilter}
options={departmentOptions}
width='100%'
flexShrink={0}
flexGrow={0}
placeholder={t('Select_an_option')}
renderItem={({ label, value, ...props }) => {
if (withCheckbox) {
return (
<CheckOption
{...props}
label={<span style={{ whiteSpace: 'normal' }}>{label}</span>}
selected={value ? selectedValues.has(value) : false}
/>
);
}
return <Option {...props} label={label} />;
}}
endReached={() => fetchNextPage()}
/>
);
};
export default memo(AutoCompleteDepartmentMultiple);

View File

@@ -0,0 +1,41 @@
import { CheckOption, PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMonitorsList } from '../hooks/useMonitorsList';
export type AutoCompleteMonitorsProps = Omit<
ComponentProps<typeof PaginatedMultiSelectFiltered>,
'options' | 'setFilter' | 'endReached' | 'filter' | 'renderItem'
>;
const AutoCompleteMonitors = ({ value = [], onBlur, onChange, ...props }: AutoCompleteMonitorsProps) => {
const { t } = useTranslation();
const [monitorsFilter, setMonitorsFilter] = useState('');
const debouncedMonitorsFilter = useDebouncedValue(monitorsFilter, 500);
const { data: monitorsOptions, fetchNextPage } = useMonitorsList({ filter: debouncedMonitorsFilter });
const selectedValues = useMemo(() => new Set(value.map((item) => item.value)), [value]);
return (
<PaginatedMultiSelectFiltered
withTitle
{...props}
value={value}
filter={monitorsFilter}
setFilter={setMonitorsFilter}
options={monitorsOptions}
placeholder={t('Select_an_option')}
endReached={() => fetchNextPage()}
onBlur={onBlur}
onChange={onChange}
renderItem={({ label, value, ...props }) => (
<CheckOption {...props} label={label} selected={value ? selectedValues.has(value) : false} />
)}
/>
);
};
export default memo(AutoCompleteMonitors);

View File

@@ -0,0 +1,61 @@
import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage';
import { PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useState } from 'react';
import { useAgentsList } from '../hooks/useAgentsList';
export type AutoCompleteMultipleAgentProps = Omit<
ComponentProps<typeof PaginatedMultiSelectFiltered>,
'options' | 'renderItem' | 'setFilter' | 'filter' | 'endReached' | 'value' | 'onChange'
> & {
excludeId?: string;
showIdleAgents?: boolean;
onlyAvailable?: boolean;
value: PaginatedMultiSelectOption[];
onChange: (value: PaginatedMultiSelectOption[]) => void;
};
const AutoCompleteMultipleAgent = ({
value,
error,
placeholder,
excludeId,
showIdleAgents = true,
onlyAvailable = false,
withTitle = false,
onChange,
...props
}: AutoCompleteMultipleAgentProps) => {
const [agentsFilter, setAgentsFilter] = useState<string>('');
const debouncedAgentsFilter = useDebouncedValue(agentsFilter, 500);
const { data: agentsItems, fetchNextPage } = useAgentsList({
filter: debouncedAgentsFilter,
onlyAvailable,
excludeId,
showIdleAgents,
});
return (
<PaginatedMultiSelectFiltered
{...props}
withTitle={withTitle}
value={value}
error={error}
placeholder={placeholder}
onChange={onChange}
width='100%'
flexShrink={0}
flexGrow={0}
filter={agentsFilter}
setFilter={setAgentsFilter as (value: string | number | undefined) => void}
options={agentsItems}
endReached={() => fetchNextPage()}
/>
);
};
export default memo(AutoCompleteMultipleAgent);

View File

@@ -0,0 +1,44 @@
import { Box } from '@rocket.chat/fuselage';
import { InfoPanelField, InfoPanelLabel, InfoPanelText } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { FormSkeleton } from '../directory/components/FormSkeleton';
export type CustomFieldProps = {
id: string;
value: string;
};
const CustomField = ({ id, value }: CustomFieldProps) => {
const { t } = useTranslation();
const getCustomField = useEndpoint('GET', '/v1/livechat/custom-fields/:_id', { _id: id });
const { data, isPending, isError } = useQuery({
queryKey: ['/v1/livechat/custom-field', id],
queryFn: () => getCustomField(),
});
if (isPending) {
return <FormSkeleton />;
}
if (isError || !data?.customField) {
return <Box marginBlockStart={16}>{t('Custom_Field_Not_Found')}</Box>;
}
const { label } = data.customField;
if (!label) {
return null;
}
return (
<InfoPanelField>
<InfoPanelLabel>{label}</InfoPanelLabel>
<InfoPanelText>{value}</InfoPanelText>
</InfoPanelField>
);
};
export default CustomField;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type FieldProps = { children?: ReactNode };
const Field = ({ children }: FieldProps) => <Box marginBlock={16}>{children}</Box>;
export default Field;

View File

@@ -0,0 +1,11 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type FormSkeletonProps = ComponentPropsWithoutRef<typeof Box>;
export const FormSkeleton = (props: FormSkeletonProps) => (
<Box width='full' paddingBlock={24} {...props}>
<Skeleton marginBlockEnd={8} />
<Skeleton marginBlockEnd={4} />
</Box>
);

View File

@@ -0,0 +1,19 @@
import type { cssFn } from '@rocket.chat/css-in-js';
import { css } from '@rocket.chat/css-in-js';
import type { ComponentProps, CSSProperties, ReactNode } from 'react';
import { UserCardInfo } from '../../../components/UserCard';
const wordBreak = css`
word-break: break-word;
`;
export type InfoProps = Omit<ComponentProps<typeof UserCardInfo>, 'className' | 'style' | 'children'> & {
className?: string | cssFn;
style?: CSSProperties;
children?: ReactNode;
};
const Info = ({ className, ...props }: InfoProps) => <UserCardInfo className={[className, wordBreak]} flexShrink={0} {...props} />;
export default Info;

View File

@@ -0,0 +1,7 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export type LabelProps = ComponentProps<typeof Box>;
const Label = (props: LabelProps) => <Box marginBlockEnd={8} fontScale='p2m' color='default' {...props} />;
export default Label;

View File

@@ -0,0 +1,19 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './OmnichannelBadges.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,68 @@
import { LivechatPriorityWeight } from '@rocket.chat/core-typings';
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta } from '@storybook/react';
import OmnichannelBadges from './OmnichannelBadges';
import { createFakeSubscription } from '../../../../../tests/mocks/data';
import type { OmnichannelContextValue } from '../../../../contexts/OmnichannelContext';
import { OmnichannelContext } from '../../../../contexts/OmnichannelContext';
const meta = {
component: OmnichannelBadges,
parameters: {
layout: 'centered',
},
args: {
room: createFakeSubscription({ t: 'l' }) as unknown as ISubscription & IRoom,
},
decorators: [
(Story, context) => {
const { isOverMacLimit = false } = context.parameters;
const AppRoot = mockAppRoot().build();
const contextValue = {
enabled: true,
isOverMacLimit,
livechatPriorities: {
enabled: true,
data: [{ name: 'Highest', i18n: 'highest', sortItem: LivechatPriorityWeight.HIGHEST, dirty: false }],
},
} as OmnichannelContextValue;
return (
<OmnichannelContext.Provider value={contextValue}>
<AppRoot>
<Story />
</AppRoot>
</OmnichannelContext.Provider>
);
},
],
} satisfies Meta<Omit<typeof OmnichannelBadges, 'room'>>;
export default meta;
export const WithPriority = {
args: {
room: createFakeSubscription({
t: 'l',
priorityWeight: LivechatPriorityWeight.HIGHEST,
}),
},
parameters: {
isOverMacLimit: false,
},
};
export const OverMacLimit = {
args: {
room: createFakeSubscription({
t: 'l',
priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED,
}),
},
parameters: {
isOverMacLimit: true,
},
};

View File

@@ -0,0 +1,25 @@
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useOmnichannelPriorities } from '../../hooks/useOmnichannelPriorities';
import { PriorityIcon } from '../../priorities/PriorityIcon';
import RoomActivityIcon from '../RoomActivityIcon';
export type OmnichannelBadgesProps = { room: ISubscription & IRoom };
const OmnichannelBadges = ({ room }: OmnichannelBadgesProps) => {
const { enabled: isPriorityEnabled } = useOmnichannelPriorities();
if (!isOmnichannelRoom(room)) {
return null;
}
return (
<>
{isPriorityEnabled ? <PriorityIcon level={room.priorityWeight} /> : null}
<RoomActivityIcon room={room} />
</>
);
};
export default OmnichannelBadges;

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders OverMacLimit without crashing 1`] = `
<body>
<div>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-warning rcx-icon rcx-css-ayadvf"
title="Workspace_exceeded_MAC_limit_disclaimer"
>
</i>
</div>
</body>
`;
exports[`renders WithPriority without crashing 1`] = `
<body>
<div>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-double-up rcx-icon rcx-css-aob2og"
title=""
>
</i>
</div>
</body>
`;

View File

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

View File

@@ -0,0 +1,32 @@
import { OmnichannelSortingMechanismSettingType as OmniSortingType } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOmnichannelEnterpriseEnabled } from '../hooks/useOmnichannelEnterpriseEnabled';
export const useOmnichannelSortingDisclaimer = () => {
const isOmnichannelEnabled = useOmnichannelEnterpriseEnabled();
const sortingMechanism = useSetting<OmniSortingType>('Omnichannel_sorting_mechanism', OmniSortingType.Timestamp);
const [{ [sortingMechanism]: type }] = useState({
[OmniSortingType.Priority]: 'Priorities',
[OmniSortingType.SLAs]: 'SLA_Policies',
[OmniSortingType.Timestamp]: '',
} as const);
return isOmnichannelEnabled ? type : '';
};
export const OmnichannelSortingDisclaimer = () => {
const { t } = useTranslation();
const type = useOmnichannelSortingDisclaimer();
if (!type) {
return null;
}
return <>{t('Omnichannel_sorting_disclaimer', { sortingMechanism: t(type) })}</>;
};

View File

@@ -0,0 +1,27 @@
import { Icon, Tag } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type OmnichannelVerificationTagProps = {
verified?: boolean;
onClick?: () => void;
};
const OmnichannelVerificationTag = ({ verified, onClick }: OmnichannelVerificationTagProps) => {
const { t } = useTranslation();
const { data: hasLicense = false } = useHasLicenseModule('contact-id-verification');
const isVerified = hasLicense && verified;
return (
<Tag
variant={isVerified ? 'primary' : undefined}
onClick={!isVerified && onClick ? onClick : undefined}
icon={<Icon size='x12' marginInlineEnd={4} name={isVerified ? 'success-circle' : 'question-mark'} />}
>
{isVerified ? t('Verified') : t('Unverified')}
</Tag>
);
};
export default OmnichannelVerificationTag;

View File

@@ -0,0 +1,20 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useIsRoomOverMacLimit } from '../hooks/useIsRoomOverMacLimit';
export type RoomActivityIconProps = {
room: IOmnichannelRoom;
};
const RoomActivityIcon = ({ room }: RoomActivityIconProps) => {
const { t } = useTranslation();
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
return isRoomOverMacLimit ? (
<Icon name='warning' verticalAlign='middle' size='x20' color='danger' title={t('Workspace_exceeded_MAC_limit_disclaimer')} />
) : null;
};
export default RoomActivityIcon;

View File

@@ -0,0 +1,117 @@
import { TextInput, Chip, Button, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CurrentChatTags } from '../additionalForms';
import { FormSkeleton } from './FormSkeleton';
import { useLivechatTags } from '../hooks/useLivechatTags';
export type TagsProps = {
tags?: string[];
handler: (value: string[]) => void;
error?: string;
tagRequired?: boolean;
department?: string;
};
const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps) => {
const { t } = useTranslation();
const tagsFieldId = useId();
const { data: tagsResult, isLoading } = useLivechatTags({
department,
viewAll: !department,
});
const customTags = useMemo(() => {
return tags.filter((tag) => !tagsResult?.tags.find((rtag) => rtag.name === tag));
}, [tags, tagsResult?.tags]);
const dispatchToastMessage = useToastMessageDispatch();
const [tagValue, handleTagValue] = useState('');
const paginatedTagValue = useMemo(() => tags.map((tag) => ({ label: tag, value: tag })), [tags]);
const removeTag = (tagToRemove: string): void => {
if (!tags) return;
handler(tags.filter((tag) => tag !== tagToRemove));
};
const handleTagTextSubmit = useStableCallback(() => {
if (!tags) {
return;
}
if (!tagValue || tagValue.trim() === '') {
dispatchToastMessage({ type: 'error', message: t('Enter_a_tag') });
handleTagValue('');
return;
}
if (tags.includes(tagValue)) {
dispatchToastMessage({ type: 'error', message: t('Tag_already_exists') });
return;
}
handler([...tags, tagValue]);
handleTagValue('');
});
if (isLoading) {
return <FormSkeleton />;
}
return (
<>
<FieldLabel htmlFor={tagsFieldId} required={tagRequired} marginBlock={4}>
{t('Tags')}
</FieldLabel>
{tagsResult?.tags?.length ? (
<FieldRow>
<CurrentChatTags
id={tagsFieldId}
value={paginatedTagValue}
handler={(tags): void => {
handler(tags.map((tag) => tag.label));
}}
department={department}
viewAll={!department}
/>
</FieldRow>
) : (
<>
<FieldRow>
<TextInput
error={error}
value={tagValue}
id={tagsFieldId}
onChange={({ currentTarget }: ChangeEvent<HTMLInputElement>): void => handleTagValue(currentTarget.value)}
flexGrow={1}
placeholder={t('Enter_a_tag')}
/>
<Button disabled={!tagValue} marginInlineStart={8} title={t('Add')} onClick={handleTagTextSubmit}>
{t('Add')}
</Button>
</FieldRow>
</>
)}
{customTags.length > 0 && (
<FieldRow justifyContent='flex-start'>
{customTags?.map((tag, i) => (
<Chip key={i} onClick={(): void => removeTag(tag)} marginInlineEnd={8}>
{tag}
</Chip>
))}
</FieldRow>
)}
</>
);
};
export default Tags;

View File

@@ -0,0 +1,13 @@
import { render, screen } from '@testing-library/react';
import AutoCompleteDepartmentAgent from './AutoCompleteDepartmentAgent';
it('should not display the placeholder when there is a value', () => {
const { rerender } = render(<AutoCompleteDepartmentAgent value='' onChange={jest.fn()} placeholder='Select an agent' agents={[]} />);
expect(screen.getByPlaceholderText('Select an agent')).toBeInTheDocument();
rerender(<AutoCompleteDepartmentAgent value='agent1' onChange={jest.fn()} placeholder='Select an agent' agents={[]} />);
expect(screen.queryByPlaceholderText('Select an agent')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,64 @@
import type { ILivechatDepartmentAgents, Serialized } from '@rocket.chat/core-typings';
import { AutoComplete, Box, Chip, Option, OptionAvatar, OptionContent } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { AllHTMLAttributes } from 'react';
import { useMemo, useState } from 'react';
export type AutoCompleteDepartmentAgentProps = Omit<AllHTMLAttributes<HTMLInputElement>, 'onChange'> & {
error?: boolean;
value: string;
onChange(value: string): void;
agents?: Serialized<ILivechatDepartmentAgents>[];
};
const AutoCompleteDepartmentAgent = ({ value, onChange, agents, placeholder, ...props }: AutoCompleteDepartmentAgentProps) => {
const [filter, setFilter] = useState('');
const debouncedFilter = useDebouncedValue(filter, 1000);
const options = useMemo(() => {
if (!agents) {
return [];
}
return agents
.filter((agent) => agent.username?.includes(debouncedFilter))
.sort((a, b) => a.username.localeCompare(b.username))
.map((agent) => ({
value: agent.agentId,
label: agent.username,
}));
}, [agents, debouncedFilter]);
return (
<AutoComplete
{...props}
placeholder={!value ? placeholder : undefined}
filter={filter}
setFilter={setFilter}
value={value}
onChange={onChange as (value: string | string[]) => void}
options={options}
renderSelected={({ selected: { value, label }, ...props }) => {
return (
<Chip {...props} height='x20' value={value} onClick={() => onChange('')} marginInlineEnd={4}>
<UserAvatar size='x20' username={label} />
<Box is='span' margin='none' marginInlineStart={4}>
{label}
</Box>
</Chip>
);
}}
renderItem={({ value, label, ...props }) => (
<Option key={value} {...props}>
<OptionAvatar>
<UserAvatar username={label} size='x20' />
</OptionAvatar>
<OptionContent>{label}</OptionContent>
</Option>
)}
/>
);
};
export default AutoCompleteDepartmentAgent;

View File

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

View File

@@ -0,0 +1,108 @@
import type {
ILivechatAgent,
ILivechatDepartment,
IOutboundProviderMetadata,
IOutboundProviderTemplate,
ILivechatContact,
} from '@rocket.chat/core-typings';
import { Box, Margins } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PreviewItem from './PreviewItem';
import { formatPhoneNumber } from '../../../../../../lib/formatPhoneNumber';
import type { TemplateParameters } from '../../types/template';
import TemplatePreview from '../TemplatePreview';
export type OutboundMessagePreviewProps = ComponentProps<typeof Box> & {
template?: IOutboundProviderTemplate;
contactName?: ILivechatContact['name'];
providerName?: IOutboundProviderMetadata['providerName'];
providerType?: IOutboundProviderMetadata['providerType'];
departmentName?: ILivechatDepartment['name'];
agentName?: ILivechatAgent['name'];
agentUsername?: ILivechatAgent['username'];
sender?: string;
recipient?: string;
templateParameters?: TemplateParameters;
};
const formatContact = (rawValue?: string, providerType?: IOutboundProviderMetadata['providerType']) => {
if (!rawValue) {
return undefined;
}
if (providerType === 'phone') {
return formatPhoneNumber(rawValue);
}
return rawValue;
};
const OutboundMessagePreview = ({
template,
contactName,
providerName,
providerType,
departmentName,
agentName,
agentUsername,
sender: rawSender,
recipient: rawRecipient,
templateParameters,
...props
}: OutboundMessagePreviewProps) => {
const { t } = useTranslation();
const recipient = useMemo(() => formatContact(rawRecipient, providerType), [providerType, rawRecipient]);
const sender = useMemo(() => formatContact(rawSender, providerType), [providerType, rawSender]);
const replies = useMemo(() => {
if (agentName) {
return `${agentName} ${agentUsername ? `(${agentUsername})` : ''} ${departmentName ? `at ${departmentName}` : ''}`;
}
if (agentUsername) {
return `@${agentUsername} ${departmentName ? `at ${departmentName}` : ''}`;
}
return departmentName;
}, [agentName, agentUsername, departmentName]);
return (
<Box {...props} is='section'>
<ul>
<Margins blockStart={24}>
<Box is='li' marginBlockStart={0}>
<PreviewItem icon='doc' label={t('Template')}>
{template?.name}
</PreviewItem>
</Box>
<Box is='li'>
<PreviewItem icon='user' label={t('To')}>
{contactName && `${contactName} ${recipient ? `(${recipient})` : ''}`}
</PreviewItem>
</Box>
<Box is='li'>
<PreviewItem icon='send' label={t('From')}>
{providerName && `${providerName} ${sender ? `(${sender})` : ''}`}
</PreviewItem>
</Box>
<Box is='li'>
<PreviewItem icon='reply' label={t('Replies')}>
{replies}
</PreviewItem>
</Box>
</Margins>
</ul>
{template ? (
<Box marginBlockStart={30}>
<TemplatePreview template={template} parameters={templateParameters} />
</Box>
) : null}
</Box>
);
};
export default OutboundMessagePreview;

View File

@@ -0,0 +1,36 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import type { Keys } from '@rocket.chat/icons';
import type { ReactNode } from 'react';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
export type PreviewItemProps = {
label: string;
icon: Keys;
children: ReactNode;
};
const PreviewItem = ({ icon, label, children }: PreviewItemProps) => {
const { t } = useTranslation();
const id = useId();
return (
<Box display='flex'>
<Icon size={18} name={icon} />
<Box marginInlineStart={6}>
<Box is='span' id={id} fontScale='p2m'>
{label}
</Box>
<div aria-labelledby={id}>
{children ?? (
<Box fontScale='p2' color='disabled'>
{t('None')}
</Box>
)}
</div>
</Box>
</Box>
);
};
export default PreviewItem;

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
import { faker } from '@faker-js/faker/locale/af_ZA';
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import OutboundMessageWizard from './OutboundMessageWizard';
import { MessageStep, ReviewStep, RecipientStep, RepliesStep } from './steps';
import {
createFakeAgent,
createFakeContact,
createFakeContactWithManagerData,
createFakeDepartment,
} from '../../../../../../../tests/mocks/data';
import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../../tests/mocks/data/outbound-message';
const mockDepartment = createFakeDepartment({ name: `${faker.commerce.department()} Department` });
const contactWithManagerMock = createFakeContactWithManagerData({
_id: 'contact-1',
name: 'John Doe',
phones: [{ phoneNumber: '+12125554567' }],
});
const { contactManager: _, ...contactNormal } = contactWithManagerMock;
const contactMock = createFakeContact(contactNormal);
const providerMock = createFakeProviderMetadata({
providerId: 'provider-1',
providerName: 'WhatsApp',
templates: {
'+12127774567': [createFakeOutboundTemplate({ phoneNumber: '+12127774567' })],
},
});
const mockAgent = createFakeAgent();
const mockDepartmentAgent = {
...mockAgent,
username: mockAgent.username || '',
agentId: mockAgent._id,
departmentId: mockDepartment._id,
departmentEnabled: true,
count: 0,
order: 0,
};
const AppRoot = mockAppRoot()
.withEndpoint('GET', '/v1/livechat/department', () => ({ departments: [mockDepartment], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/users/agent', () => ({ users: [{ ...mockAgent, departments: [] }], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/department/:_id', () => ({ department: mockDepartment, agents: [mockDepartmentAgent] }))
.withEndpoint('GET', '/v1/livechat/users/agent/:_id', () => ({ user: mockAgent }))
.withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => ({ providers: [providerMock] }))
.withEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', () => ({ metadata: providerMock }))
.withEndpoint('GET', '/v1/omnichannel/contacts.get', () => ({ contact: contactMock }))
.withEndpoint('GET', '/v1/omnichannel/contacts.search', () => ({ contacts: [contactWithManagerMock], count: 1, offset: 0, total: 1 }))
.build();
const meta = {
component: OutboundMessageWizard,
subcomponents: {
RecipientStep,
MessageStep,
RepliesStep,
ReviewStep,
},
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: [
(Story) => (
<AppRoot>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</AppRoot>
),
],
} satisfies Meta<typeof OutboundMessageWizard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
defaultValues: {},
},
};
export const WithDefaultValues: Story = {
args: {
defaultValues: {
contactId: contactMock._id,
providerId: providerMock.providerId,
},
},
};

View File

@@ -0,0 +1,207 @@
import { Box } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar';
import { Wizard, useWizard, WizardContent, WizardTabs } from '@rocket.chat/ui-client';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useLayoutEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import OutboundMessageWizardErrorState from './components/OutboundMessageWizardErrorState';
import OutboubdMessageWizardSkeleton from './components/OutboundMessageWizardSkeleton';
import type { SubmitPayload } from './forms';
import { ReviewStep, MessageStep, RecipientStep, RepliesStep } from './steps';
import GenericError from '../../../../../../components/GenericError';
import { useEndpointMutation } from '../../../../../../hooks/useEndpointMutation';
import { useHasLicenseModule } from '../../../../../../hooks/useHasLicenseModule';
import { formatPhoneNumber } from '../../../../../../lib/formatPhoneNumber';
import { omnichannelQueryKeys } from '../../../../../../lib/queryKeys';
import { useOmnichannelEnabled } from '../../../../hooks/useOmnichannelEnabled';
import useOutboundProvidersList from '../../hooks/useOutboundProvidersList';
import { useOutboundMessageUpsellModal } from '../../modals';
import { formatOutboundMessagePayload, isMessageStepValid, isRecipientStepValid, isRepliesStepValid } from '../../utils/outbound-message';
export type OutboundMessageWizardProps = {
defaultValues?: Partial<Pick<SubmitPayload, 'contactId' | 'providerId' | 'recipient' | 'sender'>>;
onSuccess?(): void;
onError?(): void;
};
const OutboundMessageWizard = ({ defaultValues = {}, onSuccess, onError }: OutboundMessageWizardProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const dispatchToastMessage = useToastBarDispatch();
const [state, setState] = useState<Partial<SubmitPayload>>(defaultValues);
const { contact, sender, provider, department, agent, template, templateParameters, recipient } = state;
const templates = sender ? provider?.templates[sender] : [];
const upsellModal = useOutboundMessageUpsellModal();
const isOmnichannelEnabled = useOmnichannelEnabled();
const hasOmnichannelModule = useHasLicenseModule('livechat-enterprise');
const hasOutboundModule = useHasLicenseModule('outbound-messaging');
const hasOutboundPermission = usePermission('outbound.send-messages');
const isLoadingModule = hasOutboundModule.isPending || hasOmnichannelModule.isPending;
const sendOutboundMessage = useEndpointMutation('POST', '/v1/omnichannel/outbound/providers/:id/message', {
keys: { id: provider?.providerId || '' },
onError: () => undefined, // error being handled in handleSend
});
const {
data: hasProviders = false,
isLoading: isLoadingProviders,
isError: isErrorProviders,
refetch: refetchProviders,
} = useOutboundProvidersList<boolean>({
select: ({ providers = [] }) => providers.length > 0,
});
const wizardApi = useWizard({
steps: [
{ id: 'recipient', title: t('Recipient') },
{ id: 'message', title: t('Message') },
{ id: 'replies', title: t('Replies') },
{ id: 'review', title: t('Review') },
],
});
useEffect(
() => () => {
// Clear cached providers and metadata on unmount to avoid stale data
void queryClient.removeQueries({ queryKey: omnichannelQueryKeys.outboundProviders() });
},
[queryClient],
);
useLayoutEffect(() => {
if (isLoadingModule || isLoadingProviders) {
return;
}
if (!hasOmnichannelModule.data || !hasOutboundModule.data || !hasProviders) {
upsellModal.open();
}
}, [hasOmnichannelModule.data, hasOutboundModule.data, hasProviders, isLoadingModule, isLoadingProviders, upsellModal]);
const handleSubmit = useStableCallback((values: SubmitPayload) => {
if (!hasOutboundModule.data) {
upsellModal.open();
return;
}
setState((state) => ({ ...state, ...values }));
});
const handleSend = useStableCallback(async () => {
try {
if (!isRecipientStepValid(state)) {
throw new Error('error-invalid-recipient-step');
}
if (!isMessageStepValid(state)) {
throw new Error('error-invalid-message-step');
}
if (!isRepliesStepValid(state)) {
throw new Error('error-invalid-replies-step');
}
const { template, sender, recipient, templateParameters, departmentId, agentId } = state;
const payload = formatOutboundMessagePayload({
type: 'template',
template,
sender,
recipient,
templateParameters,
departmentId,
agentId,
});
await sendOutboundMessage.mutateAsync(payload);
dispatchToastMessage({
type: 'success',
message: t('Outbound_message_sent_to__name__', { name: contact?.name || formatPhoneNumber(recipient) }),
});
onSuccess?.();
} catch (error) {
dispatchToastMessage({ type: 'error', message: t('Outbound_message_not_sent') });
// only console.error when in debug mode
const urlParams = new URLSearchParams(window.location.search);
const debug = urlParams.get('debug') === 'true';
if (debug) {
console.error(error);
}
onError?.();
}
});
const handleDirtyStep = useStableCallback(() => {
wizardApi.resetNextSteps();
});
if (!isOmnichannelEnabled) {
return <OutboundMessageWizardErrorState title={t('error-not-authorized')} description={t('Omnichannel_is_not_enabled')} />;
}
if (!hasOutboundPermission) {
return (
<OutboundMessageWizardErrorState title={t('error-not-authorized')} description={t('You_are_not_authorized_to_access_this_feature')} />
);
}
if (isLoadingModule || isLoadingProviders) {
return <OutboubdMessageWizardSkeleton />;
}
if (isErrorProviders) {
return <OutboundMessageWizardErrorState onRetry={refetchProviders} />;
}
return (
<ErrorBoundary fallbackRender={() => <GenericError icon='circle-exclamation' />}>
<Wizard api={wizardApi} display='flex' flexDirection='column' height='100%'>
<WizardTabs />
<Box marginBlockStart={16} minHeight={0} flexGrow={1}>
<WizardContent id='recipient'>
<RecipientStep defaultValues={state} onDirty={handleDirtyStep} onSubmit={handleSubmit} />
</WizardContent>
<WizardContent id='message'>
<MessageStep defaultValues={state} contact={contact} templates={templates} onSubmit={handleSubmit} />
</WizardContent>
<WizardContent id='replies'>
<RepliesStep defaultValues={state} onSubmit={handleSubmit} />
</WizardContent>
<WizardContent id='review'>
<ReviewStep
sender={sender}
recipient={recipient}
contactName={contact?.name}
departmentName={department?.name}
providerType={provider?.providerType}
providerName={provider?.providerName}
agentUsername={agent?.username}
template={template}
templateParameters={templateParameters}
onSend={handleSend}
/>
</WizardContent>
</Box>
</Wizard>
</ErrorBoundary>
);
};
export default OutboundMessageWizard;

View File

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

View File

@@ -0,0 +1,26 @@
import { States, StatesIcon, StatesTitle, StatesActions, StatesAction, StatesSubtitle } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
type Props = {
title?: string;
description?: string;
onRetry?(): void;
};
const OutboundMessageWizardErrorState = ({ title, description, onRetry }: Props) => {
const { t } = useTranslation();
return (
<States width='100%' height='100%'>
<StatesIcon name='circle-exclamation' />
<StatesTitle>{title ?? t('Something_went_wrong')}</StatesTitle>
{description ? <StatesSubtitle>{description}</StatesSubtitle> : null}
{onRetry ? (
<StatesActions>
<StatesAction onClick={onRetry}>{t('Retry')}</StatesAction>
</StatesActions>
) : null}
</States>
);
};
export default OutboundMessageWizardErrorState;

View File

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

View File

@@ -0,0 +1,37 @@
import { css } from '@rocket.chat/css-in-js';
import { IconButton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type RetryButtonProps = {
loading: boolean;
onClick(): void;
};
/* NOTE: Necessary hack due to Field styles interfering with icons */
const btnStyle = css`
i {
font-family: 'RocketChat';
font-style: normal;
}
`;
const RetryButton = ({ loading, onClick }: RetryButtonProps) => {
const { t } = useTranslation();
return (
<IconButton
mini
danger
secondary
className={btnStyle}
disabled={loading}
rcx-button--loading={loading}
icon={loading ? 'loading' : 'reload'}
title={loading ? t('Loading...') : t('Retry')}
marginInlineStart={4}
onClick={() => onClick()}
/>
);
};
export default RetryButton;

View File

@@ -0,0 +1,183 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import MessageForm from './MessageForm';
import { createFakeContactWithManagerData } from '../../../../../../../../../tests/mocks/data';
import { createFakeOutboundTemplate } from '../../../../../../../../../tests/mocks/data/outbound-message';
const component = {
header: { type: 'header', text: 'New {{1}} appointment' },
body: { type: 'body', text: 'Hello {{1}}' },
} as const;
const template1 = createFakeOutboundTemplate({
id: 'template-1',
name: 'Template One',
components: [component.body],
});
export const template2 = createFakeOutboundTemplate({
id: 'template-2',
name: 'Template Two',
components: [component.header, component.body],
});
const mockTemplates = [template1, template2];
const mockContact = createFakeContactWithManagerData({ _id: 'contact-1', name: 'John Doe' });
const appRoot = mockAppRoot().withTranslations('en', 'core', {
Template: 'Template',
template: 'template',
Select_template: 'Select template',
Required_field: '{{field}} is required',
Template_message: 'Template message',
No_templates_available: 'No templates available',
Error_loading__name__information: 'Error loading {{name}} information',
Submit: 'Submit',
});
describe('MessageForm', () => {
const defaultProps = {
templates: mockTemplates,
contact: mockContact,
onSubmit: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should pass accessibility tests', async () => {
const { container } = render(<MessageForm {...defaultProps} />, { wrapper: appRoot.build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render correctly', async () => {
render(<MessageForm {...defaultProps} />, { wrapper: appRoot.build() });
expect(screen.getByLabelText('Template*')).toBeInTheDocument();
});
it('should render with default values', async () => {
const defaultValues = { templateId: 'template-1' };
render(<MessageForm {...defaultProps} defaultValues={defaultValues} />, { wrapper: appRoot.build() });
expect(screen.getByLabelText('Template*')).toHaveTextContent('Template One');
});
it('should show TemplateEditor when a template is selected', async () => {
render(<MessageForm {...defaultProps} />, { wrapper: appRoot.build() });
expect(screen.queryByText(component.body.text)).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Template*'));
await userEvent.click(await screen.findByRole('option', { name: /Template One/ }));
expect(screen.getByLabelText('Template*')).toHaveTextContent('Template One');
expect(await screen.findByText(component.body.text)).toBeInTheDocument();
});
it('should call onSubmit with correct data on successful submission', async () => {
const handleSubmit = jest.fn();
render(<MessageForm {...defaultProps} onSubmit={handleSubmit} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByLabelText('Template*'));
await userEvent.click(await screen.findByRole('option', { name: /Template One/ }));
await userEvent.type(screen.getByLabelText('Body {{1}}*'), 'Hello World');
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining({
templateId: 'template-1',
template: template1,
templateParameters: { body: [{ type: 'text', format: 'text', value: 'Hello World' }] },
}),
);
});
});
it('should show required error when submitting without a template', async () => {
const handleSubmit = jest.fn();
render(<MessageForm {...defaultProps} onSubmit={handleSubmit} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(await screen.findByText('Template message is required')).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
it('should show "no templates available" error if templates array is empty', async () => {
const handleSubmit = jest.fn();
render(<MessageForm {...defaultProps} templates={[]} onSubmit={handleSubmit} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(await screen.findByText('No templates available')).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
it('should throw an error if the selected template is not found on submit', async () => {
const handleSubmit = jest.fn();
const defaultValues = { templateId: 'template-1' };
render(<MessageForm {...defaultProps} templates={[template2]} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByLabelText('Template*')).not.toHaveAccessibleDescription('Error loading template information');
expect(handleSubmit).not.toHaveBeenCalled();
});
it('should show parameter is required when submitting without filling all parameters', async () => {
const handleSubmit = jest.fn();
const defaultValues = { templateId: 'template-1' };
render(<MessageForm {...defaultProps} onSubmit={handleSubmit} defaultValues={defaultValues} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByLabelText('Body {{1}}*')).toHaveAccessibleDescription('Body {{1}} is required');
expect(handleSubmit).not.toHaveBeenCalled();
await userEvent.type(screen.getByLabelText('Body {{1}}*'), 'Hello World');
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByLabelText('Body {{1}}*')).not.toHaveAccessibleDescription('Body {{1}} is required');
expect(handleSubmit).toHaveBeenCalled();
});
it('should render custom actions via renderActions prop', async () => {
const renderActions = jest.fn(({ isSubmitting }) => <button disabled={isSubmitting}>Custom Action</button>);
render(<MessageForm {...defaultProps} renderActions={renderActions} />, { wrapper: appRoot.build() });
expect(screen.getByRole('button', { name: 'Custom Action' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument();
expect(renderActions).toHaveBeenCalledWith({ isSubmitting: false });
});
it('should clear parameters field when template changes', async () => {
const defaultValues = { templateId: 'template-1' };
render(<MessageForm {...defaultProps} templates={[template1, template2]} defaultValues={defaultValues} onSubmit={jest.fn()} />, {
wrapper: appRoot.build(),
});
await userEvent.type(screen.getByLabelText('Body {{1}}*'), 'Hello World');
await userEvent.click(screen.getByLabelText('Template*'));
await userEvent.click(await screen.findByRole('option', { name: /Template Two/ }));
expect(screen.getByLabelText('Body {{1}}*')).toHaveValue('');
});
});

View File

@@ -0,0 +1,107 @@
import type { IOutboundProviderTemplate, Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { Box, Button, FieldGroup, Scrollable } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar';
import type { ReactNode } from 'react';
import { useId, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import TemplateField from './components/TemplateField';
import TemplatePlaceholderField from './components/TemplatePlaceholderField';
import TemplatePreviewForm from './components/TemplatePreviewField';
import type { TemplateParameters } from '../../../../types/template';
import { extractParameterMetadata } from '../../../../utils/template';
import Form from '../../components/OutboundMessageForm';
import { FormFetchError } from '../../utils/errors';
export type MessageFormData = {
templateId: string;
templateParameters: TemplateParameters;
};
export type MessageFormSubmitPayload = {
templateId: string;
template: IOutboundProviderTemplate;
templateParameters: TemplateParameters;
};
export type MessageFormProps = {
contact?: Omit<Serialized<ILivechatContact>, 'contactManager'>;
templates?: IOutboundProviderTemplate[];
onSubmit(values: MessageFormSubmitPayload): void;
renderActions?(state: { isSubmitting: boolean }): ReactNode;
defaultValues?: {
templateId?: string;
templateParameters?: TemplateParameters;
};
};
const MessageForm = (props: MessageFormProps) => {
const { defaultValues, templates, contact, renderActions, onSubmit } = props;
const dispatchToastMessage = useToastBarDispatch();
const { t } = useTranslation();
const messageFormId = useId();
const {
control,
formState: { isSubmitting },
handleSubmit,
setValue,
} = useForm<MessageFormData>({
mode: 'onChange',
defaultValues: {
templateParameters: defaultValues?.templateParameters ?? {},
templateId: defaultValues?.templateId ?? '',
},
});
const templateId = useWatch({ control, name: 'templateId' });
const template = useMemo(() => templates?.find((template) => template.id === templateId), [templates, templateId]);
const parametersMetadata = useMemo(() => (template ? extractParameterMetadata(template) : []), [template]);
const customActions = useMemo(() => renderActions?.({ isSubmitting }), [isSubmitting, renderActions]);
const submit = useStableCallback(async (values: MessageFormData) => {
try {
const { templateId, templateParameters } = values;
// It shouldn't be possible to get here without a template due to form validations.
// Adding this to be safe and ts compliant
if (!template) {
throw new FormFetchError('error-template-not-found');
}
onSubmit({ templateId, templateParameters, template });
} catch {
dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') });
}
});
return (
<Form id={messageFormId} onSubmit={handleSubmit(submit)} noValidate>
<Scrollable vertical>
<FieldGroup justifyContent='start' paddingInline={2}>
<TemplateField control={control} templates={templates} onChange={() => setValue('templateParameters', {})} />
{parametersMetadata.map((metadata) => (
<TemplatePlaceholderField key={metadata.id} control={control} metadata={metadata} contact={contact} />
))}
{template ? <TemplatePreviewForm control={control} template={template} /> : null}
</FieldGroup>
</Scrollable>
{customActions ?? (
<Box marginBlockStart={24} display='flex' justifyContent='end'>
<Button type='submit' primary loading={isSubmitting}>
{t('Submit')}
</Button>
</Box>
)}
</Form>
);
};
MessageForm.displayName = 'MessageForm';
export default MessageForm;

View File

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

View File

@@ -0,0 +1,61 @@
import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings';
import { Field, FieldError, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import type { Control } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { TemplateParameter, TemplateParameterMetadata } from '../../../../../types/template';
import TemplatePlaceholderInput from '../../../../TemplatePlaceholderSelector';
import type { MessageFormData } from '../MessageForm';
export type TemplatePlaceholderFieldProps = ComponentProps<typeof Field> & {
control: Control<MessageFormData>;
metadata: TemplateParameterMetadata;
contact?: Omit<Serialized<ILivechatContact>, 'contactManager'>;
};
const TemplatePlaceholderField = ({ control, metadata, contact, ...props }: TemplatePlaceholderFieldProps) => {
const { t } = useTranslation();
const { id, type, name, placeholder, format, componentType, index } = metadata;
const fieldLabel = `${t(name)} ${placeholder}`;
const {
field,
fieldState: { error },
} = useController({
control,
name: `templateParameters.${componentType}.${index}` as const,
defaultValue: { type, value: '', format } as TemplateParameter,
rules: { validate: (param) => (!param?.value?.trim() ? t('Required_field', { field: fieldLabel }) : true) },
shouldUnregister: true,
});
return (
<Field key={id} {...props}>
<FieldLabel required htmlFor={id}>
{fieldLabel}
</FieldLabel>
<FieldRow>
<TemplatePlaceholderInput
id={id}
type={type}
name={field.name}
contact={contact}
aria-describedby={error ? `${id}-error` : undefined}
value={field.value?.value ?? ''}
error={error?.message}
onChange={(value) => field.onChange({ type, value, format })}
/>
</FieldRow>
{error ? (
<FieldError aria-live='assertive' id={`${id}-error`}>
{error.message}
</FieldError>
) : null}
</Field>
);
};
export default TemplatePlaceholderField;

View File

@@ -0,0 +1,22 @@
import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings';
import type { Control } from 'react-hook-form';
import { useWatch } from 'react-hook-form';
import TemplatePreview from '../../../../TemplatePreview';
import type { MessageFormData } from '../MessageForm';
export type TemplatePreviewFieldProps = {
control: Control<MessageFormData>;
template: IOutboundProviderTemplate;
};
/**
* This component exists to isolate the re-rendering that happens whenever templateParameters changes.
*/
const TemplatePreviewField = ({ control, template }: TemplatePreviewFieldProps) => {
const templateParameters = useWatch({ control, name: 'templateParameters' });
return <TemplatePreview template={template} parameters={templateParameters} />;
};
export default TemplatePreviewField;

View File

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

View File

@@ -0,0 +1,484 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { screen, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { VirtuosoMockContext } from 'react-virtuoso';
import RecipientForm from './RecipientForm';
import { createFakeContactChannel, createFakeContactWithManagerData } from '../../../../../../../../../tests/mocks/data';
import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../../../../tests/mocks/data/outbound-message';
import type { OmnichannelContextValue } from '../../../../../../../../contexts/OmnichannelContext';
import { OmnichannelContext } from '../../../../../../../../contexts/OmnichannelContext';
const recipientOnePhoneNumber = '+12125554567';
const recipientTwoPhoneNumber = '+12125557788';
const senderOnePhoneNumber = '+12127774567';
const senderTwoPhoneNumber = '+12127778877';
const contactOneMock = createFakeContactWithManagerData({
_id: 'contact-1',
name: 'Contact 1',
phones: [{ phoneNumber: recipientOnePhoneNumber }],
unknown: false,
channels: [
createFakeContactChannel({
name: 'provider-1',
lastChat: { _id: '', ts: new Date().toISOString() },
}),
],
});
const contactTwoMock = createFakeContactWithManagerData({
_id: 'contact-2',
name: 'Contact 2',
phones: [{ phoneNumber: recipientTwoPhoneNumber }],
unknown: false,
channels: [
createFakeContactChannel({
name: 'provider-2',
lastChat: { _id: '', ts: new Date().toISOString() },
}),
],
});
const getContactMock = jest.fn().mockImplementation(() => ({ contact: contactOneMock }));
const getContactsMock = jest.fn().mockImplementation(() => ({
contacts: [contactOneMock, contactTwoMock],
count: 2,
offset: 0,
total: 2,
success: true,
}));
const providerOneMock = createFakeProviderMetadata({
providerId: 'provider-1',
providerName: 'Provider 1',
templates: {
[senderOnePhoneNumber]: [createFakeOutboundTemplate({ phoneNumber: senderOnePhoneNumber })],
},
});
const providerTwoMock = createFakeProviderMetadata({
providerId: 'provider-2',
providerName: 'Provider 2',
templates: {
[senderTwoPhoneNumber]: [createFakeOutboundTemplate({ phoneNumber: senderTwoPhoneNumber })],
},
});
const getProvidersMock = jest.fn().mockImplementation(() => ({ providers: [providerOneMock, providerTwoMock] }));
const getProviderMock = jest.fn().mockImplementation(() => ({ metadata: providerOneMock }));
const appRoot = mockAppRoot()
.withJohnDoe()
.withEndpoint('GET', '/v1/omnichannel/contacts.get', () => getContactMock())
.withEndpoint('GET', '/v1/omnichannel/contacts.search', () => getContactsMock())
.withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => getProvidersMock())
.withEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', () => getProviderMock())
.withTranslations('en', 'core', {
Error_loading__name__information: 'Error loading {{name}} information',
Last_contact__time__: 'Last contact {{time, relativeTime}}',
No_phone_number_yet_edit_contact: 'No phone number yet <1>Edit contact</1>',
No_phone_number_available_for_selected_channel: 'No phone number available for the selected channel',
Submit: 'Submit',
})
.wrap((children) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 28 }}>
<OmnichannelContext.Provider value={{ enabled: true } as OmnichannelContextValue}>{children}</OmnichannelContext.Provider>
</VirtuosoMockContext.Provider>
));
describe('RecipientForm', () => {
const defaultProps = {
onDirty: jest.fn(),
onSubmit: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly with all required fields', async () => {
render(<RecipientForm {...defaultProps} />, { wrapper: appRoot.build() });
expect(screen.getByLabelText('Contact*')).toBeInTheDocument();
expect(screen.getByLabelText('Channel*')).toBeInTheDocument();
expect(screen.getByLabelText('To*')).toBeInTheDocument();
expect(screen.getByLabelText('From*')).toBeInTheDocument();
await waitFor(() => expect(screen.getByLabelText('Contact*')).not.toHaveAttribute('aria-disabled'));
await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveAttribute('aria-disabled', 'true'));
expect(screen.getByLabelText('To*')).toHaveClass('disabled');
expect(screen.getByLabelText('From*')).toHaveClass('disabled');
});
it('should pass accessibility tests', async () => {
const { container } = render(<RecipientForm {...defaultProps} />, { wrapper: appRoot.build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render with default values', async () => {
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
recipient: recipientOnePhoneNumber,
sender: senderOnePhoneNumber,
};
render(<RecipientForm defaultValues={defaultValues} {...defaultProps} />, { wrapper: appRoot.build() });
await waitFor(() => expect(screen.getByLabelText('Contact*')).toHaveTextContent('Contact 1'));
await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveTextContent('Provider 1'));
expect(screen.getByLabelText('To*')).toHaveTextContent(/\+1 212-555-4567/);
expect(screen.getByLabelText('From*')).toHaveTextContent(/\+1 212-777-4567/);
});
it('should disable provider selection when no contact is selected', async () => {
render(<RecipientForm {...defaultProps} />, { wrapper: appRoot.build() });
await waitFor(() => expect(screen.getByLabelText('Contact*')).not.toHaveAttribute('aria-disabled'));
expect(screen.getByLabelText('Channel*')).toHaveAttribute('aria-disabled', 'true');
});
it('should disable recipient selection when no provider is selected', async () => {
const defaultValues = { contactId: 'contact-1' };
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} />, { wrapper: appRoot.build() });
await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveAttribute('aria-disabled', 'false'));
expect(screen.getByLabelText('To*')).toHaveClass('disabled');
});
it('should disable sender selection when no recipient is selected', async () => {
const defaultValues = { contactId: 'contact-1', providerId: 'provider-1' };
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} />, { wrapper: appRoot.build() });
await waitFor(() => expect(screen.getByLabelText('To*')).not.toHaveClass('disabled'));
expect(screen.getByLabelText('From*')).toHaveClass('disabled');
});
it('should show retry button when contact fetch fails', async () => {
getContactMock.mockImplementationOnce(() => Promise.reject());
render(<RecipientForm defaultValues={{ contactId: 'contact-1' }} {...defaultProps} />, { wrapper: appRoot.build() });
await waitFor(() => expect(screen.getByLabelText('Contact*')).toHaveAccessibleDescription('Error loading contact information'));
const retryButton = screen.getByRole('button', { name: 'Retry' });
expect(retryButton).toBeInTheDocument();
});
it('should show retry button when contact is unknown', async () => {
getContactMock.mockImplementationOnce(() => ({ contact: createFakeContactWithManagerData({ _id: 'contact-1', unknown: true }) }));
render(<RecipientForm defaultValues={{ contactId: 'contact-1' }} {...defaultProps} />, { wrapper: appRoot.build() });
await waitFor(() => expect(screen.getByLabelText('Contact*')).toHaveAccessibleDescription('Error loading contact information'));
const retryButton = screen.getByRole('button', { name: 'Retry' });
expect(retryButton).toBeInTheDocument();
});
it('should disable recipient field when contact fetch fails', async () => {
getContactMock.mockImplementationOnce(() => Promise.reject());
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
};
render(<RecipientForm defaultValues={defaultValues} {...defaultProps} />, { wrapper: appRoot.build() });
await waitFor(() => expect(screen.getByLabelText('To*')).not.toHaveTextContent('Loading...'));
expect(screen.getByLabelText('To*')).toHaveClass('disabled');
});
it('should show retry button when provider fetch fails', async () => {
getProviderMock.mockImplementationOnce(() => Promise.reject());
render(<RecipientForm defaultValues={{ contactId: 'contact-1', providerId: 'provider-1' }} {...defaultProps} />, {
wrapper: appRoot.build(),
});
const channelErrorMessage = await screen.findByText('Error loading channel information');
expect(channelErrorMessage).toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: 'Retry' });
expect(retryButton).toBeInTheDocument();
});
it('should disable sender when provider fetch fails', async () => {
getProviderMock.mockImplementationOnce(() => Promise.reject());
render(<RecipientForm defaultValues={{ contactId: 'contact-1', providerId: 'provider-1' }} {...defaultProps} />, {
wrapper: appRoot.build(),
});
await waitFor(() => expect(screen.getByLabelText('From*')).not.toHaveTextContent('Loading...'));
expect(screen.getByLabelText('From*')).toHaveClass('disabled');
});
it('should call retry contact fetch when retry button is clicked', async () => {
getContactMock.mockImplementationOnce(() => Promise.reject());
render(<RecipientForm defaultValues={{ contactId: 'contact-1' }} {...defaultProps} />, {
wrapper: appRoot.build(),
});
expect(await screen.findByText('Error loading contact information')).toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: 'Retry' });
await userEvent.click(retryButton);
await waitFor(() => expect(screen.queryByText('Error loading contact information')).not.toBeInTheDocument());
});
it('should call retry channel fetch when retry button is clicked', async () => {
getProviderMock.mockImplementationOnce(() => Promise.reject());
render(<RecipientForm defaultValues={{ contactId: 'contact-1', providerId: 'provider-1' }} {...defaultProps} />, {
wrapper: appRoot.build(),
});
expect(await screen.findByText('Error loading channel information')).toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: 'Retry' });
await userEvent.click(retryButton);
await waitFor(() => expect(screen.queryByText('Error loading channel information')).not.toBeInTheDocument());
});
it('should display channel hint when provider has lastChat', async () => {
render(<RecipientForm defaultValues={{ contactId: 'contact-1', providerId: 'provider-1' }} {...defaultProps} />, {
wrapper: appRoot.build(),
});
await waitFor(() => expect(screen.getByLabelText('Channel*')).toHaveAccessibleDescription('Last contact less than a minute ago'));
});
it('should call onSubmit with correct values when form is submitted', async () => {
const handleSubmit = jest.fn();
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
recipient: recipientOnePhoneNumber,
sender: senderOnePhoneNumber,
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() =>
expect(handleSubmit).toHaveBeenCalledWith({
contactId: 'contact-1',
providerId: 'provider-1',
recipient: recipientOnePhoneNumber,
sender: senderOnePhoneNumber,
contact: contactOneMock,
provider: providerOneMock,
}),
);
});
it('should not call onSubmit when form is invalid', async () => {
const handleSubmit = jest.fn();
render(<RecipientForm {...defaultProps} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled());
});
it('should display no phone number error when contact has no phones', async () => {
const handleSubmit = jest.fn();
getContactMock.mockImplementationOnce(() => ({
contact: createFakeContactWithManagerData({ ...contactOneMock, phones: undefined }),
}));
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
const errorMessage = await screen.findByText(/No phone number yet/);
expect(errorMessage).toBeInTheDocument();
const editLink = screen.getByText(/edit contact/i);
expect(editLink).toBeInTheDocument();
});
it('should call onDirty when form becomes dirty', async () => {
const mockOnDirty = jest.fn();
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
recipient: recipientOnePhoneNumber,
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onDirty={mockOnDirty} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByLabelText('From*'));
const phoneOption = await screen.findByRole('option', { name: '+1 212-777-4567' });
expect(phoneOption).toBeInTheDocument();
await userEvent.click(phoneOption);
expect(mockOnDirty).toHaveBeenCalledTimes(1);
});
it('should clear recipient field when contact changes', async () => {
getContactMock.mockImplementationOnce(() => ({ contact: contactOneMock }));
getContactMock.mockImplementationOnce(() => ({ contact: contactTwoMock }));
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
recipient: recipientOnePhoneNumber,
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={jest.fn()} />, {
wrapper: appRoot.build(),
});
await waitFor(() => expect(screen.getByLabelText('To*')).toHaveTextContent(/\+1 212-555-4567/));
await userEvent.click(screen.getByLabelText('Contact*'));
await userEvent.click(await screen.findByRole('option', { name: /Contact 2/ }));
await waitFor(() => expect(screen.getByLabelText('To*')).toHaveTextContent('Contact_detail'));
});
it('should validate sender when no phone numbers available for selected channel', async () => {
const handleSubmit = jest.fn();
getProviderMock.mockImplementationOnce(() => ({
metadata: createFakeProviderMetadata({ ...providerOneMock, templates: {} }),
}));
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
recipient: recipientOnePhoneNumber,
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByLabelText('From*')).toHaveAccessibleDescription('No phone number available for the selected channel');
});
it('should clear sender field when provider changes', async () => {
getProviderMock.mockImplementationOnce(() => ({ metadata: providerOneMock }));
getProviderMock.mockImplementationOnce(() => ({ metadata: providerTwoMock }));
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
recipient: recipientOnePhoneNumber,
sender: senderOnePhoneNumber,
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={jest.fn()} />, {
wrapper: appRoot.build(),
});
await waitFor(() => expect(screen.getByLabelText('From*')).toHaveTextContent(/\+1 212-777-4567/));
await userEvent.click(screen.getByLabelText('Channel*'));
await userEvent.click(await screen.findByRole('option', { name: /Provider 2/ }));
await waitFor(() => expect(screen.getByLabelText('From*')).toHaveTextContent('Workspace_detail'));
});
it('should validate contact field is required', async () => {
const handleSubmit = jest.fn();
render(<RecipientForm {...defaultProps} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByLabelText('Contact*')).toHaveAccessibleDescription('Required_field');
await userEvent.click(screen.getByLabelText('Contact*'));
await userEvent.click(await screen.findByRole('option', { name: /Contact 1/ }));
expect(screen.getByLabelText('Contact*')).not.toHaveAccessibleDescription('Required_field');
});
it('should validate channel field is required', async () => {
const handleSubmit = jest.fn();
const defaultValues = {
contactId: 'contact-1',
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByLabelText('Channel*')).toHaveAccessibleDescription('Required_field');
await userEvent.click(screen.getByLabelText('Channel*'));
await userEvent.click(await screen.findByRole('option', { name: /Provider 1/ }));
expect(screen.getByLabelText('Channel*')).not.toHaveAccessibleDescription('Required_field');
await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled());
});
it('should validate recipient field is required', async () => {
const handleSubmit = jest.fn();
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByLabelText('To*')).toHaveAccessibleDescription('Required_field');
await userEvent.click(screen.getByLabelText('To*'));
await userEvent.click(await screen.findByRole('option', { name: /\+1 212-555-4567/ }));
expect(screen.getByLabelText('To*')).not.toHaveAccessibleDescription('Required_field');
await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled());
});
it('should validate sender field is required', async () => {
const handleSubmit = jest.fn();
const defaultValues = {
contactId: 'contact-1',
providerId: 'provider-1',
recipient: recipientOnePhoneNumber,
};
render(<RecipientForm {...defaultProps} defaultValues={defaultValues} onSubmit={handleSubmit} />, {
wrapper: appRoot.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByLabelText('From*')).toHaveAccessibleDescription('Required_field');
await userEvent.click(screen.getByLabelText('From*'));
await userEvent.click(await screen.findByRole('option', { name: /\+1 212-777-4567/ }));
expect(screen.getByLabelText('From*')).not.toHaveAccessibleDescription('Required_field');
await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,229 @@
import type { IOutboundProviderMetadata, Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { Box, Button, FieldGroup, Scrollable } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEffect, useId, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ChannelField from './components/ChannelField';
import ContactField from './components/ContactField';
import RecipientField from './components/RecipientField';
import SenderField from './components/SenderField';
import { omnichannelQueryKeys } from '../../../../../../../../lib/queryKeys';
import Form from '../../components/OutboundMessageForm';
import { ContactNotFoundError, ProviderNotFoundError } from '../../utils/errors';
export type RecipientFormData = {
contactId: string;
providerId: string;
recipient: string;
sender: string;
};
export type RecipientFormSubmitPayload = {
contactId: string;
contact: Serialized<ILivechatContact>;
providerId: string;
provider: IOutboundProviderMetadata;
recipient: string;
sender: string;
};
export type RecipientFormProps = {
defaultValues?: Partial<RecipientFormData>;
onDirty?(): void;
onSubmit(values: RecipientFormSubmitPayload): void;
renderActions?(state: { isSubmitting: boolean }): ReactNode;
};
const RecipientForm = (props: RecipientFormProps) => {
const { defaultValues, renderActions, onDirty, onSubmit } = props;
const dispatchToastMessage = useToastBarDispatch();
const { trigger, control, handleSubmit, formState, clearErrors, setValue } = useForm<RecipientFormData>({
mode: 'onChange',
defaultValues: {
contactId: defaultValues?.contactId ?? '',
providerId: defaultValues?.providerId ?? '',
recipient: defaultValues?.recipient ?? '',
sender: defaultValues?.sender ?? '',
},
});
const { isDirty, isSubmitting } = formState;
const recipientFormId = useId();
const [contactId, providerId, recipient] = useWatch({
name: ['contactId', 'providerId', 'recipient'],
control,
});
const { t } = useTranslation();
const getContact = useEndpoint('GET', '/v1/omnichannel/contacts.get');
const getProvider = useEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', { id: providerId });
const customActions = useMemo(() => renderActions?.({ isSubmitting }), [isSubmitting, renderActions]);
const {
data: contact,
isError: isErrorContact,
isSuccess: isSuccessContact,
isFetching: isFetchingContact,
refetch: refetchContact,
} = useQuery({
queryKey: omnichannelQueryKeys.contact(contactId),
queryFn: async () => {
const data = await getContact({ contactId });
// TODO: Can be safely removed once unknown contacts handling is added to the endpoint
if (data?.contact && data.contact.unknown) {
throw new ContactNotFoundError();
}
return data;
},
staleTime: 5 * 60 * 1000,
select: (data) => data?.contact || undefined,
enabled: !!contactId,
});
const {
data: provider,
isError: isErrorProvider,
isSuccess: isSuccessProvider,
isFetching: isFetchingProvider,
refetch: refetchProvider,
} = useQuery({
queryKey: omnichannelQueryKeys.outboundProviderMetadata(providerId),
queryFn: () => getProvider(),
select: (data) => data?.metadata,
staleTime: 5 * 60 * 1000,
enabled: !!providerId,
});
const hasRecipientOptions = !!contact && !!contact.phones?.length;
const hasProviderOptions = !!provider && !!Object.keys(provider.templates).length;
const isContactNotFound = isSuccessContact && !contact;
const isProviderNotFound = isSuccessProvider && !provider;
const validateContactField = useStableCallback((shouldValidate = false) => {
trigger('contactId');
setValue('recipient', '', { shouldValidate });
});
const validateProviderField = useStableCallback((shouldValidate = false) => {
trigger('providerId');
setValue('sender', '', { shouldValidate });
});
useEffect(() => {
if (isSuccessContact && !hasRecipientOptions) {
trigger('recipient');
}
return () => clearErrors('recipient');
}, [clearErrors, trigger, hasRecipientOptions, isSuccessContact]);
useEffect(() => {
if (isSuccessProvider && !hasProviderOptions) {
trigger('sender');
}
return () => clearErrors('sender');
}, [clearErrors, trigger, hasProviderOptions, isSuccessProvider]);
useEffect(() => {
isErrorContact && validateContactField();
return () => clearErrors('contactId');
}, [clearErrors, isErrorContact, validateContactField]);
useEffect(() => {
isErrorProvider && validateProviderField();
return () => clearErrors('providerId');
}, [clearErrors, isErrorProvider, validateProviderField]);
useEffect(() => {
isDirty && onDirty && onDirty();
}, [isDirty, onDirty]);
const submit = useStableCallback(async (values: RecipientFormData) => {
try {
// Wait if contact or provider is still being fetched in background
const [updatedContact, updatedProvider] = await Promise.all([
isFetchingContact ? refetchContact().then((r) => r.data) : Promise.resolve(contact),
isFetchingProvider ? refetchProvider().then((r) => r.data) : Promise.resolve(provider),
]);
if (!updatedContact) {
throw new ContactNotFoundError();
}
if (!updatedProvider) {
throw new ProviderNotFoundError();
}
onSubmit({ ...values, provider: updatedProvider, contact: updatedContact });
} catch (error) {
if (error instanceof ContactNotFoundError) {
validateContactField(true);
return;
}
if (error instanceof ProviderNotFoundError) {
validateProviderField(true);
return;
}
dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') });
}
});
return (
<Form id={recipientFormId} onSubmit={handleSubmit(submit)} noValidate>
<Scrollable vertical>
<FieldGroup justifyContent='start' paddingInline={2}>
<ContactField
control={control}
isError={isErrorContact || isContactNotFound}
isFetching={isFetchingContact}
onRetry={refetchContact}
/>
<ChannelField
control={control}
contact={contact}
disabled={!contactId}
isFetching={isFetchingProvider}
isError={isErrorProvider || isProviderNotFound}
onRetry={refetchProvider}
/>
<RecipientField
control={control}
contact={contact}
type={provider?.providerType || 'phone'}
disabled={!providerId || !contact}
isLoading={isFetchingContact}
/>
<SenderField control={control} provider={provider} disabled={!recipient || !provider} isLoading={isFetchingProvider} />
</FieldGroup>
</Scrollable>
{customActions ?? (
<Box marginBlockStart={24} display='flex' justifyContent='end' flexGrow={0} flexShrink={0}>
<Button type='submit' primary loading={isSubmitting}>
{t('Submit')}
</Button>
</Box>
)}
</Form>
);
};
export default RecipientForm;

View File

@@ -0,0 +1,90 @@
import type { Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { Field, FieldError, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useId, useMemo } from 'react';
import type { ComponentProps } from 'react';
import type { Control } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useTimeFromNow } from '../../../../../../../../../hooks/useTimeFromNow';
import { findLastChatFromChannel } from '../../../../../utils/findLastChatFromChannel';
import AutoCompleteOutboundProvider from '../../../../AutoCompleteOutboundProvider';
import RetryButton from '../../../components/RetryButton';
import { cxp } from '../../../utils/cx';
import type { RecipientFormData } from '../RecipientForm';
export type ProviderFieldProps = ComponentProps<typeof Field> & {
control: Control<RecipientFormData>;
contact?: Omit<Serialized<ILivechatContact>, 'contactManager'>;
disabled?: boolean;
isError: boolean;
isFetching: boolean;
onRetry: () => void;
};
const ProviderField = ({
control,
contact,
disabled = false,
isError = false,
isFetching = false,
onRetry,
...props
}: ProviderFieldProps) => {
const { t } = useTranslation();
const channelFieldId = useId();
const getTimeFromNow = useTimeFromNow(true);
const {
field: providerField,
fieldState: { error: providerFieldError },
} = useController({
control,
name: 'providerId',
rules: {
validate: {
fetchError: () => (isError ? t('Error_loading__name__information', { name: t('channel') }) : true),
required: (value) => (!value ? t('Required_field', { field: t('Channel') }) : true),
},
},
});
const providerLastChat = useMemo(() => {
return findLastChatFromChannel(contact?.channels, providerField.value);
}, [contact?.channels, providerField.value]);
return (
<Field {...props}>
<FieldLabel is='span' required id={channelFieldId}>
{t('Channel')}
</FieldLabel>
<FieldRow>
<AutoCompleteOutboundProvider
contact={contact}
aria-labelledby={channelFieldId}
aria-invalid={!!providerFieldError}
aria-describedby={cxp(channelFieldId, {
error: !!providerFieldError,
hint: !!providerLastChat,
})}
error={providerFieldError?.message}
disabled={disabled}
placeholder={t('Select_channel')}
value={providerField.value}
onChange={providerField.onChange}
/>
</FieldRow>
{providerFieldError && (
<FieldError aria-live='assertive' id={`${channelFieldId}-error`} display='flex' alignItems='center'>
{providerFieldError.message}
{isError && <RetryButton loading={isFetching} onClick={onRetry} />}
</FieldError>
)}
{providerLastChat && (
<FieldHint id={`${channelFieldId}-hint`}>{t('Last_contact__time__', { time: getTimeFromNow(providerLastChat) })}</FieldHint>
)}
</Field>
);
};
export default ProviderField;

View File

@@ -0,0 +1,85 @@
import { Box, Field, FieldError, FieldLabel, FieldRow, Option, OptionContent, OptionDescription } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useId } from 'react';
import type { ComponentProps } from 'react';
import { useController, type Control } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { formatPhoneNumber } from '../../../../../../../../../lib/formatPhoneNumber';
import AutoCompleteContact from '../../../../../../AutoCompleteContact';
import RetryButton from '../../../components/RetryButton';
import type { RecipientFormData } from '../RecipientForm';
export type ContactFieldProps = ComponentProps<typeof Field> & {
control: Control<RecipientFormData>;
isError: boolean;
isFetching: boolean;
onRetry: () => void;
};
type RenderFnType = Required<ComponentProps<typeof AutoCompleteContact>>['renderItem'];
const ContactField = ({ control, isError = false, isFetching = false, onRetry, ...props }: ContactFieldProps) => {
const { t } = useTranslation();
const contactFieldId = useId();
const {
field: contactField,
fieldState: { error: contactFieldError },
} = useController({
control,
name: 'contactId',
rules: {
validate: {
fetchError: () => (isError ? t('Error_loading__name__information', { name: t('contact') }) : true),
required: (value) => (!value ? t('Required_field', { field: t('Contact') }) : true),
},
},
});
const renderContactOption = useStableCallback<RenderFnType>(({ label, ...props }, { phones }) => {
const phoneList = phones?.map((p) => formatPhoneNumber(p.phoneNumber)).join(', ');
return (
<Option {...props} avatar={<UserAvatar title={label} username={label} size='x20' />}>
<Box is={OptionContent} marginInlineEnd='auto' flexGrow={0} flexShrink={0} flexBasis={0}>
{label}
</Box>
{phones?.length ? (
<Box withTruncatedText flexGrow={0} flexShrink={1} title={phoneList}>
<OptionDescription>{`(${phoneList})`}</OptionDescription>
</Box>
) : null}
</Option>
);
});
return (
<Field {...props}>
<FieldLabel is='span' required id={contactFieldId}>
{t('Contact')}
</FieldLabel>
<FieldRow>
<AutoCompleteContact
aria-labelledby={contactFieldId}
aria-describedby={contactFieldError && `${contactFieldId}-error`}
aria-invalid={!!contactFieldError}
placeholder={t('Select_recipient')}
value={contactField.value}
onChange={contactField.onChange}
error={contactFieldError?.message}
renderItem={renderContactOption}
/>
</FieldRow>
{contactFieldError && (
<FieldError aria-live='assertive' id={`${contactFieldId}-error`} display='flex' alignItems='center'>
{contactFieldError.message}
{isError && <RetryButton loading={isFetching} onClick={onRetry} />}
</FieldError>
)}
</Field>
);
};
export default ContactField;

View File

@@ -0,0 +1,73 @@
import type { Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { Field, FieldError, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { ComponentProps } from 'react';
import { useController, type Control } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import RecipientSelect from '../../../../RecipientSelect';
import type { RecipientFormData } from '../RecipientForm';
export type RecipientFieldProps = ComponentProps<typeof Field> & {
control: Control<RecipientFormData>;
contact?: Omit<Serialized<ILivechatContact>, 'contactManager'>;
type: 'phone' | 'email';
disabled?: boolean;
isLoading: boolean;
};
const RecipientField = ({ control, contact, type, disabled = false, isLoading = false, ...props }: RecipientFieldProps) => {
const { t } = useTranslation();
const recipientFieldId = useId();
const {
field: recipientField,
fieldState: { error: recipientFieldError },
} = useController({
control,
name: 'recipient',
rules: {
validate: {
noPhoneNumber: () => (type === 'phone' && contact ? !!contact.phones?.length : true),
required: (value) => (!value ? t('Required_field', { field: t('To') }) : true),
},
},
});
return (
<Field {...props}>
<FieldLabel is='span' required id={`${recipientFieldId}`}>
{t('To')}
</FieldLabel>
<FieldRow>
<RecipientSelect
contact={contact}
type={type}
aria-busy={isLoading}
aria-labelledby={recipientFieldId}
aria-describedby={recipientFieldError && `${recipientFieldId}-error`}
aria-invalid={!!recipientFieldError}
error={recipientFieldError && 'error'}
placeholder={isLoading ? t('Loading...') : t('Contact_detail')}
value={recipientField.value}
disabled={disabled || isLoading}
onChange={recipientField.onChange}
/>
</FieldRow>
{recipientFieldError?.type === 'required' && (
<FieldError aria-live='assertive' id={`${recipientFieldId}-error`}>
{recipientFieldError.message}
</FieldError>
)}
{recipientFieldError?.type === 'noPhoneNumber' && (
<FieldError aria-live='assertive' id={`${recipientFieldId}-error`}>
<Trans i18nKey='No_phone_number_yet_edit_contact'>
No phone number yet <a href={`/omnichannel-directory/contacts/edit/${contact?._id}`}>Edit contact</a>
</Trans>
</FieldError>
)}
</Field>
);
};
export default RecipientField;

View File

@@ -0,0 +1,66 @@
import type { Serialized, IOutboundProviderMetadata } from '@rocket.chat/core-typings';
import { Field, FieldError, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { ComponentProps } from 'react';
import { useController, type Control } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import SenderSelect from '../../../../SenderSelect';
import type { RecipientFormData } from '../RecipientForm';
export type SenderFieldProps = ComponentProps<typeof Field> & {
control: Control<RecipientFormData>;
provider?: Serialized<IOutboundProviderMetadata>;
disabled?: boolean;
isLoading: boolean;
};
const SenderField = ({ control, provider, disabled = false, isLoading = false, ...props }: SenderFieldProps) => {
const { t } = useTranslation();
const senderFieldId = useId();
const hasProviderOptions = !!provider && !!Object.keys(provider.templates).length;
const {
field: senderField,
fieldState: { error: senderFieldError },
} = useController({
control,
name: 'sender',
rules: {
validate: {
noPhoneNumber: () => hasProviderOptions || t('No_phone_number_available_for_selected_channel'),
required: (value) => (!value ? t('Required_field', { field: t('From') }) : true),
},
},
});
return (
<Field {...props}>
<FieldLabel is='span' required id={senderFieldId}>
{t('From')}
</FieldLabel>
<FieldRow>
<SenderSelect
provider={provider}
aria-busy={isLoading}
aria-labelledby={senderFieldId}
aria-describedby={senderFieldError && `${senderFieldId}-error`}
aria-invalid={!!senderFieldError}
error={senderFieldError?.message}
disabled={disabled || isLoading}
placeholder={isLoading ? t('Loading...') : t('Workspace_detail')}
value={senderField.value}
onChange={senderField.onChange}
/>
</FieldRow>
{senderFieldError && (
<FieldError aria-live='assertive' id={`${senderFieldId}-error`}>
{senderFieldError.message}
</FieldError>
)}
</Field>
);
};
export default SenderField;

View File

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

View File

@@ -0,0 +1,268 @@
import type { ILivechatAgent, Serialized } from '@rocket.chat/core-typings';
import { ILivechatAgentStatus, UserStatus } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { screen, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { VirtuosoMockContext } from 'react-virtuoso';
import RepliesForm from './RepliesForm';
import { createFakeDepartment, createFakeUser } from '../../../../../../../../../tests/mocks/data';
const mockDepartment = createFakeDepartment({
_id: 'department-1',
name: 'Department 1',
});
const mockUser = createFakeUser({
_id: 'agent-1',
username: 'agent.one',
name: 'Agent One',
roles: ['livechat-agent'],
});
const mockAgentOne: Serialized<ILivechatAgent> = {
_id: 'agent-1',
username: 'agent.one',
name: 'Agent 1',
status: UserStatus.ONLINE,
statusLivechat: ILivechatAgentStatus.AVAILABLE,
emails: [{ address: 'a1@test.com', verified: true }],
_updatedAt: '',
createdAt: '',
active: true,
lastRoutingTime: '',
livechatCount: 1,
roles: [],
type: '',
};
const mockAgentTwo: Serialized<ILivechatAgent> = {
_id: 'agent-2',
username: 'agent.two',
name: 'Agent 2',
status: UserStatus.ONLINE,
statusLivechat: ILivechatAgentStatus.AVAILABLE,
emails: [{ address: 'a2@test.com', verified: true }],
_updatedAt: '',
createdAt: '',
active: true,
lastRoutingTime: '',
livechatCount: 1,
roles: [],
type: '',
};
const mockDepartmentAgentOne = {
...mockAgentOne,
username: mockAgentOne.username || '',
agentId: mockUser._id,
departmentId: mockDepartment._id,
departmentEnabled: true,
count: 0,
order: 0,
};
const mockDepartmentAgentTwo = {
...mockAgentTwo,
username: mockAgentTwo.username || '',
agentId: mockAgentTwo._id,
departmentId: mockDepartment._id,
departmentEnabled: true,
count: 0,
order: 0,
};
const getDepartmentMock = jest.fn();
const getDepartmentsAutocompleteMock = jest.fn().mockImplementation(() => ({
departments: [mockDepartment],
count: 1,
total: 1,
offset: 0,
}));
const appRoot = (permissions = ['outbound.can-assign-self-only', 'outbound.can-assign-any-agent']) => {
const root = mockAppRoot()
.withUser(mockUser)
.withEndpoint('GET', '/v1/livechat/department/:_id', () => getDepartmentMock())
.withEndpoint('GET', '/v1/livechat/department', () => getDepartmentsAutocompleteMock())
.withTranslations('en', 'core', {
Department: 'Department',
Agent: 'Agent',
optional: 'optional',
Select_department: 'Select department',
Select_agent: 'Select agent',
Error_loading__name__information: 'Error loading {{name}} information',
Retry: 'Retry',
Outbound_message_agent_hint: 'Leave empty so any agent from the designated department can manage the replies.',
Outbound_message_agent_hint_no_permission:
"You don't have permission to assign an agent. The reply will be assigned to the department.",
})
.wrap((children) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 28 }}>{children}</VirtuosoMockContext.Provider>
));
permissions.forEach((permission) => {
root.withPermission(permission);
});
return root;
};
describe('RepliesForm', () => {
beforeEach(() => {
jest.clearAllMocks();
getDepartmentMock.mockImplementation(() => ({
department: mockDepartment,
agents: [mockDepartmentAgentOne, mockDepartmentAgentTwo],
}));
});
it('should pass accessibility tests', async () => {
const { container } = render(<RepliesForm onSubmit={jest.fn()} />, { wrapper: appRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders correctly with all fields', async () => {
render(<RepliesForm onSubmit={jest.fn()} />, { wrapper: appRoot().build() });
expect(screen.getByLabelText('Department (optional)')).toBeInTheDocument();
expect(screen.getByLabelText('Agent (optional)')).toBeInTheDocument();
expect(screen.getByLabelText('Department (optional)')).not.toHaveAttribute('aria-disabled');
expect(screen.getByLabelText('Agent (optional)')).toBeDisabled();
});
xit('should render with default values', async () => {
const defaultValues = {
departmentId: 'department-1',
agentId: 'agent-1',
};
render(<RepliesForm defaultValues={defaultValues} onSubmit={jest.fn()} />, {
wrapper: appRoot().build(),
});
await waitFor(() => expect(screen.getByLabelText('Department (optional)')).toHaveTextContent('Department 1'));
await waitFor(() => expect(screen.getByLabelText('Agent (optional)')).toHaveTextContent('agent.one'));
});
it('should enable agent selection when a department is selected', async () => {
render(<RepliesForm onSubmit={jest.fn()} />, { wrapper: appRoot().build() });
const departmentInput = screen.getByLabelText('Department (optional)');
const agentInput = screen.getByLabelText('Agent (optional)');
await waitFor(() => expect(departmentInput).not.toHaveAttribute('aria-busy', 'true'));
expect(agentInput).toBeDisabled();
await userEvent.click(departmentInput);
await userEvent.click(await screen.findByRole('option', { name: 'Department 1' }));
await waitFor(() => expect(agentInput).toBeEnabled());
});
it('should show retry button when department fetch fails and retry when button is clicked', async () => {
getDepartmentMock
.mockRejectedValueOnce(new Error('API Error')) // useDepartmentList call
.mockRejectedValueOnce(new Error('API Error')); // RepliesForm call
render(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={jest.fn()} />, {
wrapper: appRoot().build(),
});
const departmentErrorMessage = await screen.findByText('Error loading department information');
expect(departmentErrorMessage).toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: 'Retry' });
await userEvent.click(retryButton);
await waitFor(() => expect(screen.queryByText('Error loading department information')).not.toBeInTheDocument());
});
xit('should call submit with correct values when form is submitted', async () => {
const handleSubmit = jest.fn();
const defaultValues = {
departmentId: 'department-1',
agentId: 'agent-1',
};
render(<RepliesForm defaultValues={defaultValues} onSubmit={handleSubmit} />, {
wrapper: appRoot().build(),
});
await waitFor(() =>
expect(handleSubmit).toHaveBeenCalledWith({
departmentId: 'department-1',
department: mockDepartment,
agentId: 'agent-1',
agent: mockAgentOne,
}),
);
});
it('should not submit if department is not found', async () => {
const handleSubmit = jest.fn();
getDepartmentMock.mockResolvedValue({ department: null, agents: [] });
render(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={handleSubmit} />, { wrapper: appRoot().build() });
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled());
});
it('should not submit if agent is not found', async () => {
getDepartmentMock.mockResolvedValue({ department: mockDepartment, agents: [] });
const handleSubmit = jest.fn();
render(<RepliesForm defaultValues={{ departmentId: 'department-1', agentId: 'agent-1' }} onSubmit={handleSubmit} />, {
wrapper: appRoot([]).build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(handleSubmit).not.toHaveBeenCalled());
});
it('should not enable "Agent" field if the user doesnt have assign agent permissions', async () => {
render(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={jest.fn()} />, {
wrapper: appRoot([]).build(),
});
expect(screen.getByLabelText('Agent (optional)')).toBeDisabled();
await expect(screen.getByLabelText('Agent (optional)')).toHaveAccessibleDescription(
`You don't have permission to assign an agent. The reply will be assigned to the department.`,
);
});
it('should display only self when user doesnt have assign any permission', async () => {
render(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={jest.fn()} />, {
wrapper: appRoot(['outbound.can-assign-self-only']).build(),
});
await userEvent.click(screen.getByLabelText('Agent (optional)'));
const agentOneOption = await screen.findByRole('option', { name: 'agent.one' });
const agentTwoOption = screen.queryByRole('option', { name: 'agent.two' });
expect(agentOneOption).toBeInTheDocument();
expect(agentTwoOption).not.toBeInTheDocument();
});
it('should display all agents when user has assign any permission', async () => {
render(<RepliesForm defaultValues={{ departmentId: 'department-1' }} onSubmit={jest.fn()} />, {
wrapper: appRoot(['outbound.can-assign-any-agent']).build(),
});
await userEvent.click(screen.getByLabelText('Agent (optional)'));
const agentOneOption = await screen.findByRole('option', { name: 'agent.one' });
const agentTwoOption = await screen.findByRole('option', { name: 'agent.two' });
expect(agentOneOption).toBeInTheDocument();
expect(agentTwoOption).toBeInTheDocument();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import type { MessageFormSubmitPayload } from './MessageForm';
import type { RecipientFormSubmitPayload } from './RecipientForm';
import type { RepliesFormSubmitPayload } from './RepliesForm';
export type SubmitPayload = RecipientFormSubmitPayload & MessageFormSubmitPayload & RepliesFormSubmitPayload;
export { default as RecipientForm } from './RecipientForm';
export { default as MessageForm } from './MessageForm';
export { default as RepliesForm } from './RepliesForm';

View File

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

View File

@@ -0,0 +1,129 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import { composeStories } from '@storybook/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { act, type ComponentProps } from 'react';
import MessageStep from './MessageStep';
import * as stories from './MessageStep.stories';
import { createFakeContact } from '../../../../../../../../tests/mocks/data';
import { createFakeOutboundTemplate } from '../../../../../../../../tests/mocks/data/outbound-message';
import type { MessageFormSubmitPayload } from '../forms/MessageForm';
import type MessageForm from '../forms/MessageForm';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
jest.mock('tinykeys', () => ({
__esModule: true,
default: jest.fn().mockReturnValue(() => () => undefined),
}));
let isSubmitting = false;
let currentOnSubmit: (payload: MessageFormSubmitPayload) => void = () => undefined;
const mockMessageForm = jest.fn().mockImplementation((props) => {
currentOnSubmit = props.onSubmit;
return <div data-testid='message-form'>{props.renderActions?.({ isSubmitting })}</div>;
});
jest.mock('../forms/MessageForm', () => ({
__esModule: true,
default: (props: ComponentProps<typeof MessageForm>) => mockMessageForm(props),
}));
const steps = new StepsLinkedList([
{ id: 'test-step-1', title: 'Test Step 1' },
{ id: 'test-step-2', title: 'Test Step 2' },
{ id: 'test-step-3', title: 'Test Step 3' },
]);
const mockWizardApi = {
steps,
currentStep: steps.head?.next ?? null,
next: jest.fn(),
previous: jest.fn(),
register: jest.fn(),
goTo: jest.fn(),
resetNextSteps: jest.fn(),
};
const appRoot = mockAppRoot()
.withJohnDoe()
.wrap((children) => {
return <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
});
describe('MessageStep', () => {
beforeEach(() => {
currentOnSubmit = () => undefined;
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render message form with correct props', () => {
const defaultValues = { templateId: 'test-template-id' };
const contact = createFakeContact();
const templates = [createFakeOutboundTemplate()];
render(<MessageStep contact={contact} templates={templates} defaultValues={defaultValues} onSubmit={jest.fn()} />, {
wrapper: appRoot.build(),
});
expect(screen.getByTestId('message-form')).toBeInTheDocument();
expect(mockMessageForm).toHaveBeenCalledWith(
expect.objectContaining({
defaultValues,
contact,
templates,
}),
);
});
it('should call previous step when back button is clicked', async () => {
render(<MessageStep onSubmit={jest.fn()} />, { wrapper: appRoot.build() });
const backButton = screen.getByRole('button', { name: 'Back' });
await userEvent.click(backButton);
await waitFor(() => expect(mockWizardApi.previous).toHaveBeenCalled());
});
it('shows a loading state on the button while submit is pending', async () => {
isSubmitting = true;
const handleSubmit = jest.fn();
render(<MessageStep onSubmit={handleSubmit} />, { wrapper: appRoot.build() });
const nextButton = screen.getByRole('button', { name: 'Next' });
await userEvent.click(nextButton);
expect(nextButton).toBeDisabled();
expect(handleSubmit).not.toHaveBeenCalled();
});
it('should call onSubmit with form values when form submits successfully', async () => {
const expectedPayload = {
templateId: 'test-template-id',
template: createFakeOutboundTemplate({ id: 'test-template-id' }),
templateParameters: {},
};
const onSubmit = jest.fn();
render(<MessageStep onSubmit={onSubmit} />, { wrapper: appRoot.build() });
act(() => currentOnSubmit(expectedPayload));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expectedPayload));
await waitFor(() => expect(mockWizardApi.next).toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,72 @@
import { Box } from '@rocket.chat/fuselage';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import MessageStep from './MessageStep';
import { createFakeContact } from '../../../../../../../../tests/mocks/data';
import { createFakeOutboundTemplate } from '../../../../../../../../tests/mocks/data/outbound-message';
const steps = new StepsLinkedList([
{ id: 'test-step-1', title: 'Test Step 1' },
{ id: 'test-step-2', title: 'Test Step 2' },
{ id: 'test-step-3', title: 'Test Step 3' },
]);
const mockWizardApi = {
steps,
currentStep: steps.head?.next ?? null,
next: () => undefined,
previous: () => undefined,
register: () => () => undefined,
goTo: () => undefined,
resetNextSteps: () => undefined,
};
const meta = {
component: MessageStep,
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: (Story) => (
<WizardContext.Provider value={mockWizardApi}>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</WizardContext.Provider>
),
} satisfies Meta<typeof MessageStep>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
defaultValues: {},
templates: [createFakeOutboundTemplate({ id: 'template-1' })],
contact: createFakeContact(),
onSubmit: action('onSubmit'),
},
};
export const WithDefaultValues: Story = {
args: {
onSubmit: action('onSubmit'),
contact: createFakeContact(),
templates: [createFakeOutboundTemplate({ id: 'template-1' })],
defaultValues: {
templateId: 'template-1',
templateParameters: {
header: [{ type: 'text', format: 'text', value: 'Dentist' }],
body: [
{ type: 'text', format: 'text', value: 'John Doe' },
{ type: 'text', format: 'text', value: 'tomorrow' },
{ type: 'text', format: 'text', value: '10:00 AM' },
{ type: 'text', format: 'text', value: '14:00 PM' },
{ type: 'text', format: 'text', value: 'slot' },
{ type: 'text', format: 'text', value: 'John Doe' },
],
},
},
},
};

View File

@@ -0,0 +1,36 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useWizardContext, WizardActions, WizardBackButton, WizardNextButton } from '@rocket.chat/ui-client';
import type { ComponentProps } from 'react';
import type { MessageFormSubmitPayload } from '../forms/MessageForm';
import MessageForm from '../forms/MessageForm';
export type MessageStepProps = Omit<ComponentProps<typeof MessageForm>, 'onSubmit'> & {
onSubmit(values: MessageFormSubmitPayload): void;
};
const MessageStep = ({ contact, templates, defaultValues, onSubmit }: MessageStepProps) => {
const { next } = useWizardContext();
const handleSubmit = useStableCallback(async (values: MessageFormSubmitPayload) => {
onSubmit(values);
next();
});
return (
<MessageForm
contact={contact}
templates={templates}
defaultValues={defaultValues}
onSubmit={handleSubmit}
renderActions={({ isSubmitting }) => (
<WizardActions>
<WizardBackButton />
<WizardNextButton manual type='submit' loading={isSubmitting} />
</WizardActions>
)}
/>
);
};
export default MessageStep;

View File

@@ -0,0 +1,107 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import { composeStories } from '@storybook/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import type { ComponentProps } from 'react';
import RecipientStep from './RecipientStep';
import * as stories from './RecipientStep.stories';
import { createFakeContact } from '../../../../../../../../tests/mocks/data';
import { createFakeProviderMetadata } from '../../../../../../../../tests/mocks/data/outbound-message';
import type RecipientForm from '../forms/RecipientForm';
import type { RecipientFormSubmitPayload } from '../forms/RecipientForm';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
jest.mock('tinykeys', () => ({
__esModule: true,
default: jest.fn().mockReturnValue(() => () => undefined),
}));
let isSubmitting = false;
let currentOnSubmit: (payload: RecipientFormSubmitPayload) => void = () => undefined;
const mockRecipientForm = jest.fn().mockImplementation((props) => {
currentOnSubmit = props.onSubmit;
return <div data-testid='recipient-form'>{props.renderActions?.({ isSubmitting })}</div>;
});
jest.mock('../forms/RecipientForm', () => ({
__esModule: true,
default: (props: ComponentProps<typeof RecipientForm>) => mockRecipientForm(props),
}));
const steps = new StepsLinkedList([
{ id: 'test-step-1', title: 'Test Step 1' },
{ id: 'test-step-2', title: 'Test Step 2' },
]);
const mockWizardApi = {
steps,
currentStep: steps.head,
next: jest.fn(),
previous: jest.fn(),
register: jest.fn(),
goTo: jest.fn(),
resetNextSteps: jest.fn(),
};
const appRoot = mockAppRoot()
.withJohnDoe()
.wrap((children) => {
return <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
});
describe('RecipientStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('shows a loading state on the button while submit is pending', async () => {
isSubmitting = true;
const { rerender } = render(<RecipientStep onSubmit={jest.fn()} />, { wrapper: appRoot.build() });
const nextButton = screen.getByRole('button', { name: 'Next' });
await userEvent.click(nextButton);
expect(nextButton).toBeDisabled();
expect(mockWizardApi.next).not.toHaveBeenCalled();
isSubmitting = false;
rerender(<RecipientStep onSubmit={jest.fn()} />);
await waitFor(() => expect(nextButton).not.toBeDisabled());
});
it('should call onSubmit with form values when form submits successfully', async () => {
const expectedPayload: RecipientFormSubmitPayload = {
contact: createFakeContact({ _id: 'contact-id' }),
contactId: 'contact-id',
providerId: 'provider-id',
provider: createFakeProviderMetadata({ providerId: 'provider-id' }),
recipient: '',
sender: '',
};
const onSubmit = jest.fn();
render(<RecipientStep onSubmit={onSubmit} />, { wrapper: appRoot.build() });
act(() => currentOnSubmit(expectedPayload));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expectedPayload));
});
});

View File

@@ -0,0 +1,97 @@
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import RecipientStep from './RecipientStep';
import { createFakeContact, createFakeContactChannel, createFakeContactWithManagerData } from '../../../../../../../../tests/mocks/data';
import { createFakeOutboundTemplate, createFakeProviderMetadata } from '../../../../../../../../tests/mocks/data/outbound-message';
const steps = new StepsLinkedList([
{ id: 'test-step-1', title: 'Test Step 1' },
{ id: 'test-step-2', title: 'Test Step 2' },
{ id: 'test-step-3', title: 'Test Step 3' },
]);
const mockWizardApi = {
steps,
currentStep: steps.head?.next ?? null,
next: () => undefined,
previous: () => undefined,
register: () => () => undefined,
goTo: () => undefined,
resetNextSteps: () => undefined,
};
const recipientPhone = { raw: '+12127774567', formatted: '+1 212-777-4567' };
const senderPhone = { raw: '+12125554567', formatted: '+1 212-555-4567' };
const contactWithManagerMock = createFakeContactWithManagerData({
_id: 'contact-1',
name: 'John Doe',
phones: [{ phoneNumber: recipientPhone.raw }],
channels: [
createFakeContactChannel({
name: 'provider-1',
lastChat: { _id: '', ts: new Date().toISOString() },
}),
],
});
const { contactManager: _, ...contactNormal } = contactWithManagerMock;
const contactMock = createFakeContact(contactNormal);
const providerMock = createFakeProviderMetadata({
providerId: 'provider-1',
providerName: 'WhatsApp',
templates: {
[senderPhone.raw]: [createFakeOutboundTemplate({ phoneNumber: senderPhone.raw })],
},
});
const AppRoot = mockAppRoot()
.withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => ({ providers: [providerMock] }))
.withEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', () => ({ metadata: providerMock }))
.withEndpoint('GET', '/v1/omnichannel/contacts.get', () => ({ contact: contactMock }))
.withEndpoint('GET', '/v1/omnichannel/contacts.search', () => ({ contacts: [contactWithManagerMock], count: 1, offset: 0, total: 1 }))
.build();
const meta = {
component: RecipientStep,
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: [
(Story) => (
<AppRoot>
<WizardContext.Provider value={mockWizardApi}>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</WizardContext.Provider>
</AppRoot>
),
],
} satisfies Meta<typeof RecipientStep>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
defaultValues: {},
onSubmit: action('onSubmit'),
},
};
export const WithDefaultValues: Story = {
args: {
onSubmit: action('onSubmit'),
defaultValues: {
contactId: contactWithManagerMock._id,
providerId: providerMock.providerId,
recipient: recipientPhone.raw,
sender: senderPhone.raw,
},
},
};

View File

@@ -0,0 +1,35 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useWizardContext, WizardActions, WizardNextButton } from '@rocket.chat/ui-client';
import type { RecipientFormData, RecipientFormSubmitPayload } from '../forms/RecipientForm';
import RecipientForm from '../forms/RecipientForm';
export type RecipientStepProps = {
defaultValues?: Partial<RecipientFormData>;
onDirty?: () => void;
onSubmit(values: RecipientFormSubmitPayload): void;
};
const RecipientStep = ({ defaultValues, onDirty, onSubmit }: RecipientStepProps) => {
const { next } = useWizardContext();
const handleSubmit = useStableCallback((values: RecipientFormSubmitPayload) => {
onSubmit(values);
next();
});
return (
<RecipientForm
defaultValues={defaultValues}
onDirty={onDirty}
onSubmit={handleSubmit}
renderActions={({ isSubmitting }) => (
<WizardActions>
<WizardNextButton manual type='submit' loading={isSubmitting} />
</WizardActions>
)}
/>
);
};
export default RecipientStep;

View File

@@ -0,0 +1,123 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import { composeStories } from '@storybook/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { act, type ComponentProps } from 'react';
import RepliesStep from './RepliesStep';
import * as stories from './RepliesStep.stories';
import type RepliesForm from '../forms/RepliesForm';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
let isSubmitting = false;
let currentOnSubmit: (payload: Record<string, unknown>) => void = () => undefined;
const mockRepliesForm = jest.fn().mockImplementation((props) => {
currentOnSubmit = props.onSubmit;
return <div data-testid='replies-form'>{props.renderActions?.({ isSubmitting })}</div>;
});
jest.mock('../forms/RepliesForm', () => ({
__esModule: true,
default: (props: ComponentProps<typeof RepliesForm>) => mockRepliesForm(props),
}));
const steps = new StepsLinkedList([
{ id: 'test-step-1', title: 'Test Step 1' },
{ id: 'test-step-2', title: 'Test Step 2' },
{ id: 'test-step-3', title: 'Test Step 3' },
]);
const mockWizardApi = {
steps,
currentStep: steps.head?.next ?? null,
next: jest.fn(),
previous: jest.fn(),
register: jest.fn(),
goTo: jest.fn(),
resetNextSteps: jest.fn(),
};
const appRoot = mockAppRoot()
.withJohnDoe()
.wrap((children) => {
return <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
});
describe('RepliesStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should pass accessibility tests', async () => {
const defaultValues = { departmentId: 'test-department-id', agentId: 'test-agent-id' };
const { container } = render(<RepliesStep defaultValues={defaultValues} onSubmit={jest.fn()} />, {
wrapper: appRoot.build(),
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render message form with correct props', () => {
const defaultValues = { departmentId: 'test-department-id', agentId: 'test-agent-id' };
render(<RepliesStep defaultValues={defaultValues} onSubmit={jest.fn()} />, {
wrapper: appRoot.build(),
});
expect(screen.getByTestId('replies-form')).toBeInTheDocument();
expect(mockRepliesForm).toHaveBeenCalledWith(expect.objectContaining({ defaultValues }));
});
it('should call onSubmit with form values when form submits successfully', async () => {
const expectedPayload = {
departmentId: 'test-department-id',
agentId: 'test-agent-id',
};
const onSubmit = jest.fn();
render(<RepliesStep onSubmit={onSubmit} />, { wrapper: appRoot.build() });
await act(() => currentOnSubmit(expectedPayload));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expectedPayload));
});
it('should call previous step when back button is clicked', async () => {
render(<RepliesStep onSubmit={jest.fn()} />, { wrapper: appRoot.build() });
const backButton = screen.getByRole('button', { name: 'Back' });
await userEvent.click(backButton);
await waitFor(() => expect(mockWizardApi.previous).toHaveBeenCalled());
});
it('shows a loading state on the button while submit is pending', async () => {
isSubmitting = true;
const { rerender } = render(<RepliesStep onSubmit={jest.fn()} />, { wrapper: appRoot.build() });
const nextButton = screen.getByRole('button', { name: 'Next' });
await userEvent.click(nextButton);
expect(nextButton).toBeDisabled();
isSubmitting = false;
rerender(<RepliesStep onSubmit={jest.fn()} />);
await waitFor(() => expect(nextButton).not.toBeDisabled());
});
});

View File

@@ -0,0 +1,83 @@
import { faker } from '@faker-js/faker';
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import RepliesStep from './RepliesStep';
import { createFakeAgent, createFakeDepartment } from '../../../../../../../../tests/mocks/data';
const steps = new StepsLinkedList([
{ id: 'test-step-1', title: 'Test Step 1' },
{ id: 'test-step-2', title: 'Test Step 2' },
{ id: 'test-step-3', title: 'Test Step 3' },
]);
const mockWizardApi = {
steps,
currentStep: steps.head?.next ?? null,
next: () => undefined,
previous: () => undefined,
register: () => () => undefined,
goTo: () => undefined,
resetNextSteps: () => undefined,
};
const mockDepartment = createFakeDepartment({ name: `${faker.commerce.department()} Department` });
const mockAgent = createFakeAgent({ _id: 'agent-1' });
const mockDepartmentAgent = {
...mockAgent,
username: mockAgent.username || '',
agentId: mockAgent._id,
departmentId: mockDepartment._id,
departmentEnabled: true,
count: 0,
order: 0,
};
const AppRoot = mockAppRoot()
.withEndpoint('GET', '/v1/livechat/department', () => ({ departments: [mockDepartment], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/users/agent', () => ({ users: [{ ...mockAgent, departments: [] }], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/department/:_id', () => ({ department: mockDepartment, agents: [mockDepartmentAgent] }))
.withEndpoint('GET', '/v1/livechat/users/agent/:_id', () => ({ user: mockAgent }))
.build();
const meta = {
component: RepliesStep,
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: [
(Story) => (
<AppRoot>
<WizardContext.Provider value={mockWizardApi}>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</WizardContext.Provider>
</AppRoot>
),
],
} satisfies Meta<typeof RepliesStep>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
defaultValues: {},
onSubmit: action('onSubmit'),
},
};
export const WithDefaultValues: Story = {
args: {
onSubmit: action('onSubmit'),
defaultValues: {
departmentId: mockDepartment._id,
agentId: mockAgent._id,
},
},
};

View File

@@ -0,0 +1,34 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useWizardContext, WizardActions, WizardBackButton, WizardNextButton } from '@rocket.chat/ui-client';
import type { RepliesFormData, RepliesFormSubmitPayload } from '../forms/RepliesForm';
import RepliesForm from '../forms/RepliesForm';
export type RepliesStepProps = {
defaultValues?: Partial<RepliesFormData>;
onSubmit(values: RepliesFormSubmitPayload): void;
};
const RepliesStep = ({ defaultValues, onSubmit }: RepliesStepProps) => {
const { next } = useWizardContext();
const handleSubmit = useStableCallback(async (values: RepliesFormSubmitPayload) => {
onSubmit(values);
next();
});
return (
<RepliesForm
defaultValues={defaultValues}
onSubmit={handleSubmit}
renderActions={({ isSubmitting }) => (
<WizardActions>
<WizardBackButton />
<WizardNextButton manual type='submit' loading={isSubmitting} />
</WizardActions>
)}
/>
);
};
export default RepliesStep;

View File

@@ -0,0 +1,138 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import { composeStories } from '@storybook/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import type { ComponentProps } from 'react';
import ReviewStep from './ReviewStep';
import * as stories from './ReviewStep.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
jest.mock('../../OutboundMessagePreview', () => ({
__esModule: true,
default: (props: any) => <div data-testid='outbound-message-preview' {...props} />,
}));
const steps = new StepsLinkedList([
{ id: 'test-step-1', title: 'Test Step 1' },
{ id: 'test-step-2', title: 'Test Step 2' },
{ id: 'test-step-3', title: 'Test Step 3' },
]);
const mockWizardApi = {
steps,
currentStep: steps.head?.next ?? null,
next: jest.fn(),
previous: jest.fn(),
register: jest.fn(),
goTo: jest.fn(),
resetNextSteps: jest.fn(),
};
const appRoot = mockAppRoot()
.withJohnDoe()
.withTranslations('en', 'core', {
Send: 'Send',
})
.wrap((children) => {
return <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
})
.build();
describe('ReviewStep', () => {
const onSendMock = jest.fn();
const defaultProps: ComponentProps<typeof ReviewStep> = {
onSend: onSendMock,
};
beforeEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders correctly with OutboundMessagePreview and a send button', () => {
render(<ReviewStep {...defaultProps} />, { wrapper: appRoot });
expect(screen.getByTestId('outbound-message-preview')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument();
});
it('should pass accessibility tests', async () => {
const { container } = render(<ReviewStep {...defaultProps} />, { wrapper: appRoot });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should call previous step when back button is clicked', async () => {
render(<ReviewStep {...defaultProps} />, { wrapper: appRoot });
const backButton = screen.getByRole('button', { name: 'Back' });
await userEvent.click(backButton);
await waitFor(() => expect(mockWizardApi.previous).toHaveBeenCalled());
});
it('calls onSend when the send button is clicked', async () => {
onSendMock.mockResolvedValue(undefined);
render(<ReviewStep {...defaultProps} />, { wrapper: appRoot });
const sendButton = screen.getByRole('button', { name: 'Send' });
await userEvent.click(sendButton);
expect(onSendMock).toHaveBeenCalledTimes(1);
});
it('shows a loading state on the button while onSend is pending', async () => {
let resolvePromise: (value: unknown) => void = jest.fn();
onSendMock.mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve;
}),
);
render(<ReviewStep {...defaultProps} />, { wrapper: appRoot });
const sendButton = screen.getByRole('button', { name: 'Send' });
await userEvent.click(sendButton);
await waitFor(() => expect(sendButton).toBeDisabled());
resolvePromise(undefined);
await waitFor(() => expect(sendButton).not.toBeDisabled());
});
it('removes loading state if onSend rejects', async () => {
let rejectPromise: (reason?: any) => void = jest.fn();
onSendMock.mockReturnValue(
new Promise((_, reject) => {
rejectPromise = reject;
}),
);
render(<ReviewStep {...defaultProps} />, { wrapper: appRoot });
const sendButton = screen.getByRole('button', { name: 'Send' });
await userEvent.click(sendButton);
await waitFor(() => expect(sendButton).toBeDisabled());
rejectPromise(new Error('Failed to send'));
await waitFor(() => expect(sendButton).not.toBeDisabled());
});
});

View File

@@ -0,0 +1,67 @@
import { Box } from '@rocket.chat/fuselage';
import { WizardContext, StepsLinkedList } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import ReviewStep from './ReviewStep';
import { createFakeOutboundTemplate } from '../../../../../../../../tests/mocks/data/outbound-message';
const steps = new StepsLinkedList([
{ id: 'test-step-1', title: 'Test Step 1' },
{ id: 'test-step-2', title: 'Test Step 2' },
{ id: 'test-step-3', title: 'Test Step 3' },
]);
const mockWizardApi = {
steps,
currentStep: steps.head?.next ?? null,
next: () => undefined,
previous: () => undefined,
register: () => () => undefined,
goTo: () => undefined,
resetNextSteps: () => undefined,
};
const meta = {
component: ReviewStep,
parameters: {
controls: { hideNoControlsWarning: true },
},
decorators: [
(Story) => (
<WizardContext.Provider value={mockWizardApi}>
<Box maxWidth={600} margin='auto'>
<Story />
</Box>
</WizardContext.Provider>
),
],
} satisfies Meta<typeof ReviewStep>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
agentName: 'John Doe',
contactName: 'Jane Smith',
agentUsername: 'johndoe',
departmentName: 'Support',
providerName: 'Rocket.Chat',
providerType: 'phone',
sender: '+1234567890',
recipient: '+0987654321',
templateParameters: {
header: [{ type: 'text', format: 'text', value: 'Dentist' }],
body: [
{ type: 'text', format: 'text', value: 'John Doe' },
{ type: 'text', format: 'text', value: 'tomorrow' },
{ type: 'text', format: 'text', value: '10:00 AM' },
{ type: 'text', format: 'text', value: '14:00 PM' },
{ type: 'text', format: 'text', value: 'slot' },
{ type: 'text', format: 'text', value: 'John Doe' },
],
},
template: createFakeOutboundTemplate(),
onSend: () => Promise.resolve(),
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export { default as RecipientStep } from './RecipientStep';
export { default as MessageStep } from './MessageStep';
export { default as RepliesStep } from './RepliesStep';
export { default as ReviewStep } from './ReviewStep';

View File

@@ -0,0 +1,34 @@
/**
* A utility function that creates a string from an object of conditional values
*
* @param conditionals An object where keys are strings and values are booleans
* @returns A string of space-separated entries from keys with truthy values
*
* @example
* cx({ 'btn': true, 'btn-primary': true, 'disabled': false })
* // returns 'btn btn-primary'
*/
export const cx = (conditionals: Record<string, boolean | undefined | null>): string => {
return Object.entries(conditionals)
.filter(([_, value]) => Boolean(value))
.map(([key]) => key)
.join(' ');
};
/**
* A utility function that creates a string with prefixed keys from an object of conditional values
*
* @param prefix String to prepend to each key
* @param conditionals An object where keys are strings and values are booleans
* @returns A string of space-separated entries from prefixed keys with truthy values
*
* @example
* cxp('form-field', { 'error': true, 'hint': false })
* // returns 'form-field-error'
*/
export const cxp = (prefix: string, conditionals: Record<string, boolean | undefined | null>): string => {
return Object.entries(conditionals)
.filter(([_, value]) => Boolean(value))
.map(([key]) => `${prefix}-${key}`)
.join(' ');
};

View File

@@ -0,0 +1,23 @@
export class FormValidationError extends Error {
constructor(message: string) {
super(message);
}
}
export class FormFetchError extends Error {
constructor(message: string) {
super(message);
}
}
export class ContactNotFoundError extends FormFetchError {
constructor() {
super('error-contact-not-found');
}
}
export class ProviderNotFoundError extends FormFetchError {
constructor() {
super('error-provider-not-found');
}
}

View File

@@ -0,0 +1,31 @@
import type { Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { Select } from '@rocket.chat/fuselage';
import type { ComponentProps, Key } from 'react';
import { useMemo } from 'react';
import { formatPhoneNumber } from '../../../../../lib/formatPhoneNumber';
export type RecipientSelectProps = Omit<ComponentProps<typeof Select>, 'options' | 'onChange' | 'value'> & {
type: 'phone' | 'email';
contact: Serialized<ILivechatContact> | undefined;
value: string;
onChange: (value: Key) => void;
};
const RecipientSelect = ({ contact, type, value, disabled, onChange, ...props }: RecipientSelectProps) => {
const options = useMemo<[string, string][]>(() => {
if (!contact) {
return [];
}
if (type === 'phone') {
return contact.phones?.map((item) => [item.phoneNumber, formatPhoneNumber(item.phoneNumber)]) ?? [];
}
return contact.emails?.map((item) => [item.address, item.address]) ?? [];
}, [contact, type]);
return <Select {...props} aria-disabled={disabled} disabled={disabled} value={value} options={options} onChange={onChange} />;
};
export default RecipientSelect;

View File

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

View File

@@ -0,0 +1,23 @@
import type { IconButton } from '@rocket.chat/fuselage';
import { Button } from '@rocket.chat/fuselage';
import { forwardRef, type ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
export type TemplatePlaceholderButtonProps = Omit<ComponentProps<typeof IconButton>, 'color' | 'icon'> & {
icon?: ComponentProps<typeof IconButton>['icon'];
};
const TemplatePlaceholderButton = forwardRef<HTMLButtonElement, TemplatePlaceholderButtonProps>(
({ icon: _icon, pressed: _pressed, small: _small, size: _size, ...props }, ref) => {
const { t } = useTranslation();
return (
<Button ref={ref} {...props}>
{t('Placeholder')}
</Button>
);
},
);
TemplatePlaceholderButton.displayName = 'TemplatePlaceholderButton';
export default TemplatePlaceholderButton;

View File

@@ -0,0 +1,52 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'react';
import TemplatePlaceholderInput from './TemplatePlaceholderInput';
import type TemplatePlaceholderSelector from './TemplatePlaceholderSelector';
const appRoot = mockAppRoot().build();
jest.mock('./TemplatePlaceholderSelector', () => ({ onSelect, onOpenChange }: ComponentProps<typeof TemplatePlaceholderSelector>) => (
<div>
<button type='button' onClick={() => onSelect('Selected value')}>
Placeholder
</button>
<button type='button' onClick={() => onOpenChange?.(false)}>
Close
</button>
</div>
));
it('should call onChange when value changes', async () => {
const onChange = jest.fn();
render(<TemplatePlaceholderInput value='' onChange={onChange} />, { wrapper: appRoot });
await userEvent.type(screen.getByRole('textbox'), 'Hi');
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledWith('H');
expect(onChange).toHaveBeenCalledWith('i');
});
it('should call onChange when a placeholder is selected', async () => {
const onChange = jest.fn();
render(<TemplatePlaceholderInput value='' onChange={onChange} />, { wrapper: appRoot });
await userEvent.click(screen.getByRole('button', { name: 'Placeholder' }));
expect(onChange).toHaveBeenCalledWith('Selected value');
});
it('should focus the input when the placeholder menu is closed', async () => {
render(<TemplatePlaceholderInput value='' onChange={jest.fn()} />, { wrapper: appRoot });
await userEvent.click(screen.getByRole('button', { name: 'Placeholder' }));
expect(screen.getByRole('textbox')).not.toHaveFocus();
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.getByRole('textbox')).toHaveFocus();
});

View File

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

View File

@@ -0,0 +1,34 @@
import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { ComponentProps } from 'react';
import PlaceholderButton from './TemplatePlaceholderButton';
import { useAgentSection } from './hooks/useAgentSection';
import { useContactSection } from './hooks/useContactSection';
import { useCustomFieldsSection } from './hooks/useCustomFieldsSection';
type PlaceholderSelectorProps = Pick<ComponentProps<typeof PlaceholderButton>, 'marginInlineStart' | 'disabled'> & {
contact?: Serialized<ILivechatContact>;
onSelect(value: string): void;
onOpenChange?(isOpen: boolean): void;
};
const TemplatePlaceholderSelector = ({ contact, disabled, onSelect, onOpenChange, ...props }: PlaceholderSelectorProps) => {
const contactSection = useContactSection({ contact, onSelect });
const customFieldsSection = useCustomFieldsSection({ customFields: contact?.customFields, onSelect });
const agentSection = useAgentSection({ onSelect });
return (
<GenericMenu
button={<PlaceholderButton {...props} />}
onOpenChange={onOpenChange}
title=''
disabled={disabled}
maxWidth='100%'
sections={[...contactSection, ...customFieldsSection, ...agentSection]}
placement='bottom-end'
/>
);
};
export default TemplatePlaceholderSelector;

View File

@@ -0,0 +1,47 @@
import { useUser } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { formatPhoneNumber } from '../../../../../../../lib/formatPhoneNumber';
type UseAgentSectionProps = {
onSelect(value: string): void;
};
export const useAgentSection = ({ onSelect }: UseAgentSectionProps) => {
const { t } = useTranslation();
const user = useUser();
return useMemo(() => {
if (!user) {
return [{ title: t('Agent'), items: [{ id: 'no-contact-data', content: t('No_data_found') }] }];
}
const renderItem = (label: string, value: string, index?: number) => (
<p>
{index ? `${label} (${index})` : label} <span>{value}</span>
</p>
);
const nameItem = {
id: `${user._id}.name`,
content: renderItem(t('Name'), user.name || user.username || t('Unnamed')),
onClick: () => onSelect(user.name || user._id),
};
const phoneItem = {
id: `${user._id}.phone`,
content: renderItem(t('Phone_number'), user.phone ? formatPhoneNumber(user.phone) : t('None')),
onClick: () => onSelect(user.phone || ''),
};
const emails =
user.emails?.map((item, index) => ({
id: `${user._id}.email.${index}`,
content: renderItem(t('Email'), item.address, index + 1),
onClick: () => onSelect(item.address),
})) || [];
return [{ title: t('Agent'), items: [nameItem, phoneItem, ...emails] }];
}, [user, onSelect, t]);
};

View File

@@ -0,0 +1,46 @@
import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type UseContactSectionProps = {
contact?: Serialized<ILivechatContact>;
onSelect(value: string): void;
};
export const useContactSection = ({ contact, onSelect }: UseContactSectionProps) => {
const { t } = useTranslation();
return useMemo(() => {
if (!contact) {
return [{ title: t('Contact'), items: [{ id: 'no-contact-data', content: t('No_data_found') }] }];
}
const renderItem = (label: string, value: string, index?: number) => (
<p>
{index ? `${label} (${index})` : label} <span>{value}</span>
</p>
);
const nameItem = {
id: `${contact._id}.name`,
content: renderItem(t('Name'), contact.name),
onClick: () => onSelect(contact.name || contact._id),
};
const phones =
contact.phones?.map((item, index) => ({
id: `${contact._id}.phone.${index}`,
content: renderItem(t('Phone_number'), item.phoneNumber, index + 1),
onClick: () => onSelect(item.phoneNumber),
})) || [];
const emails =
contact.emails?.map((item, index) => ({
id: `${contact._id}.email.${index}`,
content: renderItem(t('Email'), item.address, index + 1),
onClick: () => onSelect(item.address),
})) || [];
return [{ title: t('Contact'), items: [nameItem, ...phones, ...emails] }];
}, [contact, onSelect, t]);
};

View File

@@ -0,0 +1,37 @@
import { Box } from '@rocket.chat/fuselage';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type UseCustomFieldsSectionProps = {
customFields?: Record<string, unknown>;
onSelect(value: string): void;
};
export const useCustomFieldsSection = ({ customFields, onSelect }: UseCustomFieldsSectionProps) => {
const { t } = useTranslation();
return useMemo(() => {
const entries = customFields ? Object.entries(customFields) : [];
if (!entries.length) {
return [{ title: t('Custom_fields'), items: [{ id: 'no-custom-fields-data', content: t('No_data_found') }] }];
}
const items = entries.map(([label, value]) => ({
id: label,
onClick: () => onSelect(String(value)),
content: (
<p>
<Box is='span' fontScale='p2m'>
{label}
</Box>{' '}
<Box is='span' fontScale='p2'>
{String(value)}
</Box>
</p>
),
}));
return [{ title: t('Custom_fields'), items }];
}, [customFields, onSelect, t]);
};

View File

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

View File

@@ -0,0 +1,49 @@
import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings';
import { Box, Callout } from '@rocket.chat/fuselage';
import { useId, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import MarkdownText from '../../../../../components/MarkdownText';
import type { TemplateParameters, ComponentType } from '../types/template';
import { processTemplatePreviewText } from '../utils/template';
export type TemplatePreviewProps = {
template: IOutboundProviderTemplate;
parameters?: TemplateParameters;
};
const TemplatePreview = ({ template, parameters = {} }: TemplatePreviewProps) => {
const { t } = useTranslation();
const previewId = useId();
const { components } = template;
const content = useMemo<Partial<Record<ComponentType, string>>>(() => {
return Object.fromEntries(
components.map((component) => {
if (component.type === 'header' && component.format !== 'text') {
return [component.type, `[${t('Media')}]`];
}
const values = parameters[component.type];
const processedText = component.text ? processTemplatePreviewText(component.text, values) : '';
return [component.type, processedText] as const;
}),
);
}, [components, parameters, t]);
return (
<Box marginBlockStart={12}>
<Box is='p' fontScale='p2m' marginBlockEnd={2} id={previewId}>
{t('Message_preview')}
</Box>
<Callout type='info' icon='balloon-text' title={content.header || ''} aria-labelledby={previewId}>
<MarkdownText parseEmoji content={content.body || ''} />
<MarkdownText parseEmoji color='annotation' content={content.footer || ''} />
</Callout>
</Box>
);
};
export default TemplatePreview;

View File

@@ -0,0 +1,60 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TemplateSelect from './TemplateSelect';
import { createFakeOutboundTemplate } from '../../../../../../tests/mocks/data/outbound-message';
const component = {
header: { type: 'header', text: 'New {{1}} appointment' },
body: { type: 'body', text: 'Hello {{1}}' },
} as const;
const template1 = createFakeOutboundTemplate({
id: 'template-1',
name: 'Template One',
language: 'en_US',
components: [component.body],
});
export const template2 = createFakeOutboundTemplate({
id: 'template-2',
name: 'Template Two',
language: 'pt_BR',
components: [component.header, component.body],
});
const mockTemplates = [template1, template2];
const appRoot = mockAppRoot().build();
describe('TemplateSelect', () => {
it('should render correctly', () => {
render(<TemplateSelect templates={mockTemplates} placeholder='Select template' value='' onChange={jest.fn()} />, { wrapper: appRoot });
expect(screen.getByPlaceholderText('Select template')).toBeInTheDocument();
});
it('should display the correct template options with language descriptions', async () => {
render(<TemplateSelect templates={mockTemplates} placeholder='Select template' value='' onChange={jest.fn()} />, { wrapper: appRoot });
await userEvent.click(screen.getByPlaceholderText('Select template'));
const optionOne = await screen.findByRole('option', { name: /Template One/ });
const optionTwo = await screen.findByRole('option', { name: /Template Two/ });
expect(optionOne).toBeInTheDocument();
expect(optionTwo).toBeInTheDocument();
});
it('should display the template language as the option description', async () => {
render(<TemplateSelect templates={mockTemplates} placeholder='Select template' value='' onChange={jest.fn()} />, { wrapper: appRoot });
await userEvent.click(screen.getByPlaceholderText('Select template'));
const optionOne = await screen.findByRole('option', { name: /Template One/ });
const optionTwo = await screen.findByRole('option', { name: /Template Two/ });
expect(optionOne).toHaveTextContent('English');
expect(optionTwo).toHaveTextContent('português (Brasil)');
});
});

View File

@@ -0,0 +1,50 @@
import type { IOutboundProviderTemplate } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Option, OptionDescription, SelectFiltered } from '@rocket.chat/fuselage';
import { useLanguages } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import type { Key, ComponentProps } from 'react';
export type TemplateSelectProps = Omit<ComponentProps<typeof SelectFiltered>, 'value' | 'onChange' | 'options'> & {
templates: IOutboundProviderTemplate[];
value: string;
onChange(value: Key): void;
};
const TemplateSelect = ({ templates, value, onChange, ...props }: TemplateSelectProps) => {
const languages = useLanguages();
const [options, templateMap] = useMemo(() => {
const templateMap = new Map<string, IOutboundProviderTemplate>();
const templateOptions: SelectOption[] = [];
for (const template of templates) {
templateMap.set(template.id, template);
templateOptions.push([template.id, template.name]);
}
return [templateOptions, templateMap];
}, [templates]);
return (
<SelectFiltered
{...props}
value={value}
options={options}
onChange={onChange}
renderItem={({ label, value: templateId, ...props }) => {
const { language: templateLanguage = '' } = templateMap.get(templateId) || {};
const normalizedTemplateLanguage = templateLanguage.replace(/_/g, '-').replace(/en-US/g, 'en');
const language = languages.find((lang) => lang.key === normalizedTemplateLanguage);
return (
<Option {...props} label={label}>
{language ? <OptionDescription>{language.name}</OptionDescription> : null}
</Option>
);
}}
/>
);
};
export default TemplateSelect;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More