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 10 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": patch
tiagoevanp marked this conversation as resolved.
Show resolved Hide resolved
---

Introduce users select to UiKit
tiagoevanp marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Option, OptionDescription } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { ReactElement } from 'react';

type UserAutoCompleteMultipleOptionProps = {
label: {
_federated?: boolean;
} & Pick<IUser, 'username' | 'name'>;
};

const UserAutoCompleteMultipleOption = ({
tiagoevanp marked this conversation as resolved.
Show resolved Hide resolved
label,
...props
}: UserAutoCompleteMultipleOptionProps): ReactElement => {
const { name, username, _federated } = label;

return (
<Option
{...props}
data-qa-type='autocomplete-user-option'
avatar={<UserAvatar username={username || ''} size='x20' />}
icon={_federated ? 'globe' : undefined}
key={username}
label={
(
<>
{name || username}{' '}
{!_federated && <OptionDescription>({username})</OptionDescription>}
tiagoevanp marked this conversation as resolved.
Show resolved Hide resolved
</>
) as any
}
children={undefined}
/>
);
};

export default UserAutoCompleteMultipleOption;
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Options } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactElement, Ref } from 'react';
import { forwardRef, createContext, useContext } from 'react';

import UserAutoCompleteMultipleOption from './MulitUserAutoCompleteMultipleOption';

// This is a hack in order to bypass the MultiSelect filter.
// The select requires a forwarded ref component in the renderOptions property
// but we also need to pass internal state to this renderer, as well as the props that also come from the Select.

type OptionsContextValue = {
options: ComponentProps<typeof Options>['options'];
};

export const OptionsContext = createContext<OptionsContextValue>({
options: [],
});
const MultiUserAutoCompleteMultipleOptions = forwardRef(
function MultiUserAutoCompleteMultipleOptions(
{ onSelect, ...props }: ComponentProps<typeof Options>,
ref: Ref<HTMLElement>
): ReactElement {
const { options } = useContext(OptionsContext);
return (
<Options
{...props}
key='AutocompleteOptions'
options={options}
onSelect={onSelect}
ref={ref}
renderItem={UserAutoCompleteMultipleOption}
/>
);
}
);

export default MultiUserAutoCompleteMultipleOptions;
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { MultiSelectFiltered, Box, Chip } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type * as UiKit from '@rocket.chat/ui-kit';
import { useQuery } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import { memo, useState, useCallback, useMemo } from 'react';

import { useStringFromTextObject } from '../../hooks/useStringFromTextObject';
import { useUiKitState } from '../../hooks/useUiKitState';
import type { BlockProps } from '../../utils/BlockProps';
import AutocompleteOptions, {
OptionsContext,
} from './MultiUserAutoCompleteMultipleOptions';

type MultiUsersSelectElementProps = BlockProps<UiKit.MultiUsersSelectElement>;

type MultiUserAutoCompleteOptionType = {
name: string;
username: string;
};

type MultiUserAutoCompleteOptions = {
[k: string]: MultiUserAutoCompleteOptionType;
};

const MultiUsersSelectElement = ({
block,
context,
}: MultiUsersSelectElementProps): ReactElement => {
const [{ loading, value, error }, action] = useUiKitState(block, context);
const fromTextObjectToString = useStringFromTextObject();
const [filter, setFilter] = useState('');
const [selectedCache, setSelectedCache] =
useState<MultiUserAutoCompleteOptions>({});

const debouncedFilter = useDebouncedValue(filter, 500);
const getUsers = useEndpoint('GET', '/v1/users.autocomplete');

const { data } = useQuery(
['users.autocomplete', debouncedFilter],
async () => {
const users = await getUsers({
selector: JSON.stringify({ term: debouncedFilter }),
});
const options = users.items.map(
(item): [string, MultiUserAutoCompleteOptionType] => [
item.username,
item,
]
);

return options;
},
{ keepPreviousData: true }
);

const options = useMemo(() => data || [], [data]);

const onAddUser = useCallback(
(username: string): void => {
const user = options.find(([val]) => val === username)?.[1];
if (!user) {
throw new Error(
'UserAutoCompleteMultiple - onAddSelected - failed to cache option'
);
}
setSelectedCache((selectedCache) => ({
...selectedCache,
[username]: user,
}));
},
[setSelectedCache, options]
);

const onRemoveUser = useCallback(
(username: string): void =>
setSelectedCache((selectedCache) => {
const users = { ...selectedCache };
delete users[username];
return users;
}),
[setSelectedCache]
);

const handleChange = useCallback(
(value) => {
action({ target: { value } });
},
[action]
);

const handleOnChange = useCallback(
(usernames: string[]) => {
handleChange(usernames);
const newAddedUsername = usernames.filter(
(username) => !value.includes(username)
)[0];
const removedUsername = value.filter(
(username) => !usernames.includes(username)
)[0];
setFilter('');
newAddedUsername && onAddUser(newAddedUsername);
removedUsername && onRemoveUser(removedUsername);
},
[handleChange, setFilter, onAddUser, onRemoveUser, value]
);

return (
<OptionsContext.Provider value={{ options }}>
<MultiSelectFiltered
error={error}
disabled={loading}
data-qa-type='user-auto-complete-input'
placeholder={fromTextObjectToString(block.placeholder)}
value={value}
onChange={handleOnChange}
filter={filter}
setFilter={setFilter}
renderSelected={({
value,
onMouseDown,
}: {
value: string;
onMouseDown: () => void;
}) => {
const currentCachedOption = selectedCache[value] || {};

return (
<Chip
key={value}
height='x20'
onMouseDown={onMouseDown}
mie={4}
mb={2}
>
<UserAvatar size='x20' username={value} />
<Box is='span' margin='none' mis={4}>
{currentCachedOption.name ||
currentCachedOption.username ||
value}
</Box>
</Chip>
);
}}
renderOptions={AutocompleteOptions}
options={options
.concat(Object.entries(selectedCache))
.map(([, item]) => [item.username, item.name || item.username])}
data-qa='create-channel-users-autocomplete'
/>
</OptionsContext.Provider>
);
};

export default memo(MultiUsersSelectElement);
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { AutoComplete, Box, Chip, Option } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type * as UiKit from '@rocket.chat/ui-kit';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';

import { useUiKitState } from '../../hooks/useUiKitState';
import type { BlockProps } from '../../utils/BlockProps';

type UsersSelectElementProps = BlockProps<UiKit.UsersSelectElement>;

type UserAutoCompleteOptionType = {
value: string;
label: string;
};

const UsersSelectElement = ({ block, context }: UsersSelectElementProps) => {
const [{ loading, value }, action] = useUiKitState(block, context);

const [filter, setFilter] = useState('');
const debouncedFilter = useDebouncedValue(filter, 1000);
const getUsers = useEndpoint('GET', '/v1/users.autocomplete');

const { data } = useQuery(
['users.autoComplete', debouncedFilter],
async () => {
const users = await getUsers({
selector: JSON.stringify({ term: debouncedFilter }),
});
const options = users.items.map(
(item): UserAutoCompleteOptionType => ({
value: item.username,
label: item.name || item.username,
})
);

return options;
},
{ keepPreviousData: true }
);

const options = useMemo(() => data || [], [data]);
tiagoevanp marked this conversation as resolved.
Show resolved Hide resolved

const handleChange = useCallback(
(value) => {
action({ target: { value } });
},
[action]
);

return (
<AutoComplete
// error={error}
disabled={loading}
value={value}
options={options}
onChange={handleChange}
filter={filter}
setFilter={setFilter}
data-qa-id='UserAutoComplete'
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}
label={label}
avatar={<UserAvatar size='x20' username={value} />}
{...props}
/>
)}
/>
);
};

export default UsersSelectElement;
20 changes: 10 additions & 10 deletions packages/fuselage-ui-kit/src/stories/payloads/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
},
{
Expand Down
Loading
Loading