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

refactor: replace Menu in favor of GenericMenu in AdminUsers #34834

Merged
merged 3 commits into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
132 changes: 24 additions & 108 deletions apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,13 @@
import type { IUser } from '@rocket.chat/core-typings';
import { ButtonGroup, Menu, Option } from '@rocket.chat/fuselage';
import { useRoute, usePermission } from '@rocket.chat/ui-contexts';
import { ButtonGroup, IconButton } from '@rocket.chat/fuselage';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { ReactElement } from 'react';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import type { AdminUsersTab } from './AdminUsersPage';
import { useChangeAdminStatusAction } from './hooks/useChangeAdminStatusAction';
import { useChangeUserStatusAction } from './hooks/useChangeUserStatusAction';
import { useDeleteUserAction } from './hooks/useDeleteUserAction';
import { useResetE2EEKeyAction } from './hooks/useResetE2EEKeyAction';
import { useResetTOTPAction } from './hooks/useResetTOTPAction';
import type { AdminUserInfoActionsProps } from './hooks/useAdminUserInfoActions';
import { useAdminUserInfoActions } from './hooks/useAdminUserInfoActions';
import { UserInfoAction } from '../../../components/UserInfo';
import { useActionSpread } from '../../hooks/useActionSpread';

type AdminUserInfoActionsProps = {
username: IUser['username'];
userId: IUser['_id'];
isFederatedUser: IUser['federated'];
isActive: boolean;
isAdmin: boolean;
tab: AdminUsersTab;
onChange: () => void;
onReload: () => void;
};

// TODO: Replace menu
const AdminUserInfoActions = ({
username,
userId,
Expand All @@ -37,104 +19,38 @@ const AdminUserInfoActions = ({
onReload,
}: AdminUserInfoActionsProps): ReactElement => {
const { t } = useTranslation();
const directRoute = useRoute('direct');
const userRoute = useRoute('admin-users');
const canDirectMessage = usePermission('create-d');
const canEditOtherUserInfo = usePermission('edit-other-user-info');

const changeAdminStatusAction = useChangeAdminStatusAction(userId, isAdmin, onChange);
const changeUserStatusAction = useChangeUserStatusAction(userId, isActive, onChange);
const deleteUserAction = useDeleteUserAction(userId, onChange, onReload);
const resetTOTPAction = useResetTOTPAction(userId);
const resetE2EKeyAction = useResetE2EEKeyAction(userId);

const directMessageClick = useCallback(
() =>
username &&
directRoute.push({
rid: username,
}),
[directRoute, username],
);

const editUserClick = useCallback(
() =>
userRoute.push({
context: 'edit',
id: userId,
}),
[userId, userRoute],
);

const isNotPendingDeactivatedNorFederated = tab !== 'pending' && tab !== 'deactivated' && !isFederatedUser;
const options = useMemo(
() => ({
...(canDirectMessage && {
directMessage: {
icon: 'balloon' as const,
label: t('Direct_Message'),
title: t('Direct_Message'),
action: directMessageClick,
},
}),
...(canEditOtherUserInfo && {
editUser: {
icon: 'edit' as const,
label: t('Edit'),
title: isFederatedUser ? t('Edit_Federated_User_Not_Allowed') : t('Edit'),
action: editUserClick,
disabled: isFederatedUser,
},
}),
...(isNotPendingDeactivatedNorFederated && changeAdminStatusAction && { makeAdmin: changeAdminStatusAction }),
...(isNotPendingDeactivatedNorFederated && resetE2EKeyAction && { resetE2EKey: resetE2EKeyAction }),
...(isNotPendingDeactivatedNorFederated && resetTOTPAction && { resetTOTP: resetTOTPAction }),
...(changeUserStatusAction && !isFederatedUser && { changeActiveStatus: changeUserStatusAction }),
...(deleteUserAction && { delete: deleteUserAction }),
}),
[
canDirectMessage,
canEditOtherUserInfo,
changeAdminStatusAction,
changeUserStatusAction,
deleteUserAction,
directMessageClick,
editUserClick,
isFederatedUser,
isNotPendingDeactivatedNorFederated,
resetE2EKeyAction,
resetTOTPAction,
t,
],
);

const { actions: actionsDefinition, menu: menuOptions } = useActionSpread(options);
const { actions: actionsDefinition, menuActions: menuOptions } = useAdminUserInfoActions({
username,
userId,
isFederatedUser,
isActive,
isAdmin,
tab,
onChange,
onReload,
});

const menu = useMemo(() => {
if (!menuOptions) {
return null;
}

return (
<Menu
mi={4}
placement='bottom-start'
small={false}
secondary
flexShrink={0}
<GenericMenu
key='menu'
renderItem={({ label: { label, icon }, ...props }): ReactElement => (
<Option label={label} title={label} icon={icon} variant={label === 'Delete' ? 'danger' : ''} {...props} />
)}
options={menuOptions}
button={<IconButton icon='kebab' secondary />}
title={t('More')}
sections={menuOptions}
placement='bottom-end'
small={false}
/>
);
}, [menuOptions]);
}, [t, menuOptions]);

// TODO: sanitize Action type to avoid any
const actions = useMemo(() => {
const mapAction = ([key, { label, icon, action, disabled, title }]: any): ReactElement => (
<UserInfoAction key={key} title={title} label={label} onClick={action} disabled={disabled} icon={icon} />
const mapAction = ([key, { content, title, icon, onClick, disabled }]: any): ReactElement => (
<UserInfoAction key={key} title={title} label={content} onClick={onClick} disabled={disabled} icon={icon} />
);
return [...actionsDefinition.map(mapAction), menu].filter(Boolean);
}, [actionsDefinition, menu]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ it('should not render voip extension column when voice call is disabled', async
expect(screen.queryByText('Voice_call_extension')).not.toBeInTheDocument();

screen.getByRole('button', { name: 'More_actions' }).click();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument();
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /Assign_extension/ })).not.toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /Unassign_extension/ })).not.toBeInTheDocument();
});

it('should not render voip extension column or actions if user doesnt have the required permission', async () => {
Expand All @@ -63,9 +63,9 @@ it('should not render voip extension column or actions if user doesnt have the r
expect(screen.queryByText('Voice_call_extension')).not.toBeInTheDocument();

screen.getByRole('button', { name: 'More_actions' }).click();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument();
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /Assign_extension/ })).not.toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /Unassign_extension/ })).not.toBeInTheDocument();
});

it('should render "Unassign_extension" button when user has a associated extension', async () => {
Expand All @@ -91,9 +91,9 @@ it('should render "Unassign_extension" button when user has a associated extensi
expect(screen.getByText('Voice_call_extension')).toBeInTheDocument();

screen.getByRole('button', { name: 'More_actions' }).click();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument();
expect(screen.getByRole('option', { name: /Unassign_extension/ })).toBeInTheDocument();
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /Assign_extension/ })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /Unassign_extension/ })).toBeInTheDocument();
});

it('should render "Assign_extension" button when user has no associated extension', async () => {
Expand All @@ -119,7 +119,7 @@ it('should render "Assign_extension" button when user has no associated extensio
expect(screen.getByText('Voice_call_extension')).toBeInTheDocument();

screen.getByRole('button', { name: 'More_actions' }).click();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
expect(screen.getByRole('option', { name: /Assign_extension/ })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument();
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /Assign_extension/ })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /Unassign_extension/ })).not.toBeInTheDocument();
});
55 changes: 20 additions & 35 deletions apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { UserStatus as Status } from '@rocket.chat/core-typings';
import type { IRole, IUser, Serialized } from '@rocket.chat/core-typings';
import { Box, Button, Menu, Option } from '@rocket.chat/fuselage';
import { Box, Button } from '@rocket.chat/fuselage';
import type { DefaultUserInfo } from '@rocket.chat/rest-typings';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { ReactElement, MouseEvent, KeyboardEvent } from 'react';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { KeyboardEvent, MouseEvent, ReactElement } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';

Expand Down Expand Up @@ -101,38 +102,26 @@ const UsersTableRow = ({
});

const isNotPendingDeactivatedNorFederated = tab !== 'pending' && tab !== 'deactivated' && !isFederatedUser;
const menuOptions = useMemo(
const actions = useMemo(
() => ({
...(voipExtensionAction && {
voipExtensionAction: {
label: { label: voipExtensionAction.label, icon: voipExtensionAction.icon },
action: voipExtensionAction.action,
},
voipExtensionAction,
}),
...(isNotPendingDeactivatedNorFederated &&
changeAdminStatusAction && {
makeAdmin: {
label: { label: changeAdminStatusAction.label, icon: changeAdminStatusAction.icon },
action: changeAdminStatusAction.action,
},
changeAdminStatusAction,
}),
...(isNotPendingDeactivatedNorFederated &&
resetE2EKeyAction && {
resetE2EKey: { label: { label: resetE2EKeyAction.label, icon: resetE2EKeyAction.icon }, action: resetE2EKeyAction.action },
}),
...(isNotPendingDeactivatedNorFederated &&
resetTOTPAction && {
resetTOTP: { label: { label: resetTOTPAction.label, icon: resetTOTPAction.icon }, action: resetTOTPAction.action },
resetE2EKeyAction,
}),
...(isNotPendingDeactivatedNorFederated && resetTOTPAction && { resetTOTPAction }),
...(changeUserStatusAction &&
!isFederatedUser && {
changeActiveStatus: {
label: { label: changeUserStatusAction.label, icon: changeUserStatusAction.icon },
action: changeUserStatusAction.action,
},
changeUserStatusAction,
}),
...(deleteUserAction && {
delete: { label: { label: deleteUserAction.label, icon: deleteUserAction.icon }, action: deleteUserAction.action },
deleteUserAction,
}),
}),
[
Expand All @@ -147,6 +136,14 @@ const UsersTableRow = ({
],
);

const menuOptions = Object.entries(actions).map(([_key, item]) => {
return {
...item,
id: item.content || item.title || '',
content: item.content || item.title,
};
});

const handleResendWelcomeEmail = () => resendWelcomeEmail.mutateAsync({ email: emails?.[0].address });

return (
Expand Down Expand Up @@ -208,25 +205,13 @@ const UsersTableRow = ({
{t('Resend_welcome_email')}
</Button>
) : (
<Button small primary onClick={changeUserStatusAction?.action} disabled={isSeatsCapExceeded}>
<Button small primary onClick={changeUserStatusAction?.onClick} disabled={isSeatsCapExceeded}>
{t('Activate')}
</Button>
)}
</>
)}

<Menu
mi={4}
placement='bottom-start'
flexShrink={0}
key='menu'
aria-label={t('More_actions')}
title={t('More_actions')}
renderItem={({ label: { label, icon }, ...props }): ReactElement => (
<Option label={label} title={label} icon={icon} variant={label === 'Delete' ? 'danger' : ''} {...props} />
)}
options={menuOptions}
/>
<GenericMenu detached title={t('More_actions')} sections={[{ title: '', items: menuOptions }]} placement='bottom-end' />
</Box>
</GenericTableCell>
</GenericTableRow>
Expand Down
Loading
Loading