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

View File

@@ -0,0 +1,58 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import OutboundMessageModal from './OutboundMessageModal';
jest.mock('../../components/OutboundMessageWizard', () => ({
__esModule: true,
default: () => <div>Outbound message Wizard</div>,
}));
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Close: 'Close',
Discard: 'Discard',
Discard_message: 'Discard message',
Outbound_message: 'Outbound message',
This_action_cannot_be_undone: 'This action cannot be undone',
Keep_editing: 'Keep editing',
Are_you_sure_you_want_to_discard_this_outbound_message: 'Are you sure you want to discard this outbound message?',
})
.build();
it('should display confirmation before closing the modal', async () => {
const onClose = jest.fn();
render(<OutboundMessageModal onClose={onClose} />, { wrapper: appRoot });
expect(screen.getByRole('dialog', { name: 'Outbound message' })).toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: 'Discard message' })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.getByRole('dialog', { name: 'Discard message' })).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Discard message' })).toHaveAccessibleDescription(
'Are you sure you want to discard this outbound message?',
);
await userEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(onClose).toHaveBeenCalled();
});
it('should close confirmation and leave modal open when cancel is clicked', async () => {
const onClose = jest.fn();
render(<OutboundMessageModal onClose={onClose} />, { wrapper: appRoot });
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.getByRole('dialog', { name: 'Discard message' })).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Discard message' })).toHaveAccessibleDescription(
'Are you sure you want to discard this outbound message?',
);
await userEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
expect(screen.queryByRole('dialog', { name: 'Discard message' })).not.toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Outbound message' })).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,70 @@
import { Modal, ModalBackdrop, ModalClose, ModalContent, ModalHeader, ModalTitle } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useEffect, useId, useState } from 'react';
import type { KeyboardEvent, ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import OutboundMessageCloseConfirmationModal from './OutboundMessageCloseConfirmationModal';
import OutboundMessageWizard from '../../components/OutboundMessageWizard';
export type OutboundMessageModalProps = {
defaultValues?: ComponentProps<typeof OutboundMessageWizard>['defaultValues'];
onClose: () => void;
};
const OutboundMessageModal = ({ defaultValues, onClose }: OutboundMessageModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const [initialRoute] = useState(router.getLocationPathname());
const [isClosing, setClosingConfirmation] = useState(false);
const modalId = useId();
useEffect(() => {
// NOTE: close the modal when the route changes.
// This is necessary to ensure that the modal closes when navigating the user to the edit contact page or other relevant routes.
return router.subscribeToRouteChange(() => {
if (initialRoute === router.getLocationPathname()) {
return;
}
onClose();
});
}, [initialRoute, onClose, router]);
const handleKeyDown = useStableCallback((e: KeyboardEvent<HTMLDivElement>): void => {
if (e.key !== 'Escape') {
return;
}
e.stopPropagation();
e.preventDefault();
// Toggle confirmation visibility on Esc.
setClosingConfirmation(!isClosing);
});
return (
<ModalBackdrop
backgroundColor='transparent'
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<Modal aria-labelledby={modalId} display={isClosing ? 'none' : undefined}>
<ModalHeader>
<ModalTitle id={modalId}>{t('Outbound_message')}</ModalTitle>
<ModalClose aria-label={t('Close')} onClick={() => setClosingConfirmation(true)} />
</ModalHeader>
<ModalContent paddingBlockEnd={16} height='100%'>
<OutboundMessageWizard defaultValues={defaultValues} onSuccess={onClose} onError={onClose} />
</ModalContent>
</Modal>
{isClosing ? <OutboundMessageCloseConfirmationModal onConfirm={onClose} onCancel={() => setClosingConfirmation(false)} /> : null}
</ModalBackdrop>
);
};
export default OutboundMessageModal;

View File

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

View File

@@ -0,0 +1,18 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import OutboundMessageModal from './OutboundMessageModal';
export const useOutboundMessageModal = () => {
const setModal = useSetModal();
const close = useStableCallback((): void => setModal(null));
const open = useStableCallback((defaultValues?: ComponentProps<typeof OutboundMessageModal>['defaultValues']) => {
setModal(<OutboundMessageModal defaultValues={defaultValues} onClose={close} />);
});
return useMemo(() => ({ open, close }), [open, close]);
};

View File

@@ -0,0 +1,116 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import OutboundMessageUpsellModal from './OutboundMessageUpsellModal';
import { CONTACT_SALES_LINK, OUTBOUND_DOCS_LINK } from '../../constants';
const openExternalLink = jest.fn();
jest.mock('../../../../../../hooks/useExternalLink', () => ({
useExternalLink: jest.fn(() => openExternalLink),
}));
jest.mock('../../../../../../../client/lib/getURL', () => ({
getURL: (url: string) => url,
}));
const appRoot = mockAppRoot().withJohnDoe().withTranslations('en', 'core', {
Learn_more: 'Learn more',
Contact_sales: 'Contact sales',
Outbound_message_upsell_annotation: 'Outbound_message_upsell_annotation',
No_phone_number_available_for_selected_channel: 'No phone number available for the selected channel',
});
describe('OutboundMessageUpsellModal', () => {
const onClose = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('hasModule is false', () => {
describe('user is not admin', () => {
it('should render only "Learn more" button', () => {
render(<OutboundMessageUpsellModal onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.getByRole('button', { name: 'Learn more' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Contact sales' })).not.toBeInTheDocument();
});
it('should render the annotation', () => {
render(<OutboundMessageUpsellModal onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.getByText('Outbound_message_upsell_annotation')).toBeInTheDocument();
});
it('should call openExternalLink with docs link when "Learn more" is clicked', async () => {
render(<OutboundMessageUpsellModal onClose={onClose} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('button', { name: 'Learn more' }));
expect(openExternalLink).toHaveBeenCalledWith(OUTBOUND_DOCS_LINK);
});
});
describe('user is admin', () => {
it('should render "Learn more" and "Contact sales" buttons', () => {
render(<OutboundMessageUpsellModal isAdmin onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.getByRole('button', { name: 'Learn more' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Contact sales' })).toBeInTheDocument();
});
it('should render "Upgrade" button when isCommunity is true', () => {
render(<OutboundMessageUpsellModal isAdmin isCommunity onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Contact sales' })).not.toBeInTheDocument();
});
it('should call openExternalLink with docs link when "Learn more" is clicked', async () => {
render(<OutboundMessageUpsellModal isAdmin onClose={onClose} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('button', { name: 'Learn more' }));
expect(openExternalLink).toHaveBeenCalledWith(OUTBOUND_DOCS_LINK);
});
it('should call openExternalLink with sales link when "Contact sales" is clicked', async () => {
render(<OutboundMessageUpsellModal isAdmin onClose={onClose} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('button', { name: 'Contact sales' }));
expect(openExternalLink).toHaveBeenCalledWith(CONTACT_SALES_LINK);
});
it('should not render the annotation', () => {
render(<OutboundMessageUpsellModal isAdmin onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.queryByText('Outbound_message_upsell_annotation')).not.toBeInTheDocument();
});
});
});
describe('hasModule is true', () => {
describe('user is not admin', () => {
it('should render only "Learn more" button', () => {
render(<OutboundMessageUpsellModal hasModule onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.getByRole('button', { name: 'Learn more' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Contact sales' })).not.toBeInTheDocument();
});
it('should render the annotation', () => {
render(<OutboundMessageUpsellModal hasModule onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.getByText('Outbound_message_upsell_annotation')).toBeInTheDocument();
});
it('should call openExternalLink with docs link when "Learn more" is clicked', async () => {
render(<OutboundMessageUpsellModal hasModule onClose={onClose} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('button', { name: 'Learn more' }));
expect(openExternalLink).toHaveBeenCalledWith(OUTBOUND_DOCS_LINK);
});
});
describe('user is admin', () => {
it('should render only "Learn more" button', () => {
render(<OutboundMessageUpsellModal hasModule isAdmin onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.getByRole('button', { name: 'Learn more' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Contact sales' })).not.toBeInTheDocument();
});
it('should not render the annotation', () => {
render(<OutboundMessageUpsellModal hasModule isAdmin onClose={onClose} />, { wrapper: appRoot.build() });
expect(screen.queryByText('Outbound_message_upsell_annotation')).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import OutboundMessageUpsellModal from './OutboundMessageUpsellModal';
const g = globalThis as typeof globalThis & {
__meteor_runtime_config__?: { ROOT_URL_PATH_PREFIX?: string };
};
g.__meteor_runtime_config__ = {
...(g.__meteor_runtime_config__ ?? {}),
ROOT_URL_PATH_PREFIX: '',
};
export default {
component: OutboundMessageUpsellModal,
args: {
onClose: action('onClose'),
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof OutboundMessageUpsellModal>;
type Story = StoryObj<typeof OutboundMessageUpsellModal>;
export const Default: Story = {
args: {
hasModule: false,
isAdmin: false,
},
};
export const WithModuleForAdmin: Story = {
args: {
hasModule: true,
isAdmin: true,
},
};
export const WithModuleForNonAdmin: Story = {
args: {
hasModule: true,
isAdmin: false,
},
};

View File

@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import GenericUpsellModal from '../../../../../../components/GenericUpsellModal';
import { useExternalLink } from '../../../../../../hooks/useExternalLink';
import { getURL } from '../../../../../../lib/getURL';
import { CONTACT_SALES_LINK, OUTBOUND_DOCS_LINK } from '../../constants';
export type OutboundMessageUpsellModalProps = {
hasModule?: boolean;
isAdmin?: boolean;
isCommunity?: boolean;
onClose: () => void;
};
const OutboundMessageUpsellModal = ({ isCommunity, hasModule, isAdmin, onClose }: OutboundMessageUpsellModalProps) => {
const { t } = useTranslation();
const openExternalLink = useExternalLink();
const props = useMemo(() => {
if (isAdmin && !hasModule) {
return {
cancelText: t('Learn_more'),
onCancel: () => openExternalLink(OUTBOUND_DOCS_LINK),
confirmText: isCommunity ? t('Upgrade') : t('Contact_sales'),
onConfirm: () => openExternalLink(CONTACT_SALES_LINK),
onClose,
};
}
return {
cancelText: t('Learn_more'),
annotation: !isAdmin ? t('Outbound_message_upsell_annotation') : undefined,
onCancel: () => openExternalLink(OUTBOUND_DOCS_LINK),
onClose,
};
}, [hasModule, isAdmin, isCommunity, onClose, openExternalLink, t]);
return (
<GenericUpsellModal
{...props}
title={t('Outbound_message_upsell_title')}
description={t('Outbound_message_upsell_description')}
img={getURL('images/outbound-message-upsell.svg')}
imgAlt={t('Outbound_message_upsell_image_alt')}
imgHeight={255}
/>
);
};
export default OutboundMessageUpsellModal;

View File

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

View File

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

View File

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