diff --git a/.changeset/cuddly-eels-perform.md b/.changeset/cuddly-eels-perform.md new file mode 100644 index 0000000000000..bc99f3c96e302 --- /dev/null +++ b/.changeset/cuddly-eels-perform.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes members tab > add members not removing selected items diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.tsx index bcb0e0272a3fa..8b5e6e99cfaa6 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.tsx @@ -34,7 +34,7 @@ import { useId, useEffect, useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useEncryptedRoomDescription } from './useEncryptedRoomDescription'; -import UserAutoCompleteMultipleFederated from '../../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import { useCreateChannelTypePermission } from '../../../hooks/useCreateChannelTypePermission'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import { useIsFederationEnabled } from '../../../hooks/useIsFederationEnabled'; @@ -260,7 +260,13 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal !federated && hasExternalMembers(members) ? t('You_cannot_add_external_users_to_non_federated_room') : true, }} render={({ field: { onChange, value } }): ReactElement => ( - + )} /> {errors.members && ( diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateDirectMessage.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateDirectMessage.tsx index 2382eee8cafc8..39239a8222db6 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateDirectMessage.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateDirectMessage.tsx @@ -20,7 +20,7 @@ import { useMutation } from '@tanstack/react-query'; import { useId, memo } from 'react'; import { useForm, Controller } from 'react-hook-form'; -import UserAutoCompleteMultipleFederated from '../../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import { goToRoomById } from '../../../lib/utils/goToRoomById'; type CreateDirectMessageProps = { onClose: () => void }; @@ -78,11 +78,12 @@ const CreateDirectMessage = ({ onClose }: CreateDirectMessageProps) => { }} control={control} render={({ field: { name, onChange, value, onBlur } }) => ( - {t('Invite_Users')} - + diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx index 7c315bdc1aeda..827f88ee309e4 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx @@ -1,55 +1,124 @@ -import { AutoComplete, OptionAvatar, Option, OptionContent, OptionDescription } from '@rocket.chat/fuselage'; +import { MultiSelectFiltered } 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 { useQuery } from '@tanstack/react-query'; -import type { ComponentProps, ReactElement } from 'react'; -import { memo, useMemo, useState } from 'react'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import type { ReactElement, AllHTMLAttributes } from 'react'; +import { memo, useState, useCallback, useMemo } from 'react'; +import AutocompleteOptions, { OptionsContext } from './UserAutoCompleteMultipleOptions'; import UserAvatarChip from './UserAvatarChip'; +import { usersQueryKeys } from '../../lib/queryKeys'; -const query = ( - term = '', -): { - selector: string; -} => ({ selector: JSON.stringify({ term }) }); +type UserAutoCompleteMultipleProps = { + onChange: (value: Array) => void; + value: Array | undefined; + placeholder?: string; + federated?: boolean; + error?: string; +} & Omit, 'is' | 'onChange' | 'value'>; -type UserAutoCompleteMultipleProps = Omit, 'filter'>; +type UserAutoCompleteOptionType = { + name: string; + username: string; + _federated?: boolean; +}; + +type UserAutoCompleteOptions = { + [k: string]: UserAutoCompleteOptionType; +}; -// TODO: useDisplayUsername -const UserAutoCompleteMultiple = ({ onChange, ...props }: UserAutoCompleteMultipleProps): ReactElement => { +const matrixRegex = new RegExp('@(.*:.*)'); + +const UserAutoCompleteMultiple = ({ onChange, value, placeholder, federated, ...props }: UserAutoCompleteMultipleProps): ReactElement => { const [filter, setFilter] = useState(''); - const debouncedFilter = useDebouncedValue(filter, 1000); - const usersAutoCompleteEndpoint = useEndpoint('GET', '/v1/users.autocomplete'); + const [selectedCache, setSelectedCache] = useState({}); + + const debouncedFilter = useDebouncedValue(filter, 500); + const getUsers = useEndpoint('GET', '/v1/users.autocomplete'); + const { data } = useQuery({ - queryKey: ['usersAutoComplete', debouncedFilter], - queryFn: async () => usersAutoCompleteEndpoint(query(debouncedFilter)), + queryKey: usersQueryKeys.userAutoComplete(debouncedFilter, federated ?? false), + + queryFn: async () => { + const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) }); + const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]); + + // Add extra option if filter text matches `username:server` + // Used to add federated users that do not exist yet + if (federated && matrixRegex.test(debouncedFilter)) { + options.unshift([debouncedFilter, { name: debouncedFilter, username: debouncedFilter, _federated: true }]); + } + + return options; + }, + + placeholderData: keepPreviousData, }); - const options = useMemo(() => data?.items.map((user) => ({ value: user.username, label: user.name })) || [], [data]); + 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 handleOnChange = useCallback( + (usernames: string[]) => { + onChange(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); + }, + [onChange, setFilter, onAddUser, onRemoveUser, value], + ); return ( - ( - - )} - renderItem={({ value, label, ...props }): ReactElement => ( - - )} - options={options} - /> + + void }) => { + const currentCachedOption = selectedCache[username] || {}; + + return ( + + ); + }} + renderOptions={AutocompleteOptions} + options={options.concat(Object.entries(selectedCache)).map(([, item]) => [item.username, item.name || item.username])} + /> + ); }; diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx deleted file mode 100644 index e7ca3e76dd39a..0000000000000 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { MultiSelectFiltered } from '@rocket.chat/fuselage'; -import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import type { ReactElement, AllHTMLAttributes } from 'react'; -import { memo, useState, useCallback, useMemo } from 'react'; - -import AutocompleteOptions, { OptionsContext } from './UserAutoCompleteMultipleOptions'; -import UserAvatarChip from './UserAvatarChip'; - -type UserAutoCompleteMultipleFederatedProps = { - onChange: (value: Array) => void; - value: Array; - placeholder?: string; -} & Omit, 'is' | 'onChange'>; - -type UserAutoCompleteOptionType = { - name: string; - username: string; - _federated?: boolean; -}; - -type UserAutoCompleteOptions = { - [k: string]: UserAutoCompleteOptionType; -}; - -const matrixRegex = new RegExp('@(.*:.*)'); - -const UserAutoCompleteMultipleFederated = ({ - onChange, - value, - placeholder, - ...props -}: UserAutoCompleteMultipleFederatedProps): ReactElement => { - const [filter, setFilter] = useState(''); - const [selectedCache, setSelectedCache] = useState({}); - - const debouncedFilter = useDebouncedValue(filter, 500); - const getUsers = useEndpoint('GET', '/v1/users.autocomplete'); - - const { data } = useQuery({ - queryKey: ['users.autocomplete', debouncedFilter], - - queryFn: async () => { - const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) }); - const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]); - - // Add extra option if filter text matches `username:server` - // Used to add federated users that do not exist yet - if (matrixRegex.test(debouncedFilter)) { - options.unshift([debouncedFilter, { name: debouncedFilter, username: debouncedFilter, _federated: true }]); - } - - return options; - }, - - placeholderData: keepPreviousData, - }); - - 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 handleOnChange = useCallback( - (usernames: string[]) => { - onChange(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); - }, - [onChange, setFilter, onAddUser, onRemoveUser, value], - ); - - return ( - - void }) => { - const currentCachedOption = selectedCache[username] || {}; - - return ( - - ); - }} - renderOptions={AutocompleteOptions} - options={options.concat(Object.entries(selectedCache)).map(([, item]) => [item.username, item.name || item.username])} - data-qa='create-channel-users-autocomplete' - /> - - ); -}; - -export default memo(UserAutoCompleteMultipleFederated); diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index d0414b91cd36a..84270d4f545db 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -123,6 +123,7 @@ export const usersQueryKeys = { all: ['users'] as const, userInfo: ({ uid, username }: { uid?: IUser['_id']; username?: IUser['username'] }) => [...usersQueryKeys.all, 'info', { uid, username }] as const, + userAutoComplete: (filter: string, federated: boolean) => [...usersQueryKeys.all, 'autocomplete', filter, federated] as const, }; export const teamsQueryKeys = { diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx index f959c6be10620..9ab6fe1aafcb4 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx @@ -34,7 +34,7 @@ import type { ComponentProps, ReactElement } from 'react'; import { useId, useEffect, useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; -import UserAutoCompleteMultipleFederated from '../../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import { useCreateChannelTypePermission } from '../../../hooks/useCreateChannelTypePermission'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import { useIsFederationEnabled } from '../../../hooks/useIsFederationEnabled'; @@ -75,8 +75,6 @@ const getFederationHintKey = (licenseModule: boolean, featureToggle: boolean, fe return 'Federation_Matrix_Federated_Description'; }; -const hasExternalMembers = (members: string[]): boolean => members.some((member) => member.startsWith('@')); - const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateChannelModalProps): ReactElement => { const t = useTranslation(); const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']); @@ -258,12 +256,14 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh - !federated && hasExternalMembers(members) ? t('You_cannot_add_external_users_to_non_federated_room') : true, - }} render={({ field: { onChange, value } }): ReactElement => ( - + )} /> {errors.members && ( diff --git a/apps/meteor/client/sidebar/header/CreateChannel/__snapshots__/CreateChannelModal.spec.tsx.snap b/apps/meteor/client/sidebar/header/CreateChannel/__snapshots__/CreateChannelModal.spec.tsx.snap index 5409a33e47fa4..56ecb40563c6a 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/__snapshots__/CreateChannelModal.spec.tsx.snap +++ b/apps/meteor/client/sidebar/header/CreateChannel/__snapshots__/CreateChannelModal.spec.tsx.snap @@ -143,7 +143,6 @@ exports[`renders Default without crashing 1`] = `
@@ -570,7 +569,6 @@ exports[`renders DefaultVersion2 without crashing 1`] = `
diff --git a/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx b/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx index acfac1c479c3e..6610962cb4f1e 100644 --- a/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx +++ b/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx @@ -20,7 +20,7 @@ import { useMutation } from '@tanstack/react-query'; import { useId, memo } from 'react'; import { useForm, Controller } from 'react-hook-form'; -import UserAutoCompleteMultipleFederated from '../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; +import UserAutoCompleteMultiple from '../../components/UserAutoCompleteMultiple'; import { goToRoomById } from '../../lib/utils/goToRoomById'; const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { @@ -76,11 +76,12 @@ const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { }} control={control} render={({ field: { name, onChange, value, onBlur } }) => ( -
- +
+ +
@@ -540,27 +546,33 @@ exports[`renders DefaultVersion2 without crashing 1`] = ` Teams_New_Add_members_Label
- +
+ +
diff --git a/apps/meteor/client/views/audit/components/tabs/DirectTab.tsx b/apps/meteor/client/views/audit/components/tabs/DirectTab.tsx index 7e74c5263978a..5be7564fa6190 100644 --- a/apps/meteor/client/views/audit/components/tabs/DirectTab.tsx +++ b/apps/meteor/client/views/audit/components/tabs/DirectTab.tsx @@ -33,7 +33,7 @@ const DirectTab = ({ form: { control } }: DirectTabProps): ReactElement => { diff --git a/apps/meteor/client/views/audit/components/tabs/UsersTab.tsx b/apps/meteor/client/views/audit/components/tabs/UsersTab.tsx index 02cf1fa6f2fda..40cc292ad2bec 100644 --- a/apps/meteor/client/views/audit/components/tabs/UsersTab.tsx +++ b/apps/meteor/client/views/audit/components/tabs/UsersTab.tsx @@ -32,7 +32,7 @@ const UsersTab = ({ form: { control } }: UsersTabProps): ReactElement => { {t('Users')} users.some((user) => user.startsWith('@')); @@ -38,6 +37,7 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement => const roomIsFederated = isRoomFederated(room); // we are dropping the non native federation for now const isFederationBlocked = room && !isRoomNativeFederated(room); + const isFederated = roomIsFederated && !isFederationBlocked; const { closeTab } = useRoomToolbox(); const saveAction = useMethod('addUsersToRoom'); @@ -73,24 +73,21 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement => {t('Choose_users')} - {roomIsFederated ? ( - !isFederationBlocked && ( - } + !isFederated && (!hasExternalUsers(users) || t('You_cannot_add_external_users_to_non_federated_room')), + }} + render={({ field }) => ( + - ) - ) : ( - !hasExternalUsers(users) || t('You_cannot_add_external_users_to_non_federated_room') }} - render={({ field }) => ( - - )} - /> - )} + )} + /> {errors.users && ( {errors.users.message} diff --git a/apps/meteor/tests/e2e/administration.spec.ts b/apps/meteor/tests/e2e/administration.spec.ts index 00add48b595db..5769794f13f3d 100644 --- a/apps/meteor/tests/e2e/administration.spec.ts +++ b/apps/meteor/tests/e2e/administration.spec.ts @@ -311,7 +311,7 @@ test.describe.parallel('administration', () => { await poAdminRoles.inputRoom.fill(channelName); await page.getByRole('option', { name: channelName }).click(); - await poAdminRoles.inputUsers.fill('user1'); + await poAdminRoles.inputUsers.pressSequentially('user1'); await page.getByRole('option', { name: 'user1' }).click(); await poAdminRoles.btnAdd.click(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-members.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-members.ts index 10ac01af28b66..d23622e7d70f5 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-members.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-members.ts @@ -30,8 +30,8 @@ export class HomeFlextabMembers { async addUser(username: string) { await this.page.locator('role=button[name="Add"]').click(); - await this.page.locator('//label[contains(text(), "Choose users")]/..//input').fill(username); - await this.page.locator(`[data-qa-type="autocomplete-user-option"] >> text=${username}`).first().click(); + await this.page.getByRole('textbox', { name: 'Choose users' }).pressSequentially(username); + await this.page.getByRole('option', { name: username }).click(); await this.page.locator('role=button[name="Add users"]').click(); } diff --git a/apps/meteor/tests/e2e/page-objects/home-team.ts b/apps/meteor/tests/e2e/page-objects/home-team.ts index f44b2584f7f27..150e6a8108aef 100644 --- a/apps/meteor/tests/e2e/page-objects/home-team.ts +++ b/apps/meteor/tests/e2e/page-objects/home-team.ts @@ -30,8 +30,8 @@ export class HomeTeam { } async addMember(memberName: string): Promise { - await this.page.locator('role=textbox[name="Members"]').type(memberName, { delay: 100 }); - await this.page.locator(`.rcx-option__content:has-text("${memberName}")`).click(); + await this.page.getByRole('textbox', { name: 'Add people' }).pressSequentially(memberName, { delay: 100 }); + await this.page.getByRole('option', { name: memberName }).click(); } get btnTeamCreate(): Locator {