feat(UiKit): Users select (#31455)
parent
526cbf15fd
commit
a565999ae0
@ -0,0 +1,6 @@ |
||||
--- |
||||
"@rocket.chat/fuselage-ui-kit": minor |
||||
"@rocket.chat/ui-kit": minor |
||||
--- |
||||
|
||||
Introduced new elements for apps to select users |
||||
@ -0,0 +1,105 @@ |
||||
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 { contextualBarParser } from '../../surfaces'; |
||||
import MultiUsersSelectElement from './MultiUsersSelectElement'; |
||||
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,76 @@ |
||||
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 type { ReactElement } from 'react'; |
||||
import { memo, useCallback, useState } from 'react'; |
||||
|
||||
import { useUiKitState } from '../../hooks/useUiKitState'; |
||||
import type { BlockProps } from '../../utils/BlockProps'; |
||||
import { useUsersData } from './hooks/useUsersData'; |
||||
|
||||
type MultiUsersSelectElementProps = BlockProps<UiKit.MultiUsersSelectElement>; |
||||
|
||||
const MultiUsersSelectElement = ({ |
||||
block, |
||||
context, |
||||
}: MultiUsersSelectElementProps): ReactElement => { |
||||
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) => { |
||||
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 |
||||
}): ReactElement => ( |
||||
<Chip {...props} height='x20' value={value} onClick={onRemove} mie={4}> |
||||
<UserAvatar size='x20' username={value} /> |
||||
<Box is='span' margin='none' mis={4}> |
||||
{label} |
||||
</Box> |
||||
</Chip> |
||||
)} |
||||
renderItem={({ value, label, ...props }): ReactElement => ( |
||||
<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,82 @@ |
||||
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 { contextualBarParser } from '../../surfaces'; |
||||
import UsersSelectElement from './UsersSelectElement'; |
||||
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,62 @@ |
||||
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 { useUiKitState } from '../../hooks/useUiKitState'; |
||||
import type { BlockProps } from '../../utils/BlockProps'; |
||||
import { useUsersData } from './hooks/useUsersData'; |
||||
|
||||
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) => { |
||||
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} mie={4}> |
||||
<UserAvatar size='x20' username={value} /> |
||||
<Box verticalAlign='middle' is='span' margin='none' mi={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 { 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( |
||||
['users.autoComplete', filter], |
||||
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 || []; |
||||
}, |
||||
{ keepPreviousData: true } |
||||
); |
||||
|
||||
return data; |
||||
}; |
||||
@ -1,6 +1,7 @@ |
||||
import type { Actionable } from '../Actionable'; |
||||
import type { PlainText } from '../text/PlainText'; |
||||
|
||||
/** @todo */ |
||||
export type MultiUsersSelectElement = Actionable<{ |
||||
type: 'multi_users_select'; |
||||
placeholder?: PlainText; |
||||
}>; |
||||
|
||||
@ -1,6 +1,7 @@ |
||||
import type { Actionable } from '../Actionable'; |
||||
import type { PlainText } from '../text/PlainText'; |
||||
|
||||
/** @todo */ |
||||
export type UsersSelectElement = Actionable<{ |
||||
type: 'users_select'; |
||||
placeholder?: PlainText; |
||||
}>; |
||||
|
||||
Loading…
Reference in new issue