feat(UiKit): Channels select (#31918)
parent
2d84fe2f41
commit
ee5cdfc367
@ -0,0 +1,6 @@ |
||||
--- |
||||
"@rocket.chat/fuselage-ui-kit": minor |
||||
"@rocket.chat/ui-kit": minor |
||||
--- |
||||
|
||||
Introduced new elements for apps to select channels |
||||
@ -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(), |
||||
})); |
||||
@ -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; |
||||
}; |
||||
@ -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; |
||||
}>; |
||||
|
||||
Loading…
Reference in new issue