feat(UiKit): Users select (#31455)

pull/32040/head^2
Tiago Evangelista Pinto 2 years ago committed by GitHub
parent 526cbf15fd
commit a565999ae0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/cuddly-cycles-nail.md
  2. 105
      packages/fuselage-ui-kit/src/elements/UsersSelectElement/MultiUsersSelectElement.spec.tsx
  3. 76
      packages/fuselage-ui-kit/src/elements/UsersSelectElement/MultiUsersSelectElement.tsx
  4. 82
      packages/fuselage-ui-kit/src/elements/UsersSelectElement/UserSelectElement.spec.tsx
  5. 62
      packages/fuselage-ui-kit/src/elements/UsersSelectElement/UsersSelectElement.tsx
  6. 32
      packages/fuselage-ui-kit/src/elements/UsersSelectElement/hooks/useUsersData.ts
  7. 20
      packages/fuselage-ui-kit/src/stories/payloads/actions.ts
  8. 40
      packages/fuselage-ui-kit/src/surfaces/FuselageSurfaceRenderer.tsx
  9. 3
      packages/ui-kit/src/blocks/elements/MultiUsersSelectElement.ts
  10. 3
      packages/ui-kit/src/blocks/elements/UsersSelectElement.ts
  11. 4
      packages/ui-kit/src/rendering/ActionOf.ts

@ -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;
};

@ -31,11 +31,11 @@ export const actionsWithAllSelects: readonly UiKit.LayoutBlock[] = [
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'users_select',
// placeholder: {
// type: 'plain_text',
// text: 'Select a user',
// emoji: true,
// },
placeholder: {
type: 'plain_text',
text: 'Select a user',
emoji: true,
},
},
{
appId: 'dummy-app-id',
@ -122,11 +122,11 @@ export const actionsWithInitializedSelects: readonly UiKit.LayoutBlock[] = [
blockId: 'dummy-block-id',
actionId: 'dummy-action-id',
type: 'users_select',
// placeholder: {
// type: 'plain_text',
// text: 'Select a user',
// emoji: true,
// },
placeholder: {
type: 'plain_text',
text: 'Select a user',
emoji: true,
},
// initialUser: 'U123',
},
{

@ -26,6 +26,8 @@ import RadioButtonElement from '../elements/RadioButtonElement';
import StaticSelectElement from '../elements/StaticSelectElement';
import TimePickerElement from '../elements/TimePickerElement';
import ToggleSwitchElement from '../elements/ToggleSwitchElement';
import MultiUsersSelectElement from '../elements/UsersSelectElement/MultiUsersSelectElement';
import UsersSelectElement from '../elements/UsersSelectElement/UsersSelectElement';
type TextObjectRenderers = {
[TTextObject in UiKit.TextObject as TTextObject['type']]: (
@ -531,6 +533,25 @@ export abstract class FuselageSurfaceRenderer extends UiKit.SurfaceRenderer<Reac
);
}
users_select(
block: UiKit.UsersSelectElement,
context: UiKit.BlockContext,
index: number
): ReactElement | null {
if (context === UiKit.BlockContext.FORM) {
return (
<UsersSelectElement
block={block}
context={context}
index={index}
surfaceRenderer={this}
/>
);
}
return null;
}
channels_select(
block: UiKit.ChannelsSelectElement,
context: UiKit.BlockContext,
@ -550,6 +571,25 @@ export abstract class FuselageSurfaceRenderer extends UiKit.SurfaceRenderer<Reac
return null;
}
multi_users_select(
block: UiKit.MultiUsersSelectElement,
context: UiKit.BlockContext,
index: number
): ReactElement | null {
if (context === UiKit.BlockContext.FORM) {
return (
<MultiUsersSelectElement
block={block}
context={context}
index={index}
surfaceRenderer={this}
/>
);
}
return null;
}
multi_channels_select(
block: UiKit.MultiChannelsSelectElement,
context: UiKit.BlockContext,

@ -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;
}>;

@ -35,7 +35,7 @@ export type ActionOf<TElement extends ActionableElement> = TElement extends Butt
: TElement extends MultiStaticSelectElement
? MultiStaticSelectElement['initialValue']
: TElement extends MultiUsersSelectElement
? unknown
? Option['value'][]
: TElement extends OverflowElement
? OverflowElement['options'][number]['value']
: TElement extends PlainTextInputElement
@ -43,7 +43,7 @@ export type ActionOf<TElement extends ActionableElement> = TElement extends Butt
: TElement extends StaticSelectElement
? StaticSelectElement['initialValue']
: TElement extends UsersSelectElement
? unknown
? Option['value']
: TElement extends ToggleSwitchElement
? Option['value'][]
: TElement extends RadioButtonElement

Loading…
Cancel
Save