snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import ABACUpsellModal from './ABACUpsellModal';
|
||||
import { createFakeLicenseInfo } from '../../../../tests/mocks/data';
|
||||
|
||||
jest.mock('../../GenericUpsellModal/hooks', () => ({
|
||||
useUpsellActions: jest.fn(() => ({
|
||||
shouldShowUpsell: true,
|
||||
cloudWorkspaceHadTrial: false,
|
||||
handleManageSubscription: jest.fn(),
|
||||
handleTalkToSales: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock getURL utility
|
||||
jest.mock('../../../../client/lib/getURL', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
const appRoot = mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Premium_capability: 'Premium capability',
|
||||
Attribute_based_access_control: 'Attribute-Based Access Control',
|
||||
Attribute_based_access_control_title: 'Automate complex access management across your entire organization',
|
||||
Attribute_based_access_control_description:
|
||||
'ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.',
|
||||
Upgrade: 'Upgrade',
|
||||
Cancel: 'Cancel',
|
||||
})
|
||||
.withEndpoint('GET', '/v1/licenses.info', async () => ({
|
||||
license: createFakeLicenseInfo(),
|
||||
}))
|
||||
.build();
|
||||
|
||||
describe('ABACUpsellModal', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
const mockOnConfirm = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the modal with correct content', () => {
|
||||
const { baseElement } = render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should have no accessibility violations', async () => {
|
||||
const { container } = render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should call onConfirm when upgrade button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: 'Upgrade' });
|
||||
await user.click(upgradeButton);
|
||||
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onClose when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
|
||||
|
||||
// Look for close button (usually has aria-label or is the X button)
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple button clicks correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: 'Upgrade' });
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
|
||||
// Click upgrade first
|
||||
await user.click(upgradeButton);
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Click cancel
|
||||
await user.click(cancelButton);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Total calls
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import ABACUpsellModal from './ABACUpsellModal';
|
||||
|
||||
const meta = {
|
||||
component: ABACUpsellModal,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
onClose: action('onClose'),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const AppRoot = mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Attribute_based_access_control: 'Attribute-Based Access Control',
|
||||
Attribute_based_access_control_title: 'Automate complex access management across your entire organization',
|
||||
Attribute_based_access_control_description:
|
||||
'ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.',
|
||||
})
|
||||
.build();
|
||||
|
||||
return (
|
||||
<AppRoot>
|
||||
<Story />
|
||||
</AppRoot>
|
||||
);
|
||||
},
|
||||
],
|
||||
} satisfies Meta<typeof ABACUpsellModal>;
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
onClose: action('onClose'),
|
||||
onConfirm: action('onConfirm'),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getURL } from '../../../lib/getURL';
|
||||
import GenericUpsellModal from '../../GenericUpsellModal';
|
||||
|
||||
export type ABACUpsellModalProps = {
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
const ABACUpsellModal = ({ onClose, onConfirm }: ABACUpsellModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<GenericUpsellModal
|
||||
tagline={t('Premium_capability')}
|
||||
title={t('Attribute_based_access_control')}
|
||||
subtitle={t('Attribute_based_access_control_title')}
|
||||
description={t('Attribute_based_access_control_description')}
|
||||
img={getURL('images/abac-upsell-modal.svg')}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onClose}
|
||||
imgHeight={256}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ABACUpsellModal;
|
||||
@@ -0,0 +1,112 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`ABACUpsellModal should render the modal with correct content 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_0_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
|
||||
>
|
||||
Premium capability
|
||||
</div>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_0_-title"
|
||||
>
|
||||
Attribute-Based Access Control
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<figure
|
||||
class="rcx-modal__hero-image-wrapper"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="rcx-box rcx-box--full rcx-modal__hero-image rcx-css-1qfl5uy"
|
||||
src="images/abac-upsell-modal.svg"
|
||||
/>
|
||||
</figure>
|
||||
<h3
|
||||
class="rcx-box rcx-box--full rcx-css-1sfto34"
|
||||
>
|
||||
Automate complex access management across your entire organization
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
|
||||
style="white-space: break-spaces;"
|
||||
>
|
||||
ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Upgrade
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
53
apps/meteor/client/components/ActionManagerBusyState.tsx
Normal file
53
apps/meteor/client/components/ActionManagerBusyState.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager';
|
||||
|
||||
const ActionManagerBusyState = () => {
|
||||
const { t } = useTranslation();
|
||||
const actionManager = useUiKitActionManager();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBusyStateChange = ({ busy }: { busy: boolean }) => setBusy(busy);
|
||||
|
||||
actionManager.on('busy', handleBusyStateChange);
|
||||
|
||||
return () => {
|
||||
actionManager.off('busy', handleBusyStateChange);
|
||||
};
|
||||
}, [actionManager]);
|
||||
|
||||
if (busy) {
|
||||
return (
|
||||
<Box
|
||||
className={css`
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
`}
|
||||
position='absolute'
|
||||
insetInlineStart='50%'
|
||||
padding={16}
|
||||
backgroundColor='tint'
|
||||
color='default'
|
||||
textAlign='center'
|
||||
fontSize='p2'
|
||||
elevation='2'
|
||||
borderRadius='0 0 4px 4px'
|
||||
zIndex={99999}
|
||||
>
|
||||
{t('Loading')}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ActionManagerBusyState;
|
||||
31
apps/meteor/client/components/AppLayoutThemeWrapper.tsx
Normal file
31
apps/meteor/client/components/AppLayoutThemeWrapper.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PaletteStyleTag } from '@rocket.chat/fuselage';
|
||||
import { useDarkMode } from '@rocket.chat/fuselage-hooks';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type AppLayoutThemeWrapperProps = { children: ReactNode };
|
||||
|
||||
const AppLayoutThemeWrapper = ({ children }: AppLayoutThemeWrapperProps) => {
|
||||
const dark = useDarkMode();
|
||||
|
||||
/*
|
||||
* Paint the root canvas so mobile browsers fill the overscroll/safe-area regions
|
||||
* (and tint their toolbars) with the theme background instead of falling back to white.
|
||||
*/
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('background-color', 'var(--rcx-color-surface-tint)');
|
||||
|
||||
return () => {
|
||||
document.documentElement.style.removeProperty('background-color');
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaletteStyleTag theme={dark ? 'dark' : 'light'} tagId='app-layout-palette' />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayoutThemeWrapper;
|
||||
27
apps/meteor/client/components/AutoupdateToastMessage.tsx
Normal file
27
apps/meteor/client/components/AutoupdateToastMessage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Button } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useIdleActiveEvents } from '../hooks/useIdleActiveEvents';
|
||||
|
||||
export const AutoupdateToastMessage = () => {
|
||||
const { t } = useTranslation();
|
||||
useIdleActiveEvents({ id: 'autoupdate', awayOnWindowBlur: true }, () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
className={css`
|
||||
gap: 8px;
|
||||
`}
|
||||
>
|
||||
{t('An_update_is_available')}
|
||||
<Button primary small onClick={() => window.location.reload()}>
|
||||
{t('Reload_to_update')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
6
apps/meteor/client/components/Backdrop.tsx
Normal file
6
apps/meteor/client/components/Backdrop.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ModalBackdrop } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type BackdropProps = ComponentProps<typeof ModalBackdrop>;
|
||||
|
||||
export const Backdrop = (props: BackdropProps) => <ModalBackdrop backgroundColor='transparent' {...props} />;
|
||||
89
apps/meteor/client/components/ConfirmOwnerChangeModal.tsx
Normal file
89
apps/meteor/client/components/ConfirmOwnerChangeModal.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
export type ConfirmOwnerChangeModalProps = {
|
||||
shouldChangeOwner: string[];
|
||||
shouldBeRemoved: string[];
|
||||
contentTitle?: string;
|
||||
} & Pick<ComponentPropsWithoutRef<typeof GenericModal>, 'onConfirm' | 'onCancel' | 'confirmText'>;
|
||||
|
||||
const ConfirmOwnerChangeModal = ({
|
||||
shouldChangeOwner,
|
||||
shouldBeRemoved,
|
||||
contentTitle,
|
||||
confirmText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmOwnerChangeModalProps) => {
|
||||
const getChangeOwnerRooms = useStableCallback(() => {
|
||||
if (shouldChangeOwner.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (shouldChangeOwner.length === 1) {
|
||||
return (
|
||||
<Trans
|
||||
i18nKey='A_new_owner_will_be_assigned_automatically_to_the__roomName__room'
|
||||
values={{ roomName: shouldChangeOwner[0] }}
|
||||
components={{ bold: <Box is='span' fontWeight='bold' /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (shouldChangeOwner.length <= 5) {
|
||||
return (
|
||||
<Trans
|
||||
i18nKey='A_new_owner_will_be_assigned_automatically_to_those__count__rooms__rooms__'
|
||||
values={{ count: shouldChangeOwner.length, rooms: shouldChangeOwner.join(', ') }}
|
||||
components={{ br: <br />, bold: <Box is='span' fontWeight='bold' /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Trans
|
||||
i18nKey='A_new_owner_will_be_assigned_automatically_to__count__rooms'
|
||||
values={{ count: shouldChangeOwner.length }}
|
||||
components={{ bold: <Box is='span' fontWeight='bold' /> }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const getRemovedRooms = useStableCallback(() => {
|
||||
if (shouldBeRemoved.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (shouldBeRemoved.length === 1) {
|
||||
return (
|
||||
<Trans
|
||||
i18nKey='The_empty_room__roomName__will_be_removed_automatically'
|
||||
values={{ roomName: shouldBeRemoved[0] }}
|
||||
components={{ bold: <Box is='span' fontWeight='bold' /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (shouldBeRemoved.length <= 5) {
|
||||
return (
|
||||
<Trans
|
||||
i18nKey='__count__empty_rooms_will_be_removed_automatically__rooms__'
|
||||
values={{ count: shouldBeRemoved.length, rooms: shouldBeRemoved.join(', ') }}
|
||||
components={{ br: <br />, bold: <Box is='span' fontWeight='bold' /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Trans i18nKey='__count__empty_rooms_will_be_removed_automatically' values={{ count: shouldBeRemoved.length }} />;
|
||||
});
|
||||
|
||||
return (
|
||||
<GenericModal variant='danger' onClose={onCancel} onCancel={onCancel} confirmText={confirmText} onConfirm={onConfirm}>
|
||||
{contentTitle}
|
||||
|
||||
{shouldChangeOwner && <Box marginBlock={16}>{getChangeOwnerRooms()}</Box>}
|
||||
{shouldBeRemoved && <Box marginBlock={16}>{getRemovedRooms()}</Box>}
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmOwnerChangeModal;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
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 CreateDiscussion from './CreateDiscussion';
|
||||
import * as stories from './CreateDiscussion.stories';
|
||||
import { createFakeRoom } from '../../../tests/mocks/data';
|
||||
|
||||
jest.mock('../../lib/rooms/roomCoordinator', () => ({
|
||||
roomCoordinator: {
|
||||
getRoomDirectives: () => ({
|
||||
getRoomName: () => 'General',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
const room1 = createFakeRoom({ encrypted: true, fname: 'Encrypted Room 1' });
|
||||
const room2 = createFakeRoom({ encrypted: false, fname: 'Unencrypted Room 2' });
|
||||
|
||||
const appRoot = mockAppRoot()
|
||||
.withEndpoint('POST', '/v1/rooms.createDiscussion', () => ({
|
||||
success: true,
|
||||
discussion: {
|
||||
...createFakeRoom({
|
||||
_id: 'discussion-id',
|
||||
t: 'p' as const,
|
||||
name: 'discussion-name',
|
||||
fname: 'Discussion Name',
|
||||
prid: 'parent-room-id',
|
||||
}),
|
||||
rid: 'discussion-id',
|
||||
} as any,
|
||||
}))
|
||||
.withEndpoint(
|
||||
'GET',
|
||||
'/v1/rooms.autocomplete.channelAndPrivate',
|
||||
() =>
|
||||
({
|
||||
items: [room1, room2],
|
||||
}) as any,
|
||||
)
|
||||
.withTranslations('en', 'core', {
|
||||
Encrypted: 'Encrypted',
|
||||
Discussion_first_message_title: 'Message',
|
||||
Discussion_target_channel: 'Parent channel or team',
|
||||
})
|
||||
.build();
|
||||
|
||||
describe('CreateDiscussion', () => {
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const { baseElement } = render(<Story />, { wrapper: appRoot });
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
describe('Encrypted parent room behavior', () => {
|
||||
it('should disable encrypted toggle and first message field when parent room is encrypted', () => {
|
||||
render(<CreateDiscussion onClose={jest.fn()} encryptedParentRoom={true} />, { wrapper: appRoot });
|
||||
|
||||
const encryptedToggle = screen.getByRole('checkbox', { name: 'Encrypted' });
|
||||
expect(encryptedToggle).toBeDisabled();
|
||||
|
||||
const firstMessageField = screen.getByLabelText('Message');
|
||||
expect(firstMessageField).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable encrypted toggle and first message field when an encrypted parent room is selected', async () => {
|
||||
render(<CreateDiscussion onClose={jest.fn()} />, { wrapper: appRoot });
|
||||
|
||||
const parentRoomSelect = screen.getByRole('textbox', { name: 'Parent channel or team' });
|
||||
|
||||
await userEvent.click(parentRoomSelect);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Encrypted Room 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('Encrypted Room 1'));
|
||||
|
||||
const encryptedToggle = screen.getByRole('checkbox', { name: 'Encrypted' });
|
||||
expect(encryptedToggle).toBeDisabled();
|
||||
|
||||
const firstMessageField = screen.getByLabelText('Message');
|
||||
expect(firstMessageField).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import CreateDiscussion from './CreateDiscussion';
|
||||
|
||||
export default {
|
||||
component: CreateDiscussion,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
actions: { argTypesRegex: '^on.*' },
|
||||
},
|
||||
args: {
|
||||
onClose: () => undefined,
|
||||
},
|
||||
} satisfies Meta<typeof CreateDiscussion>;
|
||||
|
||||
export const Default: StoryObj<typeof CreateDiscussion> = {};
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Icon, Box } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
ToggleSwitch,
|
||||
TextInput,
|
||||
TextAreaInput,
|
||||
FieldHint,
|
||||
FieldLabel,
|
||||
FieldRow,
|
||||
FieldError,
|
||||
} from '@rocket.chat/fuselage-forms';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
|
||||
import { useEncryptedRoomDescription } from '../../navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription';
|
||||
import { useGoToRoom } from '../../views/room/hooks/useGoToRoom';
|
||||
import RoomAutoComplete from '../RoomAutoComplete';
|
||||
import UserAutoCompleteMultiple from '../UserAutoCompleteMultiple';
|
||||
import DefaultParentRoomField from './DefaultParentRoomField';
|
||||
|
||||
type CreateDiscussionFormValues = {
|
||||
name: string;
|
||||
parentRoom: IRoom['_id'];
|
||||
encrypted: boolean;
|
||||
usernames: Array<IUser['username']>;
|
||||
firstMessage: string;
|
||||
topic: string;
|
||||
};
|
||||
|
||||
export type CreateDiscussionProps = {
|
||||
parentMessageId?: IMessage['_id'];
|
||||
encryptedParentRoom?: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (rid: string) => void | Promise<void>;
|
||||
defaultParentRoom?: IRoom['_id'];
|
||||
nameSuggestion?: string;
|
||||
};
|
||||
|
||||
const CreateDiscussion = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
defaultParentRoom,
|
||||
parentMessageId,
|
||||
nameSuggestion,
|
||||
encryptedParentRoom = false,
|
||||
}: CreateDiscussionProps) => {
|
||||
const t = useTranslation();
|
||||
|
||||
const [encryptedDisabled, setEncryptedDisabled] = useState(encryptedParentRoom);
|
||||
|
||||
const {
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
name: nameSuggestion || '',
|
||||
parentRoom: '',
|
||||
encrypted: encryptedParentRoom,
|
||||
usernames: [],
|
||||
firstMessage: '',
|
||||
topic: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onParentRoomChange = useStableCallback((room: IRoom | undefined) => {
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
setValue('encrypted', room.encrypted === true);
|
||||
setEncryptedDisabled(room.encrypted === true);
|
||||
});
|
||||
|
||||
const { encrypted } = watch();
|
||||
|
||||
const createDiscussion = useEndpoint('POST', '/v1/rooms.createDiscussion');
|
||||
|
||||
const goToRoom = useGoToRoom();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const createDiscussionMutation = useMutation({
|
||||
mutationFn: createDiscussion,
|
||||
onSuccess: ({ discussion }) => {
|
||||
goToRoom(discussion._id);
|
||||
void onSuccess?.(discussion._id);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = async ({ name, parentRoom, encrypted, usernames, firstMessage, topic }: CreateDiscussionFormValues) => {
|
||||
createDiscussionMutation.mutate({
|
||||
prid: defaultParentRoom || parentRoom,
|
||||
t_name: name,
|
||||
users: usernames,
|
||||
reply: encrypted ? undefined : firstMessage,
|
||||
topic,
|
||||
...(parentMessageId && { pmid: parentMessageId }),
|
||||
});
|
||||
};
|
||||
|
||||
const getEncryptedHint = useEncryptedRoomDescription('discussion');
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
variant='warning'
|
||||
icon={null}
|
||||
title={t('Discussion_title')}
|
||||
onCancel={onClose}
|
||||
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleCreate)} {...props} />}
|
||||
confirmText={t('Create')}
|
||||
cancelText={t('Cancel')}
|
||||
confirmLoading={createDiscussionMutation.isPending}
|
||||
>
|
||||
<Box marginBlockEnd={24}>{t('Discussion_description')}</Box>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel required>{t('Discussion_target_channel')}</FieldLabel>
|
||||
<FieldRow>
|
||||
{defaultParentRoom && (
|
||||
<Controller
|
||||
control={control}
|
||||
name='parentRoom'
|
||||
render={() => <DefaultParentRoomField defaultParentRoom={defaultParentRoom} />}
|
||||
/>
|
||||
)}
|
||||
{!defaultParentRoom && (
|
||||
<Controller
|
||||
control={control}
|
||||
name='parentRoom'
|
||||
rules={{ required: t('Required_field', { field: t('Discussion_target_channel') }) }}
|
||||
render={({ field }) => (
|
||||
<RoomAutoComplete
|
||||
{...field}
|
||||
error={Boolean(errors.parentRoom?.message)}
|
||||
placeholder={t('Search_options')}
|
||||
disabled={Boolean(defaultParentRoom)}
|
||||
aria-required='true'
|
||||
setSelectedRoom={onParentRoomChange}
|
||||
renderRoomIcon={({ encrypted }) => (encrypted ? <Icon name='key' /> : null)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FieldRow>
|
||||
{errors.parentRoom && <FieldError>{errors.parentRoom.message}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel required>{t('Name')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='name'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Name') }) }}
|
||||
render={({ field }) => (
|
||||
<TextInput {...field} aria-required='true' endAddon={<Icon name='baloons' size='x20' />} error={errors.name?.message} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.name && <FieldError>{errors.name.message}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Topic')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller name='topic' control={control} render={({ field }) => <TextInput {...field} error={errors.topic?.message} />} />
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<FieldHint>{t('Displayed_next_to_name')}</FieldHint>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Members')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
control={control}
|
||||
name='usernames'
|
||||
render={({ field }) => <UserAutoCompleteMultiple {...field} placeholder={t('Add_people')} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Discussion_first_message_title')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
control={control}
|
||||
name='firstMessage'
|
||||
render={({ field }) => <TextAreaInput {...field} rows={5} disabled={encrypted} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
{encrypted ? (
|
||||
<FieldHint>{t('Discussion_first_message_disabled_due_to_e2e')}</FieldHint>
|
||||
) : (
|
||||
<FieldHint>{t('First_message_hint')}</FieldHint>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel>{t('Encrypted')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='encrypted'
|
||||
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} disabled={encryptedDisabled} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint>{getEncryptedHint({ isPrivate: true, encrypted })}</FieldHint>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateDiscussion;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Skeleton, Callout } from '@rocket.chat/fuselage';
|
||||
import { TextInput } from '@rocket.chat/fuselage-forms';
|
||||
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
|
||||
|
||||
export type DefaultParentRoomFieldProps = {
|
||||
defaultParentRoom: string;
|
||||
} & Omit<ComponentPropsWithoutRef<typeof TextInput>, 'defaultValue' | 'disabled'>;
|
||||
|
||||
const DefaultParentRoomField = ({ defaultParentRoom, ...props }: DefaultParentRoomFieldProps) => {
|
||||
const t = useTranslation();
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
roomId: defaultParentRoom,
|
||||
}),
|
||||
[defaultParentRoom],
|
||||
);
|
||||
|
||||
const roomsInfoEndpoint = useEndpoint('GET', '/v1/rooms.info');
|
||||
|
||||
const { data, isPending, isError } = useQuery({
|
||||
queryKey: ['defaultParentRoomInfo', query],
|
||||
queryFn: async () => roomsInfoEndpoint(query),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Skeleton width='full' />;
|
||||
}
|
||||
|
||||
if (!data?.room || isError) {
|
||||
return <Callout type='danger'>{t('Error')}</Callout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
defaultValue={roomCoordinator.getRoomName(data.room.t, {
|
||||
_id: data.room._id,
|
||||
fname: data.room.fname,
|
||||
name: data.room.name,
|
||||
prid: data.room.prid,
|
||||
})}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultParentRoomField;
|
||||
@@ -0,0 +1,347 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`CreateDiscussion renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_0_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<form
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_0_-title"
|
||||
>
|
||||
Discussion_title
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-heem01"
|
||||
>
|
||||
Discussion_description
|
||||
</div>
|
||||
<fieldset
|
||||
class="rcx-box rcx-box--full rcx-field-group"
|
||||
role="group"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
id="react-aria-_r_1_-label"
|
||||
>
|
||||
Parent channel or team
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-autocomplete rcx-css-t3n91h"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-6d871f"
|
||||
role="group"
|
||||
>
|
||||
<input
|
||||
aria-describedby=""
|
||||
aria-invalid="false"
|
||||
aria-labelledby="react-aria-_r_1_-label"
|
||||
aria-required="true"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-rcil7k"
|
||||
id="react-aria-_r_1_"
|
||||
name="parentRoom"
|
||||
placeholder="Search_options"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-autocomplete__addon"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-l5txo2"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="react-aria-_r_2_-label"
|
||||
>
|
||||
Name
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
|
||||
>
|
||||
<input
|
||||
aria-describedby=""
|
||||
aria-invalid="false"
|
||||
aria-required="true"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
|
||||
id="react-aria-_r_2_-label"
|
||||
name="name"
|
||||
size="1"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-input-box__addon"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-baloons rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="react-aria-_r_3_-label"
|
||||
>
|
||||
Topic
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<input
|
||||
aria-describedby="react-aria-_r_3_-hint"
|
||||
aria-invalid="false"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
|
||||
id="react-aria-_r_3_-label"
|
||||
name="topic"
|
||||
size="1"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__hint"
|
||||
id="react-aria-_r_3_-hint"
|
||||
>
|
||||
Displayed_next_to_name
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
id="react-aria-_r_4_-label"
|
||||
>
|
||||
Members
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-select"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1sr8su7"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-w398ts"
|
||||
>
|
||||
<input
|
||||
aria-controls="react-aria-_r_4_-listbox"
|
||||
aria-describedby=""
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-invalid="false"
|
||||
aria-labelledby="react-aria-_r_4_-label"
|
||||
autocomplete="off"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-pqpp8q"
|
||||
id="react-aria-_r_4_"
|
||||
name="usernames"
|
||||
placeholder="Add_people"
|
||||
role="combobox"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-select__addon rcx-css-x7bl3q rcx-css-1bprq55"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="react-aria-_r_5_-label"
|
||||
>
|
||||
Message
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<textarea
|
||||
aria-describedby="react-aria-_r_5_-hint"
|
||||
aria-invalid="false"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-textarea rcx-input-box"
|
||||
cols="1"
|
||||
id="react-aria-_r_5_-label"
|
||||
name="firstMessage"
|
||||
rows="5"
|
||||
size="1"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__hint"
|
||||
id="react-aria-_r_5_-hint"
|
||||
>
|
||||
First_message_hint
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
>
|
||||
Encrypted
|
||||
</span>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch"
|
||||
>
|
||||
<div
|
||||
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
|
||||
>
|
||||
Encrypted
|
||||
</div>
|
||||
<input
|
||||
aria-describedby="react-aria-_r_6_-hint"
|
||||
aria-invalid="false"
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch__input"
|
||||
id="react-aria-_r_6_-label"
|
||||
name="encrypted"
|
||||
type="checkbox"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch__fake"
|
||||
/>
|
||||
</label>
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__hint"
|
||||
id="react-aria-_r_6_-hint"
|
||||
>
|
||||
Not_available_for_this_workspace
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Create
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
1
apps/meteor/client/components/CreateDiscussion/index.ts
Normal file
1
apps/meteor/client/components/CreateDiscussion/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './CreateDiscussion';
|
||||
35
apps/meteor/client/components/Emoji.tsx
Normal file
35
apps/meteor/client/components/Emoji.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import styled from '@rocket.chat/styled';
|
||||
|
||||
import { getEmojiClassNameAndDataTitle } from '../lib/utils/renderEmoji';
|
||||
|
||||
export type EmojiProps = {
|
||||
emojiHandle: string; // :emoji:
|
||||
className?: string;
|
||||
fillContainer?: boolean;
|
||||
};
|
||||
|
||||
const EmojiComponent = styled('span', ({ fillContainer: _fillContainer, ...props }: { fillContainer?: boolean }) => props)`
|
||||
${({ fillContainer }) =>
|
||||
fillContainer
|
||||
? `
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
function Emoji({ emojiHandle, className = undefined, fillContainer }: EmojiProps) {
|
||||
const { className: emojiClassName, image, ...props } = getEmojiClassNameAndDataTitle(emojiHandle);
|
||||
|
||||
return (
|
||||
<EmojiComponent
|
||||
className={[emojiClassName, className, image ? 'rcx-message__emoji--custom' : ''].filter(Boolean).join(' ')}
|
||||
style={image?.length ? { backgroundImage: image } : undefined}
|
||||
fillContainer={fillContainer}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Emoji;
|
||||
43
apps/meteor/client/components/FilterByText.tsx
Normal file
43
apps/meteor/client/components/FilterByText.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Box, Icon, TextInput, Margins } from '@rocket.chat/fuselage';
|
||||
import { useAutoFocus, useMergedRefs } from '@rocket.chat/fuselage-hooks';
|
||||
import type { ChangeEvent, ComponentPropsWithoutRef, SubmitEvent } from 'react';
|
||||
import { forwardRef, memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// TODO: consider changing the type of TextInput's `onChange` to (event: ChangeEvent<HTMLInputElement>) => void
|
||||
export type FilterByTextProps = Omit<ComponentPropsWithoutRef<typeof TextInput>, 'onChange'> & {
|
||||
shouldAutoFocus?: boolean;
|
||||
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const FilterByText = forwardRef<HTMLInputElement, FilterByTextProps>(function FilterByText(
|
||||
{ placeholder, shouldAutoFocus = false, children, ...props },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const autoFocusRef = useAutoFocus(shouldAutoFocus);
|
||||
const mergedRefs = useMergedRefs(ref, autoFocusRef);
|
||||
|
||||
const handleFormSubmit = useCallback((event: SubmitEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box marginBlock={16} marginInline='neg-x4' is='form' onSubmit={handleFormSubmit} display='flex' flexWrap='wrap' alignItems='center'>
|
||||
<Box marginInline={4} display='flex' flexGrow={1}>
|
||||
<TextInput
|
||||
{...props}
|
||||
placeholder={placeholder ?? t('Search')}
|
||||
ref={mergedRefs}
|
||||
endAddon={<Icon name='magnifier' size='x20' />}
|
||||
flexGrow={2}
|
||||
minWidth='x220'
|
||||
aria-label={placeholder ?? t('Search')}
|
||||
/>
|
||||
</Box>
|
||||
{children && <Margins inline={4}>{children}</Margins>}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default memo(FilterByText);
|
||||
38
apps/meteor/client/components/FingerprintChangeModal.tsx
Normal file
38
apps/meteor/client/components/FingerprintChangeModal.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { ExternalLink, GenericModal } from '@rocket.chat/ui-client';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { links } from '../lib/links';
|
||||
|
||||
export type FingerprintChangeModalProps = {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintChangeModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<GenericModal
|
||||
variant='warning'
|
||||
title={t('Unique_ID_change_detected')}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
confirmText={t('Configuration_update')}
|
||||
cancelText={t('New_workspace')}
|
||||
>
|
||||
<Box is='p' marginBlockEnd={16}>
|
||||
<Trans i18nKey='Unique_ID_change_detected_description' />
|
||||
</Box>
|
||||
<Box is='p' marginBlockEnd={16}>
|
||||
<Trans
|
||||
i18nKey='Unique_ID_change_detected_learn_more_link'
|
||||
components={{ a: <ExternalLink to={links.go.fingerPrintChangedFaq} /> }}
|
||||
/>
|
||||
</Box>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FingerprintChangeModal;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { ExternalLink, GenericModal } from '@rocket.chat/ui-client';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { links } from '../lib/links';
|
||||
|
||||
export type FingerprintChangeModalConfirmationProps = {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onClose: () => void;
|
||||
newWorkspace: boolean;
|
||||
};
|
||||
|
||||
const FingerprintChangeModalConfirmation = ({ onConfirm, onCancel, onClose, newWorkspace }: FingerprintChangeModalConfirmationProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<GenericModal
|
||||
variant='warning'
|
||||
title={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
cancelText={t('Back')}
|
||||
confirmText={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box is='p' marginBlockEnd={16}>
|
||||
{newWorkspace ? (
|
||||
<Trans i18nKey='Confirm_new_workspace_description' />
|
||||
) : (
|
||||
<Trans i18nKey='Confirm_configuration_update_description' />
|
||||
)}
|
||||
</Box>
|
||||
<Box is='p' marginBlockEnd={16}>
|
||||
<Trans
|
||||
i18nKey='Unique_ID_change_detected_learn_more_link'
|
||||
components={{ a: <ExternalLink to={links.go.fingerPrintChangedFaq} /> }}
|
||||
/>
|
||||
</Box>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FingerprintChangeModalConfirmation;
|
||||
229
apps/meteor/client/components/GazzodownText.spec.tsx
Normal file
229
apps/meteor/client/components/GazzodownText.spec.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Markup } from '@rocket.chat/gazzodown';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import GazzodownText from './GazzodownText';
|
||||
import { useMessageListHighlights } from './message/list/MessageListContext';
|
||||
|
||||
jest.mock('@rocket.chat/ui-client', () => ({
|
||||
useFeaturePreview: () => false,
|
||||
}));
|
||||
jest.mock('@rocket.chat/fuselage-hooks', () => ({
|
||||
useLocalStorage: () => ['en', jest.fn()],
|
||||
}));
|
||||
jest.mock('./message/list/MessageListContext', () => ({
|
||||
useMessageListHighlights: jest.fn(),
|
||||
useMessageListShowRealName: jest.fn(() => false),
|
||||
}));
|
||||
jest.mock('../lib/utils/fireGlobalEvent', () => ({
|
||||
fireGlobalEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('../views/room/hooks/useGoToRoom', () => ({
|
||||
useGoToRoom: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseMessageListHighlights = useMessageListHighlights as jest.MockedFunction<typeof useMessageListHighlights>;
|
||||
const wrapper = mockAppRoot().withUserPreference('useEmojis', true).withSetting('UI_Use_Real_Name', false).withJohnDoe();
|
||||
|
||||
const HighlightTester = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<Markup
|
||||
tokens={[
|
||||
{
|
||||
type: 'PARAGRAPH',
|
||||
value: [{ type: 'PLAIN_TEXT', value: text }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('GazzodownText highlights', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should highlight Russian word "тест" in the middle of a sentence', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='Это тест сообщения' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
// Expect that the highlighted element wraps exactly "тест"
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
|
||||
});
|
||||
|
||||
it('should highlight Russian word "тест" at the beginning of a sentence', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='Тест сообщения' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
|
||||
});
|
||||
|
||||
it('should not highlight "тест" when it is part of a larger word', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='Тестирование сообщения' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
expect(screen.queryByTitle('Highlighted_chosen_word')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should highlight Russian word "тест" at the end of a sentence', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='Сообщение тест' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
|
||||
});
|
||||
|
||||
it('should highlight English word "test" regardless of its position', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='This is a test message' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
|
||||
});
|
||||
|
||||
it('should highlight all occurrences of the highlighted word in different positions', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='test is at the beginning, in the middle test, and at the end test' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
|
||||
// Expect three separate highlights.
|
||||
expect(highlightedElements.length).toBe(3);
|
||||
highlightedElements.forEach((el) => {
|
||||
expect(el).toHaveTextContent(/^test$/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('should highlight the highlighted word in a multiline text', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
|
||||
|
||||
const multilineText = `First line
|
||||
Test line
|
||||
Another line with test
|
||||
in it.`;
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text={multilineText} />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
|
||||
// At least two occurrences are expected: one for "Test" (capitalized) and one for "test"
|
||||
expect(highlightedElements.length).toBeGreaterThanOrEqual(2);
|
||||
// Verify that the highlighted texts match "test" (case-insensitive)
|
||||
highlightedElements.forEach((el) => {
|
||||
expect(el.textContent).toMatch(/^test$/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('should highlight highlighted word when surrounded by colons', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='This is :test: inside colons' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
// The highlighted element should contain only "test"
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
|
||||
});
|
||||
|
||||
it('should highlight highlighted word when colon is at the start', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='This is :test with colon at the start' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
// The highlighted element should contain only "test"
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
|
||||
});
|
||||
|
||||
it('should highlight highlighted word when colon is at the end', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='This is test: with colon at end' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
// The highlighted element should contain only "test"
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
|
||||
});
|
||||
|
||||
it('should highlight multiple different highlighted words in the same text', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }, { highlight: 'highlight' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='This test message should highlight the word highlight in multiple places: test and highlight.' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
|
||||
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
|
||||
|
||||
expect(highlightedElements.length).toBe(5);
|
||||
const texts = highlightedElements.map((el) => el.textContent?.toLowerCase());
|
||||
expect(texts).toEqual(expect.arrayContaining(['test', 'highlight']));
|
||||
});
|
||||
|
||||
it('should highlight a word containing special characters like "-", "_", ".", "/", "=", "!", ":', () => {
|
||||
// The highlight word includes special characters.
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'te-st_te.st/te=te!st:' }] as any);
|
||||
|
||||
const testText = 'This message contains te-st_te.st/te=te!st: as a highlighted word.';
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text={testText} />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^te-st_te\.st\/te=te!st:$/i);
|
||||
});
|
||||
|
||||
it('should highlight the word in a case-insensitive manner', () => {
|
||||
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'Test' }] as any);
|
||||
|
||||
render(
|
||||
<GazzodownText>
|
||||
<HighlightTester text='This is a test message' />
|
||||
</GazzodownText>,
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
|
||||
});
|
||||
});
|
||||
144
apps/meteor/client/components/GazzodownText.tsx
Normal file
144
apps/meteor/client/components/GazzodownText.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
|
||||
import type { ChannelMention, UserMention } from '@rocket.chat/gazzodown';
|
||||
import { MarkupInteractionContext } from '@rocket.chat/gazzodown';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { useLayout, useRouter, useUserPreference, useUserId, useUserCard } from '@rocket.chat/ui-contexts';
|
||||
import type { UIEvent, ReactNode } from 'react';
|
||||
import { useCallback, memo, useMemo } from 'react';
|
||||
|
||||
import { normalizeUsername } from '../../lib/utils/normalizeUsername';
|
||||
import { detectEmoji } from '../lib/utils/detectEmoji';
|
||||
import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent';
|
||||
import { useMessageListHighlights, useMessageListShowRealName } from './message/list/MessageListContext';
|
||||
import { useGoToRoom } from '../views/room/hooks/useGoToRoom';
|
||||
|
||||
export type GazzodownTextProps = {
|
||||
children: ReactNode;
|
||||
mentions?: {
|
||||
type?: 'user' | 'team';
|
||||
_id: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
}[];
|
||||
channels?: Pick<IRoom, '_id' | 'name'>[];
|
||||
searchText?: string;
|
||||
};
|
||||
|
||||
const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTextProps) => {
|
||||
const [userLanguage] = useLocalStorage('userLanguage', 'en');
|
||||
|
||||
const highlights = useMessageListHighlights();
|
||||
const { triggerProps, openUserCard } = useUserCard();
|
||||
|
||||
const highlightRegex = useMemo(() => {
|
||||
if (!highlights?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Due to unnecessary escaping in escapeRegExp, we need to remove the escape character for the following characters: - = ! :
|
||||
// This is necessary because it was crashing the client due to Invalid regular expression error.
|
||||
const alternatives = highlights.map(({ highlight }) => escapeRegExp(highlight).replace(/\\([-=!:])/g, '$1')).join('|');
|
||||
const expression = `(?<=^|[\\p{P}\\p{Z}])(${alternatives})(?=$|[\\p{P}\\p{Z}])`;
|
||||
|
||||
return (): RegExp => new RegExp(expression, 'gmiu');
|
||||
}, [highlights]);
|
||||
|
||||
const markRegex = useMemo(() => {
|
||||
if (!searchText) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (): RegExp => new RegExp(`(${searchText})(?![^<]*>)`, 'gi');
|
||||
}, [searchText]);
|
||||
|
||||
const convertAsciiToEmoji = useUserPreference<boolean>('convertAsciiEmoji', true);
|
||||
const useEmoji = useUserPreference<boolean>('useEmojis', true);
|
||||
const useRealName = useMessageListShowRealName();
|
||||
const ownUserId = useUserId();
|
||||
const showMentionSymbol = Boolean(useUserPreference<boolean>('mentionsWithSymbol'));
|
||||
|
||||
const resolveUserMention = useCallback(
|
||||
(mention: string) => {
|
||||
if (mention === 'all' || mention === 'here') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filterUser = ({ username, type }: UserMention) => {
|
||||
if (!username || type === 'team') return false;
|
||||
return normalizeUsername(username) === normalizeUsername(mention);
|
||||
};
|
||||
const filterTeam = ({ name, type }: UserMention) => type === 'team' && name === mention;
|
||||
|
||||
return mentions?.find((mention) => filterUser(mention) || filterTeam(mention));
|
||||
},
|
||||
[mentions],
|
||||
);
|
||||
|
||||
const onUserMentionClick = useCallback(
|
||||
({ username }: UserMention) => {
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (event: UIEvent): void => {
|
||||
event.stopPropagation();
|
||||
openUserCard(event, username);
|
||||
};
|
||||
},
|
||||
[openUserCard],
|
||||
);
|
||||
|
||||
const goToRoom = useGoToRoom();
|
||||
|
||||
const { isEmbedded, isMobile } = useLayout();
|
||||
|
||||
const resolveChannelMention = useCallback((mention: string) => channels?.find(({ name }) => name === mention), [channels]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const onChannelMentionClick = useCallback(
|
||||
({ _id: rid }: ChannelMention) =>
|
||||
(event: UIEvent): void => {
|
||||
if (isEmbedded) {
|
||||
fireGlobalEvent('click-mention-link', {
|
||||
path: router.buildRoutePath({
|
||||
pattern: '/channel/:name/:tab?/:context?',
|
||||
params: { name: rid },
|
||||
}),
|
||||
channel: rid,
|
||||
});
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
goToRoom(rid);
|
||||
},
|
||||
[router, isEmbedded, goToRoom],
|
||||
);
|
||||
|
||||
return (
|
||||
<MarkupInteractionContext.Provider
|
||||
value={{
|
||||
detectEmoji,
|
||||
highlightRegex,
|
||||
markRegex,
|
||||
resolveUserMention,
|
||||
onUserMentionClick,
|
||||
resolveChannelMention,
|
||||
onChannelMentionClick,
|
||||
convertAsciiToEmoji,
|
||||
useEmoji,
|
||||
useRealName,
|
||||
isMobile,
|
||||
ownUserId,
|
||||
showMentionSymbol,
|
||||
triggerProps,
|
||||
language: userLanguage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarkupInteractionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GazzodownText);
|
||||
33
apps/meteor/client/components/GenericCard/GenericCard.tsx
Normal file
33
apps/meteor/client/components/GenericCard/GenericCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Card, CardTitle, CardBody, CardControls, CardHeader, FramedIcon } from '@rocket.chat/fuselage';
|
||||
import { useId } from 'react';
|
||||
import type { ComponentProps, ReactElement } from 'react';
|
||||
|
||||
import type { GenericCardButton } from './GenericCardButton';
|
||||
|
||||
export type GenericCardProps = {
|
||||
title: string;
|
||||
body: string;
|
||||
buttons?: ReactElement<typeof GenericCardButton>[];
|
||||
icon?: ComponentProps<typeof FramedIcon>['icon'];
|
||||
type?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||
} & ComponentProps<typeof Card>;
|
||||
|
||||
export const GenericCard = ({ title, body, buttons, icon, type, ...props }: GenericCardProps) => {
|
||||
const cardId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
const iconType = type && {
|
||||
[type]: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card role='region' aria-labelledby={cardId} aria-describedby={descriptionId} {...props}>
|
||||
<CardHeader>
|
||||
{icon && <FramedIcon icon={icon} {...(type && iconType)} />}
|
||||
<CardTitle id={cardId}>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody id={descriptionId}>{body}</CardBody>
|
||||
{buttons && <CardControls>{buttons}</CardControls>}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export const GenericCardButton = (props: ComponentProps<typeof Button>) => <Button {...props} medium />;
|
||||
2
apps/meteor/client/components/GenericCard/index.ts
Normal file
2
apps/meteor/client/components/GenericCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './GenericCard';
|
||||
export * from './GenericCardButton';
|
||||
30
apps/meteor/client/components/GenericError/GenericError.tsx
Normal file
30
apps/meteor/client/components/GenericError/GenericError.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Box, States, StatesIcon, StatesTitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type GenericErrorProps = {
|
||||
icon?: IconName;
|
||||
title?: string;
|
||||
buttonTitle?: string;
|
||||
buttonAction?: () => void;
|
||||
};
|
||||
|
||||
const GenericError = ({ icon = 'magnifier', title, buttonTitle, buttonAction }: GenericErrorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box display='flex' height='100%' flexDirection='column' justifyContent='center'>
|
||||
<States>
|
||||
<StatesIcon name={icon} variation='danger' />
|
||||
<StatesTitle>{title || t('Something_went_wrong')}</StatesTitle>
|
||||
{buttonAction && (
|
||||
<StatesActions>
|
||||
<StatesAction onClick={buttonAction}>{buttonTitle || t('Reload_page')}</StatesAction>
|
||||
</StatesActions>
|
||||
)}
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericError;
|
||||
1
apps/meteor/client/components/GenericError/index.ts
Normal file
1
apps/meteor/client/components/GenericError/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './GenericError';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TextInput, Box, Icon } from '@rocket.chat/fuselage';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import GenericNoResults from './GenericNoResults';
|
||||
|
||||
export default {
|
||||
component: GenericNoResults,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
actions: { argTypesRegex: '^on.*' },
|
||||
},
|
||||
decorators: [
|
||||
(fn) => <div style={{ height: '100vh', maxHeight: 300, display: 'flex', flexDirection: 'column', marginInline: 24 }}>{fn()}</div>,
|
||||
],
|
||||
} satisfies Meta<typeof GenericNoResults>;
|
||||
|
||||
const filter = (
|
||||
<>
|
||||
<Box marginBlock={16} is='form' display='flex' flexDirection='column'>
|
||||
<TextInput flexShrink={0} placeholder='Search...' endAddon={<Icon name='magnifier' size='x20' />} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
export const NoResults: StoryFn<typeof GenericNoResults> = () => (
|
||||
<>
|
||||
{filter}
|
||||
<GenericNoResults />
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Box, States, StatesIcon, StatesLink, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type LinkProps = { linkText: string; linkHref: string } | { linkText?: never; linkHref?: never };
|
||||
type ButtonProps = { buttonTitle: string; buttonAction: () => void } | { buttonTitle?: never; buttonAction?: never };
|
||||
|
||||
export type GenericNoResultsProps = {
|
||||
icon?: IconName | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
} & LinkProps &
|
||||
ButtonProps;
|
||||
|
||||
const GenericNoResults = ({
|
||||
icon = 'magnifier',
|
||||
title,
|
||||
description,
|
||||
buttonTitle,
|
||||
buttonAction,
|
||||
linkHref,
|
||||
linkText,
|
||||
}: GenericNoResultsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box role='group' aria-label={t('No_results_found')} display='flex' height='100%' flexDirection='column' justifyContent='center'>
|
||||
<States>
|
||||
{icon && <StatesIcon name={icon} />}
|
||||
<StatesTitle>{title || t('No_results_found')}</StatesTitle>
|
||||
{description && <StatesSubtitle>{description}</StatesSubtitle>}
|
||||
{buttonTitle && buttonAction && (
|
||||
<StatesActions>
|
||||
<StatesAction onClick={buttonAction}>{buttonTitle}</StatesAction>
|
||||
</StatesActions>
|
||||
)}
|
||||
{linkText && linkHref && (
|
||||
<StatesLink href={linkHref} target='_blank'>
|
||||
{linkText}
|
||||
</StatesLink>
|
||||
)}
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericNoResults;
|
||||
1
apps/meteor/client/components/GenericNoResults/index.ts
Normal file
1
apps/meteor/client/components/GenericNoResults/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './GenericNoResults';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Box, ProgressBar } from '@rocket.chat/fuselage';
|
||||
import { useId } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const GenericResourceUsage = ({
|
||||
title,
|
||||
value,
|
||||
max,
|
||||
percentage,
|
||||
threshold = 80,
|
||||
variant = percentage < threshold ? 'success' : 'danger',
|
||||
subTitle,
|
||||
tooltip,
|
||||
...props
|
||||
}: {
|
||||
title: string;
|
||||
subTitle?: ReactNode;
|
||||
value: number;
|
||||
max: number;
|
||||
percentage: number;
|
||||
threshold?: number;
|
||||
variant?: 'warning' | 'danger' | 'success';
|
||||
tooltip?: string;
|
||||
}) => {
|
||||
const labelId = useId();
|
||||
|
||||
return (
|
||||
<Box
|
||||
title={tooltip}
|
||||
width='x180'
|
||||
height='x40'
|
||||
marginInline={8}
|
||||
fontScale='c1'
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
justifyContent='space-around'
|
||||
{...props}
|
||||
>
|
||||
<Box display='flex' justifyContent='space-between'>
|
||||
<Box color='default' id={labelId}>
|
||||
{title}
|
||||
</Box>
|
||||
{subTitle && <Box color='hint'>{subTitle}</Box>}
|
||||
<Box color='hint'>
|
||||
{value}/{max}
|
||||
</Box>
|
||||
</Box>
|
||||
<ProgressBar
|
||||
percentage={percentage}
|
||||
variant={variant}
|
||||
role='progressbar'
|
||||
aria-labelledby={labelId}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percentage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericResourceUsage;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Box, Skeleton } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type GenericResourceUsageSkeletonProps = {
|
||||
title?: string;
|
||||
} & ComponentProps<typeof Box>;
|
||||
|
||||
const GenericResourceUsageSkeleton = ({ title, ...props }: GenericResourceUsageSkeletonProps) => {
|
||||
return (
|
||||
<Box
|
||||
width='x180'
|
||||
height='x40'
|
||||
marginInline={8}
|
||||
fontScale='c1'
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
justifyContent='space-around'
|
||||
{...props}
|
||||
>
|
||||
{title ? <Box color='default'>{title}</Box> : <Skeleton width='full' />}
|
||||
<Skeleton width='full' />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericResourceUsageSkeleton;
|
||||
@@ -0,0 +1,4 @@
|
||||
import GenericResourceUsage from './GenericResourceUsage';
|
||||
import GenericResourceUsageSkeleton from './GenericResourceUsageSkeleton';
|
||||
|
||||
export { GenericResourceUsage, GenericResourceUsageSkeleton };
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import GenericUpsellModal from './GenericUpsellModal';
|
||||
import * as stories from './GenericUpsellModal.stories';
|
||||
|
||||
const appRoot = mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Premium_capability: 'Premium capability',
|
||||
Cancel: 'Cancel',
|
||||
Upgrade: 'Upgrade',
|
||||
})
|
||||
.build();
|
||||
|
||||
describe('GenericUpsellModal', () => {
|
||||
const defaultProps = {
|
||||
title: 'Test Title',
|
||||
img: 'test-image.png',
|
||||
onClose: jest.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_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();
|
||||
});
|
||||
|
||||
it('should render basic properties', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
subtitle: 'Test Subtitle',
|
||||
description: 'Test Description',
|
||||
onCancel: jest.fn(),
|
||||
onConfirm: jest.fn(),
|
||||
};
|
||||
render(<GenericUpsellModal {...props} />, { wrapper: appRoot });
|
||||
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Subtitle')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with default confirm and cancel buttons', () => {
|
||||
render(<GenericUpsellModal {...defaultProps} onCancel={jest.fn()} onConfirm={jest.fn()} />, { wrapper: appRoot });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with default tagline', () => {
|
||||
render(<GenericUpsellModal {...defaultProps} />, { wrapper: appRoot });
|
||||
|
||||
expect(screen.getByText('Premium capability')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with img properties', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onCancel: jest.fn(),
|
||||
onConfirm: jest.fn(),
|
||||
imgWidth: 300,
|
||||
imgHeight: 200,
|
||||
imgAlt: 'Alternative text',
|
||||
};
|
||||
render(<GenericUpsellModal {...props} />, { wrapper: appRoot });
|
||||
|
||||
const heroImage = screen.getByRole('img');
|
||||
expect(heroImage).toHaveAttribute('src', 'test-image.png');
|
||||
expect(heroImage).toHaveAttribute('alt', 'Alternative text');
|
||||
expect(heroImage).toHaveStyle({
|
||||
width: '300px',
|
||||
height: '200px',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import GenericUpsellModal from '.';
|
||||
|
||||
const meta = {
|
||||
component: GenericUpsellModal,
|
||||
args: {
|
||||
onClose: action('onClose'),
|
||||
img: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z',
|
||||
imgAlt: 'Placeholder image',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies Meta<typeof GenericUpsellModal>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'This is the title',
|
||||
subtitle: 'This is a subtitle',
|
||||
description:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
imgAlt: 'Placeholder image',
|
||||
onCancel: action('onCancel'),
|
||||
onConfirm: action('onConfirm'),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomTagLine: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
tagline: 'Exclusive',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomButtons: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
cancelText: 'Learn More',
|
||||
confirmText: 'Contact Sales',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAnnotation: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
annotation: 'This is an annotation.',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFixedWidthAndHeight: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
imgHeight: 600,
|
||||
imgWidth: 600,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Box, ModalHeroImage } from '@rocket.chat/fuselage';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type GenericUpsellModalProps = Omit<ComponentProps<typeof GenericModal>, 'variant' | 'children' | 'onClose' | 'onDismiss'> & {
|
||||
subtitle?: ReactNode;
|
||||
description?: ReactNode;
|
||||
img: ComponentProps<typeof ModalHeroImage>['src'];
|
||||
imgWidth?: ComponentProps<typeof ModalHeroImage>['width'];
|
||||
imgHeight?: ComponentProps<typeof ModalHeroImage>['height'];
|
||||
imgAlt?: string;
|
||||
onClose: () => void;
|
||||
onConfirm?: () => void;
|
||||
};
|
||||
|
||||
const GenericUpsellModal = ({
|
||||
tagline,
|
||||
subtitle,
|
||||
img,
|
||||
imgAlt = '',
|
||||
imgWidth,
|
||||
imgHeight,
|
||||
description,
|
||||
confirmText,
|
||||
icon = null,
|
||||
...props
|
||||
}: GenericUpsellModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
{...props}
|
||||
icon={icon}
|
||||
tagline={tagline ?? t('Premium_capability')}
|
||||
variant='upsell'
|
||||
confirmText={confirmText ?? t('Upgrade')}
|
||||
>
|
||||
<ModalHeroImage src={img} alt={imgAlt} width={imgWidth} height={imgHeight} />
|
||||
{subtitle && (
|
||||
<Box is='h3' fontScale='h3'>
|
||||
{subtitle}
|
||||
</Box>
|
||||
)}
|
||||
{description && (
|
||||
<Box style={{ whiteSpace: 'break-spaces' }} fontScale='p2' marginBlockStart={16}>
|
||||
{description}
|
||||
</Box>
|
||||
)}
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericUpsellModal;
|
||||
@@ -0,0 +1,561 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`GenericUpsellModal renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_0_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
|
||||
>
|
||||
Premium capability
|
||||
</div>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_0_-title"
|
||||
>
|
||||
This is the title
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<figure
|
||||
class="rcx-modal__hero-image-wrapper"
|
||||
>
|
||||
<img
|
||||
alt="Placeholder image"
|
||||
class="rcx-box rcx-box--full rcx-modal__hero-image"
|
||||
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
|
||||
/>
|
||||
</figure>
|
||||
<h3
|
||||
class="rcx-box rcx-box--full rcx-css-1sfto34"
|
||||
>
|
||||
This is a subtitle
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
|
||||
style="white-space: break-spaces;"
|
||||
>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Upgrade
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`GenericUpsellModal renders WithAnnotation without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_1_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
|
||||
>
|
||||
Premium capability
|
||||
</div>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_1_-title"
|
||||
>
|
||||
This is the title
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<figure
|
||||
class="rcx-modal__hero-image-wrapper"
|
||||
>
|
||||
<img
|
||||
alt="Placeholder image"
|
||||
class="rcx-box rcx-box--full rcx-modal__hero-image"
|
||||
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
|
||||
/>
|
||||
</figure>
|
||||
<h3
|
||||
class="rcx-box rcx-box--full rcx-css-1sfto34"
|
||||
>
|
||||
This is a subtitle
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
|
||||
style="white-space: break-spaces;"
|
||||
>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-rzpqmp"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer-annotation"
|
||||
>
|
||||
This is an annotation.
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Upgrade
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`GenericUpsellModal renders WithCustomButtons without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_2_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
|
||||
>
|
||||
Premium capability
|
||||
</div>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_2_-title"
|
||||
>
|
||||
This is the title
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<figure
|
||||
class="rcx-modal__hero-image-wrapper"
|
||||
>
|
||||
<img
|
||||
alt="Placeholder image"
|
||||
class="rcx-box rcx-box--full rcx-modal__hero-image"
|
||||
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
|
||||
/>
|
||||
</figure>
|
||||
<h3
|
||||
class="rcx-box rcx-box--full rcx-css-1sfto34"
|
||||
>
|
||||
This is a subtitle
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
|
||||
style="white-space: break-spaces;"
|
||||
>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Learn More
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Contact Sales
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`GenericUpsellModal renders WithCustomTagLine without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_3_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
|
||||
>
|
||||
Exclusive
|
||||
</div>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_3_-title"
|
||||
>
|
||||
This is the title
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<figure
|
||||
class="rcx-modal__hero-image-wrapper"
|
||||
>
|
||||
<img
|
||||
alt="Placeholder image"
|
||||
class="rcx-box rcx-box--full rcx-modal__hero-image"
|
||||
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
|
||||
/>
|
||||
</figure>
|
||||
<h3
|
||||
class="rcx-box rcx-box--full rcx-css-1sfto34"
|
||||
>
|
||||
This is a subtitle
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
|
||||
style="white-space: break-spaces;"
|
||||
>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Upgrade
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`GenericUpsellModal renders WithFixedWidthAndHeight without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_4_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
|
||||
>
|
||||
Premium capability
|
||||
</div>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_4_-title"
|
||||
>
|
||||
This is the title
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<figure
|
||||
class="rcx-modal__hero-image-wrapper"
|
||||
>
|
||||
<img
|
||||
alt="Placeholder image"
|
||||
class="rcx-box rcx-box--full rcx-modal__hero-image rcx-css-1k43lja"
|
||||
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
|
||||
/>
|
||||
</figure>
|
||||
<h3
|
||||
class="rcx-box rcx-box--full rcx-css-1sfto34"
|
||||
>
|
||||
This is a subtitle
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
|
||||
style="white-space: break-spaces;"
|
||||
>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Upgrade
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useUpsellActions';
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useUpsellActions } from './useUpsellActions';
|
||||
import { createFakeLicenseInfo } from '../../../../tests/mocks/data';
|
||||
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
|
||||
|
||||
jest.mock('../../../hooks/useIsEnterprise', () => ({
|
||||
useIsEnterprise: jest.fn(),
|
||||
}));
|
||||
|
||||
const appRoot = mockAppRoot()
|
||||
.withJohnDoe()
|
||||
.withEndpoint('GET', '/v1/licenses.info', async () => ({ license: createFakeLicenseInfo({ hasValidLicense: true }) }))
|
||||
.build();
|
||||
|
||||
describe('useUpsellActions hook', () => {
|
||||
beforeEach(() => {
|
||||
(useIsEnterprise as jest.Mock).mockImplementation(jest.requireActual('../../../hooks/useIsEnterprise').useIsEnterprise);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show upsell modal if not enterprise', () => {
|
||||
const { result } = renderHook(() => useUpsellActions(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current.shouldShowUpsell).toBe(true);
|
||||
expect(result.current.cloudWorkspaceHadTrial).toBe(false);
|
||||
});
|
||||
|
||||
it('should show upsell modal if enterprise but has no license module', async () => {
|
||||
const { result } = renderHook(() => useUpsellActions(false), {
|
||||
wrapper: appRoot,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.shouldShowUpsell).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT show upsell if enterprise and has license module', async () => {
|
||||
const { result } = renderHook(() => useUpsellActions(true), {
|
||||
wrapper: appRoot,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.shouldShowUpsell).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show upsell if useIsEnterprise is undefined', async () => {
|
||||
(useIsEnterprise as jest.Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
} as UseQueryResult);
|
||||
|
||||
const { result } = renderHook(() => useUpsellActions(true), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current.shouldShowUpsell).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useSetModal, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useExternalLink } from '../../../hooks/useExternalLink';
|
||||
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
|
||||
import { links } from '../../../lib/links';
|
||||
import { useCheckoutUrl } from '../../../views/admin/subscription/hooks/useCheckoutUrl';
|
||||
|
||||
const TALK_TO_SALES_URL = links.go.contactSales;
|
||||
|
||||
export const useUpsellActions = (hasLicenseModule = false) => {
|
||||
const setModal = useSetModal();
|
||||
const handleOpenLink = useExternalLink();
|
||||
const cloudWorkspaceHadTrial = useSetting('Cloud_Workspace_Had_Trial', false);
|
||||
|
||||
const { data } = useIsEnterprise();
|
||||
const shouldShowUpsell = !data?.isEnterprise || !hasLicenseModule;
|
||||
|
||||
const openExternalLink = useExternalLink();
|
||||
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'upsell-modal', action: 'upgrade' });
|
||||
|
||||
const handleManageSubscription = useCallback(() => {
|
||||
openExternalLink(manageSubscriptionUrl);
|
||||
setModal(null);
|
||||
}, [manageSubscriptionUrl, openExternalLink, setModal]);
|
||||
|
||||
const handleTalkToSales = useCallback(() => {
|
||||
handleOpenLink(TALK_TO_SALES_URL);
|
||||
setModal(null);
|
||||
}, [handleOpenLink, setModal]);
|
||||
|
||||
return { shouldShowUpsell, cloudWorkspaceHadTrial, handleManageSubscription, handleTalkToSales };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GenericUpsellModal';
|
||||
230
apps/meteor/client/components/ImageGallery/ImageGallery.tsx
Normal file
230
apps/meteor/client/components/ImageGallery/ImageGallery.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import type { IUpload } from '@rocket.chat/core-typings';
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, ButtonGroup, IconButton, Palette, PaletteStyleTag, Throbber, spacing } from '@rocket.chat/fuselage';
|
||||
import { useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigation, Zoom, Keyboard, A11y } from 'swiper/modules/index.mjs';
|
||||
import type { SwiperClass, SwiperRef } from 'swiper/swiper-react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/swiper-react.mjs';
|
||||
|
||||
import 'swiper/swiper.css';
|
||||
import 'swiper/modules/zoom.css';
|
||||
|
||||
import { usePreventPropagation } from '../../hooks/usePreventPropagation';
|
||||
|
||||
const swiperStyle = css`
|
||||
.swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.swiper-container {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
top: 0;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-color: var(--rcx-color-surface-overlay, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.swiper-slide {
|
||||
padding: ${spacing('x144')} ${spacing('x60')} ${spacing('x96')};
|
||||
}
|
||||
|
||||
.rcx-swiper-prev-button,
|
||||
.rcx-swiper-next-button {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 50%;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rcx-swiper-prev-button.swiper-button-disabled,
|
||||
.rcx-swiper-next-button.swiper-button-disabled {
|
||||
cursor: auto;
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.rcx-swiper-prev-button.swiper-button-hidden,
|
||||
.rcx-swiper-next-button.swiper-button-hidden {
|
||||
cursor: auto;
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rcx-swiper-prev-button,
|
||||
.swiper-rtl .rcx-swiper-next-button {
|
||||
right: auto;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.rcx-swiper-next-button,
|
||||
.swiper-rtl .rcx-swiper-prev-button {
|
||||
right: 10px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.rcx-lazy-preloader {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
color: ${Palette.text['font-pure-white']};
|
||||
}
|
||||
|
||||
.rcx-swiper-controls {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 10px;
|
||||
z-index: 2;
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background-color: ${Palette.surface['surface-sidebar']};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ImageGallery = ({
|
||||
images,
|
||||
onClose,
|
||||
loadMore,
|
||||
}: {
|
||||
images: Pick<IUpload, '_id' | 'path' | 'url' | 'description'>[];
|
||||
onClose: () => void;
|
||||
loadMore?: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const swiperRef = useRef<SwiperRef>(null);
|
||||
const [, setSwiperInst] = useState<SwiperClass>();
|
||||
const [zoomScale, setZoomScale] = useState(1);
|
||||
const [gridSize, setGridSize] = useState(images.length);
|
||||
|
||||
const handleZoom = (ratio: number) => {
|
||||
if (swiperRef.current?.swiper.zoom) {
|
||||
const { scale, in: zoomIn } = swiperRef.current?.swiper.zoom;
|
||||
setZoomScale(scale + ratio);
|
||||
return zoomIn(scale + ratio);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => handleZoom(1);
|
||||
const handleZoomOut = () => handleZoom(-1);
|
||||
const handleResize = () => handleZoom(-(zoomScale - 1));
|
||||
|
||||
const preventPropagation = usePreventPropagation();
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<PaletteStyleTag theme='dark' selector='.swiper-container.image-gallery' tagId='image-gallery-palette' />
|
||||
<FocusScope contain autoFocus>
|
||||
<Box role='dialog' aria-modal='true' aria-label={t('Image_gallery')} className={swiperStyle}>
|
||||
<div role='presentation' className='swiper-container image-gallery' onClick={onClose}>
|
||||
<ButtonGroup role='toolbar' className='rcx-swiper-controls' onClick={preventPropagation}>
|
||||
{zoomScale !== 1 && (
|
||||
<IconButton
|
||||
name='resize'
|
||||
small
|
||||
icon='arrow-collapse'
|
||||
title={t('Resize')}
|
||||
rcx-swiper-zoom-out
|
||||
secondary
|
||||
onClick={handleResize}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
name='zoom-out'
|
||||
small
|
||||
icon='h-bar'
|
||||
title={t('Zoom_out')}
|
||||
rcx-swiper-zoom-out
|
||||
onClick={handleZoomOut}
|
||||
secondary
|
||||
disabled={zoomScale === 1}
|
||||
/>
|
||||
<IconButton name='zoom-in' small icon='plus' title={t('Zoom_in')} rcx-swiper-zoom-in secondary onClick={handleZoomIn} />
|
||||
<IconButton
|
||||
name='close'
|
||||
small
|
||||
icon='cross'
|
||||
aria-label={t('Close_gallery')}
|
||||
className='rcx-swiper-close-button'
|
||||
secondary
|
||||
onClick={onClose}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<IconButton
|
||||
icon='chevron-right'
|
||||
aria-label={t('Next_image')}
|
||||
className='rcx-swiper-prev-button'
|
||||
secondary
|
||||
onClick={preventPropagation}
|
||||
/>
|
||||
<IconButton
|
||||
icon='chevron-left'
|
||||
aria-label={t('Previous_image')}
|
||||
className='rcx-swiper-next-button'
|
||||
secondary
|
||||
onClick={preventPropagation}
|
||||
/>
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
navigation={{
|
||||
nextEl: '.rcx-swiper-next-button',
|
||||
prevEl: '.rcx-swiper-prev-button',
|
||||
}}
|
||||
keyboard
|
||||
zoom={{ toggle: false }}
|
||||
lazyPreloaderClass='rcx-lazy-preloader'
|
||||
runCallbacksOnInit
|
||||
onKeyPress={(_: SwiperClass, keyCode: string) => String(keyCode) === '27' && onClose()}
|
||||
modules={[Navigation, Zoom, Keyboard, A11y]}
|
||||
onInit={(swiper: SwiperClass) => setSwiperInst(swiper)}
|
||||
onSlidesGridLengthChange={(swiper: SwiperClass) => {
|
||||
swiper.slideTo(images.length - gridSize, 0);
|
||||
setGridSize(images.length);
|
||||
}}
|
||||
onReachBeginning={loadMore}
|
||||
initialSlide={images.length - 1}
|
||||
>
|
||||
{[...images].reverse().map(({ _id, path, url, description }) => (
|
||||
<SwiperSlide key={_id}>
|
||||
<div className='swiper-zoom-container'>
|
||||
{/* eslint-disable-next-line
|
||||
jsx-a11y/no-noninteractive-element-interactions,
|
||||
jsx-a11y/click-events-have-key-events
|
||||
*/}
|
||||
<img
|
||||
src={path || url}
|
||||
loading='lazy'
|
||||
alt={description || ''}
|
||||
data-qa-zoom-scale={zoomScale}
|
||||
onClick={preventPropagation}
|
||||
/>
|
||||
<div className='rcx-lazy-preloader'>
|
||||
<Throbber inheritColor />
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</Box>
|
||||
</FocusScope>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { IconButton, ModalBackdrop } from '@rocket.chat/fuselage';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import GenericError from '../GenericError/GenericError';
|
||||
|
||||
const closeButtonStyle = css`
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
`;
|
||||
|
||||
export const ImageGalleryError = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createPortal(
|
||||
<ModalBackdrop display='flex' justifyContent='center' color='pure-white'>
|
||||
<GenericError buttonAction={onClose} buttonTitle={t('Close')} />
|
||||
<IconButton icon='cross' aria-label={t('Close_gallery')} className={closeButtonStyle} onClick={onClose} />
|
||||
</ModalBackdrop>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { IconButton, ModalBackdrop, Throbber } from '@rocket.chat/fuselage';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const closeButtonStyle = css`
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
`;
|
||||
|
||||
export const ImageGalleryLoading = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createPortal(
|
||||
<ModalBackdrop display='flex' justifyContent='center' color='pure-white'>
|
||||
<IconButton icon='cross' aria-label={t('Close_gallery')} className={closeButtonStyle} onClick={onClose} />
|
||||
<Throbber inheritColor />
|
||||
</ModalBackdrop>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
3
apps/meteor/client/components/ImageGallery/index.ts
Normal file
3
apps/meteor/client/components/ImageGallery/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ImageGallery';
|
||||
export * from './ImageGalleryError';
|
||||
export * from './ImageGalleryLoading';
|
||||
39
apps/meteor/client/components/InfiniteListAnchor.tsx
Normal file
39
apps/meteor/client/components/InfiniteListAnchor.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export type InfiniteListAnchorProps = {
|
||||
loadMore: () => void;
|
||||
} & ComponentProps<typeof Box>;
|
||||
|
||||
const InfiniteListAnchor = ({ loadMore, ...props }: InfiniteListAnchorProps) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const target = ref.current;
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(e) => {
|
||||
if (e[0].isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
return <Box width={5} height={5} ref={ref} {...props} />;
|
||||
};
|
||||
|
||||
export default InfiniteListAnchor;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import * as stories from './RetentionPolicyCallout.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(2024, 5, 1, 0, 0, 0));
|
||||
|
||||
const { baseElement } = render(<Story />);
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
// We have to use real timers here because `jest-axe` is breaking otherwise
|
||||
jest.useRealTimers();
|
||||
const { container } = render(<Story />);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import RetentionPolicyCallout from './RetentionPolicyCallout';
|
||||
import { createRenteionPolicySettingsMock as createMock } from '../../../tests/mocks/client/mockRetentionPolicySettings';
|
||||
import { createFakeRoom } from '../../../tests/mocks/data';
|
||||
|
||||
export default {
|
||||
component: RetentionPolicyCallout,
|
||||
} satisfies Meta<typeof RetentionPolicyCallout>;
|
||||
|
||||
const fakeRoom = createFakeRoom();
|
||||
|
||||
const DefaultWrapper = createMock({ appliesToChannels: true, TTLChannels: 60000 });
|
||||
|
||||
export const Default: StoryFn<typeof RetentionPolicyCallout> = () => (
|
||||
<DefaultWrapper>
|
||||
<RetentionPolicyCallout room={fakeRoom} />
|
||||
</DefaultWrapper>
|
||||
);
|
||||
|
||||
const InvalidSettingsWrapper = createMock({
|
||||
appliesToChannels: true,
|
||||
TTLChannels: 60000,
|
||||
advancedPrecisionCron: '* * * 12 * *',
|
||||
advancedPrecision: true,
|
||||
});
|
||||
|
||||
export const InvalidSettings: StoryFn<typeof RetentionPolicyCallout> = () => (
|
||||
<InvalidSettingsWrapper>
|
||||
<RetentionPolicyCallout room={fakeRoom} />
|
||||
</InvalidSettingsWrapper>
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Callout } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { usePruneWarningMessage } from '../../hooks/usePruneWarningMessage';
|
||||
import { withErrorBoundary } from '../withErrorBoundary';
|
||||
|
||||
export type RetentionPolicyCalloutProps = { room: IRoom };
|
||||
|
||||
const RetentionPolicyCallout = ({ room }: RetentionPolicyCalloutProps) => {
|
||||
const message = usePruneWarningMessage(room);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Callout arial-label={t('Retention_policy_warning_callout')} role='alert' aria-live='polite' type='warning'>
|
||||
<div>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
export default withErrorBoundary(RetentionPolicyCallout);
|
||||
@@ -0,0 +1,75 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<section
|
||||
aria-live="polite"
|
||||
arial-label="Retention_policy_warning_callout"
|
||||
class="rcx-box rcx-box--full rcx-callout rcx-callout--warning"
|
||||
role="alert"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-warning rcx-icon rcx-callout__icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__wrapper"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__wrapper-content"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__content"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
1 minute June 1, 2024 at 12:30 AM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`renders InvalidSettings without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<section
|
||||
aria-live="polite"
|
||||
arial-label="Retention_policy_warning_callout"
|
||||
class="rcx-box rcx-box--full rcx-callout rcx-callout--warning"
|
||||
role="alert"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-warning rcx-icon rcx-callout__icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__wrapper"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__wrapper-content"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__content"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
1 minute June 12, 2024 at 12:00 AM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import * as stories from './InvitationBadge.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
test.each(testCases)(`renders %s without crashing`, async (_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();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { Meta } from '@storybook/react';
|
||||
|
||||
import InvitationBadge from './InvitationBadge';
|
||||
|
||||
const meta = {
|
||||
component: InvitationBadge,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [
|
||||
mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Invited__date__: 'Invited {{date}}',
|
||||
})
|
||||
.buildStoryDecorator(),
|
||||
],
|
||||
} satisfies Meta<typeof InvitationBadge>;
|
||||
|
||||
export default meta;
|
||||
|
||||
export const WithISOStringDate = {
|
||||
args: {
|
||||
invitationDate: '2025-01-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDateObject = {
|
||||
args: {
|
||||
invitationDate: new Date('2025-01-01T12:00:00Z'),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Icon } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTimeAgo } from '../../hooks/useTimeAgo';
|
||||
|
||||
export type InvitationBadgeProps = Omit<ComponentProps<typeof Icon>, 'name' | 'color' | 'role'> & {
|
||||
invitationDate: string | Date;
|
||||
};
|
||||
|
||||
const InvitationBadge = ({ invitationDate, ...props }: InvitationBadgeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const timeAgo = useTimeAgo();
|
||||
|
||||
return (
|
||||
<Icon
|
||||
size='x20'
|
||||
{...props}
|
||||
role='status'
|
||||
color='info'
|
||||
name='mail'
|
||||
aria-hidden='false'
|
||||
title={t('Invited__date__', { date: timeAgo(invitationDate) })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitationBadge;
|
||||
@@ -0,0 +1,31 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`renders WithDateObject without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<i
|
||||
aria-hidden="false"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-mail rcx-icon rcx-css-oq4j9d"
|
||||
role="status"
|
||||
title="Invited January 1st, 2025"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`renders WithISOStringDate without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<i
|
||||
aria-hidden="false"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-mail rcx-icon rcx-css-oq4j9d"
|
||||
role="status"
|
||||
title="Invited January 1st, 2025"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
1
apps/meteor/client/components/InvitationBadge/index.ts
Normal file
1
apps/meteor/client/components/InvitationBadge/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './InvitationBadge';
|
||||
92
apps/meteor/client/components/ListItem.stories.tsx
Normal file
92
apps/meteor/client/components/ListItem.stories.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Tile, OptionTitle, Box } from '@rocket.chat/fuselage';
|
||||
import type { StoryObj, Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import ListItem from './Sidebar/ListItem';
|
||||
|
||||
export default {
|
||||
component: Tile,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: '`ListItem` can be used on DropDown lists, to render an item with icon and text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Tile>;
|
||||
|
||||
export const ListWithIcon: StoryFn<typeof Tile> = () => {
|
||||
return (
|
||||
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
|
||||
<Box flexShrink={1} paddingBlock={12}>
|
||||
<OptionTitle>Title</OptionTitle>
|
||||
<ListItem text='Item 1' icon='info' />
|
||||
<ListItem text='Item 2' icon='star' />
|
||||
<ListItem text='Item 3' icon='hashtag' />
|
||||
<ListItem text='Item 4' icon='team' />
|
||||
</Box>
|
||||
</Tile>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoIcon: StoryFn<typeof Tile> = () => {
|
||||
return (
|
||||
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
|
||||
<Box flexShrink={1} paddingBlock={12}>
|
||||
<OptionTitle>Title</OptionTitle>
|
||||
<ListItem text='Item 1' />
|
||||
<ListItem text='Item 2' />
|
||||
<ListItem text='Item 3' />
|
||||
<ListItem text='Item 4' />
|
||||
</Box>
|
||||
</Tile>
|
||||
);
|
||||
};
|
||||
|
||||
export const MixedWithGap: StoryObj<typeof Tile> = {
|
||||
render: () => {
|
||||
return (
|
||||
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
|
||||
<Box flexShrink={1} paddingBlock={12}>
|
||||
<OptionTitle>Title</OptionTitle>
|
||||
<ListItem text='Item 1' icon='hashtag' />
|
||||
<ListItem text='Item 2' icon='team' />
|
||||
<ListItem text='Item 3' gap />
|
||||
<ListItem text='Item 4' icon='airplane' />
|
||||
</Box>
|
||||
</Tile>
|
||||
);
|
||||
},
|
||||
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
" When using `ListItem`, you can also use the `gap` prop to add spacing to the left. If the list is mixed with items **with and without** icons, it's recommended to add the gap.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MixedWithoutGap: StoryObj<typeof Tile> = {
|
||||
render: () => {
|
||||
return (
|
||||
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
|
||||
<Box flexShrink={1} paddingBlock={12}>
|
||||
<OptionTitle>Title</OptionTitle>
|
||||
<ListItem text='Item 1' icon='hashtag' />
|
||||
<ListItem text='Item 2' icon='team' />
|
||||
<ListItem text='Item 3' />
|
||||
<ListItem text='Item 4' icon='airplane' />
|
||||
</Box>
|
||||
</Tile>
|
||||
);
|
||||
},
|
||||
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Not recommended. Prefer adding the `gap` prop to the items without icons.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
33
apps/meteor/client/components/ListSkeleton.tsx
Normal file
33
apps/meteor/client/components/ListSkeleton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Box, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
const availablePercentualWidths = [47, 68, 75, 82];
|
||||
|
||||
export type ListSkeletonProps = {
|
||||
listCount?: number;
|
||||
};
|
||||
|
||||
const ListSkeleton = ({ listCount = 2 }: ListSkeletonProps) => {
|
||||
const widths = useMemo(
|
||||
() => Array.from({ length: listCount }, (_, index) => `${availablePercentualWidths[index % availablePercentualWidths.length]}%`),
|
||||
[listCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box display='flex' height='100%' justifyContent='flex-start' flexDirection='column'>
|
||||
{widths.map((width, index) => (
|
||||
<Box key={index} paddingInline={24} paddingBlock={16} display='flex'>
|
||||
<Box>
|
||||
<Skeleton variant='rect' width={36} height={36} />
|
||||
</Box>
|
||||
<Box marginInlineStart={8} flexGrow={1}>
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width={width} />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ListSkeleton);
|
||||
13
apps/meteor/client/components/LoadingIndicator.tsx
Normal file
13
apps/meteor/client/components/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export type LoadingIndicatorProps = { variation?: 'medium' | 'large' };
|
||||
|
||||
const LoadingIndicator = ({ variation }: LoadingIndicatorProps) => {
|
||||
return (
|
||||
<div className='loading__animation'>
|
||||
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
|
||||
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
|
||||
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
||||
17
apps/meteor/client/components/LocalTime.tsx
Normal file
17
apps/meteor/client/components/LocalTime.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUTCClock } from '../hooks/useUTCClock';
|
||||
|
||||
export type LocalTimeProps = {
|
||||
utcOffset: number;
|
||||
};
|
||||
|
||||
const LocalTime = ({ utcOffset }: LocalTimeProps) => {
|
||||
const time = useUTCClock(utcOffset);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <>{t('Local_Time_time', { time })}</>;
|
||||
};
|
||||
|
||||
export default memo(LocalTime);
|
||||
614
apps/meteor/client/components/MarkdownText.spec.tsx
Normal file
614
apps/meteor/client/components/MarkdownText.spec.tsx
Normal file
@@ -0,0 +1,614 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import dompurify from 'dompurify';
|
||||
|
||||
import MarkdownText, { supportedURISchemes } from './MarkdownText';
|
||||
import '@testing-library/jest-dom';
|
||||
import { emoji } from '../lib/emoji/lib';
|
||||
import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
|
||||
|
||||
jest.mock('../lib/getMarkdownParserLimit');
|
||||
|
||||
const getMarkdownParserLimitMock = jest.mocked(getMarkdownParserLimit);
|
||||
|
||||
const MOCKED_BASE_URI = 'http://localhost/';
|
||||
|
||||
// Mock getBaseURI from @rocket.chat/ui-client to ensure consistent behavior in tests
|
||||
// This will affect the isExternal function imported above.
|
||||
jest.mock('@rocket.chat/ui-client', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-client'), // Import and retain default behavior for other exports
|
||||
getBaseURI: jest.fn(() => MOCKED_BASE_URI), // Mock getBaseURI for consistent test behavior
|
||||
}));
|
||||
|
||||
// List of common URI schemes. This list was taken from https://en.wikipedia.org/wiki/List_of_URI_schemes
|
||||
const commonUriSchemes = [
|
||||
'file',
|
||||
'ftp',
|
||||
'http',
|
||||
'https',
|
||||
'mailto',
|
||||
'tel',
|
||||
'imap',
|
||||
'irc',
|
||||
'nntp',
|
||||
'acap',
|
||||
'icap',
|
||||
'mtqp',
|
||||
'wss',
|
||||
'admin',
|
||||
'app',
|
||||
'freeplane',
|
||||
'geo',
|
||||
'javascript',
|
||||
'jdbc',
|
||||
'msteams',
|
||||
'ms-access',
|
||||
'ms-excel',
|
||||
'ms-infopath',
|
||||
'ms-powerpoint',
|
||||
'ms-project',
|
||||
'ms-publisher',
|
||||
'ms-spd',
|
||||
'ms-visio',
|
||||
'ms-word',
|
||||
'odbc',
|
||||
'psns',
|
||||
'rdar',
|
||||
's3',
|
||||
'shortcuts',
|
||||
'slack',
|
||||
'stratum',
|
||||
'trueconf',
|
||||
'viber',
|
||||
'zoommtgzoomus',
|
||||
];
|
||||
|
||||
const nonSupportedUriSchemes = commonUriSchemes.filter((scheme) => !supportedURISchemes.includes(scheme));
|
||||
|
||||
const testUris = [
|
||||
'example.com',
|
||||
'example.com/path',
|
||||
'example.com/path/to/resource',
|
||||
'localhost/home',
|
||||
'localhost/path/to/',
|
||||
'localhost/path/to/resource',
|
||||
];
|
||||
|
||||
const getTestCases = (schemes: string[], uris: string[]): { scheme: string; links: string[] }[] => {
|
||||
return schemes.map((scheme) => {
|
||||
return {
|
||||
scheme,
|
||||
links: uris.map((uri) => `${scheme}://${uri}`),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const isExternal = (link: string): boolean => link.indexOf(MOCKED_BASE_URI) !== 0;
|
||||
|
||||
const normalizeHtml = (html: any) => {
|
||||
return html.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
const markdownText = `
|
||||
# Heading 1
|
||||
**Paragraph text**: *Bold with one asterisk* **Bold with two asterisks** Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
## Heading 2
|
||||
_Italic Text_: _Italic with one underscore_ __Italic with two underscores__ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
### Heading 3
|
||||
Lists, Links and elements
|
||||
**Unordered List**
|
||||
- List Item 1
|
||||
- List Item 2
|
||||
- List Item 3
|
||||
- List Item 4
|
||||
**Ordered List**
|
||||
1. List Item 1
|
||||
2. List Item 2
|
||||
3. List Item 3
|
||||
4. List Item 4
|
||||
**Links:**
|
||||
[Rocket.Chat](rocket.chat)
|
||||
gabriel.engel@rocket.chat
|
||||
+55991999999
|
||||
\`Inline code\`
|
||||
\`\`\`typescript
|
||||
const test = 'this is code'
|
||||
\`\`\`
|
||||
**Bold text within __Italics__**
|
||||
*Bold text with single asterik and underscore within _Italics_*
|
||||
__Italics within **Bold** text__
|
||||
_Italics within *Bold* text with single underscore and asterik_
|
||||
`;
|
||||
|
||||
it('should render html elements as expected using default parser', async () => {
|
||||
const { container } = render(<MarkdownText content={markdownText} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const normalizedHtml = normalizeHtml(container.innerHTML);
|
||||
|
||||
expect(normalizedHtml).toContain('<h1>Heading 1</h1>');
|
||||
expect(normalizedHtml).toContain(
|
||||
'<strong>Paragraph text</strong>: <strong>Bold with one asterisk</strong> <strong>Bold with two asterisks</strong> Lorem ipsum dolor sit amet',
|
||||
);
|
||||
expect(normalizedHtml).toContain('<h2>Heading 2</h2>');
|
||||
expect(normalizedHtml).toContain(
|
||||
'<em>Italic Text</em>: <em>Italic with one underscore</em> <em>Italic with two underscores</em> Lorem ipsum dolor sit amet',
|
||||
);
|
||||
expect(normalizedHtml).toContain('<h3>Heading 3</h3>');
|
||||
expect(normalizedHtml).toContain('<ul> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
|
||||
expect(normalizedHtml).toContain('<ol> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
|
||||
|
||||
expect(normalizedHtml).toContain('<a');
|
||||
expect(normalizedHtml).toContain('title=""');
|
||||
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
|
||||
expect(normalizedHtml).toContain('target="_blank"');
|
||||
expect(normalizedHtml).toContain('>Rocket.Chat</a>');
|
||||
|
||||
expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
|
||||
expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
|
||||
expect(normalizedHtml).toContain('gabriel.engel@rocket.chat');
|
||||
|
||||
expect(normalizedHtml).toContain('+55991999999');
|
||||
expect(normalizedHtml).toContain('<code>Inline code</code>');
|
||||
expect(normalizedHtml).toContain('<pre><code class="language-typescript">const test = \'this is code\' </code></pre>');
|
||||
expect(normalizedHtml).toContain('<strong>Bold text within <em>Italics</em></strong>');
|
||||
expect(normalizedHtml).toContain('<strong>Bold text with single asterik and underscore within <em>Italics</em></strong>');
|
||||
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
|
||||
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
|
||||
});
|
||||
|
||||
it('should render html elements as expected using inline parser', async () => {
|
||||
const { container } = render(<MarkdownText content={markdownText} variant='inline' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const normalizedHtml = normalizeHtml(container.innerHTML);
|
||||
|
||||
expect(normalizedHtml).toContain('# Heading 1');
|
||||
expect(normalizedHtml).toContain(
|
||||
'<strong>Bold with one asterisk</strong> <strong>Bold with two asterisks</strong> Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
|
||||
);
|
||||
expect(normalizedHtml).toContain('## Heading 2');
|
||||
expect(normalizedHtml).toContain(
|
||||
'<em>Italic Text</em>: <em>Italic with one underscore</em> <em>Italic with two underscores</em> Lorem ipsum dolor sit amet',
|
||||
);
|
||||
expect(normalizedHtml).toContain('### Heading 3');
|
||||
expect(normalizedHtml).toContain('<strong>Unordered List</strong> - List Item 1 - List Item 2 - List Item 3 - List Item 4');
|
||||
expect(normalizedHtml).toContain('<strong>Ordered List</strong> 1. List Item 1 2. List Item 2 3. List Item 3 4. List Item 4');
|
||||
|
||||
expect(normalizedHtml).toContain('<a');
|
||||
expect(normalizedHtml).toContain('title=""');
|
||||
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
|
||||
expect(normalizedHtml).toContain('target="_blank"');
|
||||
expect(normalizedHtml).toContain('>Rocket.Chat</a>');
|
||||
|
||||
expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
|
||||
expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
|
||||
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
|
||||
expect(normalizedHtml).toContain('target="_blank"');
|
||||
|
||||
expect(normalizedHtml).toContain('+55991999999');
|
||||
expect(normalizedHtml).toContain('Inline code');
|
||||
expect(normalizedHtml).toContain(`typescript const test = 'this is code'`);
|
||||
expect(normalizedHtml).toContain('<strong>Bold text within <em>Italics</em></strong>');
|
||||
expect(normalizedHtml).toContain('<strong>Bold text with single asterik and underscore within <em>Italics</em></strong>');
|
||||
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
|
||||
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
|
||||
});
|
||||
|
||||
describe('links handling', () => {
|
||||
it.each([
|
||||
{
|
||||
caseName: 'transform external http',
|
||||
link: 'http://example.com',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'http://example.com',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform external https',
|
||||
link: 'https://example.com',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'https://example.com',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform notes',
|
||||
link: 'notes://example.com/path',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'notes://example.com/path',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform ftp',
|
||||
link: 'ftp://example.com/file.txt',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'ftp://example.com/file.txt',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform ftps',
|
||||
link: 'ftps://example.com/file.txt',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'ftps://example.com/file.txt',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform tel',
|
||||
link: 'tel:+1234567890',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'tel:+1234567890',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform mailto',
|
||||
link: 'mailto:test@example.com',
|
||||
query: () => screen.getByRole('link', { name: 'mailto:test@example.com' }),
|
||||
expectedHref: 'mailto:test@example.com',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: 'mailto:test@example.com',
|
||||
},
|
||||
{
|
||||
caseName: 'transform sms',
|
||||
link: 'sms:+1234567890?body=hello',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'sms:+1234567890?body=hello',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform cid',
|
||||
link: 'cid:someimage@example.com',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'cid:someimage@example.com',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'filter relative',
|
||||
link: '/channel/general',
|
||||
query: () => screen.getByText('Test Link'),
|
||||
expectedHref: undefined,
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform absolute',
|
||||
link: 'http://localhost/another-channel',
|
||||
query: () => screen.getByRole('link', { name: 'Go to: another-channel' }),
|
||||
expectedHref: 'http://localhost/another-channel',
|
||||
expectedRel: undefined,
|
||||
expectedTarget: undefined,
|
||||
expectedTitleAttribute: 'Go to: another-channel',
|
||||
},
|
||||
{
|
||||
caseName: 'filter unknown scheme',
|
||||
link: 'invalid://example.com',
|
||||
query: () => screen.getByText('Test Link'),
|
||||
expectedHref: undefined,
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'filter javascript',
|
||||
link: "javascript:alert('XSS')",
|
||||
query: () => screen.getByText('Test Link'),
|
||||
expectedHref: undefined,
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
] as const)('should $caseName links', ({ link, query, expectedHref, expectedRel, expectedTarget, expectedTitleAttribute }) => {
|
||||
const markdownContent = `[Test Link](${link})`;
|
||||
render(<MarkdownText content={markdownContent} variant='document' />, {
|
||||
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
|
||||
});
|
||||
|
||||
const anchorElement = query();
|
||||
|
||||
expect(anchorElement).toBeInTheDocument();
|
||||
expect(anchorElement.tagName).toBe('A');
|
||||
expect(anchorElement).toHaveTextContent('Test Link');
|
||||
|
||||
if (expectedHref !== undefined) expect(anchorElement).toHaveAttribute('href', expectedHref);
|
||||
else expect(anchorElement).not.toHaveAttribute('href');
|
||||
|
||||
if (expectedRel !== undefined) expect(anchorElement).toHaveAttribute('rel', expectedRel);
|
||||
else expect(anchorElement).not.toHaveAttribute('rel');
|
||||
|
||||
if (expectedTarget !== undefined) expect(anchorElement).toHaveAttribute('target', expectedTarget);
|
||||
else expect(anchorElement).not.toHaveAttribute('target');
|
||||
|
||||
if (expectedTitleAttribute !== undefined) expect(anchorElement).toHaveAttribute('title', expectedTitleAttribute);
|
||||
else expect(anchorElement).not.toHaveAttribute('title');
|
||||
});
|
||||
|
||||
describe('multiple links', () => {
|
||||
it.each(getTestCases(supportedURISchemes, testUris))('supported scheme: $scheme', ({ scheme, links }) => {
|
||||
const getTextContent = (link: string) => `Test link - ${link}`;
|
||||
const markdownContent = links.map((link) => `[${getTextContent(link)}](${link})`).join('\n');
|
||||
|
||||
render(<MarkdownText content={markdownContent} variant='document' />, {
|
||||
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
|
||||
});
|
||||
|
||||
links.forEach((link) => {
|
||||
const text = getTextContent(link);
|
||||
const title = `Go to: ${link.replace(MOCKED_BASE_URI, '')}`;
|
||||
const anchorElement = screen.getByText(text);
|
||||
|
||||
expect(anchorElement).toBeInTheDocument();
|
||||
expect(anchorElement.tagName).toBe('A');
|
||||
expect(anchorElement).toHaveTextContent(text);
|
||||
expect(anchorElement).toHaveAttribute('href', link);
|
||||
|
||||
if (scheme === 'mailto') {
|
||||
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
|
||||
expect(anchorElement).toHaveAttribute('target', '_blank');
|
||||
expect(anchorElement).toHaveAttribute('href', link);
|
||||
expect(anchorElement).toHaveAttribute('title', link);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExternal(link)) {
|
||||
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
|
||||
expect(anchorElement).toHaveAttribute('target', '_blank');
|
||||
expect(anchorElement).toHaveAttribute('title', '');
|
||||
return;
|
||||
}
|
||||
|
||||
expect(anchorElement).toHaveAttribute('title', title);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(getTestCases(nonSupportedUriSchemes, testUris))('unsupported scheme: $scheme', ({ links }) => {
|
||||
const getTextContent = (link: string) => `Test link - ${link}`;
|
||||
const markdownContent = links.map((link) => `[${getTextContent(link)}](${link})`).join('\n');
|
||||
|
||||
render(<MarkdownText content={markdownContent} variant='document' />, {
|
||||
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
|
||||
});
|
||||
|
||||
links.forEach((link) => {
|
||||
const text = getTextContent(link);
|
||||
const anchorElement = screen.getByText(text);
|
||||
|
||||
expect(anchorElement).toBeInTheDocument();
|
||||
expect(anchorElement.tagName).toBe('A');
|
||||
expect(anchorElement).toHaveTextContent(text);
|
||||
expect(anchorElement).not.toHaveAttribute('href');
|
||||
|
||||
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
|
||||
expect(anchorElement).toHaveAttribute('target', '_blank');
|
||||
expect(anchorElement).toHaveAttribute('title', '');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('code handling', () => {
|
||||
it.each([
|
||||
{
|
||||
caseName: 'inline with special characters',
|
||||
content: '`2 < 3 > 1 & 4 "Test"`',
|
||||
expected: '<code>2 < 3 > 1 & 4 "Test"</code>',
|
||||
},
|
||||
{
|
||||
caseName: 'inline with encoded special characters',
|
||||
content: '`< = < > = > & = &`',
|
||||
expected: '<code>< = &lt; > = &gt; & = &amp;</code>',
|
||||
},
|
||||
{
|
||||
caseName: 'block with language',
|
||||
content: "```typescript\nconst test = 'this is code'\n```",
|
||||
expected: '<code class="language-typescript">const test = \'this is code\' </code>',
|
||||
},
|
||||
{
|
||||
caseName: 'block without language',
|
||||
content: '```\nTwo < Three > One & Four "Test"\n```',
|
||||
expected: '<code>Two < Three > One & Four "Test" </code>',
|
||||
},
|
||||
{
|
||||
caseName: 'block with encoded special characters',
|
||||
content: '```\nTwo < Three > One & Four "Test"\n```',
|
||||
expected: '<code>Two &lt; Three &gt; One &amp; Four "Test" </code>',
|
||||
},
|
||||
] as const)('should render $caseName', ({ content, expected }) => {
|
||||
render(<MarkdownText content={`${content}`} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
expect(screen.getByRole('code').outerHTML).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('line breaks handling', () => {
|
||||
it('should convert newlines to <br> in document variant', () => {
|
||||
const content = 'First Line\nSecond Line\nThird Line';
|
||||
const { container } = render(<MarkdownText content={content} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const html = container.innerHTML;
|
||||
expect(html).toContain('First Line<br>Second Line<br>Third Line');
|
||||
});
|
||||
|
||||
it('should convert newlines to <br> in inline variant', () => {
|
||||
const content = 'First Line\nSecond Line\nThird Line';
|
||||
const { container } = render(<MarkdownText content={content} variant='inline' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const html = container.innerHTML;
|
||||
expect(html).not.toContain('<br>');
|
||||
});
|
||||
|
||||
it('should not convert newlines to <br> in inlineWithoutBreaks variant', () => {
|
||||
const content = 'First Line\nSecond Line\nThird Line';
|
||||
const { container } = render(<MarkdownText content={content} variant='inlineWithoutBreaks' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const html = container.innerHTML;
|
||||
expect(html).not.toContain('<br>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOMPurify hook registration', () => {
|
||||
it('should register hook only once at module level', () => {
|
||||
// Import the module to trigger hook registration
|
||||
|
||||
const addHookSpy = jest.spyOn(dompurify, 'addHook');
|
||||
|
||||
// Clear any previous calls from module initialization
|
||||
addHookSpy.mockClear();
|
||||
|
||||
const { rerender, unmount } = render(<MarkdownText content='[Test Link](https://example.com)' variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
// Hook should NOT be registered during component render (it's registered at module level)
|
||||
expect(addHookSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Re-rendering with different props should not register hook again
|
||||
rerender(<MarkdownText content='[Another Link](https://example.com)' variant='document' />);
|
||||
expect(addHookSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Rendering another instance should not register hook again
|
||||
render(<MarkdownText content='[Third Link](https://test.com)' variant='inline' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
expect(addHookSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Unmounting should not affect the module-level hook
|
||||
unmount();
|
||||
expect(addHookSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
addHookSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser limit handling', () => {
|
||||
beforeEach(() => {
|
||||
getMarkdownParserLimitMock.mockClear();
|
||||
});
|
||||
|
||||
it('should render plain text without parsing when content exceeds the limit', () => {
|
||||
getMarkdownParserLimitMock.mockReturnValue(5);
|
||||
|
||||
const longContent = '**this should not be bold** because it exceeds the limit';
|
||||
render(<MarkdownText content={longContent} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const element = screen.getByText(longContent);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.tagName).not.toBe('STRONG');
|
||||
});
|
||||
|
||||
it('should render parsed markdown when content is within the limit', () => {
|
||||
getMarkdownParserLimitMock.mockReturnValue(Infinity);
|
||||
|
||||
const content = '**bold text**';
|
||||
render(<MarkdownText content={content} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const boldText = screen.getByText('bold text');
|
||||
expect(boldText).toBeInTheDocument();
|
||||
expect(boldText.tagName).toBe('STRONG');
|
||||
});
|
||||
|
||||
it('should render parsed markdown when content length equals the limit', () => {
|
||||
const content = '**hi**';
|
||||
|
||||
getMarkdownParserLimitMock.mockReturnValue(content.length);
|
||||
|
||||
render(<MarkdownText content={content} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const parsedText = screen.getByText('hi');
|
||||
expect(parsedText).toBeInTheDocument();
|
||||
expect(parsedText.tagName).toBe('STRONG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emoji parsing', () => {
|
||||
// Minimal stand-in for the native emoji package that turns the `:smile:`
|
||||
// shortcode into the same markup the real renderer produces.
|
||||
const renderSmile = (text: string): string => text.replace(/:smile:/g, '<span class="emoji" title=":smile:">😄</span>');
|
||||
|
||||
beforeAll(() => {
|
||||
emoji.packages.native = {
|
||||
emojiCategories: [],
|
||||
emojisByCategory: {},
|
||||
toneList: {},
|
||||
render: renderSmile,
|
||||
renderPicker: () => undefined,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete emoji.packages.native;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
getMarkdownParserLimitMock.mockReturnValue(Infinity);
|
||||
});
|
||||
|
||||
it('should render emoji shortcodes that appear in visible text', () => {
|
||||
const { container } = render(<MarkdownText content='hello :smile:' variant='document' parseEmoji />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const emojiElement = container.querySelector('.emoji');
|
||||
expect(emojiElement).toBeInTheDocument();
|
||||
expect(emojiElement).toHaveAttribute('title', ':smile:');
|
||||
expect(emojiElement).toHaveTextContent('😄');
|
||||
expect(container).not.toHaveTextContent(':smile:');
|
||||
});
|
||||
|
||||
it('should not corrupt a link whose href contains an emoji shortcode', () => {
|
||||
render(<MarkdownText content='[docs](https://example.test/:smile:)' variant='document' parseEmoji />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const anchorElement = screen.getByText('docs');
|
||||
expect(anchorElement.tagName).toBe('A');
|
||||
// The shortcode inside the href must stay untouched instead of being replaced by an emoji span
|
||||
expect(anchorElement).toHaveAttribute('href', 'https://example.test/:smile:');
|
||||
expect(anchorElement.querySelector('.emoji')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render emoji in text while keeping a shortcode inside an href intact', () => {
|
||||
const { container } = render(<MarkdownText content=':smile: [docs](https://example.test/:smile:)' variant='document' parseEmoji />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const anchorElement = screen.getByText('docs');
|
||||
expect(anchorElement).toHaveAttribute('href', 'https://example.test/:smile:');
|
||||
expect(anchorElement.querySelector('.emoji')).toBeNull();
|
||||
|
||||
const emojiElement = container.querySelector('.emoji');
|
||||
expect(emojiElement).toBeInTheDocument();
|
||||
expect(emojiElement).toHaveTextContent('😄');
|
||||
});
|
||||
});
|
||||
114
apps/meteor/client/components/MarkdownText.stories.tsx
Normal file
114
apps/meteor/client/components/MarkdownText.stories.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import outdent from 'outdent';
|
||||
|
||||
import MarkdownText from './MarkdownText';
|
||||
|
||||
const meta = {
|
||||
component: MarkdownText,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
controls: { hideNoControlsWarning: true },
|
||||
},
|
||||
} satisfies Meta<typeof MarkdownText>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
content: outdent`
|
||||
# h1 Heading
|
||||
## h2 Heading
|
||||
### h3 Heading
|
||||
#### h4 Heading
|
||||
##### h5 Heading
|
||||
###### h6 Heading
|
||||
|
||||
___
|
||||
|
||||
*This is bold text*
|
||||
|
||||
_This is italic text_
|
||||
|
||||
~Strikethrough~
|
||||
|
||||
+ Lorem ipsum dolor sit amet
|
||||
+ Consectetur adipiscing elit
|
||||
+ Integer molestie lorem at massa
|
||||
|
||||
1. Lorem ipsum dolor sit amet
|
||||
2. Consectetur adipiscing elit
|
||||
3. Integer molestie lorem at massa
|
||||
|
||||
\`rocket.chat();\`
|
||||
|
||||
https://rocket.chat
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
export const Document: Story = {
|
||||
args: {
|
||||
content: outdent`
|
||||
# Title
|
||||
|
||||
Paragraph text.
|
||||
|
||||
## Subtitle
|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
- List item 3
|
||||
|
||||
\`2 < 3 > 1 & 4 "Test"\`
|
||||
|
||||
\`< = < > = > & = &\`
|
||||
|
||||
\`\`\`
|
||||
Two < Three > One & Four "Test"
|
||||
\`\`\`
|
||||
|
||||
\`\`\`
|
||||
< : <
|
||||
> : >
|
||||
& : &
|
||||
\`\`\`
|
||||
|
||||
`,
|
||||
variant: 'document',
|
||||
},
|
||||
};
|
||||
|
||||
export const Inline: Story = {
|
||||
args: {
|
||||
content: outdent`
|
||||
# Title
|
||||
|
||||
Paragraph text.
|
||||
|
||||
## Subtitle
|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
- List item 3
|
||||
`,
|
||||
variant: 'inline',
|
||||
},
|
||||
};
|
||||
|
||||
export const InlineWithoutBreaks: Story = {
|
||||
args: {
|
||||
content: outdent`
|
||||
# Title
|
||||
|
||||
Paragraph text.
|
||||
|
||||
## Subtitle
|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
- List item 3
|
||||
`,
|
||||
variant: 'inlineWithoutBreaks',
|
||||
},
|
||||
};
|
||||
52
apps/meteor/client/components/MarkdownText.tsx
Normal file
52
apps/meteor/client/components/MarkdownText.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import MarkdownTextInner from './MarkdownTextInner';
|
||||
import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
|
||||
|
||||
export { supportedURISchemes } from './MarkdownTextInner';
|
||||
|
||||
type MarkdownTextParams = {
|
||||
content: string;
|
||||
variant: 'inline' | 'inlineWithoutBreaks' | 'document';
|
||||
preserveHtml: boolean;
|
||||
parseEmoji: boolean;
|
||||
withTruncatedText: boolean;
|
||||
} & ComponentProps<typeof Box>;
|
||||
|
||||
export type MarkdownTextProps = Partial<MarkdownTextParams>;
|
||||
|
||||
const preserveLineBreaks = css`
|
||||
white-space: pre-line;
|
||||
`;
|
||||
|
||||
const MarkdownText = ({ content, withTruncatedText = false, variant, preserveHtml, parseEmoji, ...boxProps }: MarkdownTextProps) => {
|
||||
if (content && content.length > getMarkdownParserLimit()) {
|
||||
// `document` parses with `breaks: true`, so its line breaks have to survive the unparsed
|
||||
// fallback; the inline variants collapse them on purpose, and truncation needs a single line.
|
||||
const keepLineBreaks = (variant ?? 'document') === 'document' && !withTruncatedText;
|
||||
|
||||
return (
|
||||
<Box
|
||||
withTruncatedText={withTruncatedText}
|
||||
{...boxProps}
|
||||
className={[boxProps.className, keepLineBreaks && preserveLineBreaks].flat()}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MarkdownTextInner
|
||||
content={content}
|
||||
withTruncatedText={withTruncatedText}
|
||||
variant={variant}
|
||||
preserveHtml={preserveHtml}
|
||||
parseEmoji={parseEmoji}
|
||||
{...boxProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownText;
|
||||
212
apps/meteor/client/components/MarkdownTextInner.tsx
Normal file
212
apps/meteor/client/components/MarkdownTextInner.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { isExternal, getBaseURI } from '@rocket.chat/ui-client';
|
||||
import dompurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji';
|
||||
|
||||
type MarkdownTextParams = {
|
||||
content: string;
|
||||
variant: 'inline' | 'inlineWithoutBreaks' | 'document';
|
||||
preserveHtml: boolean;
|
||||
parseEmoji: boolean;
|
||||
withTruncatedText: boolean;
|
||||
} & ComponentProps<typeof Box>;
|
||||
|
||||
const documentRenderer = new marked.Renderer();
|
||||
const inlineRenderer = new marked.Renderer();
|
||||
const inlineWithoutBreaks = new marked.Renderer();
|
||||
|
||||
const walkTokens = (token: marked.Token) => {
|
||||
const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/;
|
||||
const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/;
|
||||
if (boldPattern.test(token.raw) && token.type === 'em') {
|
||||
token.type = 'strong' as 'em';
|
||||
} else if (italicPattern.test(token.raw) && token.type === 'strong') {
|
||||
token.type = 'em' as 'strong';
|
||||
}
|
||||
};
|
||||
|
||||
marked.use({ walkTokens });
|
||||
|
||||
const linkMarked = (href: string | null, _title: string | null, text: string): string => {
|
||||
return `<a href="${href || ''}">${text}</a>`;
|
||||
};
|
||||
const paragraphMarked = (text: string): string => text;
|
||||
const brMarked = (): string => ' ';
|
||||
const listItemMarked = (text: string): string => {
|
||||
const cleanText = text.replace(/<p.*?>|<\/p>/gi, '');
|
||||
return `<li>${cleanText}</li>`;
|
||||
};
|
||||
const horizontalRuleMarked = (): string => '';
|
||||
const codeMarked = (code: string, language: string | undefined, _isEscaped: boolean): string => {
|
||||
if (language) {
|
||||
return `<pre><code class="language-${language}">${code} </code></pre>`;
|
||||
}
|
||||
return `<pre><code>${code} </code></pre>`;
|
||||
};
|
||||
const codespanMarked = (code: string): string => {
|
||||
return `<code>${code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')}</code>`;
|
||||
};
|
||||
|
||||
documentRenderer.link = linkMarked;
|
||||
documentRenderer.listitem = listItemMarked;
|
||||
documentRenderer.code = codeMarked;
|
||||
documentRenderer.codespan = codespanMarked;
|
||||
|
||||
inlineRenderer.link = linkMarked;
|
||||
inlineRenderer.paragraph = paragraphMarked;
|
||||
inlineRenderer.listitem = listItemMarked;
|
||||
inlineRenderer.hr = horizontalRuleMarked;
|
||||
|
||||
inlineWithoutBreaks.link = linkMarked;
|
||||
inlineWithoutBreaks.paragraph = paragraphMarked;
|
||||
inlineWithoutBreaks.br = brMarked;
|
||||
inlineWithoutBreaks.image = brMarked;
|
||||
inlineWithoutBreaks.code = paragraphMarked;
|
||||
inlineWithoutBreaks.codespan = paragraphMarked;
|
||||
inlineWithoutBreaks.listitem = listItemMarked;
|
||||
inlineWithoutBreaks.hr = horizontalRuleMarked;
|
||||
|
||||
const defaultOptions = {
|
||||
gfm: true,
|
||||
headerIds: false,
|
||||
};
|
||||
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
breaks: true,
|
||||
renderer: documentRenderer,
|
||||
};
|
||||
|
||||
const inlineOptions = {
|
||||
...defaultOptions,
|
||||
renderer: inlineRenderer,
|
||||
};
|
||||
|
||||
const inlineWithoutBreaksOptions = {
|
||||
...defaultOptions,
|
||||
renderer: inlineWithoutBreaks,
|
||||
};
|
||||
|
||||
const getRegexp = (supportedURISchemes: string[]): RegExp => {
|
||||
const schemes = supportedURISchemes.join('|');
|
||||
|
||||
return new RegExp(`^(${schemes}):`, 'im');
|
||||
};
|
||||
|
||||
export type MarkdownTextInnerProps = Partial<MarkdownTextParams>;
|
||||
|
||||
export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid'];
|
||||
|
||||
const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
|
||||
const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a';
|
||||
|
||||
// Generate a unique token at runtime to prevent enumeration attacks
|
||||
// This token marks internal links that need translation
|
||||
const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`;
|
||||
|
||||
// Register the DOMPurify hook once at module level to prevent memory leaks
|
||||
// This hook will be shared by all MarkdownText component instances
|
||||
dompurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (!isLinkElement(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = node.getAttribute('href') || '';
|
||||
const isExternalLink = isExternal(href);
|
||||
const isMailto = href.startsWith('mailto:');
|
||||
|
||||
// Set appropriate attributes based on link type
|
||||
if (isExternalLink || isMailto) {
|
||||
node.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
// Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat
|
||||
// This attribute must be preserved to guarantee users maintain their chat context
|
||||
node.setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
// Set appropriate title based on link type
|
||||
if (isMailto) {
|
||||
// For mailto links, use the email address as the title for better user experience
|
||||
// Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com"
|
||||
node.setAttribute('title', href);
|
||||
} else if (isExternalLink) {
|
||||
// For external links, set an empty title to prevent tooltips
|
||||
// This reduces visual clutter and lets users see the URL in the browser's status bar instead
|
||||
node.setAttribute('title', '');
|
||||
} else {
|
||||
// For internal links, use a token that will be replaced with translated text in the component
|
||||
// This allows us to use the contextualized translation function
|
||||
const relativePath = href.replace(getBaseURI(), '');
|
||||
node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`);
|
||||
}
|
||||
});
|
||||
|
||||
const MarkdownTextInner = ({
|
||||
content,
|
||||
variant = 'document',
|
||||
withTruncatedText = false,
|
||||
preserveHtml = false,
|
||||
parseEmoji = false,
|
||||
...boxProps
|
||||
}: MarkdownTextInnerProps) => {
|
||||
const sanitizer = dompurify.sanitize;
|
||||
const { t } = useTranslation();
|
||||
let markedOptions: marked.MarkedOptions;
|
||||
|
||||
switch (variant) {
|
||||
case 'inline':
|
||||
markedOptions = inlineOptions;
|
||||
break;
|
||||
case 'inlineWithoutBreaks':
|
||||
markedOptions = inlineWithoutBreaksOptions;
|
||||
break;
|
||||
case 'document':
|
||||
default:
|
||||
markedOptions = options;
|
||||
}
|
||||
|
||||
const __html = useMemo(() => {
|
||||
const html = ((): any => {
|
||||
if (content && typeof content === 'string') {
|
||||
const markedHtml = /inline/.test(variant)
|
||||
? marked.parseInline(new Option(content).innerHTML, markedOptions)
|
||||
: marked.parse(new Option(content).innerHTML, markedOptions);
|
||||
|
||||
if (parseEmoji) {
|
||||
// We are using the old emoji parser here. This could come
|
||||
// with additional processing use, but is the workaround available right now.
|
||||
// Should be replaced in the future with the new parser.
|
||||
return renderMessageEmoji(markedHtml);
|
||||
}
|
||||
|
||||
return markedHtml;
|
||||
}
|
||||
})();
|
||||
|
||||
const sanitizedHtml = preserveHtml
|
||||
? html
|
||||
: html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) });
|
||||
|
||||
// Replace internal link tokens with contextualized translations
|
||||
if (sanitizedHtml && typeof sanitizedHtml === 'string') {
|
||||
return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href }));
|
||||
}
|
||||
|
||||
return sanitizedHtml;
|
||||
}, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]);
|
||||
|
||||
return __html ? (
|
||||
<Box
|
||||
dangerouslySetInnerHTML={{ __html }}
|
||||
withTruncatedText={withTruncatedText}
|
||||
withRichContent={variant === 'inlineWithoutBreaks' ? 'inlineWithoutBreaks' : true}
|
||||
{...boxProps}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default MarkdownTextInner;
|
||||
34
apps/meteor/client/components/NotFoundState.tsx
Normal file
34
apps/meteor/client/components/NotFoundState.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Box, States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type NotFoundProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
const NotFoundState = ({ title, subtitle }: NotFoundProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const handleGoHomeClick = () => {
|
||||
router.navigate('/home');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display='flex' justifyContent='center' height='full'>
|
||||
<States>
|
||||
<StatesIcon name='magnifier' />
|
||||
<StatesTitle>{title}</StatesTitle>
|
||||
<StatesSubtitle>{subtitle}</StatesSubtitle>
|
||||
<Box marginBlockStart={16}>
|
||||
<StatesActions>
|
||||
<StatesAction onClick={handleGoHomeClick}>{t('Homepage')}</StatesAction>
|
||||
</StatesActions>
|
||||
</Box>
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundState;
|
||||
25
apps/meteor/client/components/PageSkeleton.tsx
Normal file
25
apps/meteor/client/components/PageSkeleton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Box, Button, ButtonGroup, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
|
||||
|
||||
const PageSkeleton = () => (
|
||||
<Page>
|
||||
<PageHeader title={<Skeleton width='x320' maxWidth='full' />}>
|
||||
<ButtonGroup>
|
||||
<Button disabled>
|
||||
<Skeleton width='x80' />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
|
||||
<Box is='p' color='hint' fontScale='p2'>
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton width='75%' />
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
export default PageSkeleton;
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
import { Children, forwardRef, isValidElement } from 'react';
|
||||
|
||||
import PaginatedVirtualList from './PaginatedVirtualList';
|
||||
|
||||
const mockVirtualizerHandle = {
|
||||
scrollToIndex: jest.fn(),
|
||||
scrollTo: jest.fn(),
|
||||
findItemIndex: jest.fn((offset: number) => offset),
|
||||
scrollOffset: 0,
|
||||
scrollSize: 1000,
|
||||
viewportSize: 300,
|
||||
};
|
||||
|
||||
type MockVListProps = {
|
||||
children: ReactNode;
|
||||
bufferSize?: number;
|
||||
onScroll?: (offset: number) => void;
|
||||
as?: React.ElementType;
|
||||
item?: React.ElementType;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
jest.mock('virtua', () => {
|
||||
return {
|
||||
Virtualizer: React.forwardRef(
|
||||
(
|
||||
{ children, bufferSize, onScroll, as: asRoot = 'div', item: asItem = 'div', style, className }: MockVListProps,
|
||||
ref: React.Ref<unknown>,
|
||||
) => {
|
||||
React.useImperativeHandle(ref, () => mockVirtualizerHandle);
|
||||
const Root = asRoot;
|
||||
const Item = asItem;
|
||||
const wrapped = Children.map(children, (child, index) => {
|
||||
const key = isValidElement(child) && child.key != null ? String(child.key) : `row-${index}`;
|
||||
return <Item key={key}>{child}</Item>;
|
||||
});
|
||||
|
||||
return (
|
||||
<Root
|
||||
className={className}
|
||||
data-buffer-size={bufferSize}
|
||||
data-testid='virtual-list'
|
||||
style={style ?? { height: '100%' }}
|
||||
onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)}
|
||||
>
|
||||
{wrapped}
|
||||
</Root>
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@rocket.chat/ui-client', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-client'),
|
||||
CustomVirtuaScrollbars: forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function CustomVirtuaScrollbars(
|
||||
{ children, ...props },
|
||||
ref,
|
||||
) {
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children;
|
||||
|
||||
return (
|
||||
<div ref={ref} {...props}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
const items = Array.from({ length: 10 }, (_, index) => ({ _id: `${index}` }));
|
||||
|
||||
type VirtualListTestItem = (typeof items)[number];
|
||||
|
||||
const renderVirtualList = (
|
||||
props: Partial<{
|
||||
items: VirtualListTestItem[];
|
||||
totalCount: number;
|
||||
renderItem: (item: VirtualListTestItem, index: number) => ReactNode;
|
||||
overscan?: number;
|
||||
onEndReached?: UseInfiniteQueryResult['fetchNextPage'];
|
||||
}> = {},
|
||||
) => render(<PaginatedVirtualList items={items} totalCount={20} renderItem={(item) => <div>{item._id}</div>} {...props} />);
|
||||
|
||||
const advanceDebouncedScroll = async () => {
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
};
|
||||
|
||||
describe('PaginatedVirtualList', () => {
|
||||
beforeEach(() => {
|
||||
mockVirtualizerHandle.scrollOffset = 0;
|
||||
mockVirtualizerHandle.scrollSize = 1000;
|
||||
mockVirtualizerHandle.viewportSize = 300;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('has no accessibility violations', async () => {
|
||||
const { container } = renderVirtualList();
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('calls onEndReached when scrolled near the bottom', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onEndReached = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderVirtualList({ onEndReached });
|
||||
expect(onEndReached).not.toHaveBeenCalled();
|
||||
|
||||
mockVirtualizerHandle.scrollOffset = 700;
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
await advanceDebouncedScroll();
|
||||
|
||||
expect(onEndReached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onEndReached when all items are loaded', () => {
|
||||
const onEndReached = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderVirtualList({ onEndReached, totalCount: items.length });
|
||||
mockVirtualizerHandle.scrollOffset = 700;
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
|
||||
expect(onEndReached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onEndReached repeatedly for the same item count', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onEndReached = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderVirtualList({ onEndReached });
|
||||
mockVirtualizerHandle.scrollOffset = 700;
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
await advanceDebouncedScroll();
|
||||
|
||||
expect(onEndReached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onEndReached after a same-size dataset reset', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onEndReached = jest.fn().mockResolvedValue(undefined);
|
||||
const { rerender } = renderVirtualList({ onEndReached });
|
||||
mockVirtualizerHandle.scrollOffset = 700;
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
await advanceDebouncedScroll();
|
||||
|
||||
const resetItems = Array.from({ length: 10 }, (_, index) => ({ _id: `reset-${index}` }));
|
||||
rerender(
|
||||
<PaginatedVirtualList items={resetItems} totalCount={20} renderItem={(item) => <div>{item._id}</div>} onEndReached={onEndReached} />,
|
||||
);
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
await advanceDebouncedScroll();
|
||||
|
||||
expect(onEndReached).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes overscan through to virtua buffer size', () => {
|
||||
renderVirtualList({ overscan: 25 });
|
||||
|
||||
expect(screen.getByTestId('virtual-list')).toHaveAttribute('data-buffer-size', '25');
|
||||
});
|
||||
|
||||
it('allows onEndReached to retry after a failed load', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onEndReached = jest.fn().mockRejectedValue(new Error('failed to load more items'));
|
||||
|
||||
renderVirtualList({ onEndReached });
|
||||
mockVirtualizerHandle.scrollOffset = 700;
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
await advanceDebouncedScroll();
|
||||
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
await advanceDebouncedScroll();
|
||||
|
||||
expect(onEndReached).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('allows onEndReached to retry after a synchronous throw', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onEndReached = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('failed to load more items');
|
||||
})
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
renderVirtualList({ onEndReached });
|
||||
mockVirtualizerHandle.scrollOffset = 700;
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
await advanceDebouncedScroll();
|
||||
fireEvent.scroll(screen.getByTestId('virtual-list'));
|
||||
await advanceDebouncedScroll();
|
||||
|
||||
expect(onEndReached).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('calls onEndReached when the viewport is underfilled', () => {
|
||||
const onEndReached = jest.fn().mockResolvedValue(undefined);
|
||||
mockVirtualizerHandle.scrollSize = 200;
|
||||
|
||||
renderVirtualList({ onEndReached });
|
||||
|
||||
expect(onEndReached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { CustomVirtuaScrollbars } from '@rocket.chat/ui-client';
|
||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import type { VirtualizerHandle } from 'virtua';
|
||||
import { Virtualizer } from 'virtua';
|
||||
|
||||
import { VirtuaListContainer } from './VirtuaListContainer';
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD = -20;
|
||||
|
||||
const scrollViewportStyle = {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
} as const;
|
||||
|
||||
type PaginatedVirtualListProps<T extends { _id: string }> = {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
renderItem: (item: T, index: number) => ReactNode;
|
||||
overscan?: number;
|
||||
onEndReached?: UseInfiniteQueryResult['fetchNextPage'];
|
||||
};
|
||||
|
||||
function PaginatedVirtualList<T extends { _id: string }>({
|
||||
items,
|
||||
totalCount,
|
||||
renderItem,
|
||||
overscan,
|
||||
onEndReached,
|
||||
}: PaginatedVirtualListProps<T>) {
|
||||
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
|
||||
const isEndReachedLockedRef = useRef(false);
|
||||
const firstItemId = items[0]?._id ?? '';
|
||||
const lastItemId = items[items.length - 1]?._id ?? '';
|
||||
|
||||
const checkEndReached = useCallback(
|
||||
async (offset: number) => {
|
||||
if (isEndReachedLockedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = virtualizerRef.current;
|
||||
|
||||
if (!handle || !onEndReached) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollSize, viewportSize } = handle;
|
||||
if (viewportSize <= 0 || items.length >= totalCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nearBottom = offset - scrollSize + viewportSize >= NEAR_BOTTOM_THRESHOLD;
|
||||
if (!nearBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEndReachedLockedRef.current = true;
|
||||
|
||||
try {
|
||||
await onEndReached();
|
||||
} catch {
|
||||
isEndReachedLockedRef.current = false;
|
||||
}
|
||||
},
|
||||
[items.length, onEndReached, totalCount],
|
||||
);
|
||||
|
||||
const handleScroll = useDebouncedCallback(
|
||||
(offset: number) => {
|
||||
checkEndReached(offset);
|
||||
},
|
||||
300,
|
||||
[checkEndReached],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
isEndReachedLockedRef.current = false;
|
||||
|
||||
const handle = virtualizerRef.current;
|
||||
if (!handle) {
|
||||
return;
|
||||
}
|
||||
checkEndReached(handle.scrollOffset);
|
||||
}, [checkEndReached, firstItemId, items.length, lastItemId, totalCount]);
|
||||
|
||||
return (
|
||||
<CustomVirtuaScrollbars>
|
||||
<div style={scrollViewportStyle}>
|
||||
<Virtualizer ref={virtualizerRef} as={VirtuaListContainer} item='li' bufferSize={overscan} onScroll={handleScroll}>
|
||||
{items.map((item, index) => (
|
||||
<div key={item._id}>{renderItem(item, index)}</div>
|
||||
))}
|
||||
</Virtualizer>
|
||||
</div>
|
||||
</CustomVirtuaScrollbars>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaginatedVirtualList;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
const listResetStyle = {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
} as const;
|
||||
|
||||
export type VirtuaListContainerProps = {
|
||||
children: ReactNode;
|
||||
style: CSSProperties;
|
||||
} & Omit<HTMLAttributes<HTMLUListElement>, 'children' | 'style'>;
|
||||
|
||||
export const VirtuaListContainer = forwardRef<HTMLUListElement, VirtuaListContainerProps>(function VirtuaListContainer(
|
||||
{ children, style, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<ul {...props} ref={ref} style={{ ...listResetStyle, ...style }}>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PaginatedVirtualList } from './PaginatedVirtualList';
|
||||
26
apps/meteor/client/components/PlanTag.tsx
Normal file
26
apps/meteor/client/components/PlanTag.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Box, Tag } from '@rocket.chat/fuselage';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import { useLicense } from '@rocket.chat/ui-client';
|
||||
|
||||
const developmentTag = process.env.NODE_ENV === 'development' ? 'Development' : null;
|
||||
function PlanTag() {
|
||||
const license = useLicense();
|
||||
|
||||
const tags = [
|
||||
developmentTag && { name: developmentTag },
|
||||
...(license.data?.tags ?? []),
|
||||
!license.isLoading && !license.isError && !license.data?.license && { name: 'Community' },
|
||||
].filter(isTruthy);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tags.map(({ name }) => (
|
||||
<Box marginInline={4} display='inline-block' verticalAlign='middle' key={name}>
|
||||
<Tag variant='primary'>{name}</Tag>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlanTag;
|
||||
8
apps/meteor/client/components/RawText.tsx
Normal file
8
apps/meteor/client/components/RawText.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/** @deprecated */
|
||||
export type RawTextProps = { children: string };
|
||||
|
||||
const RawText = ({ children }: RawTextProps) => <span dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(children) }} />;
|
||||
|
||||
export default RawText;
|
||||
28
apps/meteor/client/components/ResultsLiveRegion.tsx
Normal file
28
apps/meteor/client/components/ResultsLiveRegion.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { VisuallyHidden } from '@react-aria/visually-hidden';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ResultsLiveRegion = ({
|
||||
shouldAnnounce,
|
||||
itemCount,
|
||||
isLoading = false,
|
||||
}: {
|
||||
shouldAnnounce: boolean;
|
||||
itemCount: number;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const message = (() => {
|
||||
if (isLoading) {
|
||||
return t('Loading');
|
||||
}
|
||||
if (!shouldAnnounce) {
|
||||
return '';
|
||||
}
|
||||
return itemCount === 0 ? t('No_results_found') : t('__count__result_found', { count: itemCount });
|
||||
})();
|
||||
|
||||
return <VisuallyHidden role='status'>{message}</VisuallyHidden>;
|
||||
};
|
||||
|
||||
export default ResultsLiveRegion;
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Option, Box } from '@rocket.chat/fuselage';
|
||||
import type { AutoCompleteProps } from '@rocket.chat/fuselage';
|
||||
import { AutoComplete } from '@rocket.chat/fuselage-forms';
|
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import { RoomAvatar } from '@rocket.chat/ui-avatar';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { forwardRef, memo, useMemo, useState } from 'react';
|
||||
|
||||
type LabelType = { name: string; avatarETag?: string; type: IRoom['t']; encrypted?: IRoom['encrypted'] };
|
||||
|
||||
const generateQuery = (
|
||||
term = '',
|
||||
): {
|
||||
selector: string;
|
||||
} => ({ selector: JSON.stringify({ name: term }) });
|
||||
|
||||
export type RoomAutoCompleteProps = Omit<AutoCompleteProps<LabelType>, 'filter'> & {
|
||||
scope?: 'admin' | 'regular';
|
||||
renderRoomIcon?: (props: { encrypted: IRoom['encrypted']; type: IRoom['t'] }) => ReactNode;
|
||||
setSelectedRoom?: (room: IRoom | undefined) => void;
|
||||
};
|
||||
|
||||
const AVATAR_SIZE = 'x20';
|
||||
|
||||
const ROOM_AUTOCOMPLETE_PARAMS = {
|
||||
admin: {
|
||||
endpoint: '/v1/rooms.autocomplete.adminRooms',
|
||||
key: 'roomsAutoCompleteAdmin',
|
||||
},
|
||||
regular: {
|
||||
endpoint: '/v1/rooms.autocomplete.channelAndPrivate',
|
||||
key: 'roomsAutoCompleteRegular',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const RoomAutoComplete = forwardRef<HTMLInputElement, RoomAutoCompleteProps>(function RoomAutoComplete(
|
||||
{ value, onChange, scope = 'regular', renderRoomIcon, setSelectedRoom, ...props },
|
||||
ref,
|
||||
) {
|
||||
const [filter, setFilter] = useState('');
|
||||
const filterDebounced = useDebouncedValue(filter, 300);
|
||||
const roomsAutoCompleteEndpoint = useEndpoint('GET', ROOM_AUTOCOMPLETE_PARAMS[scope].endpoint);
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: [ROOM_AUTOCOMPLETE_PARAMS[scope].key, filterDebounced],
|
||||
queryFn: () => roomsAutoCompleteEndpoint(generateQuery(filterDebounced)),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
result.isSuccess
|
||||
? result.data.items.map(({ name, fname, _id, avatarETag, t, encrypted }) => ({
|
||||
value: _id,
|
||||
label: { name: fname || name, avatarETag, type: t, encrypted },
|
||||
}))
|
||||
: [],
|
||||
[result.data?.items, result.isSuccess],
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
{...props}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
|
||||
if (setSelectedRoom && typeof setSelectedRoom === 'function') {
|
||||
const selectedRoom = result?.data?.items.find(({ _id }) => _id === val) as unknown as IRoom;
|
||||
setSelectedRoom(selectedRoom);
|
||||
}
|
||||
}}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
renderSelected={({ selected: { value, label } }) => (
|
||||
<>
|
||||
<Box margin='none' marginInline={2}>
|
||||
<RoomAvatar size={AVATAR_SIZE} room={{ ...label, type: label?.type || 'c', _id: value }} />
|
||||
</Box>
|
||||
<Box margin='none' marginInline={2}>
|
||||
{label?.name}
|
||||
</Box>
|
||||
{renderRoomIcon?.({ encrypted: label?.encrypted, type: label?.type })}
|
||||
</>
|
||||
)}
|
||||
renderItem={({ value, label, ...props }) => (
|
||||
<Option
|
||||
{...props}
|
||||
label={
|
||||
<>
|
||||
{label?.name}
|
||||
{renderRoomIcon?.({ encrypted: label?.encrypted, type: label?.type })}
|
||||
</>
|
||||
}
|
||||
avatar={<RoomAvatar size={AVATAR_SIZE} room={{ _id: value, ...label }} />}
|
||||
/>
|
||||
)}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default memo(RoomAutoComplete);
|
||||
1
apps/meteor/client/components/RoomAutoComplete/index.ts
Normal file
1
apps/meteor/client/components/RoomAutoComplete/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './RoomAutoComplete';
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AutoComplete, Option, Chip, Box, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import { RoomAvatar } from '@rocket.chat/ui-avatar';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
|
||||
const generateQuery = (
|
||||
term = '',
|
||||
): {
|
||||
selector: string;
|
||||
} => ({ selector: JSON.stringify({ name: term }) });
|
||||
|
||||
type RoomAutoCompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'> & {
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const RoomAutoCompleteMultiple = ({ value, onChange, ...props }: RoomAutoCompleteProps) => {
|
||||
const [filter, setFilter] = useState('');
|
||||
const filterDebounced = useDebouncedValue(filter, 300);
|
||||
const autocomplete = useEndpoint('GET', '/v1/rooms.autocomplete.channelAndPrivate');
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: ['rooms.autocomplete.channelAndPrivate', filterDebounced],
|
||||
queryFn: () => autocomplete(generateQuery(filterDebounced)),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
result.isSuccess
|
||||
? result.data.items.map(({ fname, name, _id, avatarETag, t }) => ({
|
||||
value: _id,
|
||||
label: { name: fname || name, avatarETag, type: t },
|
||||
}))
|
||||
: [],
|
||||
[result.data?.items, result.isSuccess],
|
||||
);
|
||||
|
||||
if (result.isPending) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
multiple
|
||||
renderSelected={({ selected: { value, label }, onRemove, ...props }) => (
|
||||
<Chip {...props} key={value} value={value} onClick={onRemove}>
|
||||
<RoomAvatar size='x20' room={{ ...label, type: label?.type || 'c', _id: value }} />
|
||||
<Box is='span' margin='none' marginInlineStart={4}>
|
||||
{label?.name}
|
||||
</Box>
|
||||
</Chip>
|
||||
)}
|
||||
renderItem={({ value, label, ...props }) => (
|
||||
<Option
|
||||
key={value}
|
||||
{...props}
|
||||
label={label.name}
|
||||
avatar={<RoomAvatar size='x20' room={{ ...label, type: label?.type || 'c', _id: value }} />}
|
||||
/>
|
||||
)}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RoomAutoCompleteMultiple);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoomAutoCompleteMultiple';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { IOmnichannelSourceFromApp } from '@rocket.chat/core-typings';
|
||||
import { Icon, Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { useOmnichannelRoomIcon } from './context/OmnichannelRoomIconContext';
|
||||
|
||||
export type OmnichannelAppSourceRoomIconProps = {
|
||||
source: IOmnichannelSourceFromApp;
|
||||
color: ComponentProps<typeof Box>['color'];
|
||||
size: ComponentProps<typeof Icon>['size'];
|
||||
placement: 'sidebar' | 'default';
|
||||
};
|
||||
|
||||
export const OmnichannelAppSourceRoomIcon = ({ source, color, size, placement }: OmnichannelAppSourceRoomIconProps) => {
|
||||
const icon = (placement === 'sidebar' && source.sidebarIcon) || source.defaultIcon;
|
||||
const value = useOmnichannelRoomIcon(source.id, icon || '');
|
||||
|
||||
if (!value) {
|
||||
return <Icon name='headset' size={size} color={color} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box size={size} color={color}>
|
||||
<Box is='svg' size={size} aria-hidden='true'>
|
||||
<Box is='use' href={`#${value}`} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { IOmnichannelSource } from '@rocket.chat/core-typings';
|
||||
import { Icon } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
const iconMap = {
|
||||
widget: 'livechat',
|
||||
email: 'mail',
|
||||
sms: 'sms',
|
||||
app: 'headset',
|
||||
api: 'headset',
|
||||
other: 'headset',
|
||||
} as const;
|
||||
|
||||
export type OmnichannelCoreSourceRoomIconProps = {
|
||||
source: IOmnichannelSource;
|
||||
color: ComponentProps<typeof Icon>['color'];
|
||||
size: ComponentProps<typeof Icon>['size'];
|
||||
};
|
||||
|
||||
export const OmnichannelCoreSourceRoomIcon = ({ source, color, size }: OmnichannelCoreSourceRoomIconProps) => {
|
||||
const icon = iconMap[source?.type || 'other'] || 'headset';
|
||||
return <Icon name={icon} size={size} color={color} />;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IOmnichannelSource } from '@rocket.chat/core-typings';
|
||||
import { UserStatus, isOmnichannelSourceFromApp } from '@rocket.chat/core-typings';
|
||||
import type { Icon } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { OmnichannelAppSourceRoomIcon } from './OmnichannelAppSourceRoomIcon';
|
||||
import { OmnichannelCoreSourceRoomIcon } from './OmnichannelCoreSourceRoomIcon';
|
||||
|
||||
const colors = {
|
||||
busy: 'status-font-on-danger',
|
||||
away: 'status-font-on-warning',
|
||||
online: 'status-font-on-success',
|
||||
offline: 'annotation',
|
||||
disabled: 'annotation',
|
||||
} as const;
|
||||
|
||||
export type OmnichannelRoomIconProps = {
|
||||
source: IOmnichannelSource;
|
||||
color?: ComponentProps<typeof Icon>['color'];
|
||||
status?: UserStatus;
|
||||
size: ComponentProps<typeof Icon>['size'];
|
||||
placement?: 'sidebar' | 'default';
|
||||
};
|
||||
|
||||
export const OmnichannelRoomIcon = ({ source, color, status, size = 'x16', placement = 'default' }: OmnichannelRoomIconProps) => {
|
||||
const iconColor = color ?? colors[status || UserStatus.OFFLINE];
|
||||
|
||||
if (isOmnichannelSourceFromApp(source)) {
|
||||
return <OmnichannelAppSourceRoomIcon source={source} placement={placement} color={iconColor} size={size} />;
|
||||
}
|
||||
|
||||
return <OmnichannelCoreSourceRoomIcon source={source} color={iconColor} size={size} />;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useMemo, useContext, useSyncExternalStore } from 'react';
|
||||
|
||||
type IOmnichannelRoomIconContext = {
|
||||
queryIcon(app: string, icon: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined];
|
||||
};
|
||||
|
||||
export const OmnichannelRoomIconContext = createContext<IOmnichannelRoomIconContext>({
|
||||
queryIcon: () => [(): (() => void) => (): void => undefined, () => undefined],
|
||||
});
|
||||
|
||||
export const useOmnichannelRoomIcon = (app: string, icon: string): string | undefined => {
|
||||
const { queryIcon } = useContext(OmnichannelRoomIconContext);
|
||||
const [subscribe, getSnapshot] = useMemo(() => queryIcon(app, icon), [app, queryIcon, icon]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './OmnichannelRoomIcon';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { sdk } from '../../../../lib/SDKClient';
|
||||
|
||||
const OmnichannelRoomIconManager = new (class extends Emitter {
|
||||
icons = new Map<string, string>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public get(appId: string, icon: string): string | undefined {
|
||||
if (!appId || !icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.icons.has(`${appId}-${icon}`)) {
|
||||
return `${appId}-${icon}`;
|
||||
}
|
||||
// TODO: update the apps icons to send JSON instead of a string. This will allow us to use APIClient.get()
|
||||
|
||||
sdk.rest
|
||||
.send(`/apps/public/${appId}/get-sidebar-icon?icon=${icon}`, 'GET')
|
||||
.then((response: any) => {
|
||||
response.text().then((text: string) => {
|
||||
this.icons.set(
|
||||
`${appId}-${icon}`,
|
||||
DOMPurify.sanitize(text, {
|
||||
FORBID_ATTR: ['id'],
|
||||
NAMESPACE: 'http://www.w3.org/2000/svg',
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
})
|
||||
.replace(`<svg`, `<symbol id="${appId}-${icon}"`)
|
||||
.replace(`</svg>`, '</symbol>'),
|
||||
);
|
||||
this.emit('change');
|
||||
this.emit(`${appId}-${icon}`);
|
||||
});
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('error from get-sidebar-icon', error);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
export default OmnichannelRoomIconManager;
|
||||
@@ -0,0 +1,81 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { OmnichannelRoomIconContext } from '../context/OmnichannelRoomIconContext';
|
||||
import OmnichannelRoomIconManager from '../lib/OmnichannelRoomIconManager';
|
||||
|
||||
let icons = Array.from(OmnichannelRoomIconManager.icons.values());
|
||||
|
||||
export type OmnichannelRoomIconProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const OmnichannelRoomIconProvider = ({ children }: OmnichannelRoomIconProviderProps) => {
|
||||
const svgIcons = useSyncExternalStore(
|
||||
useCallback(
|
||||
(callback): (() => void) =>
|
||||
OmnichannelRoomIconManager.on('change', () => {
|
||||
icons = Array.from(OmnichannelRoomIconManager.icons.values());
|
||||
callback();
|
||||
}),
|
||||
[],
|
||||
),
|
||||
(): string[] => icons,
|
||||
);
|
||||
|
||||
return (
|
||||
<OmnichannelRoomIconContext.Provider
|
||||
value={useMemo(() => {
|
||||
const extractSnapshot = (app: string, iconName: string) => OmnichannelRoomIconManager.get(app, iconName);
|
||||
|
||||
// We cache all the icons here, so that we can use them in the OmnichannelRoomIcon component
|
||||
const snapshots = new Map<string, string | undefined>();
|
||||
|
||||
return {
|
||||
queryIcon: (
|
||||
app: string,
|
||||
iconName: string,
|
||||
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined] => [
|
||||
(callback): (() => void) =>
|
||||
OmnichannelRoomIconManager.on(`${app}-${iconName}`, () => {
|
||||
snapshots.set(`${app}-${iconName}`, extractSnapshot(app, iconName));
|
||||
|
||||
// Then we call the callback (onStoreChange), signaling React to re-render
|
||||
callback();
|
||||
}),
|
||||
|
||||
// No problem here, because it's return value is a cached in the snapshots map on subsequent calls
|
||||
() => {
|
||||
let snapshot = snapshots.get(`${app}-${iconName}`);
|
||||
|
||||
if (!snapshot) {
|
||||
snapshot = extractSnapshot(app, iconName);
|
||||
snapshots.set(`${app}-${iconName}`, snapshot);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [])}
|
||||
>
|
||||
{createPortal(
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
style={{ display: 'none' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(`<svg>${svgIcons.join('')}</svg>`, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
}).slice(5, -6),
|
||||
}}
|
||||
/>,
|
||||
document.body,
|
||||
'custom-icons',
|
||||
)}
|
||||
{children}
|
||||
</OmnichannelRoomIconContext.Provider>
|
||||
);
|
||||
};
|
||||
40
apps/meteor/client/components/RoomIcon/RoomIcon.tsx
Normal file
40
apps/meteor/client/components/RoomIcon/RoomIcon.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { Icon } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { isValidElement } from 'react';
|
||||
|
||||
import { OmnichannelRoomIcon } from './OmnichannelRoomIcon';
|
||||
import { useRoomIcon } from '../../hooks/useRoomIcon';
|
||||
|
||||
export const RoomIcon = ({
|
||||
room,
|
||||
size = 'x16',
|
||||
isIncomingCall,
|
||||
placement = 'default',
|
||||
}: {
|
||||
room: Pick<IRoom, 't' | 'prid' | 'teamMain' | 'uids' | 'u'>;
|
||||
size?: ComponentProps<typeof Icon>['size'];
|
||||
isIncomingCall?: boolean;
|
||||
placement?: 'sidebar' | 'default';
|
||||
}) => {
|
||||
const iconPropsOrReactNode = useRoomIcon(room);
|
||||
|
||||
if (isIncomingCall) {
|
||||
return <Icon name='phone' size={size} />;
|
||||
}
|
||||
|
||||
if (isOmnichannelRoom(room)) {
|
||||
return <OmnichannelRoomIcon placement={placement} source={room.source} status={room.v?.status} size={size} />;
|
||||
}
|
||||
|
||||
if (isValidElement<any>(iconPropsOrReactNode)) {
|
||||
return iconPropsOrReactNode;
|
||||
}
|
||||
|
||||
if (!iconPropsOrReactNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Icon {...iconPropsOrReactNode} size={size} />;
|
||||
};
|
||||
1
apps/meteor/client/components/RoomIcon/index.tsx
Normal file
1
apps/meteor/client/components/RoomIcon/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './RoomIcon';
|
||||
17
apps/meteor/client/components/Sidebar/Content.tsx
Normal file
17
apps/meteor/client/components/Sidebar/Content.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { CustomScrollbars } from '@rocket.chat/ui-client';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type ContentProps = ComponentPropsWithoutRef<typeof CustomScrollbars>;
|
||||
|
||||
const Content = ({ children, ...props }: ContentProps) => (
|
||||
<Box display='flex' flexDirection='column' flexGrow={1} flexShrink={1} overflow='hidden'>
|
||||
<CustomScrollbars {...props}>
|
||||
<Box display='flex' flexDirection='column' width='full' minHeight='full'>
|
||||
{children}
|
||||
</Box>
|
||||
</CustomScrollbars>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default Content;
|
||||
31
apps/meteor/client/components/Sidebar/Header.tsx
Normal file
31
apps/meteor/client/components/Sidebar/Header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Box, IconButton } from '@rocket.chat/fuselage';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type HeaderProps = {
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const Header = ({ title, onClose, children, ...props }: HeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box display='flex' flexDirection='column' paddingBlock={16} {...props}>
|
||||
{(title || onClose) && (
|
||||
<Box display='flex' flexDirection='row' alignItems='center' paddingInline={24} justifyContent='space-between' flexGrow={1}>
|
||||
{title && (
|
||||
<Box color='default' fontScale='p2b' flexShrink={1} withTruncatedText>
|
||||
{title}
|
||||
</Box>
|
||||
)}
|
||||
{onClose && <IconButton small aria-label={t('Close')} icon='cross' onClick={onClose} />}
|
||||
</Box>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
33
apps/meteor/client/components/Sidebar/ListItem.tsx
Normal file
33
apps/meteor/client/components/Sidebar/ListItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Option, OptionColumn, OptionContent, OptionIcon, OptionInput } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
|
||||
type ListItemCommonProps = {
|
||||
text: ReactNode;
|
||||
input?: ReactNode;
|
||||
loading?: boolean;
|
||||
children?: ReactNode;
|
||||
} & ComponentProps<typeof Option>;
|
||||
|
||||
type ListItemConditionalProps =
|
||||
| {
|
||||
icon?: ComponentProps<typeof OptionIcon>['name'];
|
||||
gap?: never;
|
||||
}
|
||||
| {
|
||||
icon?: never;
|
||||
gap?: boolean;
|
||||
};
|
||||
|
||||
export type ListItemProps = ListItemCommonProps & ListItemConditionalProps;
|
||||
|
||||
const ListItem = ({ icon, text, input, children, gap, ...props }: ListItemProps) => (
|
||||
<Option {...props}>
|
||||
{icon && <OptionIcon name={icon} />}
|
||||
{gap && <OptionColumn />}
|
||||
<OptionContent>{text}</OptionContent>
|
||||
{input && <OptionInput>{input}</OptionInput>}
|
||||
{children && <OptionColumn>{children}</OptionColumn>}
|
||||
</Option>
|
||||
);
|
||||
|
||||
export default ListItem;
|
||||
8
apps/meteor/client/components/Sidebar/Sidebar.tsx
Normal file
8
apps/meteor/client/components/Sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Sidebar as FuselageSidebar } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type SidebarProps = ComponentPropsWithoutRef<typeof FuselageSidebar>;
|
||||
|
||||
const Sidebar = (props: SidebarProps) => <FuselageSidebar {...props} />;
|
||||
|
||||
export default Sidebar;
|
||||
20
apps/meteor/client/components/Sidebar/SidebarGenericItem.tsx
Normal file
20
apps/meteor/client/components/Sidebar/SidebarGenericItem.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Box, SidebarItem } from '@rocket.chat/fuselage';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
export type SidebarGenericItemProps = {
|
||||
href?: string;
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
externalUrl?: boolean;
|
||||
};
|
||||
|
||||
const SidebarGenericItem = ({ href, active, externalUrl, children, ...props }: SidebarGenericItemProps) => (
|
||||
<SidebarItem selected={active} is='a' href={href} {...(externalUrl && { target: '_blank', rel: 'noopener noreferrer' })} {...props}>
|
||||
<Box display='flex' flexDirection='row' alignItems='center' paddingBlock={8} width='100%'>
|
||||
{children}
|
||||
</Box>
|
||||
</SidebarItem>
|
||||
);
|
||||
|
||||
export default memo(SidebarGenericItem);
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Divider } from '@rocket.chat/fuselage';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SidebarNavigationItem from './SidebarNavigationItem';
|
||||
import type { SidebarItem } from '../../lib/createSidebarItems';
|
||||
import { isSidebarItem } from '../../lib/createSidebarItems';
|
||||
|
||||
export type SidebarItemsAssemblerProps = {
|
||||
items: SidebarItem[];
|
||||
currentPath?: string;
|
||||
};
|
||||
|
||||
const SidebarItemsAssembler = ({ items, currentPath }: SidebarItemsAssemblerProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((props) => (
|
||||
<Fragment key={props.i18nLabel}>
|
||||
{isSidebarItem(props) ? (
|
||||
<SidebarNavigationItem
|
||||
permissionGranted={props.permissionGranted}
|
||||
pathSection={props.href ?? props.pathSection ?? ''}
|
||||
icon={props.icon}
|
||||
label={t((props.i18nLabel || props.name) as Parameters<typeof t>[0])}
|
||||
currentPath={currentPath}
|
||||
tag={props.tag && i18n.exists(props.tag) ? t(props.tag) : props.tag}
|
||||
externalUrl={props.externalUrl}
|
||||
badge={props.badge}
|
||||
/>
|
||||
) : (
|
||||
<Divider />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SidebarItemsAssembler);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box, Icon, Tag } from '@rocket.chat/fuselage';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import SidebarGenericItem from './SidebarGenericItem';
|
||||
|
||||
export type SidebarNavigationItemProps = {
|
||||
permissionGranted?: (() => boolean) | boolean;
|
||||
pathSection: string;
|
||||
icon?: IconName;
|
||||
label?: string;
|
||||
tag?: string;
|
||||
currentPath?: string;
|
||||
externalUrl?: boolean;
|
||||
badge?: () => ReactNode;
|
||||
};
|
||||
|
||||
const SidebarNavigationItem = ({
|
||||
permissionGranted,
|
||||
pathSection,
|
||||
icon,
|
||||
label,
|
||||
currentPath,
|
||||
tag,
|
||||
externalUrl,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
badge: Badge,
|
||||
}: SidebarNavigationItemProps) => {
|
||||
const path = pathSection;
|
||||
const isActive = !!path && currentPath?.includes(path);
|
||||
|
||||
if (permissionGranted === false || (typeof permissionGranted === 'function' && !permissionGranted())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGenericItem active={isActive} href={path} externalUrl={externalUrl} aria-current={isActive ? 'page' : undefined}>
|
||||
{icon && <Icon name={icon} size='x20' marginInline={4} />}
|
||||
<Box
|
||||
withTruncatedText
|
||||
fontScale='p2'
|
||||
marginInline={4}
|
||||
flexGrow={1}
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
justifyContent='space-between'
|
||||
width='100%'
|
||||
>
|
||||
{label} {tag && <Tag>{tag}</Tag>}
|
||||
</Box>
|
||||
{Badge ? <Badge /> : null}
|
||||
</SidebarGenericItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SidebarNavigationItem);
|
||||
16
apps/meteor/client/components/Sidebar/index.ts
Normal file
16
apps/meteor/client/components/Sidebar/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import Content from './Content';
|
||||
import Header from './Header';
|
||||
import ListItem from './ListItem';
|
||||
import Sidebar from './Sidebar';
|
||||
import GenericItem from './SidebarGenericItem';
|
||||
import SidebarItemsAssembler from './SidebarItemsAssembler';
|
||||
import NavigationItem from './SidebarNavigationItem';
|
||||
|
||||
export default Object.assign(Sidebar, {
|
||||
Content,
|
||||
Header,
|
||||
GenericItem,
|
||||
NavigationItem,
|
||||
ItemsAssembler: SidebarItemsAssembler,
|
||||
ListItem,
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useEmbeddedLayout } from '@rocket.chat/ui-client';
|
||||
import { useLayout, useSession } from '@rocket.chat/ui-contexts';
|
||||
import { memo } from 'react';
|
||||
|
||||
import SidebarTogglerButton from './SidebarTogglerButton';
|
||||
|
||||
const SideBarToggler = () => {
|
||||
const { sidebar } = useLayout();
|
||||
const isLayoutEmbedded = useEmbeddedLayout();
|
||||
const unreadMessagesBadge = useSession('unread') as number | string | undefined;
|
||||
|
||||
const toggleSidebar = useStableCallback(() => sidebar.toggle());
|
||||
|
||||
return (
|
||||
<SidebarTogglerButton
|
||||
pressed={!sidebar.isCollapsed}
|
||||
onClick={toggleSidebar}
|
||||
badge={!isLayoutEmbedded && unreadMessagesBadge && unreadMessagesBadge}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SideBarToggler);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Badge } from '@rocket.chat/fuselage';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type SidebarTogglerBadgeProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const SidebarTogglerBadge = ({ children }: SidebarTogglerBadgeProps) => (
|
||||
<Box
|
||||
className={css`
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
`}
|
||||
>
|
||||
<Badge variant='danger'>{children}</Badge>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default SidebarTogglerBadge;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import SidebarTogglerButton from './SidebarTogglerButton';
|
||||
|
||||
export default {
|
||||
component: SidebarTogglerButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
controls: { hideNoControlsWarning: true },
|
||||
actions: { argTypesRegex: '^on.*' },
|
||||
},
|
||||
} satisfies Meta<typeof SidebarTogglerButton>;
|
||||
|
||||
export const Example: StoryFn<typeof SidebarTogglerButton> = () => <SidebarTogglerButton onClick={action('onClick')} />;
|
||||
export const Default = {};
|
||||
|
||||
export const WithBadge = {
|
||||
args: {
|
||||
badge: 99,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Box, IconButton } from '@rocket.chat/fuselage';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SidebarTogglerBadge from './SidebarTogglerBadge';
|
||||
|
||||
export type SideBarTogglerButtonProps = {
|
||||
pressed?: boolean;
|
||||
badge?: ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const SideBarTogglerButton = ({ pressed, badge, onClick }: SideBarTogglerButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box position='relative'>
|
||||
<IconButton title={!pressed ? t('Open_sidebar') : t('Close_sidebar')} icon='burger-menu' pressed={pressed} small onClick={onClick} />
|
||||
{badge && <SidebarTogglerBadge>{badge}</SidebarTogglerBadge>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBarTogglerButton;
|
||||
1
apps/meteor/client/components/SidebarToggler/index.ts
Normal file
1
apps/meteor/client/components/SidebarToggler/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SidebarToggler';
|
||||
13
apps/meteor/client/components/Skeleton.tsx
Normal file
13
apps/meteor/client/components/Skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Box, Skeleton } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export const FormSkeleton = (props: ComponentProps<typeof Box>) => (
|
||||
<Box width='full' paddingBlock={24} {...props}>
|
||||
<Skeleton marginBlockEnd={8} />
|
||||
<Skeleton marginBlockEnd={4} />
|
||||
<Skeleton marginBlockEnd={4} />
|
||||
<Skeleton marginBlockEnd={8} />
|
||||
<Skeleton marginBlockEnd={4} />
|
||||
<Skeleton marginBlockEnd={8} />
|
||||
</Box>
|
||||
);
|
||||
53
apps/meteor/client/components/TextCopy.tsx
Normal file
53
apps/meteor/client/components/TextCopy.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Box, Button, Scrollable } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useClipboardWithToast from '../hooks/useClipboardWithToast';
|
||||
|
||||
const defaultWrapperRenderer = (text: string) => (
|
||||
<Box
|
||||
fontFamily='mono'
|
||||
alignSelf='center'
|
||||
fontScale='p2'
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
marginInlineEnd={4}
|
||||
flexGrow={1}
|
||||
maxHeight='x108'
|
||||
>
|
||||
{text}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export type TextCopyProps = {
|
||||
text: string;
|
||||
wrapper?: (text: string) => ReactNode;
|
||||
} & ComponentProps<typeof Box>;
|
||||
|
||||
const TextCopy = ({ text, wrapper = defaultWrapperRenderer, ...props }: TextCopyProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { copy } = useClipboardWithToast(text);
|
||||
|
||||
const handleClick = () => {
|
||||
copy();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
display='flex'
|
||||
flexDirection='row'
|
||||
justifyContent='stretch'
|
||||
alignItems='flex-start'
|
||||
flexGrow={1}
|
||||
paddingBlock={16}
|
||||
backgroundColor='surface'
|
||||
width='full'
|
||||
{...props}
|
||||
>
|
||||
<Scrollable vertical>{wrapper(text)}</Scrollable>
|
||||
<Button icon='copy' secondary square small flexShrink={0} onClick={handleClick} title={t('Copy')} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextCopy;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Box, Button } from '@rocket.chat/fuselage';
|
||||
import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { OnConfirm } from './TwoFactorModal';
|
||||
import { Method } from './TwoFactorModal';
|
||||
|
||||
export type TwoFactorEmailModalProps = {
|
||||
onConfirm: OnConfirm;
|
||||
onClose: () => void;
|
||||
invalidAttempt?: boolean;
|
||||
} & (
|
||||
| {
|
||||
emailOrUsername: string;
|
||||
challengeId?: never;
|
||||
}
|
||||
| {
|
||||
challengeId: string;
|
||||
emailOrUsername?: never;
|
||||
}
|
||||
);
|
||||
|
||||
type TwoFactorEmailFormData = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const TwoFactorEmailModal = ({ onConfirm, onClose, invalidAttempt, emailOrUsername, challengeId }: TwoFactorEmailModalProps) => {
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setValue,
|
||||
clearErrors,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TwoFactorEmailFormData>({
|
||||
defaultValues: { code: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (invalidAttempt) {
|
||||
setError('code', {
|
||||
type: 'manual',
|
||||
message: t('Invalid_two_factor_code'),
|
||||
});
|
||||
}
|
||||
}, [invalidAttempt, setError, t]);
|
||||
|
||||
const sendEmailCode = useEndpoint('POST', '/v1/users.2fa.sendEmailCode');
|
||||
const sendEmailCodeByChallengeId = useEndpoint('POST', '/v1/twoFactorChallenges.sendEmailCode');
|
||||
|
||||
const onClickResendCode = async (): Promise<void> => {
|
||||
try {
|
||||
if (emailOrUsername) {
|
||||
await sendEmailCode({ emailOrUsername });
|
||||
} else if (challengeId) {
|
||||
await sendEmailCodeByChallengeId({ challengeId });
|
||||
}
|
||||
dispatchToastMessage({ type: 'success', message: t('Email_sent') });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('error-email-send-failed', { message: error }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async ({ code }) => {
|
||||
try {
|
||||
await onConfirm(code, Method.EMAIL);
|
||||
} catch (error) {
|
||||
setError('code', {
|
||||
type: 'manual',
|
||||
message: t('Invalid_two_factor_code'),
|
||||
});
|
||||
setValue('code', '');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
wrapperFunction={(props) => <Box is='form' onSubmit={onSubmit} {...props} />}
|
||||
onCancel={onClose}
|
||||
confirmText={t('Verify')}
|
||||
title={t('Enter_authentication_code')}
|
||||
onClose={onClose}
|
||||
variant='warning'
|
||||
confirmDisabled={isSubmitting}
|
||||
tagline={t('Email_two-factor_authentication')}
|
||||
icon={null}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel alignSelf='stretch'>{t('Enter_the_code_we_just_emailed_you')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='code'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Code') }) }}
|
||||
render={({ field: { onChange, ...fieldProps } }) => (
|
||||
<TextInput
|
||||
{...fieldProps}
|
||||
onChange={(e) => {
|
||||
clearErrors('code');
|
||||
onChange(e);
|
||||
}}
|
||||
placeholder={t('Enter_code_here')}
|
||||
autoComplete='one-time-code'
|
||||
inputMode='numeric'
|
||||
disabled={isSubmitting}
|
||||
error={errors.code?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.code && <FieldError>{errors.code.message}</FieldError>}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Button display='flex' justifyContent='end' onClick={onClickResendCode} small marginBlockStart={24}>
|
||||
{t('Cloud_resend_email')}
|
||||
</Button>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorEmailModal;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user