snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
4
packages/fuselage-ui-kit/.gitignore
vendored
Normal file
4
packages/fuselage-ui-kit/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/dist/*
|
||||
/storybook-static/
|
||||
/bundle-report.html
|
||||
/src/version.ts
|
||||
5
packages/fuselage-ui-kit/.storybook/main.ts
Normal file
5
packages/fuselage-ui-kit/.storybook/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import baseConfig from '@rocket.chat/storybook-config/main';
|
||||
|
||||
export default baseConfig({
|
||||
stories: ['../src/**/*.stories.tsx', '../src/**/stories.tsx'],
|
||||
});
|
||||
15
packages/fuselage-ui-kit/.storybook/preview.tsx
Normal file
15
packages/fuselage-ui-kit/.storybook/preview.tsx
Normal 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;
|
||||
3992
packages/fuselage-ui-kit/CHANGELOG.md
Normal file
3992
packages/fuselage-ui-kit/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
102
packages/fuselage-ui-kit/README.md
Normal file
102
packages/fuselage-ui-kit/README.md
Normal 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
|
||||
|
||||
---
|
||||
|
||||
[](https://www.npmjs.com/package/@rocket.chat/icons/v/latest) [](https://www.npmjs.com/package/@rocket.chat/icons/v/next)  [](https://rocketchat.github.io/Rocket.Chat.Fuselage/fuselage-ui-kit)  
|
||||
|
||||
   
|
||||
|
||||
<!--/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)-->
|
||||
12
packages/fuselage-ui-kit/jest.config.ts
Normal file
12
packages/fuselage-ui-kit/jest.config.ts
Normal 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;
|
||||
104
packages/fuselage-ui-kit/package.json
Normal file
104
packages/fuselage-ui-kit/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
30
packages/fuselage-ui-kit/src/blocks/ActionsBlock.Action.tsx
Normal file
30
packages/fuselage-ui-kit/src/blocks/ActionsBlock.Action.tsx
Normal 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;
|
||||
46
packages/fuselage-ui-kit/src/blocks/ActionsBlock.tsx
Normal file
46
packages/fuselage-ui-kit/src/blocks/ActionsBlock.tsx
Normal 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);
|
||||
18
packages/fuselage-ui-kit/src/blocks/CalloutBlock.tsx
Normal file
18
packages/fuselage-ui-kit/src/blocks/CalloutBlock.tsx
Normal 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;
|
||||
32
packages/fuselage-ui-kit/src/blocks/ContextBlock.Item.tsx
Normal file
32
packages/fuselage-ui-kit/src/blocks/ContextBlock.Item.tsx
Normal 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;
|
||||
30
packages/fuselage-ui-kit/src/blocks/ContextBlock.tsx
Normal file
30
packages/fuselage-ui-kit/src/blocks/ContextBlock.tsx
Normal 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);
|
||||
11
packages/fuselage-ui-kit/src/blocks/DividerBlock.tsx
Normal file
11
packages/fuselage-ui-kit/src/blocks/DividerBlock.tsx
Normal 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);
|
||||
24
packages/fuselage-ui-kit/src/blocks/ImageBlock.styles.tsx
Normal file
24
packages/fuselage-ui-kit/src/blocks/ImageBlock.styles.tsx
Normal 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;
|
||||
`;
|
||||
83
packages/fuselage-ui-kit/src/blocks/ImageBlock.tsx
Normal file
83
packages/fuselage-ui-kit/src/blocks/ImageBlock.tsx
Normal 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);
|
||||
62
packages/fuselage-ui-kit/src/blocks/InfoCard.tsx
Normal file
62
packages/fuselage-ui-kit/src/blocks/InfoCard.tsx
Normal 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;
|
||||
32
packages/fuselage-ui-kit/src/blocks/InputBlock.tsx
Normal file
32
packages/fuselage-ui-kit/src/blocks/InputBlock.tsx
Normal 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);
|
||||
59
packages/fuselage-ui-kit/src/blocks/PreviewBlock.tsx
Normal file
59
packages/fuselage-ui-kit/src/blocks/PreviewBlock.tsx
Normal 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);
|
||||
28
packages/fuselage-ui-kit/src/blocks/SectionBlock.Fields.tsx
Normal file
28
packages/fuselage-ui-kit/src/blocks/SectionBlock.Fields.tsx
Normal 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;
|
||||
44
packages/fuselage-ui-kit/src/blocks/SectionBlock.tsx
Normal file
44
packages/fuselage-ui-kit/src/blocks/SectionBlock.tsx
Normal 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);
|
||||
41
packages/fuselage-ui-kit/src/blocks/TabNavigationBlock.tsx
Normal file
41
packages/fuselage-ui-kit/src/blocks/TabNavigationBlock.tsx
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
};
|
||||
@@ -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';
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './VideoConferenceBlock';
|
||||
28
packages/fuselage-ui-kit/src/contexts/AppIdContext.tsx
Normal file
28
packages/fuselage-ui-kit/src/contexts/AppIdContext.tsx
Normal 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;
|
||||
};
|
||||
5
packages/fuselage-ui-kit/src/contexts/SurfaceContext.ts
Normal file
5
packages/fuselage-ui-kit/src/contexts/SurfaceContext.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type SurfaceContextValue = 'attachment' | 'banner' | 'message' | 'modal' | 'contextualBar' | 'custom';
|
||||
|
||||
export const SurfaceContext = createContext<SurfaceContextValue>('message');
|
||||
34
packages/fuselage-ui-kit/src/contexts/UiKitContext.ts
Normal file
34
packages/fuselage-ui-kit/src/contexts/UiKitContext.ts
Normal 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
4
packages/fuselage-ui-kit/src/css.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
// TS7 errors on side-effect imports of `.css` (TS2882) without a declaration.
|
||||
declare module '*.css' {
|
||||
export = undefined;
|
||||
}
|
||||
57
packages/fuselage-ui-kit/src/elements/ButtonElement.tsx
Normal file
57
packages/fuselage-ui-kit/src/elements/ButtonElement.tsx
Normal 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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
31
packages/fuselage-ui-kit/src/elements/CheckboxElement.tsx
Normal file
31
packages/fuselage-ui-kit/src/elements/CheckboxElement.tsx
Normal 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;
|
||||
30
packages/fuselage-ui-kit/src/elements/DatePickerElement.tsx
Normal file
30
packages/fuselage-ui-kit/src/elements/DatePickerElement.tsx
Normal 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;
|
||||
37
packages/fuselage-ui-kit/src/elements/IconButtonElement.tsx
Normal file
37
packages/fuselage-ui-kit/src/elements/IconButtonElement.tsx
Normal 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;
|
||||
46
packages/fuselage-ui-kit/src/elements/IconElement.tsx
Normal file
46
packages/fuselage-ui-kit/src/elements/IconElement.tsx
Normal 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;
|
||||
@@ -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;
|
||||
`;
|
||||
18
packages/fuselage-ui-kit/src/elements/ImageElement.tsx
Normal file
18
packages/fuselage-ui-kit/src/elements/ImageElement.tsx
Normal 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;
|
||||
63
packages/fuselage-ui-kit/src/elements/LinearScaleElement.tsx
Normal file
63
packages/fuselage-ui-kit/src/elements/LinearScaleElement.tsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
66
packages/fuselage-ui-kit/src/elements/OverflowElement.tsx
Normal file
66
packages/fuselage-ui-kit/src/elements/OverflowElement.tsx
Normal 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;
|
||||
15
packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx
Normal file
15
packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx
Normal 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;
|
||||
@@ -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);
|
||||
27
packages/fuselage-ui-kit/src/elements/RadioButtonElement.tsx
Normal file
27
packages/fuselage-ui-kit/src/elements/RadioButtonElement.tsx
Normal 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;
|
||||
@@ -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);
|
||||
34
packages/fuselage-ui-kit/src/elements/TabElement.tsx
Normal file
34
packages/fuselage-ui-kit/src/elements/TabElement.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
packages/fuselage-ui-kit/src/elements/TimePickerElement.tsx
Normal file
30
packages/fuselage-ui-kit/src/elements/TimePickerElement.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
122
packages/fuselage-ui-kit/src/hooks/useAppTranslation.spec.tsx
Normal file
122
packages/fuselage-ui-kit/src/hooks/useAppTranslation.spec.tsx
Normal 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'));
|
||||
});
|
||||
});
|
||||
13
packages/fuselage-ui-kit/src/hooks/useAppTranslation.ts
Normal file
13
packages/fuselage-ui-kit/src/hooks/useAppTranslation.ts
Normal 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);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
6
packages/fuselage-ui-kit/src/hooks/useSurfaceType.ts
Normal file
6
packages/fuselage-ui-kit/src/hooks/useSurfaceType.ts
Normal 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);
|
||||
62
packages/fuselage-ui-kit/src/hooks/useUiKitState.spec.ts
Normal file
62
packages/fuselage-ui-kit/src/hooks/useUiKitState.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
150
packages/fuselage-ui-kit/src/hooks/useUiKitState.ts
Normal file
150
packages/fuselage-ui-kit/src/hooks/useUiKitState.ts
Normal 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];
|
||||
};
|
||||
5
packages/fuselage-ui-kit/src/index.ts
Normal file
5
packages/fuselage-ui-kit/src/index.ts
Normal 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';
|
||||
115
packages/fuselage-ui-kit/src/stories/Banner.stories.tsx
Normal file
115
packages/fuselage-ui-kit/src/stories/Banner.stories.tsx
Normal 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);
|
||||
136
packages/fuselage-ui-kit/src/stories/Message.stories.tsx
Normal file
136
packages/fuselage-ui-kit/src/stories/Message.stories.tsx
Normal 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);
|
||||
149
packages/fuselage-ui-kit/src/stories/Modal.stories.tsx
Normal file
149
packages/fuselage-ui-kit/src/stories/Modal.stories.tsx
Normal 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);
|
||||
218
packages/fuselage-ui-kit/src/stories/payloads/actions.ts
Normal file
218
packages/fuselage-ui-kit/src/stories/payloads/actions.ts
Normal 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;
|
||||
41
packages/fuselage-ui-kit/src/stories/payloads/callout.ts
Normal file
41
packages/fuselage-ui-kit/src/stories/payloads/callout.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
20
packages/fuselage-ui-kit/src/stories/payloads/conditional.ts
Normal file
20
packages/fuselage-ui-kit/src/stories/payloads/conditional.ts
Normal 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;
|
||||
67
packages/fuselage-ui-kit/src/stories/payloads/context.ts
Normal file
67
packages/fuselage-ui-kit/src/stories/payloads/context.ts
Normal file
File diff suppressed because one or more lines are too long
7
packages/fuselage-ui-kit/src/stories/payloads/divider.ts
Normal file
7
packages/fuselage-ui-kit/src/stories/payloads/divider.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type * as UiKit from '@rocket.chat/ui-kit';
|
||||
|
||||
export const divider: readonly UiKit.LayoutBlock[] = [
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
] as const;
|
||||
24
packages/fuselage-ui-kit/src/stories/payloads/image.ts
Normal file
24
packages/fuselage-ui-kit/src/stories/payloads/image.ts
Normal file
File diff suppressed because one or more lines are too long
3
packages/fuselage-ui-kit/src/stories/payloads/img.ts
Normal file
3
packages/fuselage-ui-kit/src/stories/payloads/img.ts
Normal file
File diff suppressed because one or more lines are too long
10
packages/fuselage-ui-kit/src/stories/payloads/index.ts
Normal file
10
packages/fuselage-ui-kit/src/stories/payloads/index.ts
Normal 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';
|
||||
75
packages/fuselage-ui-kit/src/stories/payloads/infoCard.ts
Normal file
75
packages/fuselage-ui-kit/src/stories/payloads/infoCard.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
149
packages/fuselage-ui-kit/src/stories/payloads/input.ts
Normal file
149
packages/fuselage-ui-kit/src/stories/payloads/input.ts
Normal 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;
|
||||
65
packages/fuselage-ui-kit/src/stories/payloads/preview.ts
Normal file
65
packages/fuselage-ui-kit/src/stories/payloads/preview.ts
Normal 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/*',
|
||||
},
|
||||
];
|
||||
173
packages/fuselage-ui-kit/src/stories/payloads/section.ts
Normal file
173
packages/fuselage-ui-kit/src/stories/payloads/section.ts
Normal file
File diff suppressed because one or more lines are too long
16
packages/fuselage-ui-kit/src/surfaces/BannerSurface.tsx
Normal file
16
packages/fuselage-ui-kit/src/surfaces/BannerSurface.tsx
Normal 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FuselageSurfaceRenderer, renderTextObject } from './FuselageSurfaceRenderer';
|
||||
|
||||
export class BannerSurfaceRenderer extends FuselageSurfaceRenderer {
|
||||
override plain_text = renderTextObject;
|
||||
|
||||
override mrkdwn = renderTextObject;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
16
packages/fuselage-ui-kit/src/surfaces/MessageSurface.tsx
Normal file
16
packages/fuselage-ui-kit/src/surfaces/MessageSurface.tsx
Normal 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;
|
||||
16
packages/fuselage-ui-kit/src/surfaces/ModalSurface.tsx
Normal file
16
packages/fuselage-ui-kit/src/surfaces/ModalSurface.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
10
packages/fuselage-ui-kit/src/surfaces/Surface.tsx
Normal file
10
packages/fuselage-ui-kit/src/surfaces/Surface.tsx
Normal 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>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
23
packages/fuselage-ui-kit/src/surfaces/index.ts
Normal file
23
packages/fuselage-ui-kit/src/surfaces/index.ts
Normal 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 };
|
||||
11
packages/fuselage-ui-kit/src/utils/BlockProps.ts
Normal file
11
packages/fuselage-ui-kit/src/utils/BlockProps.ts
Normal 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>>;
|
||||
};
|
||||
10
packages/fuselage-ui-kit/src/utils/UiKitComponent.tsx
Normal file
10
packages/fuselage-ui-kit/src/utils/UiKitComponent.tsx
Normal 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);
|
||||
@@ -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, {});
|
||||
26
packages/fuselage-ui-kit/src/utils/getInitialValue.ts
Normal file
26
packages/fuselage-ui-kit/src/utils/getInitialValue.ts
Normal 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;
|
||||
5
packages/fuselage-ui-kit/src/utils/hasElement.ts
Normal file
5
packages/fuselage-ui-kit/src/utils/hasElement.ts
Normal 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;
|
||||
6
packages/fuselage-ui-kit/src/utils/hasElements.ts
Normal file
6
packages/fuselage-ui-kit/src/utils/hasElements.ts
Normal 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);
|
||||
10
packages/fuselage-ui-kit/tsconfig.build.json
Normal file
10
packages/fuselage-ui-kit/tsconfig.build.json
Normal 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
Reference in New Issue
Block a user