Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation, useSetting, useAtLeastOnePermission, usePermission } from '@rocket.chat/ui-contexts';
import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts';

import { useCreateRoomModal } from './useCreateRoomModal';
import CreateDiscussion from '../../../components/CreateDiscussion';
import { useOutboundMessageModal } from '../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal';
import { useOutboundMessageAccess } from '../../../components/Omnichannel/OutboundMessage/hooks';
import { useOutboundMessageModal } from '../../../components/Omnichannel/OutboundMessage/modals';
import CreateChannelModal from '../actions/CreateChannelModal';
import CreateDirectMessage from '../actions/CreateDirectMessage';
import CreateTeamModal from '../actions/CreateTeamModal';
Expand All @@ -21,7 +22,7 @@ export const useCreateNewItems = (): GenericMenuItemProps[] => {
const canCreateTeam = useAtLeastOnePermission(CREATE_TEAM_PERMISSIONS);
const canCreateDirectMessages = useAtLeastOnePermission(CREATE_DIRECT_PERMISSIONS);
const canCreateDiscussion = useAtLeastOnePermission(CREATE_DISCUSSION_PERMISSIONS);
const canSendOutboundMessage = usePermission('outbound.send-messages');
const canSendOutboundMessage = useOutboundMessageAccess();

const createChannel = useCreateRoomModal(CreateChannelModal);
const createTeam = useCreateRoomModal(CreateTeamModal);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { StepsLinkedList, WizardContext } from '@rocket.chat/ui-client';
import { act, render, waitFor } from '@testing-library/react';
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';
import { useOutboundMessageUpsellModal } from '../../modals';

const openUpsellModal = jest.fn();
Expand Down Expand Up @@ -58,25 +60,51 @@ const getLicenseMock = jest.fn().mockImplementation(() => ({
}),
}));

const appRoot = mockAppRoot()
.withJohnDoe()
.withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => getProvidersMock())
.withEndpoint('GET', '/v1/licenses.info', () => getLicenseMock())
.wrap((children) => {
return <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>;
});
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() });
render(<OutboundMessageWizard />, { wrapper: appRoot().build() });

await waitFor(() => expect(openUpsellModal).toHaveBeenCalled());
});
Expand All @@ -85,16 +113,18 @@ describe('OutboundMessageWizard', () => {
getLicenseMock.mockResolvedValueOnce({ license: createFakeLicenseInfo({ activeModules: [] }) });
getProvidersMock.mockResolvedValueOnce({ providers: [createFakeProvider()] });

render(<OutboundMessageWizard />, { wrapper: appRoot.build() });
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: ['outbound-messaging'] }) });
getLicenseMock.mockResolvedValueOnce({
license: createFakeLicenseInfo({ activeModules: ['livechat-enterprise', 'outbound-messaging'] }),
});
getProvidersMock.mockResolvedValueOnce({ providers: [] });

render(<OutboundMessageWizard />, { wrapper: appRoot.build() });
render(<OutboundMessageWizard />, { wrapper: appRoot().build() });

await waitFor(() => expect(openUpsellModal).not.toHaveBeenCalled());

Expand All @@ -105,9 +135,11 @@ describe('OutboundMessageWizard', () => {

it('should not display upsell modal when module and provider is present', async () => {
getProvidersMock.mockResolvedValueOnce({ providers: [createFakeProvider()] });
getLicenseMock.mockResolvedValueOnce({ license: createFakeLicenseInfo({ activeModules: ['outbound-messaging'] }) });
getLicenseMock.mockResolvedValueOnce({
license: createFakeLicenseInfo({ activeModules: ['livechat-enterprise', 'outbound-messaging'] }),
});

render(<OutboundMessageWizard />, { wrapper: appRoot.build() });
render(<OutboundMessageWizard />, { wrapper: appRoot().build() });

await waitFor(() => expect(openUpsellModal).not.toHaveBeenCalled());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ 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, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';

import OutboundMessageWizardErrorState from './components/OutboundMessageWizardErrorState';
import type { SubmitPayload } from './forms';
import { ReviewStep, MessageStep, RecipientStep, RepliesStep } from './steps';
import { useOmnichannelEnabled } from '../../../../../hooks/omnichannel/useOmnichannelEnabled';
import { useHasLicenseModule } from '../../../../../hooks/useHasLicenseModule';
import { formatPhoneNumber } from '../../../../../lib/formatPhoneNumber';
import { omnichannelQueryKeys } from '../../../../../lib/queryKeys';
Expand All @@ -37,6 +38,7 @@ const OutboundMessageWizard = ({ defaultValues = {}, onSuccess, onError }: Outbo
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');
Expand Down Expand Up @@ -74,11 +76,15 @@ const OutboundMessageWizard = ({ defaultValues = {}, onSuccess, onError }: Outbo
[queryClient],
);

useEffect(() => {
if (!isLoadingProviders && !isLoadingModule && (!hasOutboundModule || !hasProviders)) {
useLayoutEffect(() => {
if (isLoadingModule || isLoadingProviders) {
return;
}

if (!hasOmnichannelModule || !hasOutboundModule || !hasProviders) {
upsellModal.open();
}
}, [hasOutboundModule, hasProviders, isLoadingModule, isLoadingProviders, upsellModal]);
}, [hasOmnichannelModule, hasOutboundModule, hasProviders, isLoadingModule, isLoadingProviders, upsellModal]);

const handleSubmit = useEffectEvent((values: SubmitPayload) => {
if (!hasOutboundModule) {
Expand Down Expand Up @@ -142,6 +148,10 @@ const OutboundMessageWizard = ({ defaultValues = {}, onSuccess, onError }: Outbo
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')} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Box, Skeleton } from '@rocket.chat/fuselage';

const OutboubdMessageWizardSkeleton = () => {
return (
<Box>
<Box role='status' aria-busy='true'>
<Box display='flex'>
<Skeleton width={75} height={40} />
<Skeleton mis={8} width={100} height={50} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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';
Expand Down Expand Up @@ -81,7 +83,9 @@ const appRoot = mockAppRoot()
Submit: 'Submit',
})
.wrap((children) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 28 }}>{children}</VirtuosoMockContext.Provider>
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 28 }}>
<OmnichannelContext.Provider value={{ enabled: true } as OmnichannelContextValue}>{children}</OmnichannelContext.Provider>
</VirtuosoMockContext.Provider>
));

describe('RecipientForm', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useOutboundMessageAccess';
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react';

import { useOutboundMessageAccess } from './useOutboundMessageAccess';
import { useOmnichannelEnabled } from '../../../../hooks/omnichannel/useOmnichannelEnabled';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';

jest.mock('@rocket.chat/ui-contexts', () => ({
usePermission: jest.fn(),
}));

jest.mock('../../../../hooks/omnichannel/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);

describe('useOutboundMessageAccess', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should return false if omnichannel is not enabled', () => {
useOmnichannelEnabledMock.mockReturnValue(false);
useHasLicenseModuleMock.mockReturnValue(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) => 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) => 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(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(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(true);
usePermissionMock.mockReturnValue(false);

const { result } = renderHook(() => useOutboundMessageAccess());
expect(result.current).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { usePermission } from '@rocket.chat/ui-contexts';

import { useOmnichannelEnabled } from '../../../../hooks/omnichannel/useOmnichannelEnabled';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';

export const useOutboundMessageAccess = (): boolean => {
const isOmnichannelEnabled = useOmnichannelEnabled();
const hasOmnichannelModule = useHasLicenseModule('livechat-enterprise') === true;
const hasOutboundModule = useHasLicenseModule('outbound-messaging') === true;
const hasPermission = usePermission('outbound.send-messages');

if (!isOmnichannelEnabled) {
return false;
}

if (!hasOmnichannelModule || !hasOutboundModule) {
return true;
}

return hasPermission;
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { IOutboundProvider, Serialized } from '@rocket.chat/core-typings';
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';

import { useOmnichannelEnterpriseEnabled } from '../../../../hooks/omnichannel/useOmnichannelEnterpriseEnabled';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';

Expand All @@ -16,13 +17,16 @@ type UseOutboundProvidersListProps<TData> = Omit<UseQueryOptions<OutboundProvide
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 hasModule = useHasLicenseModule('outbound-messaging');

const isOmnichannelEnabled = useOmnichannelEnterpriseEnabled();
const hasOutboundModule = useHasLicenseModule('outbound-messaging');
const canSendOutboundMessages = usePermission('outbound.send-messages');

return useQuery<OutboundProvidersResponse, Error, TData>({
queryKey: omnichannelQueryKeys.outboundProviders({ type }),
queryFn: () => getProviders({ type }),
retry: 3,
enabled: hasModule && enabled,
enabled: isOmnichannelEnabled && hasOutboundModule && canSendOutboundMessages && enabled,
staleTime,
...queryOptions,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './OutboundMessageUpsellModal';
export * from './OutboundMessageModal';
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation, useSetting, useAtLeastOnePermission, usePermission } from '@rocket.chat/ui-contexts';
import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts';

import CreateDiscussion from '../../../../components/CreateDiscussion';
import { useOutboundMessageModal } from '../../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal';
import { useOutboundMessageAccess } from '../../../../components/Omnichannel/OutboundMessage/hooks';
import { useOutboundMessageModal } from '../../../../components/Omnichannel/OutboundMessage/modals';
import CreateChannelWithData from '../../CreateChannel';
import CreateDirectMessage from '../../CreateDirectMessage';
import CreateTeam from '../../CreateTeam';
Expand All @@ -21,7 +22,7 @@ export const useCreateRoomItems = (): GenericMenuItemProps[] => {
const canCreateTeam = useAtLeastOnePermission(CREATE_TEAM_PERMISSIONS);
const canCreateDirectMessages = useAtLeastOnePermission(CREATE_DIRECT_PERMISSIONS);
const canCreateDiscussion = useAtLeastOnePermission(CREATE_DISCUSSION_PERMISSIONS);
const canSendOutboundMessage = usePermission('outbound.send-messages');
const canSendOutboundMessage = useOutboundMessageAccess();

const createChannel = useCreateRoomModal(CreateChannelWithData);
const createTeam = useCreateRoomModal(CreateTeam);
Expand Down
Loading
Loading