|
1 | | -import { AutoComplete, OptionAvatar, Option, OptionContent, OptionDescription } from '@rocket.chat/fuselage'; |
| 1 | +import { MultiSelectFiltered } from '@rocket.chat/fuselage'; |
2 | 2 | import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; |
3 | | -import { UserAvatar } from '@rocket.chat/ui-avatar'; |
4 | 3 | import { useEndpoint } from '@rocket.chat/ui-contexts'; |
5 | | -import { useQuery } from '@tanstack/react-query'; |
6 | | -import type { ComponentProps, ReactElement } from 'react'; |
7 | | -import { memo, useMemo, useState } from 'react'; |
| 4 | +import { keepPreviousData, useQuery } from '@tanstack/react-query'; |
| 5 | +import type { ReactElement, AllHTMLAttributes } from 'react'; |
| 6 | +import { memo, useState, useCallback, useMemo } from 'react'; |
8 | 7 |
|
| 8 | +import AutocompleteOptions, { OptionsContext } from './UserAutoCompleteMultipleOptions'; |
9 | 9 | import UserAvatarChip from './UserAvatarChip'; |
| 10 | +import { usersQueryKeys } from '../../lib/queryKeys'; |
10 | 11 |
|
11 | | -const query = ( |
12 | | - term = '', |
13 | | -): { |
14 | | - selector: string; |
15 | | -} => ({ selector: JSON.stringify({ term }) }); |
| 12 | +type UserAutoCompleteMultipleProps = { |
| 13 | + onChange: (value: Array<string>) => void; |
| 14 | + value: Array<string> | undefined; |
| 15 | + placeholder?: string; |
| 16 | + federated?: boolean; |
| 17 | + error?: string; |
| 18 | +} & Omit<AllHTMLAttributes<HTMLInputElement>, 'is' | 'onChange' | 'value'>; |
16 | 19 |
|
17 | | -type UserAutoCompleteMultipleProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'>; |
| 20 | +type UserAutoCompleteOptionType = { |
| 21 | + name: string; |
| 22 | + username: string; |
| 23 | + _federated?: boolean; |
| 24 | +}; |
| 25 | + |
| 26 | +type UserAutoCompleteOptions = { |
| 27 | + [k: string]: UserAutoCompleteOptionType; |
| 28 | +}; |
18 | 29 |
|
19 | | -// TODO: useDisplayUsername |
20 | | -const UserAutoCompleteMultiple = ({ onChange, ...props }: UserAutoCompleteMultipleProps): ReactElement => { |
| 30 | +const matrixRegex = new RegExp('@(.*:.*)'); |
| 31 | + |
| 32 | +const UserAutoCompleteMultiple = ({ onChange, value, placeholder, federated, ...props }: UserAutoCompleteMultipleProps): ReactElement => { |
21 | 33 | const [filter, setFilter] = useState(''); |
22 | | - const debouncedFilter = useDebouncedValue(filter, 1000); |
23 | | - const usersAutoCompleteEndpoint = useEndpoint('GET', '/v1/users.autocomplete'); |
| 34 | + const [selectedCache, setSelectedCache] = useState<UserAutoCompleteOptions>({}); |
| 35 | + |
| 36 | + const debouncedFilter = useDebouncedValue(filter, 500); |
| 37 | + const getUsers = useEndpoint('GET', '/v1/users.autocomplete'); |
| 38 | + |
24 | 39 | const { data } = useQuery({ |
25 | | - queryKey: ['usersAutoComplete', debouncedFilter], |
26 | | - queryFn: async () => usersAutoCompleteEndpoint(query(debouncedFilter)), |
| 40 | + queryKey: usersQueryKeys.userAutoComplete(debouncedFilter, federated ?? false), |
| 41 | + |
| 42 | + queryFn: async () => { |
| 43 | + const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) }); |
| 44 | + const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]); |
| 45 | + |
| 46 | + // Add extra option if filter text matches `username:server` |
| 47 | + // Used to add federated users that do not exist yet |
| 48 | + if (federated && matrixRegex.test(debouncedFilter)) { |
| 49 | + options.unshift([debouncedFilter, { name: debouncedFilter, username: debouncedFilter, _federated: true }]); |
| 50 | + } |
| 51 | + |
| 52 | + return options; |
| 53 | + }, |
| 54 | + |
| 55 | + placeholderData: keepPreviousData, |
27 | 56 | }); |
28 | 57 |
|
29 | | - const options = useMemo(() => data?.items.map((user) => ({ value: user.username, label: user.name })) || [], [data]); |
| 58 | + const options = useMemo(() => data || [], [data]); |
| 59 | + |
| 60 | + const onAddUser = useCallback( |
| 61 | + (username: string): void => { |
| 62 | + const user = options?.find(([val]) => val === username)?.[1]; |
| 63 | + if (!user) { |
| 64 | + throw new Error('UserAutoCompleteMultiple - onAddSelected - failed to cache option'); |
| 65 | + } |
| 66 | + setSelectedCache((selectedCache) => ({ ...selectedCache, [username]: user })); |
| 67 | + }, |
| 68 | + [setSelectedCache, options], |
| 69 | + ); |
| 70 | + |
| 71 | + const onRemoveUser = useCallback( |
| 72 | + (username: string): void => |
| 73 | + setSelectedCache((selectedCache) => { |
| 74 | + const users = { ...selectedCache }; |
| 75 | + delete users[username]; |
| 76 | + return users; |
| 77 | + }), |
| 78 | + [setSelectedCache], |
| 79 | + ); |
| 80 | + |
| 81 | + const handleOnChange = useCallback( |
| 82 | + (usernames: string[]) => { |
| 83 | + onChange(usernames); |
| 84 | + const newAddedUsername = usernames.filter((username) => !value?.includes(username))[0]; |
| 85 | + const removedUsername = value?.filter((username) => !usernames.includes(username))[0]; |
| 86 | + setFilter(''); |
| 87 | + newAddedUsername && onAddUser(newAddedUsername); |
| 88 | + removedUsername && onRemoveUser(removedUsername); |
| 89 | + }, |
| 90 | + [onChange, setFilter, onAddUser, onRemoveUser, value], |
| 91 | + ); |
30 | 92 |
|
31 | 93 | return ( |
32 | | - <AutoComplete |
33 | | - {...props} |
34 | | - filter={filter} |
35 | | - setFilter={setFilter} |
36 | | - onChange={onChange} |
37 | | - multiple |
38 | | - renderSelected={({ selected: { value: username, label }, onRemove, ...props }): ReactElement => ( |
39 | | - <UserAvatarChip {...props} username={username} name={label} mie={4} onClick={onRemove} /> |
40 | | - )} |
41 | | - renderItem={({ value, label, ...props }): ReactElement => ( |
42 | | - <Option data-qa-type='autocomplete-user-option' key={value} {...props}> |
43 | | - <OptionAvatar> |
44 | | - <UserAvatar username={value} size='x20' /> |
45 | | - </OptionAvatar> |
46 | | - <OptionContent> |
47 | | - {label} <OptionDescription>({value})</OptionDescription> |
48 | | - </OptionContent> |
49 | | - </Option> |
50 | | - )} |
51 | | - options={options} |
52 | | - /> |
| 94 | + <OptionsContext.Provider value={{ options }}> |
| 95 | + <MultiSelectFiltered |
| 96 | + {...props} |
| 97 | + data-qa-type='user-auto-complete-input' |
| 98 | + placeholder={placeholder} |
| 99 | + value={value} |
| 100 | + onChange={handleOnChange} |
| 101 | + filter={filter} |
| 102 | + setFilter={setFilter} |
| 103 | + renderSelected={({ value: username, onMouseDown }: { value: string; onMouseDown: () => void }) => { |
| 104 | + const currentCachedOption = selectedCache[username] || {}; |
| 105 | + |
| 106 | + return ( |
| 107 | + <UserAvatarChip |
| 108 | + mie={4} |
| 109 | + mb={2} |
| 110 | + key={username} |
| 111 | + federated={currentCachedOption._federated} |
| 112 | + name={currentCachedOption.name} |
| 113 | + username={currentCachedOption.username || username} |
| 114 | + onMouseDown={onMouseDown} |
| 115 | + /> |
| 116 | + ); |
| 117 | + }} |
| 118 | + renderOptions={AutocompleteOptions} |
| 119 | + options={options.concat(Object.entries(selectedCache)).map(([, item]) => [item.username, item.name || item.username])} |
| 120 | + /> |
| 121 | + </OptionsContext.Provider> |
53 | 122 | ); |
54 | 123 | }; |
55 | 124 |
|
|
0 commit comments