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

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

4
packages/fuselage-ui-kit/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/dist/*
/storybook-static/
/bundle-report.html
/src/version.ts

View File

@@ -0,0 +1,5 @@
import baseConfig from '@rocket.chat/storybook-config/main';
export default baseConfig({
stories: ['../src/**/*.stories.tsx', '../src/**/stories.tsx'],
});

View File

@@ -0,0 +1,15 @@
import { parameters as baseParameters, decorators as baseDecorators } from '@rocket.chat/storybook-config/preview';
import type { Preview } from '@storybook/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import 'normalize.css/normalize.css';
const queryClient = new QueryClient();
const preview: Preview = {
parameters: { ...baseParameters },
decorators: [...baseDecorators, (fn) => <QueryClientProvider client={queryClient}>{fn()}</QueryClientProvider>],
tags: ['autodocs'],
};
export default preview;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
<!--header-->
<p align="center">
<a href="https://rocket.chat" title="Rocket.Chat">
<img src="https://github.com/RocketChat/Rocket.Chat.Artwork/raw/master/Logos/2020/png/logo-horizontal-red.png" alt="Rocket.Chat" />
</a>
</p>
# `@rocket.chat/fuselage-ui-kit`
> UiKit elements for Rocket.Chat Apps built under Fuselage design system
---
[![npm@latest](https://img.shields.io/npm/v/@rocket.chat/fuselage-ui-kit/latest?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/icons/v/latest) [![npm@next](https://img.shields.io/npm/v/@rocket.chat/fuselage-ui-kit/next?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/icons/v/next) ![react version](https://img.shields.io/npm/dependency-version/@rocket.chat/fuselage-ui-kit/peer/react?style=flat-square) [![Storybook](https://cdn.jsdelivr.net/gh/storybookjs/brand@master/badge/badge-storybook.svg)](https://rocketchat.github.io/Rocket.Chat.Fuselage/fuselage-ui-kit) ![npm downloads](https://img.shields.io/npm/dw/@rocket.chat/fuselage-ui-kit?style=flat-square) ![License: MIT](https://img.shields.io/npm/l/@rocket.chat/fuselage-ui-kit?style=flat-square)
![deps](https://img.shields.io/david/RocketChat/fuselage?path=packages%2Ffuselage-ui-kit&style=flat-square) ![peer deps](https://img.shields.io/david/peer/RocketChat/fuselage?path=packages%2Ffuselage-ui-kit&style=flat-square) ![dev deps](https://img.shields.io/david/dev/RocketChat/fuselage?path=packages%2Ffuselage-ui-kit&style=flat-square) ![npm bundle size](https://img.shields.io/bundlephobia/min/@rocket.chat/fuselage-ui-kit?style=flat-square)
<!--/header-->
## Install
<!--install-->
Firstly, install the peer dependencies (prerequisites):
```sh
npm i @rocket.chat/fuselage @rocket.chat/fuselage-hooks @rocket.chat/icons @rocket.chat/styled react react-dom
# or, if you are using yarn:
yarn add @rocket.chat/fuselage @rocket.chat/fuselage-hooks @rocket.chat/icons @rocket.chat/styled react react-dom
```
Add `@rocket.chat/fuselage-ui-kit` as a dependency:
```sh
npm i @rocket.chat/fuselage-ui-kit
# or, if you are using yarn:
yarn add @rocket.chat/fuselage-ui-kit
```
<!--/install-->
## Contributing
<!--contributing(msg)-->
Contributions, issues, and feature requests are welcome!<br />
Feel free to check the [issues](https://github.com/RocketChat/fuselage/issues).
<!--/contributing(msg)-->
### Building
As this package dependends on others in this monorepo, before anything run the following at the root directory:
<!--yarn(build)-->
```sh
yarn build
```
<!--/yarn(build)-->
### Linting
To ensure the source is matching our coding style, we perform [linting](<https://en.wikipedia.org/wiki/Lint_(software)>).
Before commiting, check if your code fits our style by running:
<!--yarn(lint)-->
```sh
yarn lint
```
<!--/yarn(lint)-->
Some linter warnings and errors can be automatically fixed:
<!--yarn(lint-and-fix)-->
```sh
yarn lint-and-fix
```
<!--/yarn(lint-and-fix)-->
### Component stories
We develop and describe our visual components in the form of stories, manage by a tool called [Storybook](https://storybook.js.org/).
To start developing with Storybook, run:
<!--yarn(storybook)-->
```sh
yarn storybook
```
<!--/yarn(storybook)-->

View File

@@ -0,0 +1,12 @@
import client from '@rocket.chat/jest-presets/client';
import type { Config } from 'jest';
export default {
preset: client.preset,
setupFilesAfterEnv: [...client.setupFilesAfterEnv],
moduleNameMapper: {
'^react($|/.+)': '<rootDir>/../../node_modules/react$1',
'^react-dom($|/.+)': '<rootDir>/../../node_modules/react-dom$1',
'^react-i18next($|/.+)': '<rootDir>/../../node_modules/react-i18next$1',
},
} satisfies Config;

View File

@@ -0,0 +1,104 @@
{
"name": "@rocket.chat/fuselage-ui-kit",
"version": "34.0.0",
"private": true,
"description": "UiKit elements for Rocket.Chat Apps built under Fuselage design system",
"homepage": "https://github.com/RocketChat/Rocket.Chat#readme",
"bugs": {
"url": "https://github.com/RocketChat/Rocket.Chat/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/RocketChat/Rocket.Chat.git",
"directory": "packages/fuselage-ui-kit"
},
"license": "MIT",
"author": {
"name": "Rocket.Chat",
"url": "https://rocket.chat/"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
".:build-preview-move": "mkdir -p ../../.preview/ && cp -r ./storybook-static ../../.preview/fuselage-ui-kit",
".:build:clean": "rimraf dist",
".:build:tsc": "tsc -p tsconfig.build.json",
"build": "run-s .:build:clean .:build:tsc",
"build-preview": "yarn build-storybook",
"build-storybook": "NODE_ENV=production storybook build",
"dev": "tsc --watch --preserveWatchOutput -p tsconfig.build.json",
"docs": "cross-env NODE_ENV=production storybook build -o ../../static/fuselage-ui-kit",
"lint": "eslint .",
"storybook": "storybook dev -p 6006 --no-version-updates",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/gazzodown": "workspace:^",
"@rocket.chat/ui-kit": "workspace:~"
},
"devDependencies": {
"@rocket.chat/apps-engine": "workspace:^",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/emitter": "workspace:~",
"@rocket.chat/fuselage": "^0.89.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/mock-providers": "workspace:^",
"@rocket.chat/storybook-config": "workspace:~",
"@rocket.chat/styled": "~0.34.1",
"@rocket.chat/tsconfig": "workspace:*",
"@rocket.chat/ui-avatar": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"@rocket.chat/ui-kit": "workspace:~",
"@rocket.chat/ui-video-conf": "workspace:^",
"@storybook/react": "^9.1.20",
"@storybook/react-webpack5": "^9.1.20",
"@tanstack/react-query": "~5.65.1",
"@testing-library/dom": "~10.4.1",
"@testing-library/react": "~16.3.3",
"@testing-library/user-event": "~14.6.6",
"@types/react": "~19.2.18",
"@types/react-dom": "~19.2.5",
"cross-env": "^7.0.3",
"eslint": "~9.39.5",
"i18next": "~23.4.9",
"jest": "~30.2.0",
"normalize.css": "^8.0.1",
"npm-run-all": "^4.1.5",
"react": "~19.2.8",
"react-dom": "~19.2.8",
"react-i18next": "~13.2.2",
"react-virtuoso": "~4.18.12",
"rimraf": "^6.0.1",
"storybook": "^9.1.20",
"typescript": "~5.9.3",
"webpack": "~5.104.1"
},
"peerDependencies": {
"@rocket.chat/apps-engine": "workspace:^",
"@rocket.chat/fuselage": "*",
"@rocket.chat/fuselage-hooks": "*",
"@rocket.chat/icons": "*",
"@rocket.chat/styled": "*",
"@rocket.chat/ui-avatar": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"@rocket.chat/ui-kit": "workspace:^",
"@rocket.chat/ui-video-conf": "workspace:^",
"@tanstack/react-query": "*",
"i18next": "*",
"react": "*",
"react-dom": "*",
"react-i18next": "*"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,30 @@
import { Box } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import type { ReactElement } from 'react';
export type ActionProps = {
element: UiKit.ActionsBlock['elements'][number];
parser: UiKit.SurfaceRenderer<ReactElement<any>>;
index: number;
};
const Action = ({ element, parser, index }: ActionProps) => {
const renderedElement = parser.renderActionsBlockElement(element, index);
if (!renderedElement) {
return null;
}
return (
<Box
display='flex'
margin={4}
flexGrow={element.type !== UiKit.BlockElementType.BUTTON ? 1 : undefined}
flexBasis={element.type !== UiKit.BlockElementType.BUTTON ? '45%' : undefined}
>
{renderedElement}
</Box>
);
};
export default Action;

View File

@@ -0,0 +1,46 @@
import { Box, Button } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import { memo, useCallback, useMemo, useState } from 'react';
import Action from './ActionsBlock.Action';
import { useSurfaceType } from '../hooks/useSurfaceType';
import type { BlockProps } from '../utils/BlockProps';
export type ActionsBlockProps = BlockProps<UiKit.ActionsBlock>;
const ActionsBlock = ({ className, block, surfaceRenderer }: ActionsBlockProps) => {
const surfaceType = useSurfaceType();
const [showMoreVisible, setShowMoreVisible] = useState(() => block.elements.length > 5 && surfaceType !== 'banner');
const handleShowMoreClick = useCallback(() => {
setShowMoreVisible(false);
}, []);
const actionElements = useMemo(
() =>
(showMoreVisible ? block.elements.slice(0, 5) : block.elements).map((element) => ({
...element,
appId: element.appId ?? block.appId,
blockId: element.blockId ?? block.blockId,
})),
[block.appId, block.blockId, block.elements, showMoreVisible],
);
return (
<Box className={className} display='flex' flexWrap='wrap' margin={-4}>
{actionElements.map((element, i) => (
<Action key={i} element={element} parser={surfaceRenderer} index={i} />
))}
{showMoreVisible && (
<Box display='flex' margin={4}>
<Button small onClick={handleShowMoreClick}>
{surfaceRenderer.renderTextObject({ type: 'plain_text', text: 'Show more...' }, 0, UiKit.BlockContext.NONE)}
</Button>
</Box>
)}
</Box>
);
};
export default memo(ActionsBlock);

View File

@@ -0,0 +1,18 @@
import { Callout } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import type { BlockProps } from '../utils/BlockProps';
export type CalloutBlockProps = BlockProps<UiKit.CalloutBlock>;
const CalloutBlock = ({ block, surfaceRenderer }: CalloutBlockProps) => (
<Callout
type={block.variant}
title={block.title?.text}
actions={(block.accessory && surfaceRenderer.renderSectionAccessoryBlockElement(block.accessory, 0)) || undefined}
>
{surfaceRenderer.renderTextObject(block.text, 0, UiKit.BlockContext.NONE)}
</Callout>
);
export default CalloutBlock;

View File

@@ -0,0 +1,32 @@
import { Box } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import type { ReactElement } from 'react';
export type ItemProps = {
block: UiKit.ContextBlock['elements'][number];
surfaceRenderer: UiKit.SurfaceRenderer<ReactElement<any>>;
index: number;
};
const Item = ({ block: element, surfaceRenderer: parser, index }: ItemProps) => {
const renderedElement = parser.renderContextBlockElement(element, index);
if (!renderedElement) {
return null;
}
switch (element.type) {
case UiKit.TextObjectType.PLAIN_TEXT:
case UiKit.TextObjectType.MRKDWN:
return (
<Box is='span' fontScale='c1' color='hint' margin={4}>
{renderedElement}
</Box>
);
default:
return renderedElement;
}
};
export default Item;

View File

@@ -0,0 +1,30 @@
import { Box } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo, useMemo } from 'react';
import Item from './ContextBlock.Item';
import type { BlockProps } from '../utils/BlockProps';
export type ContextBlockProps = BlockProps<UiKit.ContextBlock>;
const ContextBlock = ({ className, block, surfaceRenderer }: ContextBlockProps) => {
const itemElements = useMemo(
() =>
block.elements.map((element) => ({
...element,
appId: block.appId,
blockId: block.blockId,
})),
[block.appId, block.blockId, block.elements],
);
return (
<Box className={className} display='flex' alignItems='center' margin={-4}>
{itemElements.map((element, i) => (
<Item key={i} block={element} surfaceRenderer={surfaceRenderer} index={i} />
))}
</Box>
);
};
export default memo(ContextBlock);

View File

@@ -0,0 +1,11 @@
import { Divider } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo } from 'react';
import type { BlockProps } from '../utils/BlockProps';
export type DividerBlockProps = BlockProps<UiKit.DividerBlock>;
const DividerBlock = ({ className }: DividerBlockProps) => <Divider className={className} marginBlock={24} />;
export default memo(DividerBlock);

View File

@@ -0,0 +1,24 @@
import styled from '@rocket.chat/styled';
const filterImageProps = ({
imageUrl: _imageUrl,
width: _width,
height: _height,
...props
}: {
imageUrl: string;
width: number;
height: number;
}) => props;
export const Image = styled('div', filterImageProps)`
box-shadow: 0 0 0px 1px rgba(204, 204, 204, 38%);
background-repeat: no-repeat;
background-position: 50%;
background-size: cover;
background-color: rgba(204, 204, 204, 38%);
background-image: url(${(props) => props.imageUrl});
width: ${(props) => String(props.width)}px;
height: ${(props) => String(props.height)}px;
overflow: hidden;
`;

View File

@@ -0,0 +1,83 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import { memo, useEffect, useState } from 'react';
import { Image } from './ImageBlock.styles';
import { useSurfaceType } from '../hooks/useSurfaceType';
import type { BlockProps } from '../utils/BlockProps';
const maxSize = 360;
const fetchImageState = (img: HTMLImageElement) => {
if (!img.complete) {
return {
loading: true,
width: maxSize,
height: (maxSize * 9) / 21,
};
}
const { naturalWidth, naturalHeight } = img;
const scaleRatio =
naturalWidth > naturalHeight ? Math.min(naturalWidth, maxSize) / naturalWidth : Math.min(naturalHeight, maxSize) / naturalHeight;
return {
loading: false,
width: naturalWidth * scaleRatio,
height: naturalHeight * scaleRatio,
};
};
export type ImageBlockProps = BlockProps<UiKit.ImageBlock>;
const ImageBlock = ({ className, block, surfaceRenderer }: ImageBlockProps) => {
const surface = useSurfaceType();
const alignment = surface === 'banner' || surface === 'message' ? 'flex-start' : 'center';
const [{ loading, width, height }, setState] = useState(() => {
const img = document.createElement('img');
img.src = block.imageUrl;
return fetchImageState(img);
});
useEffect(() => {
const img = document.createElement('img');
const handleLoad = () => {
setState(fetchImageState(img));
};
img.addEventListener('load', handleLoad);
img.src = block.imageUrl;
if (img.complete) {
img.removeEventListener('load', handleLoad);
setState(fetchImageState(img));
}
return () => {
img.removeEventListener('load', handleLoad);
};
}, [block.imageUrl]);
return (
<Box className={className} display='flex' flexDirection='column' flexWrap='nowrap' alignItems={alignment}>
<Box overflow='hidden' width={width}>
{block.title && (
<Box fontScale='c1' color='hint' withTruncatedText marginBlockEnd={4}>
{surfaceRenderer.renderTextObject(block.title, 0, UiKit.BlockContext.NONE)}
</Box>
)}
{loading ? (
<Skeleton variant='rect' width={width} height={height} />
) : (
<Image imageUrl={block.imageUrl} width={width} height={height} aria-label={block.altText} />
)}
</Box>
</Box>
);
};
export default memo(ImageBlock);

View File

@@ -0,0 +1,62 @@
import { Box } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import IconElement from '../elements/IconElement';
import type { BlockProps } from '../utils/BlockProps';
export type InfoCardProps = BlockProps<UiKit.InfoCardBlock>;
const InfoCard = ({ block, surfaceRenderer, context }: InfoCardProps) => {
const { rows, blockId } = block;
return (
<Box
display='flex'
flexDirection='column'
borderWidth={1}
borderRadius='x4'
borderColor='extra-light'
maxWidth='345px'
backgroundColor='surface-tint'
overflow='hidden'
color='default'
>
{rows.map((row, index) => {
const { elements, action, background } = row;
return (
<Box
key={`${blockId ?? 'info_card'}-${index}`}
padding={16}
backgroundColor={background === 'default' ? 'surface-light' : undefined}
display='flex'
alignItems='center'
justifyContent='space-between'
flexDirection='row'
>
<Box display='flex' alignItems='center' flexDirection='row' marginInline={-4}>
{elements.map((element, index) => {
if (element.type === 'icon') {
return (
<Box key={index} marginInline={4}>
<IconElement block={element} context={context} surfaceRenderer={surfaceRenderer} index={index} />
</Box>
);
}
if (element.type === 'plain_text' || element.type === 'mrkdwn') {
return (
<Box key={index} marginInline={4}>
<>{surfaceRenderer.renderTextObject(element, index, UiKit.BlockContext.NONE)}</>
</Box>
);
}
return null;
})}
</Box>
{action && <div>{surfaceRenderer.renderActionsBlockElement(action, index)}</div>}
</Box>
);
})}
</Box>
);
};
export default InfoCard;

View File

@@ -0,0 +1,32 @@
import { Field, FieldLabel, FieldRow, FieldError, FieldHint } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import { memo, useMemo } from 'react';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type InputBlockProps = BlockProps<UiKit.InputBlock>;
const InputBlock = ({ className, block, surfaceRenderer, context }: InputBlockProps) => {
const inputElement = useMemo(
() => ({
...block.element,
appId: block.element.appId ?? block.appId,
blockId: block.element.blockId ?? block.blockId,
}),
[block.element, block.appId, block.blockId],
);
const [{ error }] = useUiKitState(inputElement, context);
return (
<Field className={className}>
{block.label && <FieldLabel>{surfaceRenderer.renderTextObject(block.label, 0, UiKit.BlockContext.NONE)}</FieldLabel>}
<FieldRow>{surfaceRenderer.renderInputBlockElement(inputElement, 0)}</FieldRow>
{error && <FieldError>{error}</FieldError>}
{block.hint && <FieldHint>{surfaceRenderer.renderTextObject(block.hint, 0, UiKit.BlockContext.NONE)}</FieldHint>}
</Field>
);
};
export default memo(InputBlock);

View File

@@ -0,0 +1,59 @@
import {
MessageGenericPreview,
MessageGenericPreviewContent,
MessageGenericPreviewDescription,
MessageGenericPreviewCoverImage,
MessageGenericPreviewTitle,
MessageGenericPreviewFooter,
MessageGenericPreviewThumb,
Box,
} from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import { isPreviewBlockWithThumb, isPreviewBlockWithPreview } from '@rocket.chat/ui-kit';
import { memo } from 'react';
import ContextBlock from './ContextBlock';
import type { BlockProps } from '../utils/BlockProps';
export type PreviewBlockProps = BlockProps<UiKit.PreviewBlock>;
const PreviewBlock = ({ block, surfaceRenderer }: PreviewBlockProps) => (
<Box>
<MessageGenericPreview>
{isPreviewBlockWithPreview(block) && block.preview?.dimensions && (
<MessageGenericPreviewCoverImage
width={block.preview.dimensions.width}
height={block.preview.dimensions.height}
url={block.preview.url}
/>
)}
<MessageGenericPreviewContent
thumb={
isPreviewBlockWithThumb(block) ? (
<MessageGenericPreviewThumb>
<MessageGenericPreviewCoverImage height={192} width={368} url={block.thumb.url} />
</MessageGenericPreviewThumb>
) : undefined
}
>
{Array.isArray(block.title) ? (
<MessageGenericPreviewTitle externalUrl={isPreviewBlockWithPreview(block) ? block.externalUrl : undefined}>
{block.title.map((title) => surfaceRenderer.renderTextObject(title, 0, UiKit.BlockContext.NONE))}
</MessageGenericPreviewTitle>
) : null}
{Array.isArray(block.description) ? (
<MessageGenericPreviewDescription clamp>
{block.description.map((description) => surfaceRenderer.renderTextObject(description, 0, UiKit.BlockContext.NONE))}
</MessageGenericPreviewDescription>
) : null}
{block.footer && (
<MessageGenericPreviewFooter>
<ContextBlock block={block.footer} surfaceRenderer={surfaceRenderer} context={UiKit.BlockContext.BLOCK} index={0} />
</MessageGenericPreviewFooter>
)}
</MessageGenericPreviewContent>
</MessageGenericPreview>
</Box>
);
export default memo(PreviewBlock);

View File

@@ -0,0 +1,28 @@
import { Grid, GridItem } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import type { ReactElement } from 'react';
const breakpoints = {
xs: 4,
sm: 4,
md: 4,
lg: 6,
xl: 6,
} as const;
export type FieldsProps = {
fields: readonly UiKit.TextObject[];
surfaceRenderer: UiKit.SurfaceRenderer<ReactElement<any>>;
};
const Fields = ({ fields, surfaceRenderer }: FieldsProps) => (
<Grid>
{fields.map((field, i) => (
<GridItem key={i} {...breakpoints}>
{surfaceRenderer.renderTextObject(field, 0, UiKit.BlockContext.NONE)}
</GridItem>
))}
</Grid>
);
export default Fields;

View File

@@ -0,0 +1,44 @@
import { Box, FlexItem, Grid, GridItem } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo, useMemo } from 'react';
import Fields from './SectionBlock.Fields';
import type { BlockProps } from '../utils/BlockProps';
export type SectionBlockProps = BlockProps<UiKit.SectionBlock>;
const SectionBlock = ({ className, block, surfaceRenderer }: SectionBlockProps) => {
const { text, fields } = block;
const accessoryElement = useMemo(
() =>
block.accessory
? {
appId: block.appId,
blockId: block.blockId,
...block.accessory,
}
: undefined,
[block.appId, block.blockId, block.accessory],
);
return (
<Grid className={className}>
<GridItem>
{text && (
<Box is='span' fontScale='p2' color='default'>
{surfaceRenderer.renderTextObject(text, 0)}
</Box>
)}
{fields && <Fields fields={fields} surfaceRenderer={surfaceRenderer} />}
</GridItem>
{block.accessory && (
<FlexItem grow={0}>
<GridItem>{accessoryElement ? surfaceRenderer.renderSectionAccessoryBlockElement(accessoryElement, 0) : null}</GridItem>
</FlexItem>
)}
</Grid>
);
};
export default memo(SectionBlock);

View File

@@ -0,0 +1,41 @@
import { Tabs } from '@rocket.chat/fuselage';
import type { ExperimentalTabNavigationBlock } from '@rocket.chat/ui-kit';
import { memo, useState } from 'react';
import { TabElement } from '../elements/TabElement';
import type { BlockProps } from '../utils/BlockProps';
export type TabNavigationBlockProps = BlockProps<ExperimentalTabNavigationBlock>;
const TabNavigationBlock = (blockProps: TabNavigationBlockProps) => {
const {
block: { tabs },
context,
surfaceRenderer,
} = blockProps;
const [selected, select] = useState<number>();
return (
<Tabs marginBlock={24}>
{tabs.map((innerBlock, idx) => {
if (selected !== undefined) {
innerBlock.selected = idx === selected;
}
return (
<TabElement
key={`${innerBlock.blockId}_${idx}`}
index={idx}
context={context}
surfaceRenderer={surfaceRenderer}
block={innerBlock}
select={select}
/>
);
})}
</Tabs>
);
};
export default memo(TabNavigationBlock);

View File

@@ -0,0 +1,218 @@
import { getUserDisplayName, VideoConferenceStatus } from '@rocket.chat/core-typings';
import { useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts';
import type * as UiKit from '@rocket.chat/ui-kit';
import {
VideoConfMessageSkeleton,
VideoConfMessage,
VideoConfMessageRow,
VideoConfMessageIcon,
VideoConfMessageText,
VideoConfMessageFooter,
VideoConfMessageUserStack,
VideoConfMessageFooterText,
VideoConfMessageButton,
VideoConfMessageContent,
VideoConfMessageActions,
VideoConfMessageAction,
} from '@rocket.chat/ui-video-conf';
import type { MouseEventHandler } from 'react';
import { useContext, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { UiKitContext } from '../..';
import { useGoToRoom } from './hooks/useGoToRoom';
import { useVideoConfDataStream } from './hooks/useVideoConfDataStream';
import { useSurfaceType } from '../../hooks/useSurfaceType';
import type { BlockProps } from '../../utils/BlockProps';
export type VideoConferenceBlockProps = BlockProps<UiKit.VideoConferenceBlock>;
const MAX_USERS = 3;
const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => {
const { t } = useTranslation();
const { callId, appId = 'videoconf-core' } = block;
const surfaceType = useSurfaceType();
const userId = useUserId();
const goToRoom = useGoToRoom();
const displayAvatars = useUserPreference<boolean>('displayAvatars');
const showRealName = useSetting('UI_Use_Real_Name', false);
const { action, viewId = undefined, rid } = useContext(UiKitContext);
if (surfaceType !== 'message') {
throw new Error('VideoConferenceBlock cannot be rendered outside message');
}
if (!rid) {
throw new Error('VideoConferenceBlock cannot be rendered without rid');
}
const result = useVideoConfDataStream({ rid, callId });
const joinHandler: MouseEventHandler<HTMLButtonElement> = (e): void => {
void action(
{
blockId: block.blockId || '',
appId,
actionId: 'join',
value: block.blockId || '',
viewId,
},
e,
);
};
const callAgainHandler: MouseEventHandler<HTMLButtonElement> = (e): void => {
void action(
{
blockId: rid || '',
appId,
actionId: 'callBack',
value: rid || '',
viewId,
},
e,
);
};
const openCallInfo: MouseEventHandler<HTMLButtonElement> = (e) => {
void action(
{
blockId: callId,
appId,
actionId: 'info',
value: rid,
viewId,
},
e,
);
};
const openDiscussion: MouseEventHandler<HTMLButtonElement> = (_e) => {
if (data.discussionRid) {
void goToRoom(data.discussionRid);
}
};
const messageFooterText = useMemo(() => {
const usersCount = result.data?.users.length;
if (!displayAvatars) {
return t('__usersCount__joined', {
count: usersCount,
});
}
return usersCount && usersCount > MAX_USERS
? t('plus__usersCount__joined', {
count: usersCount - MAX_USERS,
})
: t('joined');
}, [displayAvatars, t, result.data?.users.length]);
if (result.isPending || result.isError) {
// TODO: error handling
return <VideoConfMessageSkeleton />;
}
const { data } = result;
const isUserCaller = data.createdBy._id === userId;
const joinedNamesOrUsernames = [...data.users]
.splice(0, MAX_USERS)
.map(({ name, username }) => getUserDisplayName(name, username, showRealName))
.join(', ');
const title =
data.users.length > MAX_USERS
? t('__usernames__and__count__more_joined', {
usernames: joinedNamesOrUsernames,
count: data.users.length - MAX_USERS,
})
: t('__usernames__joined', { usernames: joinedNamesOrUsernames });
const actions = (
<VideoConfMessageActions>
{data.discussionRid && <VideoConfMessageAction icon='discussion' title={t('Join_discussion')} onClick={openDiscussion} />}
<VideoConfMessageAction icon='info' onClick={openCallInfo} />
</VideoConfMessageActions>
);
if ('endedAt' in data) {
return (
<VideoConfMessage>
<VideoConfMessageRow>
<VideoConfMessageContent>
<VideoConfMessageIcon />
<VideoConfMessageText>{t('Call_ended')}</VideoConfMessageText>
</VideoConfMessageContent>
{actions}
</VideoConfMessageRow>
<VideoConfMessageFooter>
{data.type === 'direct' && (
<>
<VideoConfMessageButton onClick={callAgainHandler}>{isUserCaller ? t('Call_again') : t('Call_back')}</VideoConfMessageButton>
{[VideoConferenceStatus.EXPIRED, VideoConferenceStatus.DECLINED].includes(data.status) && (
<VideoConfMessageFooterText>{t('Call_was_not_answered')}</VideoConfMessageFooterText>
)}
</>
)}
{data.type !== 'direct' &&
(data.users.length ? (
<>
<VideoConfMessageUserStack users={data.users} />
<VideoConfMessageFooterText title={title}>{messageFooterText}</VideoConfMessageFooterText>
</>
) : (
[VideoConferenceStatus.EXPIRED, VideoConferenceStatus.DECLINED].includes(data.status) && (
<VideoConfMessageFooterText>{t('Call_was_not_answered')}</VideoConfMessageFooterText>
)
))}
</VideoConfMessageFooter>
</VideoConfMessage>
);
}
if (data.type === 'direct' && data.status === VideoConferenceStatus.CALLING) {
return (
<VideoConfMessage>
<VideoConfMessageRow>
<VideoConfMessageContent>
<VideoConfMessageIcon variant='incoming' />
<VideoConfMessageText>{t('Calling')}</VideoConfMessageText>
</VideoConfMessageContent>
{actions}
</VideoConfMessageRow>
<VideoConfMessageFooter>
<VideoConfMessageFooterText>{t('Waiting_for_answer')}</VideoConfMessageFooterText>
</VideoConfMessageFooter>
</VideoConfMessage>
);
}
return (
<VideoConfMessage>
<VideoConfMessageRow>
<VideoConfMessageContent>
<VideoConfMessageIcon variant='outgoing' />
<VideoConfMessageText>{t('Call_ongoing')}</VideoConfMessageText>
</VideoConfMessageContent>
{actions}
</VideoConfMessageRow>
<VideoConfMessageFooter>
<VideoConfMessageButton primary onClick={joinHandler}>
{t('Join')}
</VideoConfMessageButton>
{Boolean(data.users.length) && (
<>
<VideoConfMessageUserStack users={data.users} />
<VideoConfMessageFooterText title={title}>{messageFooterText}</VideoConfMessageFooterText>
</>
)}
</VideoConfMessageFooter>
</VideoConfMessage>
);
};
export default memo(VideoConferenceBlock);

View File

@@ -0,0 +1,66 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { MockedRouterContext, MockedServerContext } from '@rocket.chat/mock-providers';
import type { MockedRouterContextProps } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import { useGoToRoom } from './useGoToRoom';
const getWrapper =
(room: Partial<IRoom> | undefined, router?: MockedRouterContextProps['router']) =>
({ children }: { children: any }) => {
return (
<MockedServerContext handleRequest={async () => ({ room }) as any}>
<MockedRouterContext router={router}>{children}</MockedRouterContext>
</MockedServerContext>
);
};
describe('useGoToRoom', () => {
it('should not navigate if the room is not found', async () => {
const navigate = jest.fn();
const { result } = renderHook(() => useGoToRoom(), { wrapper: getWrapper(undefined, { navigate }) });
await result.current('room-id');
expect(navigate).not.toHaveBeenCalled();
});
it('should build the route by name for channels (c) and navigate to it', async () => {
const navigate = jest.fn();
const getRoomRoute = jest.fn().mockReturnValue({ path: '/channel/some-channel' });
const { result } = renderHook(() => useGoToRoom(), {
wrapper: getWrapper({ _id: 'room-id', t: 'c', name: 'some-channel' }, { navigate, getRoomRoute }),
});
await result.current('room-id');
await waitFor(() => expect(getRoomRoute).toHaveBeenCalledWith('c', { name: 'some-channel' }));
expect(navigate).toHaveBeenCalledWith({ pathname: '/channel/some-channel' });
});
it('should build the route by name for private groups (p) and navigate to it', async () => {
const navigate = jest.fn();
const getRoomRoute = jest.fn().mockReturnValue({ path: '/group/some-group' });
const { result } = renderHook(() => useGoToRoom(), {
wrapper: getWrapper({ _id: 'room-id', t: 'p', name: 'some-group' }, { navigate, getRoomRoute }),
});
await result.current('room-id');
await waitFor(() => expect(getRoomRoute).toHaveBeenCalledWith('p', { name: 'some-group' }));
expect(navigate).toHaveBeenCalledWith({ pathname: '/group/some-group' });
});
it('should build the route by rid for direct messages (d) and navigate to it', async () => {
const navigate = jest.fn();
const getRoomRoute = jest.fn().mockReturnValue({ path: '/direct/room-id' });
const { result } = renderHook(() => useGoToRoom(), {
wrapper: getWrapper({ _id: 'room-id', t: 'd', name: 'some-user' }, { navigate, getRoomRoute }),
});
await result.current('room-id');
await waitFor(() => expect(getRoomRoute).toHaveBeenCalledWith('d', { rid: 'room-id' }));
expect(navigate).toHaveBeenCalledWith({ pathname: '/direct/room-id' });
});
});

View File

@@ -0,0 +1,20 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
export const useGoToRoom = (): ((roomId: IRoom['_id']) => Promise<void>) => {
const router = useRouter();
const getRoomInfo = useEndpoint('GET', '/v1/rooms.info');
return useStableCallback(async (roomId: IRoom['_id']) => {
const { room } = await getRoomInfo({ roomId });
if (!room) return;
const { t, name, _id: rid } = room;
const { path } = router.getRoomRoute(t, ['c', 'p'].includes(t) ? { name } : { rid });
router.navigate({ pathname: path });
});
};

View File

@@ -0,0 +1,20 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useVideoConfData = ({ callId }: { callId: string }) => {
const getVideoConfInfo = useEndpoint('GET', '/v1/video-conference.info');
return useQuery({
queryKey: ['video-conference', callId],
queryFn: () => getVideoConfInfo({ callId }),
staleTime: Infinity,
refetchOnMount: (query) => {
if (query.state.data?.endedAt) {
return false;
}
return 'always';
},
});
};

View File

@@ -0,0 +1,27 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useStream } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useVideoConfData } from './useVideoConfData';
export const useVideoConfDataStream = ({ rid, callId }: { rid: IRoom['_id']; callId: string }) => {
const queryClient = useQueryClient();
const subscribeNotifyRoom = useStream('notify-room');
useEffect(
() =>
subscribeNotifyRoom(
`${rid}/videoconf`,
(id) =>
id === callId &&
queryClient.invalidateQueries({
queryKey: ['video-conference', callId],
}),
),
[rid, callId, subscribeNotifyRoom, queryClient],
);
return useVideoConfData({ callId });
};

View File

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

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useDebugValue } from 'react';
import { UiKitContext } from './UiKitContext';
const AppIdContext = createContext<string | undefined>(undefined);
export type AppIdProviderProps = {
children: ReactNode;
appId?: string;
};
export const AppIdProvider = ({ children, appId }: AppIdProviderProps) => {
if (!appId) {
return <>{children}</>;
}
return <AppIdContext.Provider value={appId}>{children}</AppIdContext.Provider>;
};
export const useAppId = () => {
const outerAppId = useContext(UiKitContext).appId ?? 'core';
const appId = useContext(AppIdContext) ?? outerAppId;
useDebugValue(appId);
return appId;
};

View File

@@ -0,0 +1,5 @@
import { createContext } from 'react';
export type SurfaceContextValue = 'attachment' | 'banner' | 'message' | 'modal' | 'contextualBar' | 'custom';
export const SurfaceContext = createContext<SurfaceContextValue>('message');

View File

@@ -0,0 +1,34 @@
import type { ActionableElement, InputElementDispatchAction } from '@rocket.chat/ui-kit';
import type { MouseEventHandler } from 'react';
import { createContext } from 'react';
type ActionId = ActionableElement['actionId'];
type ActionParams = {
blockId: string;
appId: string;
actionId: ActionId;
value: unknown;
viewId?: string;
dispatchActionConfig?: InputElementDispatchAction[];
mid?: string;
};
type UiKitContextValue = {
action: (state: ActionParams, event: Parameters<MouseEventHandler<HTMLElement>>[0]) => Promise<void> | void;
updateState?: (state: ActionParams, event: Parameters<MouseEventHandler<HTMLElement>>[0]) => Promise<void> | void;
appId?: string;
errors?: { [field: string]: string }[] | { [field: string]: string };
values: Record<ActionId, { value: unknown } | undefined>;
viewId?: string;
rid?: string;
};
export const UiKitContext = createContext<UiKitContextValue>({
action: () => undefined,
updateState: () => undefined,
appId: 'core',
values: {},
});
Object.assign(UiKitContext.Provider, { displayName: 'UiKitContext.Provider' });

4
packages/fuselage-ui-kit/src/css.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
// TS7 errors on side-effect imports of `.css` (TS2882) without a declaration.
declare module '*.css' {
export = undefined;
}

View File

@@ -0,0 +1,57 @@
import { Button, Throbber } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import type { MouseEventHandler } from 'react';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type ButtonElementProps = BlockProps<UiKit.ButtonElement>;
const ButtonElement = ({ block, context, surfaceRenderer }: ButtonElementProps) => {
const [{ loading }, action] = useUiKitState(block, context);
const { style, url, text, value, secondary } = block;
const handleClick: MouseEventHandler = (e) => {
void action({ target: e.currentTarget });
};
if (url) {
return (
<Button
is='a'
target='_blank'
small
minWidth='4ch'
disabled={loading}
href={url}
primary={style === 'primary'}
danger={style === 'danger'}
success={style === 'success'}
warning={style === 'warning'}
secondary={secondary}
onClick={handleClick}
>
{loading ? <Throbber /> : surfaceRenderer.renderTextObject(text, 0, UiKit.BlockContext.NONE)}
</Button>
);
}
return (
<Button
small
minWidth='4ch'
disabled={loading}
primary={style === 'primary'}
danger={style === 'danger'}
success={style === 'success'}
warning={style === 'warning'}
secondary={secondary}
value={value}
onClick={handleClick}
>
{loading ? <Throbber /> : surfaceRenderer.renderTextObject(text, 0, UiKit.BlockContext.NONE)}
</Button>
);
};
export default ButtonElement;

View File

@@ -0,0 +1,90 @@
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms';
import { MockedServerContext } from '@rocket.chat/mock-providers';
import type { ChannelsSelectElement as ChannelsSelectElementType } from '@rocket.chat/ui-kit';
import { BlockContext } from '@rocket.chat/ui-kit';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ChannelsSelectElement from './ChannelsSelectElement';
import { contextualBarParser } from '../../surfaces';
import { useChannelsData } from './hooks/useChannelsData';
const channelsBlock: ChannelsSelectElementType = {
type: 'channels_select',
appId: 'test',
blockId: 'test',
actionId: 'test',
};
jest.mock('./hooks/useChannelsData');
const mockedOptions: ReturnType<typeof useChannelsData> = [
{
value: 'channel1_id',
label: {
name: 'Channel 1',
avatarETag: 'test',
type: RoomType.CHANNEL,
},
},
{
value: 'channel2_id',
label: {
name: 'Channel 2',
avatarETag: 'test',
type: RoomType.CHANNEL,
},
},
{
value: 'channel3_id',
label: {
name: 'Channel 3',
avatarETag: 'test',
type: RoomType.CHANNEL,
},
},
];
const mockUseChannelsData = jest.mocked(useChannelsData);
mockUseChannelsData.mockReturnValue(mockedOptions);
describe('UiKit ChannelsSelect Element', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
render(
<MockedServerContext>
<ChannelsSelectElement index={0} block={channelsBlock} context={BlockContext.FORM} surfaceRenderer={contextualBarParser} />
</MockedServerContext>,
);
});
it('should render a UiKit channel selector', async () => {
expect(await screen.findByRole('textbox')).toBeInTheDocument();
});
it('should open the channel selector', async () => {
const input = await screen.findByRole('textbox');
input.focus();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
});
it('should select a channel', async () => {
const input = await screen.findByRole('textbox');
input.focus();
const option = (await screen.findAllByRole('option'))[0];
await userEvent.click(option, { delay: null });
const selected = await screen.findByRole('button');
expect(selected).toHaveValue('channel1_id');
});
});

View File

@@ -0,0 +1,66 @@
import { AutoComplete, Option, Box, Chip } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo, useCallback, useState } from 'react';
import { useChannelsData } from './hooks/useChannelsData';
import { useUiKitState } from '../../hooks/useUiKitState';
import type { BlockProps } from '../../utils/BlockProps';
export type ChannelsSelectElementProps = BlockProps<UiKit.ChannelsSelectElement>;
const ChannelsSelectElement = ({ block, context }: ChannelsSelectElementProps) => {
const [{ value, loading }, action] = useUiKitState(block, context);
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const options = useChannelsData({ filter: filterDebounced });
const handleChange = useCallback(
(value: string | string[]) => {
if (!Array.isArray(value)) void action({ target: { value } });
},
[action],
);
return (
<AutoComplete
value={value}
onChange={handleChange}
disabled={loading}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { value, label } }) => (
<Chip height='x20' value={value} marginInlineEnd={4}>
<RoomAvatar size='x20' room={{ _id: value, ...label, type: label?.type || 'c' }} />
<Box verticalAlign='middle' is='span' margin='none' marginInline={4}>
{label.name}
</Box>
</Chip>
)}
renderItem={({ value, label, ...props }) => (
<Option
key={value}
{...props}
label={label.name}
avatar={
<RoomAvatar
size='x20'
room={{
type: label.type,
_id: value,
avatarETag: label.avatarETag,
}}
{...props}
/>
}
/>
)}
options={options}
/>
);
};
export default memo(ChannelsSelectElement);

View File

@@ -0,0 +1,112 @@
import { MockedServerContext } from '@rocket.chat/mock-providers';
import type { MultiChannelsSelectElement as MultiChannelsSelectElementType } from '@rocket.chat/ui-kit';
import { BlockContext } from '@rocket.chat/ui-kit';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MultiChannelsSelectElement from './MultiChannelsSelectElement';
import { contextualBarParser } from '../../surfaces';
import { useChannelsData } from './hooks/useChannelsData';
const channelsBlock: MultiChannelsSelectElementType = {
type: 'multi_channels_select',
appId: 'test',
blockId: 'test',
actionId: 'test',
};
jest.mock('./hooks/useChannelsData');
const mockedOptions: ReturnType<typeof useChannelsData> = [
{
value: 'channel1_id',
label: {
name: 'Channel 1',
avatarETag: 'test',
type: 'c',
},
},
{
value: 'channel2_id',
label: {
name: 'Channel 2',
avatarETag: 'test',
type: 'c',
},
},
{
value: 'channel3_id',
label: {
name: 'Channel 3',
avatarETag: 'test',
type: 'c',
},
},
];
const mockUseChannelsData = jest.mocked(useChannelsData);
mockUseChannelsData.mockReturnValue(mockedOptions);
describe('UiKit MultiChannelsSelect Element', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
render(
<MockedServerContext>
<MultiChannelsSelectElement index={0} block={channelsBlock} context={BlockContext.FORM} surfaceRenderer={contextualBarParser} />
</MockedServerContext>,
);
});
it('should render a UiKit multiple channels selector', async () => {
expect(await screen.findByRole('textbox')).toBeInTheDocument();
});
it('should open the channels selector', async () => {
const input = await screen.findByRole('textbox');
input.focus();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
});
it('should select channels', async () => {
const input = await screen.findByRole('textbox');
input.focus();
const option1 = (await screen.findAllByRole('option'))[0];
await userEvent.click(option1, { delay: null });
const option2 = (await screen.findAllByRole('option'))[2];
await userEvent.click(option2, { delay: null });
const selected = await screen.findAllByRole('button');
expect(selected[0]).toHaveValue('channel1_id');
expect(selected[1]).toHaveValue('channel3_id');
});
it('should remove a selected channel', async () => {
const input = await screen.findByRole('textbox');
input.focus();
const option1 = (await screen.findAllByRole('option'))[0];
await userEvent.click(option1, { delay: null });
const option2 = (await screen.findAllByRole('option'))[2];
await userEvent.click(option2, { delay: null });
const selected1 = (await screen.findAllByRole('button'))[0];
expect(selected1).toHaveValue('channel1_id');
await userEvent.click(selected1, { delay: null });
const remainingSelected = (await screen.findAllByRole('button'))[0];
expect(remainingSelected).toHaveValue('channel3_id');
});
});

View File

@@ -0,0 +1,57 @@
import { AutoComplete, Option, Chip, Box } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo, useCallback, useState } from 'react';
import { useChannelsData } from './hooks/useChannelsData';
import { useUiKitState } from '../../hooks/useUiKitState';
import type { BlockProps } from '../../utils/BlockProps';
type MultiChannelsSelectProps = BlockProps<UiKit.MultiChannelsSelectElement>;
const MultiChannelsSelectElement = ({ block, context }: MultiChannelsSelectProps) => {
const [{ value, loading }, action] = useUiKitState(block, context);
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const options = useChannelsData({ filter: filterDebounced });
const handleChange = useCallback(
(value: string | string[]) => {
if (Array.isArray(value)) void action({ target: { value } });
},
[action],
);
return (
<AutoComplete
value={value || []}
disabled={loading}
onChange={handleChange}
filter={filter}
setFilter={setFilter}
multiple
renderSelected={({ selected: { value, label }, onRemove, ...props }) => (
<Chip key={value} {...props} value={value} onClick={onRemove}>
<RoomAvatar size='x20' room={{ _id: value, ...label, type: label?.type || 'c' }} />
<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={{ _id: value, ...label, type: label?.type || 'c' }} />}
/>
)}
options={options}
/>
);
};
export default memo(MultiChannelsSelectElement);

View File

@@ -0,0 +1,35 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
type useChannelsDataProps = {
filter: string;
};
const generateQuery = (
term = '',
): {
selector: string;
} => ({ selector: JSON.stringify({ name: term }) });
export const useChannelsData = ({ filter }: useChannelsDataProps) => {
const getRooms = useEndpoint('GET', '/v1/rooms.autocomplete.channelAndPrivate');
const { data } = useQuery({
queryKey: ['rooms.autocomplete.channelAndPrivate', filter],
queryFn: async () => {
const channels = await getRooms(generateQuery(filter));
const options = channels.items.map(({ fname, name, _id, avatarETag, t }) => ({
value: _id,
label: { name: name || fname, avatarETag, type: t },
}));
return options || [];
},
placeholderData: keepPreviousData,
});
return data;
};

View File

@@ -0,0 +1,31 @@
import { CheckBox, Box } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type CheckboxElementProps = BlockProps<UiKit.CheckboxElement>;
const CheckboxElement = ({ block, context, surfaceRenderer }: CheckboxElementProps) => {
const [{ loading, value }, action] = useUiKitState(block, context);
const { options } = block;
return (
<Box>
{options.map((option: UiKit.Option) => {
const isChecked = value?.includes(option.value);
return (
<Box key={option.value} paddingBlock={4}>
<CheckBox disabled={loading} value={option.value} checked={isChecked} onChange={action} />
<Box is='label' paddingInlineStart={8}>
{surfaceRenderer.renderTextObject(option.text, 0, UiKit.BlockContext.NONE)}
</Box>
</Box>
);
})}
</Box>
);
};
export default CheckboxElement;

View File

@@ -0,0 +1,30 @@
import { InputBox } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { useStringFromTextObject } from '../hooks/useStringFromTextObject';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type DatePickerElementProps = BlockProps<UiKit.DatePickerElement>;
const DatePickerElement = ({ block, context }: DatePickerElementProps) => {
const [{ loading, value, error }, action] = useUiKitState(block, context);
const { actionId, placeholder } = block;
const fromTextObjectToString = useStringFromTextObject();
return (
<InputBox
type='date'
error={error}
value={value as string}
disabled={loading}
id={actionId}
name={actionId}
rows={6}
placeholder={fromTextObjectToString(placeholder)}
onInput={action}
/>
);
};
export default DatePickerElement;

View File

@@ -0,0 +1,37 @@
import { IconButton } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import type { MouseEventHandler } from 'react';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type IconButtonElementProps = BlockProps<UiKit.IconButtonElement>;
const IconButtonElement = ({ block, context }: IconButtonElementProps) => {
const [{ loading }, action] = useUiKitState(block, context);
const { url, value, label, icon } = block;
const { icon: iconName, variant } = icon;
const handleClick: MouseEventHandler = (e) => {
void action({ target: e.currentTarget });
};
if (url) {
return <IconButton small is='a' target='_blank' disabled={loading} icon={iconName} href={url} onClick={handleClick} label={label} />;
}
return (
<IconButton
icon={iconName}
color={variant === 'default' ? 'default' : 'danger'}
small
label={label}
disabled={loading}
value={value}
onClick={handleClick}
/>
);
};
export default IconButtonElement;

View File

@@ -0,0 +1,46 @@
import { Icon, FramedIcon } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import type { ComponentProps } from 'react';
import type { BlockProps } from '../utils/BlockProps';
export type IconElementProps = BlockProps<UiKit.FrameableIconElement>;
const getVariantColor = (variant: UiKit.FrameableIconElement['variant']): string => {
switch (variant) {
case 'danger':
return 'danger';
case 'secondary':
return 'secondary-info';
case 'warning':
return 'status-font-on-warning';
case 'default':
default:
return 'default';
}
};
const getFramedIconProps = (
variant: UiKit.FrameableIconElement['variant'],
): Pick<ComponentProps<typeof FramedIcon>, 'warning' | 'danger' | 'neutral'> => {
switch (variant) {
case 'danger':
return { danger: true };
case 'warning':
return { warning: true };
case 'default':
case 'secondary':
default:
return { neutral: true };
}
};
const IconElement = ({ block }: IconElementProps) => {
const { icon, variant, framed } = block;
if (framed) {
return <FramedIcon size={20} icon={icon} {...getFramedIconProps(variant)} />;
}
return <Icon size={20} name={icon} color={getVariantColor(variant)} />;
};
export default IconElement;

View File

@@ -0,0 +1,17 @@
import styled from '@rocket.chat/styled';
const filterElementProps = ({ imageUrl: _imageUrl, size: _size, ...props }: { imageUrl: string; size: number }) => props;
export const Element = styled('div', filterElementProps)`
box-shadow: 0 0 0px 1px rgba(204, 204, 204, 38%);
background-repeat: no-repeat;
background-position: 50%;
background-size: cover;
background-color: rgba(204, 204, 204, 38%);
background-image: url(${(props) => props.imageUrl});
width: ${(props) => String(props.size)}px;
height: ${(props) => String(props.size)}px;
border-radius: 4px;
overflow: hidden;
margin-inline-start: 4px;
`;

View File

@@ -0,0 +1,18 @@
import * as UiKit from '@rocket.chat/ui-kit';
import { Element } from './ImageElement.styles';
import type { BlockProps } from '../utils/BlockProps';
export type ImageElementProps = BlockProps<UiKit.ImageElement>;
const ImageElement = ({ block, context }: ImageElementProps) => {
const size = (context === UiKit.BlockContext.SECTION && 88) || (context === UiKit.BlockContext.CONTEXT && 20) || undefined;
if (!size) {
return null;
}
return <Element imageUrl={block.imageUrl} size={size} />;
};
export default ImageElement;

View File

@@ -0,0 +1,63 @@
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import { memo, useMemo } from 'react';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type LinearScaleElementProps = BlockProps<UiKit.LinearScaleElement>;
const LinearScaleElement = ({ className, block, context, surfaceRenderer }: LinearScaleElementProps) => {
const { minValue = 0, maxValue = 10, initialValue, preLabel, postLabel } = block;
const [{ loading, value = initialValue, error }, action] = useUiKitState(block, context);
const points = useMemo(
() => Array.from({ length: Math.max(maxValue - minValue + 1, 1) }, (_, i) => String(minValue + i)),
[maxValue, minValue],
);
return (
<Box display='flex' flexDirection='row' flexWrap='nowrap' alignItems='center'>
{preLabel && (
<Box fontScale='c2' paddingInlineEnd={8} textAlign='start'>
{surfaceRenderer.renderTextObject(preLabel, 0, UiKit.BlockContext.NONE)}
</Box>
)}
<Box>
<ButtonGroup className={className as string} align='center'>
{points.map((point, i) => (
<Button
key={i}
className={point === String(value) ? 'active' : undefined}
disabled={loading}
danger={!!error}
minWidth='4ch'
small
value={point}
marginInline={2}
flexShrink={1}
onClick={action}
>
{surfaceRenderer.renderTextObject(
{
type: 'plain_text',
text: String(i + minValue),
},
0,
UiKit.BlockContext.NONE,
)}
</Button>
))}
</ButtonGroup>
</Box>
{postLabel && (
<Box fontScale='c2' paddingInlineStart={8} textAlign='end'>
{surfaceRenderer.renderTextObject(postLabel, 0, UiKit.BlockContext.NONE)}
</Box>
)}
</Box>
);
};
export default memo(LinearScaleElement);

View File

@@ -0,0 +1,27 @@
import { Skeleton } from '@rocket.chat/fuselage';
import { Markup } from '@rocket.chat/gazzodown';
import { parse } from '@rocket.chat/message-parser';
import type { TextObject } from '@rocket.chat/ui-kit';
import { Suspense } from 'react';
import { useAppTranslation } from '../hooks/useAppTranslation';
export type MarkdownTextElementProps = { textObject: TextObject };
const MarkdownTextElement = ({ textObject }: MarkdownTextElementProps) => {
const { t } = useAppTranslation();
const text = textObject.i18n ? t(textObject.i18n.key, { ...textObject.i18n.args }) : textObject.text;
if (!text) {
return null;
}
return (
<Suspense fallback={<Skeleton />}>
<Markup tokens={parse(text, { emoticons: false })} />
</Suspense>
);
};
export default MarkdownTextElement;

View File

@@ -0,0 +1,40 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { MultiSelectFiltered } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo, useCallback, useMemo } from 'react';
import { useStringFromTextObject } from '../hooks/useStringFromTextObject';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type MultiStaticSelectElementProps = BlockProps<UiKit.MultiStaticSelectElement>;
const MultiStaticSelectElement = ({ block, context }: MultiStaticSelectElementProps) => {
const [{ loading, value, error }, action] = useUiKitState(block, context);
const fromTextObjectToString = useStringFromTextObject();
const options = useMemo<SelectOption[]>(
() => block.options.map(({ value, text }) => [value, fromTextObjectToString(text) ?? '']),
[block.options, fromTextObjectToString],
);
const handleChange = useCallback(
(value: string[]) => {
void action({ target: { value } });
},
[action],
);
return (
<MultiSelectFiltered
value={value}
disabled={loading}
error={error}
options={options}
placeholder={fromTextObjectToString(block.placeholder)}
onChange={handleChange}
/>
);
};
export default memo(MultiStaticSelectElement);

View File

@@ -0,0 +1,66 @@
import type { OptionType } from '@rocket.chat/fuselage';
import { IconButton, PositionAnimated, Options, useCursor } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { useRef, useCallback, useMemo } from 'react';
import { useStringFromTextObject } from '../hooks/useStringFromTextObject';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type OverflowElementProps = BlockProps<UiKit.OverflowElement>;
const OverflowElement = ({ block, context }: OverflowElementProps) => {
const [{ loading }, action] = useUiKitState(block, context);
const fromTextObjectToString = useStringFromTextObject();
const fireChange = useCallback(([value]: [UiKit.ActionOf<UiKit.OverflowElement>, string]) => action({ target: { value } }), [action]);
const options = useMemo<OptionType[]>(
() => block.options.map(({ value, text, url }) => [value, fromTextObjectToString(text) ?? '', undefined, undefined, undefined, url]),
[block.options, fromTextObjectToString],
);
const [cursor, handleKeyDown, handleKeyUp, reset, [visible, hide, show]] = useCursor(-1, options, (selectedOption, [, hide]) => {
void fireChange([selectedOption[0] as string, selectedOption[1] as string]);
reset();
hide();
});
const ref = useRef<HTMLElement>(null);
const onClick = useCallback(() => {
ref.current?.focus();
show();
}, [show]);
const handleSelection = useCallback(
([value, _label, _selected, _type, url]: OptionType) => {
if (url) {
window.open(url);
}
void action({ target: { value: String(value) } });
reset();
hide();
},
[action, hide, reset],
);
return (
<>
<IconButton
ref={ref}
small
onClick={onClick}
onBlur={hide}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
disabled={loading}
icon='kebab'
/>
<PositionAnimated width='auto' visible={visible} anchor={ref} placement='bottom-start'>
<Options onSelect={handleSelection} options={options} cursor={cursor} />
</PositionAnimated>
</>
);
};
export default OverflowElement;

View File

@@ -0,0 +1,15 @@
import type { TextObject } from '@rocket.chat/ui-kit';
import { useAppTranslation } from '../hooks/useAppTranslation';
export type PlainTextElementProps = { textObject: TextObject };
const PlainTextElement = ({ textObject }: PlainTextElementProps) => {
const { t } = useAppTranslation();
const text = textObject.i18n ? t(textObject.i18n.key, { ...textObject.i18n.args }) : textObject.text;
return <>{text}</>;
};
export default PlainTextElement;

View File

@@ -0,0 +1,43 @@
import { TextAreaInput, TextInput } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo } from 'react';
import { useStringFromTextObject } from '../hooks/useStringFromTextObject';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type PlainTextInputElementProps = BlockProps<UiKit.PlainTextInputElement>;
const PlainTextInputElement = ({ block, context }: PlainTextInputElementProps) => {
const [{ loading, value, error }, action] = useUiKitState(block, context);
const fromTextObjectToString = useStringFromTextObject();
if (block.multiline) {
return (
<TextAreaInput
disabled={loading}
id={block.actionId}
name={block.actionId}
rows={6}
error={error}
value={value}
onChange={action}
placeholder={fromTextObjectToString(block.placeholder)}
/>
);
}
return (
<TextInput
disabled={loading}
id={block.actionId}
name={block.actionId}
error={error}
value={value}
onChange={action}
placeholder={fromTextObjectToString(block.placeholder)}
/>
);
};
export default memo(PlainTextInputElement);

View File

@@ -0,0 +1,27 @@
import { Box, RadioButton } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type RadioButtonElementProps = BlockProps<UiKit.RadioButtonElement>;
const RadioButtonElement = ({ block, context, surfaceRenderer }: RadioButtonElementProps) => {
const [{ loading, value }, action] = useUiKitState(block, context);
const { options } = block;
return (
<Box>
{options.map((option: UiKit.Option) => (
<Box key={option.value} paddingBlock={4}>
<RadioButton disabled={loading} checked={value === option.value} value={option.value} onChange={action} />
<Box is='label' paddingInlineStart={8}>
{surfaceRenderer.renderTextObject(option.text, 0, UiKit.BlockContext.NONE)}
</Box>
</Box>
))}
</Box>
);
};
export default RadioButtonElement;

View File

@@ -0,0 +1,39 @@
import { SelectFiltered } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo, useCallback, useMemo } from 'react';
import { useStringFromTextObject } from '../hooks/useStringFromTextObject';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type StaticSelectElementProps = BlockProps<UiKit.StaticSelectElement>;
const StaticSelectElement = ({ block, context }: StaticSelectElementProps) => {
const [{ loading, value, error }, action] = useUiKitState(block, context);
const fromTextObjectToString = useStringFromTextObject();
const options = useMemo<[string, string][]>(
() => block.options.map((option) => [option.value, fromTextObjectToString(option.text) ?? '']),
[block.options, fromTextObjectToString],
);
const handleChange = useCallback(
(value: string) => {
void action({ target: { value } });
},
[action],
);
return (
<SelectFiltered
value={value}
disabled={loading}
error={error}
options={options}
placeholder={fromTextObjectToString(block.placeholder)}
onChange={handleChange}
/>
);
};
export default memo(StaticSelectElement);

View File

@@ -0,0 +1,34 @@
import { TabsItem } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import type { Dispatch } from 'react';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export const TabElement = ({
block,
context,
surfaceRenderer,
index,
select,
}: BlockProps<UiKit.ExperimentalTabElement> & {
select: Dispatch<number>;
}) => {
const [{ loading }, action] = useUiKitState(block, context);
const { title, selected, disabled } = block;
return (
<TabsItem
selected={selected}
disabled={loading ? true : disabled}
onClick={(e) => {
if (disabled) return;
select(index);
void action(e);
}}
>
{surfaceRenderer.renderTextObject(title, 0, UiKit.BlockContext.NONE)}
</TabsItem>
);
};

View File

@@ -0,0 +1,30 @@
import { InputBox } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import { useStringFromTextObject } from '../hooks/useStringFromTextObject';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type TimePickerElementProps = BlockProps<UiKit.TimePickerElement>;
const TimePickerElement = ({ block, context }: TimePickerElementProps) => {
const [{ loading, value, error }, action] = useUiKitState(block, context);
const { actionId, placeholder } = block;
const fromTextObjectToString = useStringFromTextObject();
return (
<InputBox
type='time'
error={error}
value={value}
disabled={loading}
id={actionId}
name={actionId}
rows={6}
placeholder={fromTextObjectToString(placeholder)}
onInput={action}
/>
);
};
export default TimePickerElement;

View File

@@ -0,0 +1,31 @@
import { Box, ToggleSwitch } from '@rocket.chat/fuselage';
import * as UiKit from '@rocket.chat/ui-kit';
import { useUiKitState } from '../hooks/useUiKitState';
import type { BlockProps } from '../utils/BlockProps';
export type ToggleSwitchElementProps = BlockProps<UiKit.ToggleSwitchElement>;
const ToggleSwitchElement = ({ block, context, surfaceRenderer }: ToggleSwitchElementProps) => {
const [{ value, loading }, action] = useUiKitState(block, context);
const { options } = block;
return (
<Box>
{options.map((option: UiKit.Option) => {
const isChecked = value.includes(option.value);
return (
<Box key={option.value} paddingBlock={4}>
<ToggleSwitch disabled={loading} value={option.value} checked={isChecked} onChange={action} />
<Box is='label' paddingInlineStart={8}>
{surfaceRenderer.renderTextObject(option.text, 0, UiKit.BlockContext.NONE)}
</Box>
</Box>
);
})}
</Box>
);
};
export default ToggleSwitchElement;

View File

@@ -0,0 +1,100 @@
import { MockedServerContext } from '@rocket.chat/mock-providers';
import type { MultiUsersSelectElement as MultiUsersSelectElementType } from '@rocket.chat/ui-kit';
import { BlockContext } from '@rocket.chat/ui-kit';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MultiUsersSelectElement from './MultiUsersSelectElement';
import { contextualBarParser } from '../../surfaces';
import { useUsersData } from './hooks/useUsersData';
const usersBlock: MultiUsersSelectElementType = {
type: 'multi_users_select',
appId: 'test',
blockId: 'test',
actionId: 'test',
};
jest.mock('./hooks/useUsersData');
const mockedOptions = [
{
value: 'user1_id',
label: 'User 1',
},
{
value: 'user2_id',
label: 'User 2',
},
{
value: 'user3_id',
label: 'User 3',
},
];
const mockUseUsersData = jest.mocked(useUsersData);
mockUseUsersData.mockReturnValue(mockedOptions);
describe('UiKit MultiUsersSelect Element', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
render(
<MockedServerContext>
<MultiUsersSelectElement index={0} block={usersBlock} context={BlockContext.FORM} surfaceRenderer={contextualBarParser} />
</MockedServerContext>,
);
});
it('should render a UiKit multiple users selector', async () => {
expect(await screen.findByRole('textbox')).toBeInTheDocument();
});
it('should open the users selector', async () => {
const input = await screen.findByRole('textbox');
input.focus();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
});
it('should select users', async () => {
const input = await screen.findByRole('textbox');
input.focus();
const option1 = (await screen.findAllByRole('option'))[0];
await userEvent.click(option1, { delay: null });
const option2 = (await screen.findAllByRole('option'))[2];
await userEvent.click(option2, { delay: null });
const selected = await screen.findAllByRole('button');
expect(selected[0]).toHaveValue('user1_id');
expect(selected[1]).toHaveValue('user3_id');
});
it('should remove a user', async () => {
const input = await screen.findByRole('textbox');
input.focus();
const option1 = (await screen.findAllByRole('option'))[0];
await userEvent.click(option1, { delay: null });
const option2 = (await screen.findAllByRole('option'))[2];
await userEvent.click(option2, { delay: null });
const selected1 = (await screen.findAllByRole('button'))[0];
expect(selected1).toHaveValue('user1_id');
await userEvent.click(selected1, { delay: null });
const remainingSelected = (await screen.findAllByRole('button'))[0];
expect(remainingSelected).toHaveValue('user3_id');
});
});

View File

@@ -0,0 +1,60 @@
import { Box, Chip, AutoComplete, Option, OptionAvatar, OptionContent, OptionDescription } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type * as UiKit from '@rocket.chat/ui-kit';
import { memo, useCallback, useState } from 'react';
import { useUsersData } from './hooks/useUsersData';
import { useUiKitState } from '../../hooks/useUiKitState';
import type { BlockProps } from '../../utils/BlockProps';
export type MultiUsersSelectElementProps = BlockProps<UiKit.MultiUsersSelectElement>;
const MultiUsersSelectElement = ({ block, context }: MultiUsersSelectElementProps) => {
const [{ loading, value }, action] = useUiKitState(block, context);
const [filter, setFilter] = useState('');
const debouncedFilter = useDebouncedValue(filter, 500);
const data = useUsersData({ filter: debouncedFilter });
const handleChange = useCallback(
(value: string | string[]) => {
if (Array.isArray(value)) void action({ target: { value } });
},
[action],
);
return (
<AutoComplete
value={value || []}
options={data}
placeholder={block.placeholder?.text}
disabled={loading}
filter={filter}
setFilter={setFilter}
onChange={handleChange}
multiple
renderSelected={({ selected: { value, label }, onRemove, ...props }) => (
<Chip {...props} height='x20' value={value} onClick={onRemove} marginInlineEnd={4}>
<UserAvatar size='x20' username={value} />
<Box is='span' margin='none' marginInlineStart={4}>
{label}
</Box>
</Chip>
)}
renderItem={({ value, label, ...props }) => (
<Option key={value} {...props}>
<OptionAvatar>
<UserAvatar username={value} size='x20' />
</OptionAvatar>
<OptionContent>
{label} <OptionDescription>({value})</OptionDescription>
</OptionContent>
</Option>
)}
/>
);
};
export default memo(MultiUsersSelectElement);

View File

@@ -0,0 +1,77 @@
import { MockedServerContext } from '@rocket.chat/mock-providers';
import type { UsersSelectElement as UsersSelectElementType } from '@rocket.chat/ui-kit';
import { BlockContext } from '@rocket.chat/ui-kit';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UsersSelectElement from './UsersSelectElement';
import { contextualBarParser } from '../../surfaces';
import { useUsersData } from './hooks/useUsersData';
const userBlock: UsersSelectElementType = {
type: 'users_select',
appId: 'test',
blockId: 'test',
actionId: 'test',
};
jest.mock('./hooks/useUsersData');
const mockedOptions = [
{
value: 'user1_id',
label: 'User 1',
},
{
value: 'user2_id',
label: 'User 2',
},
{
value: 'user3_id',
label: 'User 3',
},
];
const mockUseUsersData = jest.mocked(useUsersData);
mockUseUsersData.mockReturnValue(mockedOptions);
describe('UiKit UserSelect Element', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
render(
<MockedServerContext>
<UsersSelectElement index={0} block={userBlock} context={BlockContext.FORM} surfaceRenderer={contextualBarParser} />
</MockedServerContext>,
);
});
it('should render a UiKit user selector', async () => {
expect(await screen.findByRole('textbox')).toBeInTheDocument();
});
it('should open the user selector', async () => {
const input = await screen.findByRole('textbox');
input.focus();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
});
it('should select a user', async () => {
const input = await screen.findByRole('textbox');
input.focus();
const option = (await screen.findAllByRole('option'))[0];
await userEvent.click(option, { delay: null });
const selected = await screen.findByRole('button');
expect(selected).toHaveValue('user1_id');
});
});

View File

@@ -0,0 +1,57 @@
import { AutoComplete, Box, Chip, Option } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type * as UiKit from '@rocket.chat/ui-kit';
import { useCallback, useState } from 'react';
import { useUsersData } from './hooks/useUsersData';
import { useUiKitState } from '../../hooks/useUiKitState';
import type { BlockProps } from '../../utils/BlockProps';
export type UsersSelectElementProps = BlockProps<UiKit.UsersSelectElement>;
export type UserAutoCompleteOptionType = {
value: string;
label: string;
};
const UsersSelectElement = ({ block, context }: UsersSelectElementProps) => {
const [{ value, loading }, action] = useUiKitState(block, context);
const [filter, setFilter] = useState('');
const debouncedFilter = useDebouncedValue(filter, 300);
const data = useUsersData({ filter: debouncedFilter });
const handleChange = useCallback(
(value: string | string[]) => {
if (!Array.isArray(value)) void action({ target: { value } });
},
[action],
);
return (
<AutoComplete
value={value}
placeholder={block.placeholder?.text}
disabled={loading}
options={data}
onChange={handleChange}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { value, label } }) => (
<Chip height='x20' value={value} marginInlineEnd={4}>
<UserAvatar size='x20' username={value} />
<Box verticalAlign='middle' is='span' margin='none' marginInline={4}>
{label}
</Box>
</Chip>
)}
renderItem={({ value, label, ...props }) => (
<Option key={value} {...props} label={label} avatar={<UserAvatar username={value} size='x20' />} />
)}
/>
);
};
export default UsersSelectElement;

View File

@@ -0,0 +1,32 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { UserAutoCompleteOptionType } from '../UsersSelectElement';
type useUsersDataProps = {
filter: string;
};
export const useUsersData = ({ filter }: useUsersDataProps) => {
const getUsers = useEndpoint('GET', '/v1/users.autocomplete');
const { data } = useQuery({
queryKey: ['users.autoComplete', filter],
queryFn: async () => {
const users = await getUsers({
selector: JSON.stringify({ term: filter }),
});
const options = users.items.map((item): UserAutoCompleteOptionType => ({
value: item.username,
label: item.name || item.username,
}));
return options || [];
},
placeholderData: keepPreviousData,
});
return data;
};

View File

@@ -0,0 +1,122 @@
import { renderHook, waitFor } from '@testing-library/react';
import * as i18next from 'i18next';
import { Suspense } from 'react';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { useAppTranslation } from './useAppTranslation';
import { AppIdProvider } from '../contexts/AppIdContext';
let i18n: i18next.i18n;
beforeEach(async () => {
i18n = i18next.createInstance().use(initReactI18next);
await i18n.init({
lng: 'en',
resources: {
en: {
'translation': {
test: 'a quick brown fox',
},
'app-test': {
test: 'jumped over the lazy dog',
},
'app-test-core': {
test: 'this should not be used',
},
},
},
});
});
it('should work with normal app ID (`test`)', async () => {
const { result } = renderHook(() => useAppTranslation().t('test'), {
wrapper: ({ children }) => (
<I18nextProvider i18n={i18n}>
<AppIdProvider appId='test'>{children}</AppIdProvider>
</I18nextProvider>
),
});
expect(result.current).toBe('jumped over the lazy dog');
});
it('should work with core app ID (`test-core`)', async () => {
const { result } = renderHook(() => useAppTranslation().t('test'), {
wrapper: ({ children }) => (
<I18nextProvider i18n={i18n}>
<AppIdProvider appId='test-core'>{children}</AppIdProvider>
</I18nextProvider>
),
});
expect(result.current).toBe('a quick brown fox');
});
describe('with suspense', () => {
let i18n: i18next.i18n;
beforeEach(async () => {
i18n = i18next
.createInstance({
lng: 'en',
defaultNS: 'core',
partialBundledLanguages: true,
react: {
useSuspense: true,
},
})
.use({
type: 'backend',
init: () => undefined,
read: async (language, namespace) => {
await new Promise<void>((resolve) => queueMicrotask(resolve));
if (language === 'en' && namespace === 'core') {
return {
test: 'a quick brown fox',
};
}
if (language === 'en' && namespace === 'app-test') {
return {
test: 'jumped over the lazy dog',
};
}
throw new Error(`Unexpected read request: ${language} ${namespace}`);
},
} satisfies i18next.BackendModule)
.use(initReactI18next);
await i18n.init();
});
it('should work with normal app ID (`test`)', async () => {
const { result } = renderHook(() => useAppTranslation().t('test'), {
wrapper: ({ children }) => (
<I18nextProvider i18n={i18n}>
<AppIdProvider appId='test'>
<Suspense fallback={null}>{children}</Suspense>
</AppIdProvider>
</I18nextProvider>
),
});
await waitFor(() => expect(result.current).toBe('jumped over the lazy dog'));
});
it('should work with core app ID (`test-core`)', async () => {
const { result } = renderHook(() => useAppTranslation().t('test'), {
wrapper: ({ children }) => (
<I18nextProvider i18n={i18n}>
<AppIdProvider appId='test-core'>
<Suspense fallback={null}>{children}</Suspense>
</AppIdProvider>
</I18nextProvider>
),
});
await waitFor(() => expect(result.current).toBe('a quick brown fox'));
});
});

View File

@@ -0,0 +1,13 @@
import { useDebugValue } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppId } from '../contexts/AppIdContext';
export const useAppTranslation = () => {
const appId = useAppId();
const appNs = appId.endsWith(`-core`) ? undefined : `app-${appId}`;
useDebugValue(appNs);
return useTranslation(appNs);
};

View File

@@ -0,0 +1,19 @@
import type * as UiKit from '@rocket.chat/ui-kit';
import { useCallback } from 'react';
import { useAppTranslation } from './useAppTranslation';
export const useStringFromTextObject = () => {
const { t } = useAppTranslation();
return useCallback(
(textObject: UiKit.TextObject | undefined): string | undefined => {
if (!textObject) {
return undefined;
}
return textObject.i18n ? t?.(textObject.i18n.key, { ...textObject.i18n.args }) : textObject.text;
},
[t],
);
};

View File

@@ -0,0 +1,6 @@
import { useContext } from 'react';
import type { SurfaceContextValue } from '../contexts/SurfaceContext';
import { SurfaceContext } from '../contexts/SurfaceContext';
export const useSurfaceType = (): SurfaceContextValue => useContext(SurfaceContext);

View File

@@ -0,0 +1,62 @@
import { BlockContext, type MultiStaticSelectElement } from '@rocket.chat/ui-kit';
import { act, renderHook } from '@testing-library/react';
import { useUiKitState } from './useUiKitState';
describe('state function', () => {
const context = BlockContext.NONE;
it('should handle arrays', async () => {
const element: MultiStaticSelectElement = {
type: 'multi_static_select',
placeholder: { type: 'plain_text', text: '' },
options: [],
initialValue: ['A', 'B'],
appId: 'app-id',
blockId: 'block-id',
actionId: 'action-id',
};
const { result } = renderHook(() => useUiKitState(element, context));
await act(async () => {
const [, state] = result.current;
await state({
target: {
value: ['C', 'D'],
},
});
});
expect(result.current[0].value).toEqual(['C', 'D']);
});
});
describe('action function', () => {
const context = BlockContext.ACTION;
it('should handle arrays', async () => {
const element: MultiStaticSelectElement = {
type: 'multi_static_select',
placeholder: { type: 'plain_text', text: '' },
options: [],
initialValue: ['A', 'B'],
appId: 'app-id',
blockId: 'block-id',
actionId: 'action-id',
};
const { result } = renderHook(() => useUiKitState(element, context));
await act(async () => {
const [, action] = result.current;
await action({
target: {
value: ['C', 'D'],
},
});
});
expect(result.current[0].value).toEqual(['C', 'D']);
});
});

View File

@@ -0,0 +1,150 @@
import { useStableCallback, useSafely } from '@rocket.chat/fuselage-hooks';
import * as UiKit from '@rocket.chat/ui-kit';
import { useContext, useMemo, useState } from 'react';
import { UiKitContext } from '../contexts/UiKitContext';
import { getInitialValue } from '../utils/getInitialValue';
const getElementValueFromState = (
actionId: string,
values: Record<
string,
| {
value: unknown;
}
| undefined
>,
initialValue: string | number | string[] | undefined,
) => (values && (values[actionId]?.value as string | number | string[] | undefined)) ?? initialValue;
type UiKitState<TElement extends UiKit.ActionableElement = UiKit.ActionableElement> = {
loading: boolean;
setLoading: (loading: boolean) => void;
error?: string;
value: UiKit.ActionOf<TElement>;
};
export const useUiKitState = <TElement extends UiKit.ActionableElement>(
element: TElement,
context: UiKit.BlockContext,
): [
state: UiKitState<TElement>,
action: (pseudoEvent?: Event | { target: EventTarget } | { target: { value: UiKit.ActionOf<TElement> } }) => Promise<void>,
] => {
const { blockId, actionId, appId, dispatchActionConfig } = element;
const { action, appId: appIdFromContext = undefined, viewId = undefined, updateState } = useContext(UiKitContext);
const initialValue = getInitialValue(element);
const { values, errors } = useContext(UiKitContext);
const _value = getElementValueFromState(actionId, values, initialValue);
const error = Array.isArray(errors)
? errors.find((error) => Object.keys(error).find((key) => key === actionId))?.[actionId]
: errors?.[actionId];
const [value, setValue] = useSafely(useState(_value));
const [loading, setLoading] = useSafely(useState(false));
const actionFunction = useStableCallback(async (e: any) => {
// FIXME: fix typings
const {
target: { value: elValue },
} = e;
setLoading(true);
if (Array.isArray(value)) {
if (Array.isArray(elValue)) {
setValue(elValue);
} else {
const idx = value.findIndex((value) => value === elValue);
if (idx > -1) {
setValue(value.filter((_, i) => i !== idx));
} else {
setValue([...value, elValue]);
}
}
} else {
setValue(elValue);
}
await updateState?.({ blockId, appId, actionId, value: elValue, viewId }, e);
await action(
{
blockId,
appId: appId || appIdFromContext || 'core',
actionId,
value: elValue,
viewId,
},
e,
);
setLoading(false);
});
// Used for triggering actions on text inputs. Removing the load state
// makes the text input field remain focused after running the action
const noLoadStateActionFunction = useStableCallback(async (e: any) => {
// FIXME: fix typings
const {
target: { value },
} = e;
setValue(value);
updateState && (await updateState({ blockId, appId, actionId, value, viewId }, e));
await action(
{
blockId,
appId: appId || appIdFromContext || 'core',
actionId,
value,
viewId,
dispatchActionConfig,
},
e,
);
});
const stateFunction = useStableCallback(async (e: any) => {
// FIXME: fix typings
const {
target: { value },
} = e;
setValue(value);
await updateState?.(
{
blockId,
appId: appId || appIdFromContext || 'core',
actionId,
value,
viewId,
},
e,
);
});
const result: UiKitState = useMemo(() => ({ loading, setLoading, error, value }), [loading, setLoading, error, value]);
if (
element.type === 'plain_text_input' &&
Array.isArray(element?.dispatchActionConfig) &&
element.dispatchActionConfig.includes('on_character_entered')
) {
return [result, noLoadStateActionFunction];
}
if (context === UiKit.BlockContext.SECTION || context === UiKit.BlockContext.ACTION) {
return [result, actionFunction];
}
if (Array.isArray(element?.dispatchActionConfig) && element.dispatchActionConfig.includes('on_item_selected')) {
return [result, actionFunction];
}
return [result, stateFunction];
};

View File

@@ -0,0 +1,5 @@
export * from './hooks/useUiKitState';
export * from './contexts/UiKitContext';
export * from './surfaces';
export { UiKitComponent } from './utils/UiKitComponent';
export { extractInitialStateFromLayout } from './utils/extractInitialStateFromLayout';

View File

@@ -0,0 +1,115 @@
/* eslint-disable new-cap */
import { Banner, Icon } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import { UiKitContext, UiKitBanner } from '..';
import * as payloads from './payloads';
type StoryArgs = {
blocks: readonly UiKit.LayoutBlock[];
type: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
errors: Record<string, string>;
};
export default {
argTypes: {
blocks: { control: 'object' },
type: {
control: {
type: 'radio',
},
options: ['neutral', 'info', 'success', 'warning', 'danger'],
},
errors: { control: 'object' },
},
args: {
type: 'neutral',
},
render: ({ blocks, type, errors }) => (
<UiKitContext.Provider
value={{
action: action('action'),
updateState: action('updateState'),
values: {},
errors,
}}
>
<Banner icon={<Icon name='info' size='x20' />} closeable variant={type}>
{UiKitBanner(blocks)}
</Banner>
</UiKitContext.Provider>
),
} satisfies Meta<StoryArgs>;
const createStory = (blocks: readonly UiKit.LayoutBlock[], errors: Record<string, string> = {}): StoryObj<StoryArgs> => ({
args: {
blocks,
errors,
},
});
export const Divider = createStory(payloads.divider);
export const SectionWithPlainText = createStory(payloads.sectionWithPlainText);
export const SectionWithMrkdwn = createStory(payloads.sectionWithMrkdwn);
export const SectionWithTextFields = createStory(payloads.sectionWithTextFields);
export const SectionWithButtonAccessory = createStory(payloads.sectionWithButtonAccessory);
export const SectionWithImageAccessory = createStory(payloads.sectionWithImageAccessory);
export const SectionWithOverflowMenuAccessory = createStory(payloads.sectionWithOverflowMenuAccessory);
export const SectionWithDatePickerAccessory = createStory(payloads.sectionWithDatePickerAccessory);
export const ImageWithTitle = createStory(payloads.imageWithTitle);
export const ImageWithoutTitle = createStory(payloads.imageWithoutTitle);
export const ActionsWithAllSelects = createStory(payloads.actionsWithAllSelects);
export const ActionsWithFilteredConversationsSelect = createStory(payloads.actionsWithFilteredConversationsSelect);
export const ActionsWithInitializedSelects = createStory(payloads.actionsWithInitializedSelects);
export const ActionsWithButton = createStory(payloads.actionsWithButton);
export const ActionsWithButtonAsLink = createStory(payloads.actionsWithButtonAsLink);
export const ActionsWithDatePicker = createStory(payloads.actionsWithDatePicker);
export const ContextWithPlainText = createStory(payloads.contextWithPlainText);
export const ContextWithMrkdwn = createStory(payloads.contextWithMrkdwn);
export const ContextWithTextAndImages = createStory(payloads.contextWithTextAndImages);
export const InputWithMultilinePlainTextInput = createStory(payloads.inputWithMultilinePlainTextInput, {
'input-0': 'Error',
});
export const InputWithPlainTextInput = createStory(payloads.inputWithPlainTextInput, {
'input-0': 'Error',
});
export const InputWithMultiUsersSelect = createStory(payloads.inputWithMultiUsersSelect, {
'input-0': 'Error',
});
export const InputWithStaticSelect = createStory(payloads.inputWithStaticSelect, {
'input-0': 'Error',
});
export const InputWithDatePicker = createStory(payloads.inputWithDatePicker, {
'input-0': 'Error',
});
export const InputWithLinearScale = createStory(payloads.inputWithLinearScale, {
'input-0': 'Error',
});
export const Conditional = createStory(payloads.conditional);

View File

@@ -0,0 +1,136 @@
/* eslint-disable new-cap */
import {
Message,
MessageLeftContainer,
MessageContainer,
MessageHeader,
MessageNameContainer,
MessageName,
MessageUsername,
MessageRole,
MessageTimestamp,
MessageBody,
MessageToolbarWrapper,
MessageToolbar,
MessageToolbarItem,
Avatar,
} from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import { UiKitContext, UiKitMessage } from '..';
import * as payloads from './payloads';
type StoryArgs = { blocks: readonly UiKit.LayoutBlock[]; errors: Record<string, string> };
export default {
argTypes: {
blocks: { control: 'object' },
},
render: ({ blocks, errors }) => (
<Message clickable>
<MessageLeftContainer>
<Avatar
url='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z'
size='x36'
/>
</MessageLeftContainer>
<MessageContainer>
<MessageHeader>
<MessageNameContainer>
<MessageName>Haylie George</MessageName>
<MessageUsername>@haylie.george</MessageUsername>
</MessageNameContainer>
<MessageRole>Admin</MessageRole>
<MessageRole>User</MessageRole>
<MessageRole>Owner</MessageRole>
<MessageTimestamp>12:00 PM</MessageTimestamp>
</MessageHeader>
<MessageBody>
<UiKitContext.Provider
value={{
action: action('action'),
updateState: action('updateState'),
values: {},
errors,
}}
>
{UiKitMessage(blocks)}
</UiKitContext.Provider>
</MessageBody>
</MessageContainer>
<MessageToolbarWrapper>
<MessageToolbar>
<MessageToolbarItem icon='quote' />
<MessageToolbarItem icon='clock' />
<MessageToolbarItem icon='thread' />
</MessageToolbar>
</MessageToolbarWrapper>
</Message>
),
} satisfies Meta<StoryArgs>;
const createStory = (blocks: readonly UiKit.LayoutBlock[]): StoryObj<StoryArgs> => ({
args: {
blocks,
errors: {},
},
});
export const Divider = createStory(payloads.divider);
export const SectionWithPlainText = createStory(payloads.sectionWithPlainText);
export const SectionWithMrkdwn = createStory(payloads.sectionWithMrkdwn);
export const SectionWithTextFields = createStory(payloads.sectionWithTextFields);
export const SectionWithButtonAccessory = createStory(payloads.sectionWithButtonAccessory);
export const SectionWithImageAccessory = createStory(payloads.sectionWithImageAccessory);
export const SectionWithOverflowMenuAccessory = createStory(payloads.sectionWithOverflowMenuAccessory);
export const SectionWithDatePickerAccessory = createStory(payloads.sectionWithDatePickerAccessory);
export const ImageWithTitle = createStory(payloads.imageWithTitle);
export const ImageWithoutTitle = createStory(payloads.imageWithoutTitle);
export const ActionsWithAllSelects = createStory(payloads.actionsWithAllSelects);
export const ActionsWithFilteredConversationsSelect = createStory(payloads.actionsWithFilteredConversationsSelect);
export const ActionsWithInitializedSelects = createStory(payloads.actionsWithInitializedSelects);
export const ActionsWithButton = createStory(payloads.actionsWithButton);
export const ActionsWithButtonAsLink = createStory(payloads.actionsWithButtonAsLink);
export const ActionsWithDatePicker = createStory(payloads.actionsWithDatePicker);
export const ContextWithPlainText = createStory(payloads.contextWithPlainText);
export const ContextWithMrkdwn = createStory(payloads.contextWithMrkdwn);
export const ContextWithTextAndImages = createStory(payloads.contextWithTextAndImages);
export const Conditional = createStory(payloads.conditional);
export const Preview = createStory(payloads.preview);
export const PreviewWithExternalUrl = createStory(payloads.previewWithExternalUrl);
export const InfoCard = createStory(payloads.infoCard);
export const InfoCardMultiple = createStory(payloads.infoCardMultiple);
export const InfoCardMultipleIcons = createStory(payloads.infoCardMultipleIcons);

View File

@@ -0,0 +1,149 @@
/* eslint-disable new-cap */
import {
AnimatedVisibility,
Button,
ButtonGroup,
Modal,
ModalHeader,
ModalThumb,
ModalContent,
ModalTitle,
ModalFooter,
ModalClose,
} from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import type { Meta, StoryObj } from '@storybook/react';
import type { ReactNode } from 'react';
import { action } from 'storybook/actions';
import { UiKitContext, UiKitModal } from '..';
import * as payloads from './payloads';
type VisibilityType = 'hidden' | 'visible' | 'hiding' | 'unhiding' | undefined;
const DemoModal = ({ children, visible }: { children?: ReactNode; visible: boolean }) => (
<AnimatedVisibility visibility={visible ? (AnimatedVisibility.VISIBLE as VisibilityType) : (AnimatedVisibility.HIDDEN as VisibilityType)}>
<Modal open={visible}>
<ModalHeader>
<ModalThumb url='data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==' />
<ModalTitle>Modal Header</ModalTitle>
<ModalClose onClick={action('close')} />
</ModalHeader>
<ModalContent>{children}</ModalContent>
<ModalFooter>
<ButtonGroup align='end'>
<Button onClick={action('cancel')}>Cancel</Button>
<Button primary onClick={action('submit')}>
Submit
</Button>
</ButtonGroup>
</ModalFooter>
</Modal>
</AnimatedVisibility>
);
type StoryArgs = {
blocks: readonly UiKit.LayoutBlock[];
errors: Record<string, string>;
visible: boolean;
};
export default {
argTypes: {
blocks: { control: 'object' },
errors: { control: 'object' },
visible: { control: 'boolean' },
},
args: {
visible: true,
},
render: ({ blocks, errors, visible }) => (
<DemoModal visible={visible}>
<UiKitContext.Provider
value={{
action: action('action'),
updateState: action('updateState'),
values: {},
errors,
}}
>
{UiKitModal(blocks)}
</UiKitContext.Provider>
</DemoModal>
),
} satisfies Meta<StoryArgs>;
const createStory = (blocks: readonly UiKit.LayoutBlock[], errors: Record<string, string> = {}): StoryObj<StoryArgs> => ({
args: {
blocks,
errors,
},
});
export const Divider = createStory(payloads.divider);
export const SectionWithPlainText = createStory(payloads.sectionWithPlainText);
export const SectionWithMrkdwn = createStory(payloads.sectionWithMrkdwn);
export const SectionWithTextFields = createStory(payloads.sectionWithTextFields);
export const SectionWithButtonAccessory = createStory(payloads.sectionWithButtonAccessory);
export const SectionWithImageAccessory = createStory(payloads.sectionWithImageAccessory);
export const SectionWithOverflowMenuAccessory = createStory(payloads.sectionWithOverflowMenuAccessory);
export const SectionWithDatePickerAccessory = createStory(payloads.sectionWithDatePickerAccessory);
export const ImageWithTitle = createStory(payloads.imageWithTitle);
export const ImageWithoutTitle = createStory(payloads.imageWithoutTitle);
export const ActionsWithAllSelects = createStory(payloads.actionsWithAllSelects);
export const ActionsWithFilteredConversationsSelect = createStory(payloads.actionsWithFilteredConversationsSelect);
export const ActionsWithInitializedSelects = createStory(payloads.actionsWithInitializedSelects);
export const ActionsWithButton = createStory(payloads.actionsWithButton);
export const ActionsWithButtonAsLink = createStory(payloads.actionsWithButtonAsLink);
export const ActionsWithDatePicker = createStory(payloads.actionsWithDatePicker);
export const ContextWithPlainText = createStory(payloads.contextWithPlainText);
export const ContextWithMrkdwn = createStory(payloads.contextWithMrkdwn);
export const ContextWithTextAndImages = createStory(payloads.contextWithTextAndImages);
export const InputWithMultilinePlainTextInput = createStory(payloads.inputWithMultilinePlainTextInput, {
'input-0': 'Error',
});
export const InputWithPlainTextInput = createStory(payloads.inputWithPlainTextInput, {
'input-0': 'Error',
});
export const InputWithMultiUsersSelect = createStory(payloads.inputWithMultiUsersSelect, {
'input-0': 'Error',
});
export const InputWithStaticSelect = createStory(payloads.inputWithStaticSelect, {
'input-0': 'Error',
});
export const InputWithDatePicker = createStory(payloads.inputWithDatePicker, {
'input-0': 'Error',
});
export const InputWithLinearScale = createStory(payloads.inputWithLinearScale, {
'input-0': 'Error',
});
export const Conditional = createStory(payloads.conditional);
export const Callout = createStory(payloads.callout);
export const CalloutWithAction = createStory(payloads.calloutWithAction);

View File

@@ -0,0 +1,218 @@
import type * as UiKit from '@rocket.chat/ui-kit';
export const actionsWithAllSelects: readonly UiKit.LayoutBlock[] = [
{
type: 'actions',
elements: [
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'conversations_select',
// placeholder: {
// type: 'plain_text',
// text: 'Select a conversation',
// emoji: true,
// },
},
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'channels_select',
placeholder: {
type: 'plain_text',
text: 'Select a channel',
emoji: true,
},
},
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'users_select',
placeholder: {
type: 'plain_text',
text: 'Select a user',
emoji: true,
},
},
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'static_select',
placeholder: {
type: 'plain_text',
text: 'Select an item',
emoji: true,
},
options: [
{
text: {
type: 'plain_text',
text: '*this is plain_text text*',
emoji: true,
},
value: 'value-0',
},
{
text: {
type: 'plain_text',
text: '*this is plain_text text*',
emoji: true,
},
value: 'value-1',
},
{
text: {
type: 'plain_text',
text: '*this is plain_text text*',
emoji: true,
},
value: 'value-2',
},
],
},
],
},
] as const;
export const actionsWithFilteredConversationsSelect: readonly UiKit.LayoutBlock[] = [
{
type: 'actions',
elements: [
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'conversations_select',
// placeholder: {
// type: 'plain_text',
// text: 'Select private conversation',
// emoji: true,
// },
// filter: {
// include: ['private'],
// },
},
],
},
] as const;
export const actionsWithInitializedSelects: readonly UiKit.LayoutBlock[] = [
{
type: 'actions',
elements: [
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'conversations_select',
// placeholder: {
// type: 'plain_text',
// text: 'Select a conversation',
// emoji: true,
// },
// initialConversation: 'D123',
},
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'users_select',
placeholder: {
type: 'plain_text',
text: 'Select a user',
emoji: true,
},
// initialUser: 'U123',
},
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'channels_select',
// placeholder: {
// type: 'plain_text',
// text: 'Select a channel',
// emoji: true,
// },
// initialChannel: 'C123',
},
],
},
] as const;
export const actionsWithButton: readonly UiKit.LayoutBlock[] = [
{
type: 'actions',
elements: [
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'button',
text: {
type: 'plain_text',
text: 'Click Me',
emoji: true,
},
value: 'click_me_123',
},
],
},
] as const;
export const actionsWithButtonAsLink: readonly UiKit.LayoutBlock[] = [
{
type: 'actions',
elements: [
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'button',
text: {
type: 'plain_text',
text: 'Click Me',
emoji: true,
},
url: 'https://rocket.chat',
value: 'click_me_123',
},
],
},
] as const;
export const actionsWithDatePicker: readonly UiKit.LayoutBlock[] = [
{
type: 'actions',
elements: [
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'datepicker',
initialDate: '1990-04-28',
placeholder: {
type: 'plain_text',
text: 'Select a date',
emoji: true,
},
},
{
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'datepicker',
initialDate: '1990-04-28',
placeholder: {
type: 'plain_text',
text: 'Select a date',
emoji: true,
},
},
],
},
] as const;

View File

@@ -0,0 +1,41 @@
import type * as UiKit from '@rocket.chat/ui-kit';
export const callout: readonly UiKit.LayoutBlock[] = [
{
type: 'callout',
title: {
type: 'plain_text',
text: 'Callout',
},
text: {
type: 'plain_text',
text: 'This is a callout',
},
variant: 'info',
},
] as const;
export const calloutWithAction: readonly UiKit.LayoutBlock[] = [
{
type: 'callout',
title: {
type: 'plain_text',
text: 'Callout',
},
text: {
type: 'plain_text',
text: 'This is a callout',
},
variant: 'info',
accessory: {
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'button',
text: {
type: 'plain_text',
text: 'Action',
},
},
},
];

View File

@@ -0,0 +1,20 @@
import type * as UiKit from '@rocket.chat/ui-kit';
export const conditional: readonly UiKit.LayoutBlock[] = [
{
type: 'conditional',
when: {
engine: ['rocket.chat'],
},
render: [
{
type: 'section',
text: {
type: 'plain_text',
text: 'This is a plain text section block.',
emoji: true,
},
},
],
},
] as const;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
import type * as UiKit from '@rocket.chat/ui-kit';
export const divider: readonly UiKit.LayoutBlock[] = [
{
type: 'divider',
},
] as const;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
export * from './actions';
export * from './conditional';
export * from './callout';
export * from './context';
export * from './divider';
export * from './image';
export * from './input';
export * from './section';
export * from './preview';
export * from './infoCard';

View File

@@ -0,0 +1,75 @@
import type * as UiKit from '@rocket.chat/ui-kit';
const getIconButtonPayload = (icon: Partial<UiKit.IconElement>, label?: string): UiKit.IconButtonElement => ({
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'icon_button',
icon: { type: 'icon', icon: 'info', variant: 'default', ...icon },
label: label ?? 'Call history',
});
export const infoCard: readonly UiKit.InfoCardBlock[] = [
{
type: 'info_card',
rows: [
{
background: 'default',
elements: [
{ type: 'icon', icon: 'phone-issue', variant: 'danger' },
{ type: 'plain_text', text: 'Voice call failed' },
],
action: getIconButtonPayload({ icon: 'info' }),
},
],
},
];
export const infoCardMultiple: readonly UiKit.InfoCardBlock[] = [
{
type: 'info_card',
rows: [
{
background: 'default',
elements: [
{ type: 'icon', icon: 'phone-off', variant: 'warning' },
{ type: 'plain_text', text: 'Call ended' },
],
action: getIconButtonPayload({ icon: 'info' }),
},
{
background: 'secondary',
elements: [{ type: 'plain_text', text: '00:58' }],
},
],
},
];
export const infoCardMultipleIcons: readonly UiKit.InfoCardBlock[] = [
{
type: 'info_card',
rows: [
{
background: 'default',
elements: [
{ type: 'plain_text', text: 'Framed icons' },
{ type: 'icon', icon: 'phone-off', variant: 'default', framed: true },
{ type: 'icon', icon: 'clock', variant: 'warning', framed: true },
{ type: 'icon', icon: 'phone-question-mark', variant: 'warning', framed: true },
{ type: 'icon', icon: 'phone-issue', variant: 'danger', framed: true },
],
},
{
background: 'secondary',
elements: [
{ type: 'plain_text', text: 'Icons' },
{ type: 'icon', icon: 'phone-off', variant: 'default' },
{ type: 'icon', icon: 'clock', variant: 'warning' },
{ type: 'icon', icon: 'phone-question-mark', variant: 'warning' },
{ type: 'icon', icon: 'phone-issue', variant: 'danger' },
{ type: 'icon', icon: 'info', variant: 'secondary' },
],
},
],
},
];

View File

@@ -0,0 +1,149 @@
import type * as UiKit from '@rocket.chat/ui-kit';
export const inputWithMultilinePlainTextInput: readonly UiKit.LayoutBlock[] = [
{
type: 'input',
element: {
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
type: 'plain_text_input',
multiline: true,
actionId: 'input-0',
},
label: {
type: 'plain_text',
text: 'Label',
emoji: true,
},
},
] as const;
export const inputWithPlainTextInput: readonly UiKit.LayoutBlock[] = [
{
type: 'input',
element: {
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
type: 'plain_text_input',
actionId: 'input-0',
},
label: {
type: 'plain_text',
text: 'Label',
emoji: true,
},
},
] as const;
export const inputWithMultiUsersSelect: readonly UiKit.LayoutBlock[] = [
{
type: 'input',
element: {
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
type: 'multi_users_select',
// placeholder: {
// type: 'plain_text',
// text: 'Select users',
// emoji: true,
// },
actionId: 'input-0',
},
label: {
type: 'plain_text',
text: 'Label',
emoji: true,
},
},
] as const;
export const inputWithStaticSelect: readonly UiKit.LayoutBlock[] = [
{
type: 'input',
element: {
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
type: 'static_select',
placeholder: {
type: 'plain_text',
text: 'Select an item',
emoji: true,
},
options: [
{
text: {
type: 'plain_text',
text: '*this is plain_text text*',
emoji: true,
},
value: 'value-0',
},
{
text: {
type: 'plain_text',
text: '*this is plain_text text*',
emoji: true,
},
value: 'value-1',
},
{
text: {
type: 'plain_text',
text: '*this is plain_text text*',
emoji: true,
},
value: 'value-2',
},
],
actionId: 'input-0',
},
label: {
type: 'plain_text',
text: 'Label',
emoji: true,
},
},
] as const;
export const inputWithDatePicker: readonly UiKit.LayoutBlock[] = [
{
type: 'input',
element: {
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
type: 'datepicker',
initialDate: '1990-04-28',
placeholder: {
type: 'plain_text',
text: 'Select a date',
emoji: true,
},
actionId: 'input-0',
},
label: {
type: 'plain_text',
text: 'Label',
emoji: true,
},
},
] as const;
export const inputWithLinearScale: readonly UiKit.LayoutBlock[] = [
{
type: 'input',
element: {
appId: 'dummy-app-id',
blockId: 'dummy-block-id',
type: 'linear_scale',
minValue: 0,
maxValue: 10,
initialValue: 7,
actionId: 'input-0',
},
label: {
type: 'plain_text',
text: 'Label',
emoji: true,
},
},
] as const;

View File

@@ -0,0 +1,65 @@
// import * as UiKit from '@rocket.chat/ui-kit';
import type { PreviewBlock } from '@rocket.chat/ui-kit';
import img from './img';
export const preview: PreviewBlock[] = [
{
type: 'preview',
title: [
{
type: 'plain_text',
text: 'I Need a Marg',
emoji: true,
},
],
description: [
{
type: 'plain_text',
text: 'I Need a Description',
emoji: true,
},
],
thumb: { url: img },
footer: {
type: 'context',
elements: [
{
type: 'plain_text',
text: 'google.com',
},
],
},
},
];
export const previewWithExternalUrl: PreviewBlock[] = [
{
type: 'preview',
title: [
{
type: 'plain_text',
text: 'I Need a Marg',
emoji: true,
},
],
description: [
{
type: 'plain_text',
text: 'I Need a Description',
emoji: true,
},
],
// thumb: { url: img },
footer: {
type: 'context',
elements: [
{
type: 'plain_text',
text: 'google.com',
},
],
},
externalUrl: 'https://rocketchat.github.io/Rocket.Chat.Fuselage/?path=/story/*',
},
];

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
import { Margins } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { Surface } from './Surface';
export type BannerSurfaceProps = {
children?: ReactNode;
};
const BannerSurface = ({ children }: BannerSurfaceProps) => (
<Surface type='banner'>
<Margins block={8}>{children}</Margins>
</Surface>
);
export default BannerSurface;

View File

@@ -0,0 +1,7 @@
import { FuselageSurfaceRenderer, renderTextObject } from './FuselageSurfaceRenderer';
export class BannerSurfaceRenderer extends FuselageSurfaceRenderer {
override plain_text = renderTextObject;
override mrkdwn = renderTextObject;
}

View File

@@ -0,0 +1,16 @@
import { Margins } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { Surface } from './Surface';
export type ContextualBarSurfaceProps = {
children?: ReactNode;
};
const ContextualBarSurface = ({ children }: ContextualBarSurfaceProps) => (
<Surface type='contextualBar'>
<Margins blockEnd={16}>{children}</Margins>
</Surface>
);
export default ContextualBarSurface;

View File

@@ -0,0 +1,28 @@
import * as UiKit from '@rocket.chat/ui-kit';
import type { ReactNode } from 'react';
import { FuselageSurfaceRenderer, renderTextObject } from './FuselageSurfaceRenderer';
import TabNavigationBlock from '../blocks/TabNavigationBlock';
import { AppIdProvider } from '../contexts/AppIdContext';
export class ContextualBarSurfaceRenderer extends FuselageSurfaceRenderer {
public constructor() {
super(['actions', 'context', 'divider', 'image', 'input', 'section', 'preview', 'callout', 'tab_navigation']);
}
override plain_text = renderTextObject;
override mrkdwn = renderTextObject;
tab_navigation(block: UiKit.ExperimentalTabNavigationBlock, context: UiKit.BlockContext, index: number): ReactNode {
if (context === UiKit.BlockContext.BLOCK) {
return (
<AppIdProvider key={index} appId={block.appId}>
<TabNavigationBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return null;
}
}

View File

@@ -0,0 +1,28 @@
import * as UiKit from '@rocket.chat/ui-kit';
import type { ReactNode } from 'react';
import { FuselageSurfaceRenderer, renderTextObject } from './FuselageSurfaceRenderer';
import VideoConferenceBlock from '../blocks/VideoConferenceBlock';
import { AppIdProvider } from '../contexts/AppIdContext';
export class FuselageMessageSurfaceRenderer extends FuselageSurfaceRenderer {
public constructor() {
super(['actions', 'context', 'divider', 'image', 'input', 'section', 'preview', 'video_conf', 'info_card']);
}
override plain_text = renderTextObject;
override mrkdwn = renderTextObject;
video_conf(block: UiKit.VideoConferenceBlock, context: UiKit.BlockContext, index: number): ReactNode {
if (context === UiKit.BlockContext.BLOCK) {
return (
<AppIdProvider key={index} appId={block.appId}>
<VideoConferenceBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return null;
}
}

View File

@@ -0,0 +1,372 @@
import * as UiKit from '@rocket.chat/ui-kit';
import type { ReactElement } from 'react';
import ActionsBlock from '../blocks/ActionsBlock';
import CalloutBlock from '../blocks/CalloutBlock';
import ContextBlock from '../blocks/ContextBlock';
import DividerBlock from '../blocks/DividerBlock';
import ImageBlock from '../blocks/ImageBlock';
import InfoCard from '../blocks/InfoCard';
import InputBlock from '../blocks/InputBlock';
import PreviewBlock from '../blocks/PreviewBlock';
import SectionBlock from '../blocks/SectionBlock';
import { AppIdProvider } from '../contexts/AppIdContext';
import ButtonElement from '../elements/ButtonElement';
import ChannelsSelectElement from '../elements/ChannelsSelectElement/ChannelsSelectElement';
import MultiChannelsSelectElement from '../elements/ChannelsSelectElement/MultiChannelsSelectElement';
import CheckboxElement from '../elements/CheckboxElement';
import DatePickerElement from '../elements/DatePickerElement';
import IconButtonElement from '../elements/IconButtonElement';
import IconElement from '../elements/IconElement';
import ImageElement from '../elements/ImageElement';
import LinearScaleElement from '../elements/LinearScaleElement';
import MarkdownTextElement from '../elements/MarkdownTextElement';
import MultiStaticSelectElement from '../elements/MultiStaticSelectElement';
import OverflowElement from '../elements/OverflowElement';
import PlainTextElement from '../elements/PlainTextElement';
import PlainTextInputElement from '../elements/PlainTextInputElement';
import RadioButtonElement from '../elements/RadioButtonElement';
import StaticSelectElement from '../elements/StaticSelectElement';
import TimePickerElement from '../elements/TimePickerElement';
import ToggleSwitchElement from '../elements/ToggleSwitchElement';
import MultiUsersSelectElement from '../elements/UsersSelectElement/MultiUsersSelectElement';
import UsersSelectElement from '../elements/UsersSelectElement/UsersSelectElement';
type TextObjectRenderers = {
[TTextObject in UiKit.TextObject as TTextObject['type']]: (textObject: TTextObject, index: number) => ReactElement<any> | null;
};
const textObjectRenderers: TextObjectRenderers = {
plain_text: (textObject, index) => <PlainTextElement key={index} textObject={textObject} />,
mrkdwn: (textObject, index) => <MarkdownTextElement key={index} textObject={textObject} />,
};
export const renderTextObject = (textObject: UiKit.TextObject, context: UiKit.BlockContext, index: number) => {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
switch (textObject.type) {
case 'plain_text':
return textObjectRenderers.plain_text(textObject, index);
case 'mrkdwn':
return textObjectRenderers.mrkdwn(textObject, index);
}
};
const isImageBlock = (
_elementOrBlock: UiKit.ImageBlock | UiKit.ImageElement,
context: UiKit.BlockContext,
): _elementOrBlock is UiKit.ImageBlock => context === UiKit.BlockContext.BLOCK;
type FuselageSurfaceRendererProps = ConstructorParameters<typeof UiKit.SurfaceRenderer>[0];
export abstract class FuselageSurfaceRenderer extends UiKit.SurfaceRenderer<ReactElement<any>> {
public constructor(allowedBlocks?: FuselageSurfaceRendererProps) {
super(allowedBlocks || ['actions', 'context', 'divider', 'image', 'input', 'section', 'preview', 'info_card']);
}
public plain_text(textObject: UiKit.PlainText, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return textObjectRenderers.plain_text(textObject, index);
}
public mrkdwn(textObject: UiKit.TextObject, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return <MarkdownTextElement key={index} textObject={textObject} />;
}
actions(block: UiKit.ActionsBlock, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return (
<AppIdProvider key={index} appId={block.appId}>
<ActionsBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return null;
}
preview(block: UiKit.PreviewBlock, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context !== UiKit.BlockContext.BLOCK) {
return null;
}
return <PreviewBlock key={index} block={block} context={context} index={index} surfaceRenderer={this} />;
}
context(block: UiKit.ContextBlock, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return (
<AppIdProvider key={index} appId={block.appId}>
<ContextBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return null;
}
divider(block: UiKit.DividerBlock, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return (
<AppIdProvider key={index} appId={block.appId}>
<DividerBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return null;
}
image(block: UiKit.ImageBlock | UiKit.ImageElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (isImageBlock(block, context)) {
return (
<AppIdProvider key={index} appId={block.appId}>
<ImageBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return <ImageElement key={index} block={block} context={context} index={index} surfaceRenderer={this} />;
}
input(block: UiKit.InputBlock, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return (
<AppIdProvider key={block.element.actionId || index} appId={block.appId}>
<InputBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return null;
}
section(block: UiKit.SectionBlock, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return (
<AppIdProvider key={index} appId={block.appId}>
<SectionBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return null;
}
button(block: UiKit.ButtonElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={index} appId={block.appId}>
<ButtonElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
datepicker(block: UiKit.DatePickerElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<DatePickerElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
static_select(block: UiKit.StaticSelectElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<StaticSelectElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
multi_static_select(block: UiKit.MultiStaticSelectElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<MultiStaticSelectElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
overflow(block: UiKit.OverflowElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={index} appId={block.appId}>
<OverflowElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
plain_text_input(block: UiKit.PlainTextInputElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<PlainTextInputElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
linear_scale(block: UiKit.LinearScaleElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<LinearScaleElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
toggle_switch(block: UiKit.ToggleSwitchElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<ToggleSwitchElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
radio_button(block: UiKit.RadioButtonElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<RadioButtonElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
checkbox(block: UiKit.CheckboxElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<CheckboxElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
callout(block: UiKit.CalloutBlock, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return (
<AppIdProvider key={index} appId={block.appId}>
<CalloutBlock block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
return null;
}
time_picker(block: UiKit.TimePickerElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<TimePickerElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
users_select(block: UiKit.UsersSelectElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.FORM) {
return <UsersSelectElement block={block} context={context} index={index} surfaceRenderer={this} />;
}
return null;
}
channels_select(block: UiKit.ChannelsSelectElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.FORM) {
return <ChannelsSelectElement block={block} context={context} index={index} surfaceRenderer={this} />;
}
return null;
}
multi_users_select(block: UiKit.MultiUsersSelectElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.FORM) {
return <MultiUsersSelectElement block={block} context={context} index={index} surfaceRenderer={this} />;
}
return null;
}
multi_channels_select(block: UiKit.MultiChannelsSelectElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.FORM) {
return <MultiChannelsSelectElement block={block} context={context} index={index} surfaceRenderer={this} />;
}
return null;
}
info_card(block: UiKit.InfoCardBlock, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context !== UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={index} appId={block.appId}>
<InfoCard block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
icon(block: UiKit.IconElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return <IconElement block={block} context={context} index={index} surfaceRenderer={this} />;
}
icon_button(block: UiKit.IconButtonElement, context: UiKit.BlockContext, index: number): ReactElement<any> | null {
if (context === UiKit.BlockContext.BLOCK) {
return null;
}
return (
<AppIdProvider key={block.actionId || index} appId={block.appId}>
<IconButtonElement block={block} context={context} index={index} surfaceRenderer={this} />
</AppIdProvider>
);
}
}

View File

@@ -0,0 +1,16 @@
import { Margins } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { Surface } from './Surface';
export type MessageSurfaceProps = {
children?: ReactNode;
};
const MessageSurface = ({ children }: MessageSurfaceProps) => (
<Surface type='message'>
<Margins blockEnd={16}>{children}</Margins>
</Surface>
);
export default MessageSurface;

View File

@@ -0,0 +1,16 @@
import { Margins } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { Surface } from './Surface';
export type ModalSurfaceProps = {
children?: ReactNode;
};
const ModalSurface = ({ children }: ModalSurfaceProps) => (
<Surface type='modal'>
<Margins blockEnd={16}>{children}</Margins>
</Surface>
);
export default ModalSurface;

View File

@@ -0,0 +1,11 @@
import { FuselageSurfaceRenderer, renderTextObject } from './FuselageSurfaceRenderer';
export class ModalSurfaceRenderer extends FuselageSurfaceRenderer {
public constructor() {
super(['actions', 'context', 'divider', 'image', 'input', 'section', 'preview', 'callout']);
}
override plain_text = renderTextObject;
override mrkdwn = renderTextObject;
}

View File

@@ -0,0 +1,10 @@
import type { ContextType, ReactNode } from 'react';
import { SurfaceContext } from '../contexts/SurfaceContext';
export type SurfaceProps = {
children: ReactNode;
type: ContextType<typeof SurfaceContext>;
};
export const Surface = ({ children, type }: SurfaceProps) => <SurfaceContext.Provider value={type}>{children}</SurfaceContext.Provider>;

View File

@@ -0,0 +1,17 @@
import type * as UiKit from '@rocket.chat/ui-kit';
import type { ComponentType, ReactElement, ReactNode } from 'react';
export const createSurfaceRenderer = <S extends UiKit.SurfaceRenderer<ReactElement<any>>>(
SurfaceComponent: ComponentType<{ children: ReactNode }>,
surfaceRenderer: S,
) =>
function Surface(blocks: readonly UiKit.LayoutBlock[], conditions: UiKit.Conditions = {}) {
return (
<SurfaceComponent>
{surfaceRenderer.render(blocks, {
engine: 'rocket.chat',
...conditions,
})}
</SurfaceComponent>
);
};

View File

@@ -0,0 +1,23 @@
import BannerSurface from './BannerSurface';
import { BannerSurfaceRenderer } from './BannerSurfaceRenderer';
import ContextualBarSurface from './ContextualBarSurface';
import { ContextualBarSurfaceRenderer } from './ContextualBarSurfaceRenderer';
import { FuselageMessageSurfaceRenderer } from './FuselageMessageSurfaceRenderer';
import { FuselageSurfaceRenderer, renderTextObject } from './FuselageSurfaceRenderer';
import MessageSurface from './MessageSurface';
import ModalSurface from './ModalSurface';
import { ModalSurfaceRenderer } from './ModalSurfaceRenderer';
import { Surface } from './Surface';
import { createSurfaceRenderer } from './createSurfaceRenderer';
export const bannerParser = new BannerSurfaceRenderer();
export const messageParser = new FuselageMessageSurfaceRenderer();
export const modalParser = new ModalSurfaceRenderer();
export const contextualBarParser = new ContextualBarSurfaceRenderer();
export const UiKitBanner = createSurfaceRenderer(BannerSurface, bannerParser);
export const UiKitMessage = createSurfaceRenderer(MessageSurface, messageParser);
export const UiKitModal = createSurfaceRenderer(ModalSurface, modalParser);
export const UiKitContextualBar = createSurfaceRenderer(ContextualBarSurface, contextualBarParser);
export { createSurfaceRenderer, Surface, FuselageSurfaceRenderer, renderTextObject };

View File

@@ -0,0 +1,11 @@
import type { Box } from '@rocket.chat/fuselage';
import type * as UiKit from '@rocket.chat/ui-kit';
import type { ComponentProps, ReactElement } from 'react';
export type BlockProps<B extends UiKit.Block> = {
className?: ComponentProps<typeof Box>['className'];
block: B;
context: UiKit.BlockContext;
index: number;
surfaceRenderer: UiKit.SurfaceRenderer<ReactElement<any>>;
};

View File

@@ -0,0 +1,10 @@
import type * as UiKit from '@rocket.chat/ui-kit';
import type { UiKitBanner, UiKitContextualBar, UiKitMessage, UiKitModal } from '../surfaces';
export type UiKitComponentProps = {
render: typeof UiKitBanner | typeof UiKitMessage | typeof UiKitModal | typeof UiKitContextualBar;
blocks: UiKit.LayoutBlock[];
};
export const UiKitComponent = ({ render, blocks }: UiKitComponentProps) => render(blocks);

View File

@@ -0,0 +1,34 @@
import type * as UiKit from '@rocket.chat/ui-kit';
import { type Value, getInitialValue } from './getInitialValue';
import { hasElement } from './hasElement';
import { hasElements } from './hasElements';
const isActionableElement = (element: UiKit.BlockElement | UiKit.TextObject): element is UiKit.ActionableElement =>
'actionId' in element && typeof element.actionId === 'string';
const reduceInitialValuesFromLayoutBlock = (state: { [actionId: string]: Value }, block: UiKit.LayoutBlock) => {
if (hasElement(block)) {
if (isActionableElement(block.element)) {
state[block.element.actionId] = {
value: getInitialValue(block.element),
blockId: block.blockId,
};
}
}
if (hasElements(block)) {
for (const element of block.elements) {
if (isActionableElement(element)) {
state[element.actionId] = {
value: getInitialValue(element),
blockId: block.blockId,
};
}
}
}
return state;
};
export const extractInitialStateFromLayout = (blocks: UiKit.LayoutBlock[]) => blocks.reduce(reduceInitialValuesFromLayoutBlock, {});

View File

@@ -0,0 +1,26 @@
import type * as UiKit from '@rocket.chat/ui-kit';
export type Value = { value: unknown; blockId?: string };
const hasInitialValue = (element: UiKit.ActionableElement): element is UiKit.ActionableElement & { initialValue: number | string } =>
'initialValue' in element;
const hasInitialTime = (element: UiKit.ActionableElement): element is UiKit.ActionableElement & { initialTime: string } =>
'initialTime' in element;
const hasInitialDate = (element: UiKit.ActionableElement): element is UiKit.ActionableElement & { initialDate: string } =>
'initialDate' in element;
const hasInitialOption = (element: UiKit.ActionableElement): element is UiKit.ActionableElement & { initialOption: UiKit.Option } =>
'initialOption' in element;
const hasInitialOptions = (element: UiKit.ActionableElement): element is UiKit.ActionableElement & { initialOptions: UiKit.Option[] } =>
'initialOptions' in element;
export const getInitialValue = (element: UiKit.ActionableElement) =>
(hasInitialValue(element) && element.initialValue) ||
(hasInitialTime(element) && element.initialTime) ||
(hasInitialDate(element) && element.initialDate) ||
(hasInitialOption(element) && element.initialOption.value) ||
(hasInitialOptions(element) && element.initialOptions.map((option) => option.value)) ||
undefined;

View File

@@ -0,0 +1,5 @@
import type * as UiKit from '@rocket.chat/ui-kit';
type LayoutBlockWithElement = Extract<UiKit.LayoutBlock, { element: UiKit.BlockElement | UiKit.TextObject }>;
export const hasElement = (block: UiKit.LayoutBlock): block is LayoutBlockWithElement => 'element' in block;

View File

@@ -0,0 +1,6 @@
import type * as UiKit from '@rocket.chat/ui-kit';
type LayoutBlockWithElements = Extract<UiKit.LayoutBlock, { elements: readonly (UiKit.BlockElement | UiKit.TextObject)[] }>;
export const hasElements = (block: UiKit.LayoutBlock): block is LayoutBlockWithElements =>
'elements' in block && Array.isArray(block.elements);

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist", "src/**/*.stories.tsx", ".storybook/**/*", "./src/**/*.spec.ts", "./src/**/*.spec.tsx"]
}

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