Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(UiKit): Users select #31455

Merged
merged 38 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
7dddaf5
introduce users multi select POC
tiagoevanp Jan 15, 2024
6ce22fa
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
tiagoevanp Feb 9, 2024
b7171dd
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
tiagoevanp Feb 12, 2024
eeedaf2
user auto complete
tiagoevanp Feb 12, 2024
c8e6477
fix user selec
tiagoevanp Feb 14, 2024
4eedcc2
move things
tiagoevanp Feb 14, 2024
0626682
last
tiagoevanp Feb 14, 2024
d1e5fb2
Merge branch 'develop' into feat/user-channel-uikit
casalsgh Feb 14, 2024
24f5e1c
Create cuddly-cycles-nail.md
tiagoevanp Feb 15, 2024
ca86230
Update cuddly-cycles-nail.md
tiagoevanp Feb 15, 2024
8079bc1
fix review
tiagoevanp Feb 16, 2024
2acfc5b
Merge branch 'feat/user-channel-uikit' of github.com:RocketChat/Rocke…
tiagoevanp Feb 16, 2024
bca895f
simplify
tiagoevanp Mar 5, 2024
a2d6dde
user data hoojk
tiagoevanp Mar 5, 2024
c3a4ff2
fix
tiagoevanp Mar 5, 2024
a493d7f
review
tiagoevanp Mar 5, 2024
4818d5a
fix value not iterabl
tiagoevanp Mar 6, 2024
0fc4431
remove console
tiagoevanp Mar 6, 2024
b271983
Update .changeset/cuddly-cycles-nail.md
tiagoevanp Mar 14, 2024
a2a9937
Update .changeset/cuddly-cycles-nail.md
tiagoevanp Apr 2, 2024
e50c03b
add tests
tiagoevanp Apr 3, 2024
0ce6fb4
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
tiagoevanp Apr 3, 2024
de8b9a0
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
tiagoevanp Apr 10, 2024
8ccf3cb
add more tests
tiagoevanp Apr 10, 2024
519e4e4
Merge branch 'develop' into feat/user-channel-uikit
MarcosSpessatto Apr 11, 2024
6448d97
Merge branch 'develop' into feat/user-channel-uikit
tiagoevanp Apr 11, 2024
f14f8bc
Merge branch 'develop' into feat/user-channel-uikit
MarcosSpessatto Apr 12, 2024
6756ef9
Merge branch 'develop' into feat/user-channel-uikit
tiagoevanp Apr 13, 2024
2c342a6
Merge branch 'develop' into feat/user-channel-uikit
tiagoevanp Apr 15, 2024
2b4b82a
remove unnecessary test
tiagoevanp Apr 15, 2024
44f934c
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
tiagoevanp Apr 16, 2024
fc6f8c8
Merge branch 'develop' into feat/user-channel-uikit
gabriellsh Apr 16, 2024
232041b
Merge branch 'develop' into feat/user-channel-uikit
tiagoevanp Apr 17, 2024
79ceaff
Merge branch 'develop' into feat/user-channel-uikit
kodiakhq[bot] Apr 26, 2024
0853f88
Merge branch 'feat/user-channel-uikit' of github.com:RocketChat/Rocke…
tiagoevanp May 17, 2024
3a5187b
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
tiagoevanp May 17, 2024
c08d01e
Merge branch 'develop' into feat/user-channel-uikit
tiagoevanp May 18, 2024
719635a
Merge branch 'develop' into feat/user-channel-uikit
kodiakhq[bot] May 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cuddly-cycles-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/fuselage-ui-kit": minor
"@rocket.chat/ui-kit": minor
---

Introduced new elements for apps to select users
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading