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:
36
apps/meteor/client/README.md
Normal file
36
apps/meteor/client/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# How does the folder structure work in the frontend?
|
||||
|
||||
|
||||
## The folder structure should follow rocketchat's html 'semantics' like:
|
||||
```
|
||||
main -> room
|
||||
-> header
|
||||
...
|
||||
-> contextualbar
|
||||
roominfo
|
||||
...
|
||||
-> sidebar
|
||||
-> Header
|
||||
-> search
|
||||
-> actions
|
||||
...
|
||||
-> list
|
||||
...
|
||||
-> footer
|
||||
...
|
||||
```
|
||||
Each folder is composed by:
|
||||
| File ||
|
||||
| -- | -- |
|
||||
| index.jsx | |
|
||||
| ContainerComponent.jsx |
|
||||
| contexts | (optional) |
|
||||
| providers | (optional) |
|
||||
| components | (optional) |
|
||||
| hooks | (optional) |
|
||||
| libs | (optional) |
|
||||
|
||||
We strongly suggest that you start developing your components/hooks/libs inside the folder where they will be directly used. If you ever find that you will use them in more than one place, then you should "promote" the code to an upper level, where it can be commonly accessed by all others
|
||||
|
||||
|
||||
we have being moving code from blaze to react, but sometimes we will need render under blaze enviroment, to do that use `createTemplateForComponent`
|
||||
69
apps/meteor/client/apps/RealAppsEngineUIHost.ts
Normal file
69
apps/meteor/client/apps/RealAppsEngineUIHost.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { AppsEngineUIHost } from '@rocket.chat/apps/dist/client/AppsEngineUIHost';
|
||||
import type { IExternalComponentRoomInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentRoomInfo';
|
||||
import type { IExternalComponentUserInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentUserInfo';
|
||||
|
||||
import { RoomManager } from '../lib/RoomManager';
|
||||
import { sdk } from '../lib/SDKClient';
|
||||
import { baseURI } from '../lib/baseURI';
|
||||
import { getUserAvatarURL } from '../lib/getUserAvatarURL';
|
||||
import { getUser } from '../lib/user';
|
||||
import { Rooms } from '../stores';
|
||||
|
||||
// FIXME: replace non-null assertions with proper error handling
|
||||
|
||||
export class RealAppsEngineUIHost extends AppsEngineUIHost {
|
||||
private _baseURL: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._baseURL = baseURI.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
private getUserAvatarUrl(username: string) {
|
||||
const avatarUrl = getUserAvatarURL(username)!;
|
||||
|
||||
if (!avatarUrl.startsWith('http') && !avatarUrl.startsWith('data')) {
|
||||
return `${this._baseURL}${avatarUrl}`;
|
||||
}
|
||||
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
async getClientRoomInfo(): Promise<IExternalComponentRoomInfo> {
|
||||
const room = RoomManager.opened ? Rooms.state.get(RoomManager.opened) : undefined;
|
||||
if (!room) {
|
||||
throw new Error('Room not found');
|
||||
}
|
||||
const { name: slugifiedName, _id: id } = room;
|
||||
|
||||
let cachedMembers: IExternalComponentUserInfo[] = [];
|
||||
try {
|
||||
const { members } = await sdk.rest.get('/v1/groups.members', { roomId: id });
|
||||
|
||||
cachedMembers = members.map(({ _id, username }): IExternalComponentUserInfo => ({
|
||||
id: _id,
|
||||
username: username!,
|
||||
avatarUrl: this.getUserAvatarUrl(username!),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
slugifiedName: slugifiedName!,
|
||||
members: cachedMembers,
|
||||
};
|
||||
}
|
||||
|
||||
async getClientUserInfo(): Promise<IExternalComponentUserInfo> {
|
||||
const { username, _id } = getUser()!;
|
||||
|
||||
return {
|
||||
id: _id,
|
||||
username: username!,
|
||||
avatarUrl: this.getUserAvatarUrl(username!) || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
43
apps/meteor/client/apps/gameCenter/GameCenter.tsx
Normal file
43
apps/meteor/client/apps/gameCenter/GameCenter.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import GameCenterContainer from './GameCenterContainer';
|
||||
import GameCenterList from './GameCenterList';
|
||||
import { useExternalComponentsQuery } from './hooks/useExternalComponentsQuery';
|
||||
import { preventSyntheticEvent } from '../../lib/utils/preventSyntheticEvent';
|
||||
|
||||
export type IGame = IExternalComponent;
|
||||
|
||||
const GameCenter = () => {
|
||||
const [openedGame, setOpenedGame] = useState<IGame>();
|
||||
|
||||
const { closeTab } = useRoomToolbox();
|
||||
|
||||
const result = useExternalComponentsQuery();
|
||||
|
||||
const handleClose = useStableCallback(() => closeTab());
|
||||
|
||||
const handleBack = useStableCallback((e: MouseEvent) => {
|
||||
setOpenedGame(undefined);
|
||||
preventSyntheticEvent(e);
|
||||
});
|
||||
|
||||
if (!openedGame) {
|
||||
return (
|
||||
<GameCenterList
|
||||
data-testid='game-center-list'
|
||||
handleClose={handleClose}
|
||||
handleOpenGame={setOpenedGame}
|
||||
games={result.data}
|
||||
isLoading={result.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <GameCenterContainer data-testid='game-center-container' handleBack={handleBack} handleClose={handleClose} game={openedGame} />;
|
||||
};
|
||||
|
||||
export default GameCenter;
|
||||
39
apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx
Normal file
39
apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Avatar } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
ContextualbarTitle,
|
||||
ContextualbarHeader,
|
||||
ContextualbarBack,
|
||||
ContextualbarContent,
|
||||
ContextualbarClose,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { IGame } from './GameCenter';
|
||||
|
||||
interface IGameCenterContainerProps {
|
||||
handleClose: () => void;
|
||||
handleBack: (e: any) => void;
|
||||
game: IGame;
|
||||
}
|
||||
|
||||
const GameCenterContainer = ({ handleClose, handleBack, game }: IGameCenterContainerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
{handleBack && <ContextualbarBack onClick={handleBack} />}
|
||||
<ContextualbarTitle>
|
||||
<Avatar url={game.icon} /> {game.name}
|
||||
</ContextualbarTitle>
|
||||
{handleClose && <ContextualbarClose onClick={handleClose} />}
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarContent paddingBlock={16}>
|
||||
<iframe title={t('Apps_Game_Center')} style={{ position: 'absolute', width: '95%', height: '80%' }} src={game.url} />
|
||||
</ContextualbarContent>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameCenterContainer;
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { IGame } from './GameCenter';
|
||||
import UserAutoCompleteMultiple from '../../components/UserAutoCompleteMultiple';
|
||||
import { useOpenedRoom } from '../../lib/RoomManager';
|
||||
import { sdk } from '../../lib/SDKClient';
|
||||
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
|
||||
|
||||
type Username = Exclude<IUser['username'], undefined>;
|
||||
|
||||
interface IGameCenterInvitePlayersModalProps {
|
||||
game: IGame;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GameCenterInvitePlayersModal = ({ game, onClose }: IGameCenterInvitePlayersModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState<Array<Username>>([]);
|
||||
const { name } = game;
|
||||
|
||||
const openedRoom = useOpenedRoom();
|
||||
|
||||
const sendInvite = async () => {
|
||||
const privateGroupName = `${name.replace(/\s/g, '-')}-${Random.id(10)}`;
|
||||
|
||||
try {
|
||||
const { group } = await sdk.rest.post('/v1/groups.create', { name: privateGroupName, members: users });
|
||||
|
||||
roomCoordinator.openRouteLink(group.t, { rid: group._id, name: group.name });
|
||||
|
||||
if (openedRoom === group._id) {
|
||||
await sdk.rest.post('/v1/chat.sendMessage', {
|
||||
message: {
|
||||
_id: Random.id(),
|
||||
rid: group._id,
|
||||
msg: t('Apps_Game_Center_Play_Game_Together', { name }),
|
||||
},
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<GenericModal onClose={onClose} onCancel={onClose} onConfirm={sendInvite} title={t('Apps_Game_Center_Invite_Friends')}>
|
||||
<Box marginBlockEnd={16}>{t('Invite_Users')}</Box>
|
||||
<Box marginBlockEnd={16} display='flex' justifyContent='stretch'>
|
||||
<UserAutoCompleteMultiple value={users} onChange={setUsers} federated />
|
||||
</Box>
|
||||
</GenericModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameCenterInvitePlayersModal;
|
||||
91
apps/meteor/client/apps/gameCenter/GameCenterList.tsx
Normal file
91
apps/meteor/client/apps/gameCenter/GameCenterList.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Avatar, Icon, Table, TableBody, TableCell, TableHead, TableRow } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarTitle,
|
||||
ContextualbarClose,
|
||||
ContextualbarContent,
|
||||
ContextualbarDialog,
|
||||
ContextualbarSkeleton,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { IGame } from './GameCenter';
|
||||
import GameCenterInvitePlayersModal from './GameCenterInvitePlayersModal';
|
||||
|
||||
interface IGameCenterListProps {
|
||||
handleClose: () => void;
|
||||
handleOpenGame: (game: IGame) => void;
|
||||
games?: IGame[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const GameCenterList = ({ handleClose, handleOpenGame, games, isLoading }: IGameCenterListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const setModal = useSetModal();
|
||||
|
||||
const handleInvitePlayer = useCallback(
|
||||
(game: IGame) => {
|
||||
const handleClose = (): void => {
|
||||
setModal(null);
|
||||
};
|
||||
setModal(<GameCenterInvitePlayersModal onClose={handleClose} game={game} />);
|
||||
},
|
||||
[setModal],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <ContextualbarSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarTitle>{t('Apps_Game_Center')}</ContextualbarTitle>
|
||||
{handleClose && <ContextualbarClose onClick={handleClose} />}
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarContent>
|
||||
{games && (
|
||||
<div>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('Name')}</TableCell>
|
||||
<TableCell>{t('Description')}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{games.map((game, key) => (
|
||||
<TableRow key={key} action onKeyDown={() => handleOpenGame(game)} onClick={() => handleOpenGame(game)}>
|
||||
<TableCell>
|
||||
<Avatar url={game.icon} /> {game.name}
|
||||
</TableCell>
|
||||
<TableCell>{game.description}</TableCell>
|
||||
<TableCell>
|
||||
<Icon
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleInvitePlayer(game);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleInvitePlayer(game);
|
||||
}}
|
||||
name='plus'
|
||||
title={t('Apps_Game_Center_Invite_Friends')}
|
||||
></Icon>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</ContextualbarContent>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GameCenterList);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useExternalComponentsQuery = () => {
|
||||
const getExternalComponents = useEndpoint('GET', '/apps/externalComponents');
|
||||
return useQuery({
|
||||
queryKey: ['apps/external-components'],
|
||||
|
||||
queryFn: async ({ signal }) => {
|
||||
return (await getExternalComponents(undefined, { signal })).externalComponents;
|
||||
},
|
||||
|
||||
staleTime: 10_000,
|
||||
});
|
||||
};
|
||||
184
apps/meteor/client/apps/orchestrator.ts
Normal file
184
apps/meteor/client/apps/orchestrator.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { AppClientManager } from '@rocket.chat/apps/dist/client/AppClientManager';
|
||||
import type { AppsEngineUIHost } from '@rocket.chat/apps/dist/client/AppsEngineUIHost';
|
||||
import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission';
|
||||
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
|
||||
import type { Serialized } from '@rocket.chat/core-typings';
|
||||
|
||||
import { RealAppsEngineUIHost } from './RealAppsEngineUIHost';
|
||||
import type { IAppExternalURL } from '../definitions/IAppExternalURL';
|
||||
import type { ICategory } from '../definitions/ICategory';
|
||||
import { sdk } from '../lib/SDKClient';
|
||||
import { hasAtLeastOnePermission } from '../lib/authorization';
|
||||
import { dispatchToastMessage } from '../lib/toast';
|
||||
import type { App } from '../views/marketplace/types';
|
||||
|
||||
const isErrorObject = (e: unknown): e is { error: string } =>
|
||||
typeof e === 'object' && e !== null && 'error' in e && typeof e.error === 'string';
|
||||
|
||||
class AppClientOrchestrator {
|
||||
private _appClientUIHost: AppsEngineUIHost;
|
||||
|
||||
private _manager: AppClientManager;
|
||||
|
||||
private _isLoaded: boolean;
|
||||
|
||||
constructor() {
|
||||
this._appClientUIHost = new RealAppsEngineUIHost();
|
||||
this._manager = new AppClientManager(this._appClientUIHost);
|
||||
this._isLoaded = false;
|
||||
}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
if (!this._isLoaded) {
|
||||
this._isLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
public getAppClientManager(): AppClientManager {
|
||||
return this._manager;
|
||||
}
|
||||
|
||||
public handleError(error: unknown): void {
|
||||
if (hasAtLeastOnePermission(['manage-apps'])) {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async getInstalledApps(): Promise<App[]> {
|
||||
const result = await sdk.rest.get<'/apps/installed'>('/apps/installed', { includeClusterStatus: 'true' });
|
||||
|
||||
if ('apps' in result) {
|
||||
// TODO: chapter day: multiple results are returned, but we only need one
|
||||
return result.apps;
|
||||
}
|
||||
throw new Error('Invalid response from API');
|
||||
}
|
||||
|
||||
public async getAppsFromMarketplace(isAdminUser?: boolean): Promise<{ apps: App[]; error?: unknown }> {
|
||||
let result: App[] = [];
|
||||
try {
|
||||
result = await sdk.rest.get('/apps/marketplace', { isAdminUser: isAdminUser ? isAdminUser.toString() : 'false' });
|
||||
} catch (e) {
|
||||
if (isErrorObject(e)) {
|
||||
return { apps: [], error: e.error };
|
||||
}
|
||||
if (typeof e === 'string') {
|
||||
return { apps: [], error: e };
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
// TODO: chapter day: multiple results are returned, but we only need one
|
||||
return { apps: [], error: 'Invalid response from API' };
|
||||
}
|
||||
|
||||
const apps = result.map((app: App) => {
|
||||
const { latest, appRequestStats, price, pricingPlans, purchaseType, isEnterpriseOnly, modifiedAt, bundledIn, requestedEndUser } = app;
|
||||
return {
|
||||
...latest,
|
||||
appRequestStats,
|
||||
price,
|
||||
pricingPlans,
|
||||
purchaseType,
|
||||
isEnterpriseOnly,
|
||||
modifiedAt,
|
||||
bundledIn,
|
||||
requestedEndUser,
|
||||
};
|
||||
});
|
||||
|
||||
return { apps, error: undefined };
|
||||
}
|
||||
|
||||
public async getAppsOnBundle(bundleId: string): Promise<App[]> {
|
||||
const { apps } = await sdk.rest.get(`/apps/bundles/${bundleId}/apps`);
|
||||
return apps;
|
||||
}
|
||||
|
||||
public async getApp(appId: string): Promise<App> {
|
||||
const { app } = await sdk.rest.get(`/apps/${appId}` as any);
|
||||
return app;
|
||||
}
|
||||
|
||||
public async setAppSettings(appId: string, settings: ISetting[]): Promise<void> {
|
||||
await sdk.rest.post(`/apps/${appId}/settings`, { settings });
|
||||
}
|
||||
|
||||
public async installApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise<App> {
|
||||
const { app } = (await sdk.rest.post<'/apps'>('/apps', {
|
||||
appId,
|
||||
marketplace: true,
|
||||
version,
|
||||
permissionsGranted,
|
||||
})) as { app: App };
|
||||
return app;
|
||||
}
|
||||
|
||||
public async updateApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise<App> {
|
||||
const result = await sdk.rest.post<'/apps/:id'>(`/apps/${appId}`, {
|
||||
appId,
|
||||
marketplace: true,
|
||||
version,
|
||||
permissionsGranted,
|
||||
});
|
||||
|
||||
if ('app' in result) {
|
||||
return result.app;
|
||||
}
|
||||
throw new Error('App not found');
|
||||
}
|
||||
|
||||
public async buildExternalUrl(appId: string, purchaseType: 'buy' | 'subscription' = 'buy', details = false): Promise<IAppExternalURL> {
|
||||
const result = await sdk.rest.get('/apps/buildExternalUrl', {
|
||||
appId,
|
||||
purchaseType,
|
||||
details: `${details}`,
|
||||
});
|
||||
|
||||
if ('url' in result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new Error('Failed to build external url');
|
||||
}
|
||||
|
||||
public async buildExternalAppRequest(appId: string) {
|
||||
const result = await sdk.rest.get('/apps/buildExternalAppRequest', {
|
||||
appId,
|
||||
});
|
||||
|
||||
if ('url' in result) {
|
||||
return result;
|
||||
}
|
||||
throw new Error('Failed to build App Request external url');
|
||||
}
|
||||
|
||||
public async buildIncompatibleExternalUrl(appId: string, appVersion: string, action: string): Promise<IAppExternalURL> {
|
||||
const result = await sdk.rest.get('/apps/incompatibleModal', {
|
||||
appId,
|
||||
appVersion,
|
||||
action,
|
||||
});
|
||||
|
||||
if ('url' in result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new Error('Failed to build external url');
|
||||
}
|
||||
|
||||
public async getCategories(): Promise<Serialized<ICategory[]>> {
|
||||
const result = await sdk.rest.get('/apps/categories');
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
// TODO: chapter day: multiple results are returned, but we only need one
|
||||
return result;
|
||||
}
|
||||
throw new Error('Failed to get categories');
|
||||
}
|
||||
}
|
||||
|
||||
export const AppClientOrchestratorInstance = new AppClientOrchestrator();
|
||||
10
apps/meteor/client/cachedStores/PermissionsCachedStore.ts
Normal file
10
apps/meteor/client/cachedStores/PermissionsCachedStore.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { IPermission } from '@rocket.chat/core-typings';
|
||||
|
||||
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
|
||||
import { Permissions } from '../stores';
|
||||
|
||||
export const PermissionsCachedStore = new PrivateCachedStore<IPermission>({
|
||||
name: 'permissions',
|
||||
eventType: 'notify-logged',
|
||||
store: Permissions.use,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
|
||||
import { sdk } from '../lib/SDKClient';
|
||||
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
|
||||
import { PrivateSettings } from '../stores';
|
||||
|
||||
class PrivateSettingsCachedStore extends PrivateCachedStore<ISetting> {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'private-settings',
|
||||
eventType: 'notify-logged',
|
||||
store: PrivateSettings.use,
|
||||
});
|
||||
}
|
||||
|
||||
override setupListener() {
|
||||
return sdk.stream('notify-logged', ['private-settings-changed'], async (t, setting) => {
|
||||
this.log('record received', t, setting);
|
||||
const { _id, ...fields } = setting;
|
||||
this.store.getState().update(
|
||||
(record) => record._id === _id,
|
||||
(record) => ({ ...record, ...fields }),
|
||||
);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new PrivateSettingsCachedStore();
|
||||
|
||||
export { instance as PrivateSettingsCachedStore };
|
||||
18
apps/meteor/client/cachedStores/PublicSettingsCachedStore.ts
Normal file
18
apps/meteor/client/cachedStores/PublicSettingsCachedStore.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
|
||||
import { PublicCachedStore } from '../lib/cachedStores/CachedStore';
|
||||
import { PublicSettings } from '../stores';
|
||||
|
||||
class PublicSettingsCachedStore extends PublicCachedStore<ISetting> {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'public-settings',
|
||||
eventType: 'notify-all',
|
||||
store: PublicSettings.use,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new PublicSettingsCachedStore();
|
||||
|
||||
export { instance as PublicSettingsCachedStore };
|
||||
119
apps/meteor/client/cachedStores/RoomsCachedStore.ts
Normal file
119
apps/meteor/client/cachedStores/RoomsCachedStore.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { IOmnichannelRoom, IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
|
||||
import { DEFAULT_SLA_CONFIG, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings';
|
||||
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
|
||||
import { Rooms, Subscriptions } from '../stores';
|
||||
|
||||
class RoomsCachedStore extends PrivateCachedStore<IRoom> {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'rooms',
|
||||
eventType: 'notify-user',
|
||||
store: Rooms.use,
|
||||
});
|
||||
}
|
||||
|
||||
private merge(room: IRoom, sub: SubscriptionWithRoom): SubscriptionWithRoom {
|
||||
return {
|
||||
...sub,
|
||||
encrypted: room.encrypted,
|
||||
description: room.description,
|
||||
cl: room.cl,
|
||||
topic: room.topic,
|
||||
announcement: room.announcement,
|
||||
broadcast: room.broadcast || undefined,
|
||||
archived: room.archived,
|
||||
avatarETag: room.avatarETag,
|
||||
retention: (room as IRoomWithRetentionPolicy | undefined)?.retention,
|
||||
uids: room.uids,
|
||||
usernames: room.usernames,
|
||||
usersCount: room.usersCount,
|
||||
lastMessage: room.lastMessage,
|
||||
teamId: room.teamId,
|
||||
teamMain: room.teamMain,
|
||||
v: (room as IOmnichannelRoom | undefined)?.v,
|
||||
transcriptRequest: (room as IOmnichannelRoom | undefined)?.transcriptRequest,
|
||||
servedBy: (room as IOmnichannelRoom | undefined)?.servedBy,
|
||||
onHold: (room as IOmnichannelRoom | undefined)?.onHold,
|
||||
tags: (room as IOmnichannelRoom | undefined)?.tags,
|
||||
closedAt: (room as IOmnichannelRoom | undefined)?.closedAt,
|
||||
metrics: (room as IOmnichannelRoom | undefined)?.metrics,
|
||||
muted: room.muted,
|
||||
waitingResponse: (room as IOmnichannelRoom | undefined)?.waitingResponse,
|
||||
responseBy: (room as IOmnichannelRoom | undefined)?.responseBy,
|
||||
priorityId: (room as IOmnichannelRoom | undefined)?.priorityId,
|
||||
priorityWeight: (room as IOmnichannelRoom | undefined)?.priorityWeight || LivechatPriorityWeight.NOT_SPECIFIED,
|
||||
estimatedWaitingTimeQueue:
|
||||
(room as IOmnichannelRoom | undefined)?.estimatedWaitingTimeQueue || DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE,
|
||||
slaId: (room as IOmnichannelRoom | undefined)?.slaId,
|
||||
livechatData: (room as IOmnichannelRoom | undefined)?.livechatData,
|
||||
departmentId: (room as IOmnichannelRoom | undefined)?.departmentId,
|
||||
ts: room.ts ?? sub.ts,
|
||||
source: (room as IOmnichannelRoom | undefined)?.source,
|
||||
queuedAt: (room as IOmnichannelRoom | undefined)?.queuedAt,
|
||||
federated: room.federated,
|
||||
abacAttributes: room.abacAttributes,
|
||||
...(isRoomNativeFederated(room) && {
|
||||
federation: room.federation,
|
||||
}),
|
||||
...(() => {
|
||||
const name = room.name || sub.name;
|
||||
const fname = room.fname || sub.fname || name;
|
||||
return {
|
||||
lowerCaseName: String(!room.prid ? name : fname).toLowerCase(),
|
||||
lowerCaseFName: String(fname).toLowerCase(),
|
||||
};
|
||||
})(),
|
||||
lm: (sub.lm?.getTime() ?? -1) < (room.lm?.getTime() ?? -1) ? room.lm : sub.lm,
|
||||
};
|
||||
}
|
||||
|
||||
protected override handleLoadedFromServer(rooms: IRoom[]): void {
|
||||
const indexedSubscriptions = Subscriptions.use.getState().indexBy('rid');
|
||||
|
||||
const subscriptionsWithRoom = rooms.flatMap((room) => {
|
||||
const sub = indexedSubscriptions.get(room._id);
|
||||
|
||||
if (!sub) return [];
|
||||
|
||||
return this.merge(room, sub);
|
||||
});
|
||||
|
||||
Subscriptions.use.getState().storeMany(subscriptionsWithRoom);
|
||||
}
|
||||
|
||||
protected override async handleRecordEvent(action: 'removed' | 'changed', room: IRoom) {
|
||||
await super.handleRecordEvent(action, room);
|
||||
|
||||
if (action === 'removed') return;
|
||||
|
||||
Subscriptions.use.getState().update(
|
||||
(record) => record.rid === room._id,
|
||||
(sub) => this.merge(room, sub),
|
||||
);
|
||||
}
|
||||
|
||||
protected override handleSyncEvent(action: 'removed' | 'changed', room: IRoom): void {
|
||||
if (action === 'removed') return;
|
||||
|
||||
Subscriptions.use.getState().update(
|
||||
(record) => record.rid === room._id,
|
||||
(sub) => this.merge(room, sub),
|
||||
);
|
||||
}
|
||||
|
||||
protected override deserializeFromCache(record: unknown) {
|
||||
const deserialized = super.deserializeFromCache(record);
|
||||
|
||||
if (deserialized?.lastMessage?._updatedAt) {
|
||||
deserialized.lastMessage._updatedAt = new Date(deserialized.lastMessage._updatedAt);
|
||||
}
|
||||
|
||||
return deserialized;
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new RoomsCachedStore();
|
||||
|
||||
export { instance as RoomsCachedStore };
|
||||
95
apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts
Normal file
95
apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { IOmnichannelRoom, IRoomWithRetentionPolicy, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { DEFAULT_SLA_CONFIG, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings';
|
||||
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
|
||||
import { Rooms, Subscriptions } from '../stores';
|
||||
|
||||
class SubscriptionsCachedStore extends PrivateCachedStore<SubscriptionWithRoom, ISubscription> {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'subscriptions',
|
||||
eventType: 'notify-user',
|
||||
store: Subscriptions.use,
|
||||
});
|
||||
}
|
||||
|
||||
protected override mapRecord(subscription: ISubscription): SubscriptionWithRoom {
|
||||
const room = Rooms.use.getState().find((r) => r._id === subscription.rid);
|
||||
|
||||
const lastRoomUpdate = room?.lm || subscription.ts || room?.ts;
|
||||
|
||||
return {
|
||||
...subscription,
|
||||
...(() => {
|
||||
const { name } = subscription;
|
||||
const fname = subscription.fname || name;
|
||||
return {
|
||||
lowerCaseName: String(!subscription.prid ? name : fname).toLowerCase(),
|
||||
lowerCaseFName: String(fname).toLowerCase(),
|
||||
};
|
||||
})(),
|
||||
encrypted: room?.encrypted,
|
||||
description: room?.description,
|
||||
cl: room?.cl,
|
||||
topic: room?.topic,
|
||||
announcement: room?.announcement,
|
||||
broadcast: room?.broadcast || undefined,
|
||||
archived: room?.archived,
|
||||
avatarETag: room?.avatarETag,
|
||||
retention: (room as IRoomWithRetentionPolicy | undefined)?.retention,
|
||||
lastMessage: room?.lastMessage,
|
||||
teamId: room?.teamId,
|
||||
teamMain: room?.teamMain,
|
||||
uids: room?.uids,
|
||||
usernames: room?.usernames,
|
||||
usersCount: room?.usersCount ?? 0,
|
||||
v: (room as IOmnichannelRoom | undefined)?.v,
|
||||
transcriptRequest: (room as IOmnichannelRoom | undefined)?.transcriptRequest,
|
||||
servedBy: (room as IOmnichannelRoom | undefined)?.servedBy,
|
||||
onHold: (room as IOmnichannelRoom | undefined)?.onHold,
|
||||
tags: (room as IOmnichannelRoom | undefined)?.tags,
|
||||
closedAt: (room as IOmnichannelRoom | undefined)?.closedAt,
|
||||
metrics: (room as IOmnichannelRoom | undefined)?.metrics,
|
||||
muted: room?.muted,
|
||||
waitingResponse: (room as IOmnichannelRoom | undefined)?.waitingResponse,
|
||||
responseBy: (room as IOmnichannelRoom | undefined)?.responseBy,
|
||||
priorityId: (room as IOmnichannelRoom | undefined)?.priorityId,
|
||||
slaId: (room as IOmnichannelRoom | undefined)?.slaId,
|
||||
priorityWeight: (room as IOmnichannelRoom | undefined)?.priorityWeight || LivechatPriorityWeight.NOT_SPECIFIED,
|
||||
estimatedWaitingTimeQueue:
|
||||
(room as IOmnichannelRoom | undefined)?.estimatedWaitingTimeQueue || DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE,
|
||||
livechatData: (room as IOmnichannelRoom | undefined)?.livechatData,
|
||||
departmentId: (room as IOmnichannelRoom | undefined)?.departmentId,
|
||||
ts: room?.ts ?? subscription.ts,
|
||||
source: (room as IOmnichannelRoom | undefined)?.source,
|
||||
queuedAt: (room as IOmnichannelRoom | undefined)?.queuedAt,
|
||||
federated: room?.federated,
|
||||
|
||||
...(room &&
|
||||
isRoomNativeFederated(room) && {
|
||||
federation: room.federation,
|
||||
}),
|
||||
|
||||
lm: subscription.lr ? new Date(Math.max(subscription.lr.getTime(), lastRoomUpdate?.getTime() || 0)) : lastRoomUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
async upsertSubscription(record: ISubscription): Promise<void> {
|
||||
return this.handleRecordEvent('changed', record);
|
||||
}
|
||||
|
||||
protected override deserializeFromCache(record: unknown) {
|
||||
const deserialized = super.deserializeFromCache(record);
|
||||
|
||||
if (deserialized?.lastMessage?._updatedAt) {
|
||||
deserialized.lastMessage._updatedAt = new Date(deserialized.lastMessage._updatedAt);
|
||||
}
|
||||
|
||||
return deserialized;
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new SubscriptionsCachedStore();
|
||||
|
||||
export { instance as SubscriptionsCachedStore };
|
||||
5
apps/meteor/client/cachedStores/index.ts
Normal file
5
apps/meteor/client/cachedStores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { PermissionsCachedStore } from './PermissionsCachedStore';
|
||||
export { PrivateSettingsCachedStore } from './PrivateSettingsCachedStore';
|
||||
export { PublicSettingsCachedStore } from './PublicSettingsCachedStore';
|
||||
export { RoomsCachedStore } from './RoomsCachedStore';
|
||||
export { SubscriptionsCachedStore } from './SubscriptionsCachedStore';
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user