Skip to content

Commit

Permalink
feat(UiKit): Users select (#31455)
Browse files Browse the repository at this point in the history
  • Loading branch information
tiagoevanp committed May 24, 2024
1 parent 526cbf1 commit a565999
Show file tree
Hide file tree
Showing 11 changed files with 419 additions and 14 deletions.
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

0 comments on commit a565999

Please sign in to comment.