feat(UiKit): Channels select (#31918)

pull/32290/head
Tiago Evangelista Pinto 2 years ago committed by GitHub
parent 2d84fe2f41
commit ee5cdfc367
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/strong-humans-bow.md
  2. 8
      packages/fuselage-ui-kit/jest.config.ts
  3. 11
      packages/fuselage-ui-kit/jest.setup.ts
  4. 7
      packages/fuselage-ui-kit/package.json
  5. 95
      packages/fuselage-ui-kit/src/elements/ChannelsSelectElement/ChannelsSelectElement.spec.tsx
  6. 78
      packages/fuselage-ui-kit/src/elements/ChannelsSelectElement/ChannelsSelectElement.tsx
  7. 117
      packages/fuselage-ui-kit/src/elements/ChannelsSelectElement/MultiChannelsSelectElement.spec.tsx
  8. 68
      packages/fuselage-ui-kit/src/elements/ChannelsSelectElement/MultiChannelsSelectElement.tsx
  9. 40
      packages/fuselage-ui-kit/src/elements/ChannelsSelectElement/hooks/useChannelsData.ts
  10. 10
      packages/fuselage-ui-kit/src/stories/payloads/actions.ts
  11. 40
      packages/fuselage-ui-kit/src/surfaces/FuselageSurfaceRenderer.tsx
  12. 3
      packages/ui-kit/src/blocks/elements/ChannelsSelectElement.ts
  13. 3
      packages/ui-kit/src/blocks/elements/MultiChannelsSelectElement.ts
  14. 4
      packages/ui-kit/src/rendering/ActionOf.ts
  15. 30
      yarn.lock

@ -0,0 +1,6 @@
---
"@rocket.chat/fuselage-ui-kit": minor
"@rocket.chat/ui-kit": minor
---
Introduced new elements for apps to select channels

@ -24,4 +24,12 @@ export default {
},
],
},
moduleNameMapper: {
'\\.css$': 'identity-obj-proxy',
'^react($|/.+)': '<rootDir>/../../node_modules/react$1',
},
setupFilesAfterEnv: [
'@testing-library/jest-dom/extend-expect',
'<rootDir>/jest.setup.ts',
],
};

@ -0,0 +1,11 @@
import { TextEncoder, TextDecoder } from 'util';
global.TextEncoder = TextEncoder;
// @ts-ignore
global.TextDecoder = TextDecoder;
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));

@ -34,6 +34,7 @@
".:build:cjs": "tsc -p tsconfig-cjs.json",
"test": "jest",
"lint": "eslint --ext .js,.jsx,.ts,.tsx .",
"testunit": "jest",
"typecheck": "tsc --noEmit",
"docs": "cross-env NODE_ENV=production build-storybook -o ../../static/fuselage-ui-kit",
"storybook": "start-storybook -p 6006 --no-version-updates",
@ -63,11 +64,13 @@
"@babel/preset-react": "~7.22.15",
"@babel/preset-typescript": "~7.22.15",
"@rocket.chat/apps-engine": "^1.42.2",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/eslint-config": "workspace:^",
"@rocket.chat/fuselage": "^0.53.6",
"@rocket.chat/fuselage-hooks": "^0.33.1",
"@rocket.chat/fuselage-polyfills": "~0.31.25",
"@rocket.chat/icons": "^0.35.0",
"@rocket.chat/mock-providers": "workspace:^",
"@rocket.chat/prettier-config": "~0.31.25",
"@rocket.chat/styled": "~0.31.25",
"@rocket.chat/ui-avatar": "workspace:^",
@ -82,8 +85,9 @@
"@storybook/source-loader": "~6.5.16",
"@storybook/theming": "~6.5.16",
"@tanstack/react-query": "^4.16.1",
"@testing-library/react": "^14.2.2",
"@testing-library/react": "^12.1.4",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/babel__core": "^7.20.3",
"@types/babel__preset-env": "^7.9.4",
"@types/react": "~17.0.69",
@ -106,6 +110,7 @@
"typescript": "~5.3.3"
},
"dependencies": {
"@rocket.chat/core-typings": "*",
"@rocket.chat/gazzodown": "workspace:^",
"@rocket.chat/ui-kit": "workspace:~",
"tslib": "^2.5.3"

@ -0,0 +1,95 @@
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 { contextualBarParser } from '../../surfaces';
import ChannelsSelectElement from './ChannelsSelectElement';
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,78 @@
import {
AutoComplete,
Option,
Box,
Options,
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 { useUiKitState } from '../../hooks/useUiKitState';
import type { BlockProps } from '../../utils/BlockProps';
import { useChannelsData } from './hooks/useChannelsData';
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) => {
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} mie={4}>
<RoomAvatar
size='x20'
room={{ type: label?.type || 'c', _id: value, ...label }}
/>
<Box verticalAlign='middle' is='span' margin='none' mi={4}>
{label.name}
</Box>
</Chip>
)}
renderItem={({ value, label, ...props }) => (
<Option
key={value}
{...props}
label={label.name}
avatar={
<RoomAvatar
size={Options.AvatarSize}
room={{
type: label.type,
_id: value,
avatarETag: label.avatarETag,
}}
{...props}
/>
}
/>
)}
options={options}
/>
);
};
export default memo(ChannelsSelectElement);

@ -0,0 +1,117 @@
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 { contextualBarParser } from '../../surfaces';
import MultiChannelsSelectElement from './MultiChannelsSelectElement';
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,68 @@
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 { useUiKitState } from '../../hooks/useUiKitState';
import type { BlockProps } from '../../utils/BlockProps';
import { useChannelsData } from './hooks/useChannelsData';
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) => {
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={{ type: label?.type || 'c', _id: value, ...label }}
/>
<Box is='span' margin='none' mis={4}>
{label?.name}
</Box>
</Chip>
)}
renderItem={({ value, label, ...props }) => (
<Option
key={value}
{...props}
label={label.name}
avatar={
<RoomAvatar
size='x20'
room={{ type: label?.type || 'c', _id: value, ...label }}
/>
}
/>
)}
options={options}
/>
);
};
export default memo(MultiChannelsSelectElement);

@ -0,0 +1,40 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { 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(
['rooms.autocomplete.channelAndPrivate', filter],
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 || [];
},
{
keepPreviousData: true,
}
);
return data;
};

@ -20,11 +20,11 @@ export const actionsWithAllSelects: readonly UiKit.LayoutBlock[] = [
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'channels_select',
// placeholder: {
// type: 'plain_text',
// text: 'Select a channel',
// emoji: true,
// },
placeholder: {
type: 'plain_text',
text: 'Select a channel',
emoji: true,
},
},
{
appId: 'dummy-app-id',

@ -11,6 +11,8 @@ 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 ImageElement from '../elements/ImageElement';
@ -528,4 +530,42 @@ export abstract class FuselageSurfaceRenderer extends UiKit.SurfaceRenderer<Reac
</AppIdProvider>
);
}
channels_select(
block: UiKit.ChannelsSelectElement,
context: UiKit.BlockContext,
index: number
): ReactElement | null {
if (context === UiKit.BlockContext.FORM) {
return (
<ChannelsSelectElement
block={block}
context={context}
index={index}
surfaceRenderer={this}
/>
);
}
return null;
}
multi_channels_select(
block: UiKit.MultiChannelsSelectElement,
context: UiKit.BlockContext,
index: number
): ReactElement | null {
if (context === UiKit.BlockContext.FORM) {
return (
<MultiChannelsSelectElement
block={block}
context={context}
index={index}
surfaceRenderer={this}
/>
);
}
return null;
}
}

@ -1,6 +1,7 @@
import type { Actionable } from '../Actionable';
import type { PlainText } from '../text/PlainText';
/** @todo */
export type ChannelsSelectElement = Actionable<{
type: 'channels_select';
placeholder?: PlainText;
}>;

@ -1,6 +1,7 @@
import type { Actionable } from '../Actionable';
import type { PlainText } from '../text/PlainText';
/** @todo */
export type MultiChannelsSelectElement = Actionable<{
type: 'multi_channels_select';
placeholder?: PlainText;
}>;

@ -21,7 +21,7 @@ import type { UsersSelectElement } from '../blocks/elements/UsersSelectElement';
export type ActionOf<TElement extends ActionableElement> = TElement extends ButtonElement
? ButtonElement['value']
: TElement extends ChannelsSelectElement
? unknown
? Option['value']
: TElement extends ConversationsSelectElement
? unknown
: TElement extends DatePickerElement
@ -29,7 +29,7 @@ export type ActionOf<TElement extends ActionableElement> = TElement extends Butt
: TElement extends LinearScaleElement
? LinearScaleElement['initialValue']
: TElement extends MultiChannelsSelectElement
? unknown
? Option['value'][]
: TElement extends MultiConversationsSelectElement
? unknown
: TElement extends MultiStaticSelectElement

@ -8741,12 +8741,14 @@ __metadata:
"@babel/preset-react": ~7.22.15
"@babel/preset-typescript": ~7.22.15
"@rocket.chat/apps-engine": ^1.42.2
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/eslint-config": "workspace:^"
"@rocket.chat/fuselage": ^0.53.6
"@rocket.chat/fuselage-hooks": ^0.33.1
"@rocket.chat/fuselage-polyfills": ~0.31.25
"@rocket.chat/gazzodown": "workspace:^"
"@rocket.chat/icons": ^0.35.0
"@rocket.chat/mock-providers": "workspace:^"
"@rocket.chat/prettier-config": ~0.31.25
"@rocket.chat/styled": ~0.31.25
"@rocket.chat/ui-avatar": "workspace:^"
@ -8761,8 +8763,9 @@ __metadata:
"@storybook/source-loader": ~6.5.16
"@storybook/theming": ~6.5.16
"@tanstack/react-query": ^4.16.1
"@testing-library/react": ^14.2.2
"@testing-library/react": ^12.1.4
"@testing-library/react-hooks": ^8.0.1
"@testing-library/user-event": ^14.5.2
"@types/babel__core": ^7.20.3
"@types/babel__preset-env": ^7.9.4
"@types/react": ~17.0.69
@ -12546,7 +12549,7 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/react@npm:^12.1.5, @testing-library/react@npm:~12.1.5":
"@testing-library/react@npm:^12.1.4, @testing-library/react@npm:^12.1.5, @testing-library/react@npm:~12.1.5":
version: 12.1.5
resolution: "@testing-library/react@npm:12.1.5"
dependencies:
@ -12574,20 +12577,6 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/react@npm:^14.2.2":
version: 14.2.2
resolution: "@testing-library/react@npm:14.2.2"
dependencies:
"@babel/runtime": ^7.12.5
"@testing-library/dom": ^9.0.0
"@types/react-dom": ^18.0.0
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
checksum: cb73df588592d9101429f057eaa6f320fc12524d5eb2acc8a16002c1ee2d9422a49e44841003bba42974c9ae1ced6b134f0d647826eca42ab8f19e4592971b16
languageName: node
linkType: hard
"@testing-library/user-event@npm:^13.2.1, @testing-library/user-event@npm:~13.5.0":
version: 13.5.0
resolution: "@testing-library/user-event@npm:13.5.0"
@ -12608,6 +12597,15 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/user-event@npm:^14.5.2":
version: 14.5.2
resolution: "@testing-library/user-event@npm:14.5.2"
peerDependencies:
"@testing-library/dom": ">=7.21.4"
checksum: d76937dffcf0082fbf3bb89eb2b81a31bf5448048dd61c33928c5f10e33a58e035321d39145cefd469bb5a499c68a5b4086b22f1a44e3e7c7e817dc5f6782867
languageName: node
linkType: hard
"@tokenizer/token@npm:^0.3.0":
version: 0.3.0
resolution: "@tokenizer/token@npm:0.3.0"

Loading…
Cancel
Save