Skip to content

Commit a565999

Browse files
authored
feat(UiKit): Users select (#31455)
1 parent 526cbf1 commit a565999

File tree

11 files changed

+419
-14
lines changed

11 files changed

+419
-14
lines changed

.changeset/cuddly-cycles-nail.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@rocket.chat/fuselage-ui-kit": minor
3+
"@rocket.chat/ui-kit": minor
4+
---
5+
6+
Introduced new elements for apps to select users
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { MockedServerContext } from '@rocket.chat/mock-providers';
2+
import type { MultiUsersSelectElement as MultiUsersSelectElementType } from '@rocket.chat/ui-kit';
3+
import { BlockContext } from '@rocket.chat/ui-kit';
4+
import { render, screen } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
6+
7+
import { contextualBarParser } from '../../surfaces';
8+
import MultiUsersSelectElement from './MultiUsersSelectElement';
9+
import { useUsersData } from './hooks/useUsersData';
10+
11+
const usersBlock: MultiUsersSelectElementType = {
12+
type: 'multi_users_select',
13+
appId: 'test',
14+
blockId: 'test',
15+
actionId: 'test',
16+
};
17+
18+
jest.mock('./hooks/useUsersData');
19+
20+
const mockedOptions = [
21+
{
22+
value: 'user1_id',
23+
label: 'User 1',
24+
},
25+
{
26+
value: 'user2_id',
27+
label: 'User 2',
28+
},
29+
{
30+
value: 'user3_id',
31+
label: 'User 3',
32+
},
33+
];
34+
35+
const mockUseUsersData = jest.mocked(useUsersData);
36+
mockUseUsersData.mockReturnValue(mockedOptions);
37+
38+
describe('UiKit MultiUsersSelect Element', () => {
39+
beforeAll(() => {
40+
jest.useFakeTimers();
41+
});
42+
43+
afterAll(() => {
44+
jest.useRealTimers();
45+
});
46+
47+
beforeEach(() => {
48+
render(
49+
<MockedServerContext>
50+
<MultiUsersSelectElement
51+
index={0}
52+
block={usersBlock}
53+
context={BlockContext.FORM}
54+
surfaceRenderer={contextualBarParser}
55+
/>
56+
</MockedServerContext>
57+
);
58+
});
59+
60+
it('should render a UiKit multiple users selector', async () => {
61+
expect(await screen.findByRole('textbox')).toBeInTheDocument();
62+
});
63+
64+
it('should open the users selector', async () => {
65+
const input = await screen.findByRole('textbox');
66+
input.focus();
67+
68+
expect(await screen.findByRole('listbox')).toBeInTheDocument();
69+
});
70+
71+
it('should select users', async () => {
72+
const input = await screen.findByRole('textbox');
73+
74+
input.focus();
75+
76+
const option1 = (await screen.findAllByRole('option'))[0];
77+
await userEvent.click(option1, { delay: null });
78+
79+
const option2 = (await screen.findAllByRole('option'))[2];
80+
await userEvent.click(option2, { delay: null });
81+
82+
const selected = await screen.findAllByRole('button');
83+
expect(selected[0]).toHaveValue('user1_id');
84+
expect(selected[1]).toHaveValue('user3_id');
85+
});
86+
87+
it('should remove a user', async () => {
88+
const input = await screen.findByRole('textbox');
89+
90+
input.focus();
91+
92+
const option1 = (await screen.findAllByRole('option'))[0];
93+
await userEvent.click(option1, { delay: null });
94+
95+
const option2 = (await screen.findAllByRole('option'))[2];
96+
await userEvent.click(option2, { delay: null });
97+
98+
const selected1 = (await screen.findAllByRole('button'))[0];
99+
expect(selected1).toHaveValue('user1_id');
100+
await userEvent.click(selected1, { delay: null });
101+
102+
const remainingSelected = (await screen.findAllByRole('button'))[0];
103+
expect(remainingSelected).toHaveValue('user3_id');
104+
});
105+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
Box,
3+
Chip,
4+
AutoComplete,
5+
Option,
6+
OptionAvatar,
7+
OptionContent,
8+
OptionDescription,
9+
} from '@rocket.chat/fuselage';
10+
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
11+
import { UserAvatar } from '@rocket.chat/ui-avatar';
12+
import type * as UiKit from '@rocket.chat/ui-kit';
13+
import type { ReactElement } from 'react';
14+
import { memo, useCallback, useState } from 'react';
15+
16+
import { useUiKitState } from '../../hooks/useUiKitState';
17+
import type { BlockProps } from '../../utils/BlockProps';
18+
import { useUsersData } from './hooks/useUsersData';
19+
20+
type MultiUsersSelectElementProps = BlockProps<UiKit.MultiUsersSelectElement>;
21+
22+
const MultiUsersSelectElement = ({
23+
block,
24+
context,
25+
}: MultiUsersSelectElementProps): ReactElement => {
26+
const [{ loading, value }, action] = useUiKitState(block, context);
27+
const [filter, setFilter] = useState('');
28+
29+
const debouncedFilter = useDebouncedValue(filter, 500);
30+
31+
const data = useUsersData({ filter: debouncedFilter });
32+
33+
const handleChange = useCallback(
34+
(value) => {
35+
action({ target: { value } });
36+
},
37+
[action]
38+
);
39+
40+
return (
41+
<AutoComplete
42+
value={value || []}
43+
options={data}
44+
placeholder={block.placeholder?.text}
45+
disabled={loading}
46+
filter={filter}
47+
setFilter={setFilter}
48+
onChange={handleChange}
49+
multiple
50+
renderSelected={({
51+
selected: { value, label },
52+
onRemove,
53+
...props
54+
}): ReactElement => (
55+
<Chip {...props} height='x20' value={value} onClick={onRemove} mie={4}>
56+
<UserAvatar size='x20' username={value} />
57+
<Box is='span' margin='none' mis={4}>
58+
{label}
59+
</Box>
60+
</Chip>
61+
)}
62+
renderItem={({ value, label, ...props }): ReactElement => (
63+
<Option key={value} {...props}>
64+
<OptionAvatar>
65+
<UserAvatar username={value} size='x20' />
66+
</OptionAvatar>
67+
<OptionContent>
68+
{label} <OptionDescription>({value})</OptionDescription>
69+
</OptionContent>
70+
</Option>
71+
)}
72+
/>
73+
);
74+
};
75+
76+
export default memo(MultiUsersSelectElement);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { MockedServerContext } from '@rocket.chat/mock-providers';
2+
import type { UsersSelectElement as UsersSelectElementType } from '@rocket.chat/ui-kit';
3+
import { BlockContext } from '@rocket.chat/ui-kit';
4+
import { render, screen } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
6+
7+
import { contextualBarParser } from '../../surfaces';
8+
import UsersSelectElement from './UsersSelectElement';
9+
import { useUsersData } from './hooks/useUsersData';
10+
11+
const userBlock: UsersSelectElementType = {
12+
type: 'users_select',
13+
appId: 'test',
14+
blockId: 'test',
15+
actionId: 'test',
16+
};
17+
18+
jest.mock('./hooks/useUsersData');
19+
20+
const mockedOptions = [
21+
{
22+
value: 'user1_id',
23+
label: 'User 1',
24+
},
25+
{
26+
value: 'user2_id',
27+
label: 'User 2',
28+
},
29+
{
30+
value: 'user3_id',
31+
label: 'User 3',
32+
},
33+
];
34+
35+
const mockUseUsersData = jest.mocked(useUsersData);
36+
mockUseUsersData.mockReturnValue(mockedOptions);
37+
38+
describe('UiKit UserSelect Element', () => {
39+
beforeAll(() => {
40+
jest.useFakeTimers();
41+
});
42+
43+
afterAll(() => {
44+
jest.useRealTimers();
45+
});
46+
47+
beforeEach(() => {
48+
render(
49+
<MockedServerContext>
50+
<UsersSelectElement
51+
index={0}
52+
block={userBlock}
53+
context={BlockContext.FORM}
54+
surfaceRenderer={contextualBarParser}
55+
/>
56+
</MockedServerContext>
57+
);
58+
});
59+
60+
it('should render a UiKit user selector', async () => {
61+
expect(await screen.findByRole('textbox')).toBeInTheDocument();
62+
});
63+
64+
it('should open the user selector', async () => {
65+
const input = await screen.findByRole('textbox');
66+
input.focus();
67+
68+
expect(await screen.findByRole('listbox')).toBeInTheDocument();
69+
});
70+
71+
it('should select a user', async () => {
72+
const input = await screen.findByRole('textbox');
73+
74+
input.focus();
75+
76+
const option = (await screen.findAllByRole('option'))[0];
77+
await userEvent.click(option, { delay: null });
78+
79+
const selected = await screen.findByRole('button');
80+
expect(selected).toHaveValue('user1_id');
81+
});
82+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { AutoComplete, Box, Chip, Option } from '@rocket.chat/fuselage';
2+
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
3+
import { UserAvatar } from '@rocket.chat/ui-avatar';
4+
import type * as UiKit from '@rocket.chat/ui-kit';
5+
import { useCallback, useState } from 'react';
6+
7+
import { useUiKitState } from '../../hooks/useUiKitState';
8+
import type { BlockProps } from '../../utils/BlockProps';
9+
import { useUsersData } from './hooks/useUsersData';
10+
11+
type UsersSelectElementProps = BlockProps<UiKit.UsersSelectElement>;
12+
13+
export type UserAutoCompleteOptionType = {
14+
value: string;
15+
label: string;
16+
};
17+
18+
const UsersSelectElement = ({ block, context }: UsersSelectElementProps) => {
19+
const [{ value, loading }, action] = useUiKitState(block, context);
20+
21+
const [filter, setFilter] = useState('');
22+
const debouncedFilter = useDebouncedValue(filter, 300);
23+
24+
const data = useUsersData({ filter: debouncedFilter });
25+
26+
const handleChange = useCallback(
27+
(value) => {
28+
action({ target: { value } });
29+
},
30+
[action]
31+
);
32+
33+
return (
34+
<AutoComplete
35+
value={value}
36+
placeholder={block.placeholder?.text}
37+
disabled={loading}
38+
options={data}
39+
onChange={handleChange}
40+
filter={filter}
41+
setFilter={setFilter}
42+
renderSelected={({ selected: { value, label } }) => (
43+
<Chip height='x20' value={value} mie={4}>
44+
<UserAvatar size='x20' username={value} />
45+
<Box verticalAlign='middle' is='span' margin='none' mi={4}>
46+
{label}
47+
</Box>
48+
</Chip>
49+
)}
50+
renderItem={({ value, label, ...props }) => (
51+
<Option
52+
key={value}
53+
{...props}
54+
label={label}
55+
avatar={<UserAvatar username={value} size='x20' />}
56+
/>
57+
)}
58+
/>
59+
);
60+
};
61+
62+
export default UsersSelectElement;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useEndpoint } from '@rocket.chat/ui-contexts';
2+
import { useQuery } from '@tanstack/react-query';
3+
4+
import type { UserAutoCompleteOptionType } from '../UsersSelectElement';
5+
6+
type useUsersDataProps = {
7+
filter: string;
8+
};
9+
10+
export const useUsersData = ({ filter }: useUsersDataProps) => {
11+
const getUsers = useEndpoint('GET', '/v1/users.autocomplete');
12+
13+
const { data } = useQuery(
14+
['users.autoComplete', filter],
15+
async () => {
16+
const users = await getUsers({
17+
selector: JSON.stringify({ term: filter }),
18+
});
19+
const options = users.items.map(
20+
(item): UserAutoCompleteOptionType => ({
21+
value: item.username,
22+
label: item.name || item.username,
23+
})
24+
);
25+
26+
return options || [];
27+
},
28+
{ keepPreviousData: true }
29+
);
30+
31+
return data;
32+
};

0 commit comments

Comments
 (0)