feat: New users page all tab (#31917)

pull/31671/head^2
Henrique Guimarães Ribeiro 2 years ago committed by GitHub
parent 7d5bdde4d8
commit ff4e396416
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .changeset/chilly-poems-explode.md
  2. 62
      apps/meteor/client/views/admin/users/AdminUsersPage.tsx
  3. 198
      apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx
  4. 53
      apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx
  5. 62
      apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts
  6. 2
      packages/core-typings/src/IUser.ts
  7. 3
      packages/i18n/src/locales/en.i18n.json

@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/core-typings": minor
"@rocket.chat/i18n": patch
---
Introduced a tab layout to the users page and implemented a tab called "All" that lists all users.

@ -1,7 +1,9 @@
import { Button, ButtonGroup, ContextualbarIcon } from '@rocket.chat/fuselage';
import type { IAdminUserTabs } from '@rocket.chat/core-typings';
import { Button, ButtonGroup, ContextualbarIcon, Tabs, TabsItem } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { usePermission, useRouteParameter, useTranslation, useRouter } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React, { useRef } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import UserPageHeaderContentWithSeatsCap from '../../../../ee/client/views/admin/users/UserPageHeaderContentWithSeatsCap';
import { useSeatsCap } from '../../../../ee/client/views/admin/users/useSeatsCap';
@ -12,6 +14,8 @@ import {
ContextualbarClose,
ContextualbarDialog,
} from '../../../components/Contextualbar';
import { usePagination } from '../../../components/GenericTable/hooks/usePagination';
import { useSort } from '../../../components/GenericTable/hooks/useSort';
import { Page, PageHeader, PageContent } from '../../../components/Page';
import { useShouldPreventAction } from '../../../hooks/useShouldPreventAction';
import AdminInviteUsers from './AdminInviteUsers';
@ -20,12 +24,18 @@ import AdminUserFormWithData from './AdminUserFormWithData';
import AdminUserInfoWithData from './AdminUserInfoWithData';
import AdminUserUpgrade from './AdminUserUpgrade';
import UsersTable from './UsersTable';
import useFilteredUsers from './hooks/useFilteredUsers';
const UsersPage = (): ReactElement => {
export type UsersFilters = {
text: string;
};
export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status';
const AdminUsersPage = (): ReactElement => {
const t = useTranslation();
const seatsCap = useSeatsCap();
const reload = useRef(() => null);
const router = useRouter();
const context = useRouteParameter('context');
@ -36,12 +46,36 @@ const UsersPage = (): ReactElement => {
const isCreateUserDisabled = useShouldPreventAction('activeUsers');
const paginationData = usePagination();
const sortData = useSort<'name' | 'username' | 'emails.address' | 'status'>('name');
const [tab, setTab] = useState<IAdminUserTabs>('all');
const [userFilters, setUserFilters] = useState<UsersFilters>({ text: '' });
const searchTerm = useDebouncedValue(userFilters.text, 500);
const prevSearchTerm = useRef('');
const filteredUsersQueryResult = useFilteredUsers({
searchTerm,
prevSearchTerm,
sortData,
paginationData,
tab,
});
const handleReload = (): void => {
seatsCap?.reload();
reload.current();
filteredUsersQueryResult?.refetch();
};
const isRoutePrevented = context && ['new', 'invite'].includes(context) && isCreateUserDisabled;
useEffect(() => {
prevSearchTerm.current = searchTerm;
}, [searchTerm]);
const isRoutePrevented = useMemo(
() => context && ['new', 'invite'].includes(context) && isCreateUserDisabled,
[context, isCreateUserDisabled],
);
return (
<Page flexDirection='row'>
@ -65,7 +99,19 @@ const UsersPage = (): ReactElement => {
)}
</PageHeader>
<PageContent>
<UsersTable reload={reload} />
<Tabs>
<TabsItem selected={!tab || tab === 'all'} onClick={() => setTab('all')}>
{t('All')}
</TabsItem>
</Tabs>
<UsersTable
filteredUsersQueryResult={filteredUsersQueryResult}
setUserFilters={setUserFilters}
onReload={handleReload}
paginationData={paginationData}
sortData={sortData}
tab={tab}
/>
</PageContent>
</Page>
{context && (
@ -93,4 +139,4 @@ const UsersPage = (): ReactElement => {
);
};
export default UsersPage;
export default AdminUsersPage;

@ -1,10 +1,11 @@
import { Pagination } from '@rocket.chat/fuselage';
import { useMediaQuery, useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { useEndpoint, useRoute, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ReactElement, MutableRefObject } from 'react';
import React, { useRef, useMemo, useState, useEffect } from 'react';
import type { IAdminUserTabs, Serialized } from '@rocket.chat/core-typings';
import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage';
import { useMediaQuery, useEffectEvent } from '@rocket.chat/fuselage-hooks';
import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings';
import { useRouter, useTranslation } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import type { ReactElement, Dispatch, SetStateAction } from 'react';
import React, { useCallback, useMemo } from 'react';
import FilterByText from '../../../../components/FilterByText';
import GenericNoResults from '../../../../components/GenericNoResults';
@ -15,93 +16,67 @@ import {
GenericTableBody,
GenericTableLoadingTable,
} from '../../../../components/GenericTable';
import { usePagination } from '../../../../components/GenericTable/hooks/usePagination';
import { useSort } from '../../../../components/GenericTable/hooks/useSort';
import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination';
import type { useSort } from '../../../../components/GenericTable/hooks/useSort';
import type { UsersFilters } from '../AdminUsersPage';
import UsersTableRow from './UsersTableRow';
type UsersTableProps = {
reload: MutableRefObject<() => void>;
tab: IAdminUserTabs;
onReload: () => void;
setUserFilters: Dispatch<SetStateAction<UsersFilters>>;
filteredUsersQueryResult: UseQueryResult<PaginatedResult<{ users: Serialized<DefaultUserInfo>[] }>>;
paginationData: ReturnType<typeof usePagination>;
sortData: ReturnType<typeof useSort<'name' | 'username' | 'emails.address' | 'status'>>;
};
// TODO: Missing error state
const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => {
const UsersTable = ({
filteredUsersQueryResult,
setUserFilters,
tab,
onReload,
paginationData,
sortData,
}: UsersTableProps): ReactElement | null => {
const t = useTranslation();
const usersRoute = useRoute('admin-users');
const router = useRouter();
const mediaQuery = useMediaQuery('(min-width: 1024px)');
const [text, setText] = useState('');
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
const { sortBy, sortDirection, setSort } = useSort<'name' | 'username' | 'emails.address' | 'status'>('name');
const { data, isLoading, isError, isSuccess } = filteredUsersQueryResult;
const searchTerm = useDebouncedValue(text, 500);
const prevSearchTerm = useRef<string>('');
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = paginationData;
const { sortBy, sortDirection, setSort } = sortData;
const query = useDebouncedValue(
useMemo(() => {
if (searchTerm !== prevSearchTerm.current) {
setCurrent(0);
}
const isKeyboardEvent = (
event: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>,
): event is React.KeyboardEvent<HTMLElement> => {
return (event as React.KeyboardEvent<HTMLElement>).key !== undefined;
};
return {
fields: JSON.stringify({
name: 1,
username: 1,
emails: 1,
roles: 1,
status: 1,
avatarETag: 1,
active: 1,
}),
query: JSON.stringify({
$or: [
{ 'emails.address': { $regex: escapeRegExp(searchTerm), $options: 'i' } },
{ username: { $regex: escapeRegExp(searchTerm), $options: 'i' } },
{ name: { $regex: escapeRegExp(searchTerm), $options: 'i' } },
],
}),
sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`,
count: itemsPerPage,
offset: searchTerm === prevSearchTerm.current ? current : 0,
};
}, [searchTerm, sortBy, sortDirection, itemsPerPage, current, setCurrent]),
500,
);
const handleClickOrKeyDown = useEffectEvent(
(id, e: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>): void => {
e.stopPropagation();
const getUsers = useEndpoint('GET', '/v1/users.list');
const keyboardSubmitKeys = ['Enter', ' '];
const dispatchToastMessage = useToastMessageDispatch();
if (isKeyboardEvent(e) && !keyboardSubmitKeys.includes(e.key)) {
return;
}
const { data, isLoading, error, isSuccess, refetch } = useQuery(
['users', query],
async () => {
const users = await getUsers(query);
return users;
},
{
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
router.navigate({
name: 'admin-users',
params: {
context: 'info',
id,
},
});
},
);
useEffect(() => {
reload.current = refetch;
}, [reload, refetch]);
useEffect(() => {
prevSearchTerm.current = searchTerm;
}, [searchTerm]);
const handleClick = useMutableCallback((id): void =>
usersRoute.push({
context: 'info',
id,
}),
);
const headers = useMemo(
() => [
<GenericTableHeaderCell w='x200' key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name'>
<GenericTableHeaderCell w='x240' key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name'>
{t('Name')}
</GenericTableHeaderCell>,
mediaQuery && (
@ -116,48 +91,76 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => {
{t('Username')}
</GenericTableHeaderCell>
),
<GenericTableHeaderCell
w='x120'
key='email'
direction={sortDirection}
active={sortBy === 'emails.address'}
onClick={setSort}
sort='emails.address'
>
{t('Email')}
</GenericTableHeaderCell>,
mediaQuery && (
<GenericTableHeaderCell
w='x120'
key='email'
direction={sortDirection}
active={sortBy === 'emails.address'}
onClick={setSort}
sort='emails.address'
>
{t('Email')}
</GenericTableHeaderCell>
),
mediaQuery && (
<GenericTableHeaderCell w='x120' key='roles' onClick={setSort}>
{t('Roles')}
</GenericTableHeaderCell>
),
<GenericTableHeaderCell w='x100' key='status' direction={sortDirection} active={sortBy === 'status'} onClick={setSort} sort='status'>
{t('Status')}
</GenericTableHeaderCell>,
tab === 'all' && (
<GenericTableHeaderCell
w='x100'
key='status'
direction={sortDirection}
active={sortBy === 'status'}
onClick={setSort}
sort='status'
>
{t('Registration_status')}
</GenericTableHeaderCell>
),
],
[mediaQuery, setSort, sortBy, sortDirection, t],
[mediaQuery, setSort, sortBy, sortDirection, t, tab],
);
if (error) {
return null;
}
const handleSearchTextChange = useCallback(
({ text }) => {
setUserFilters({ text });
},
[setUserFilters],
);
return (
<>
<FilterByText shouldAutoFocus placeholder={t('Search_Users')} onChange={({ text }): void => setText(text)} />
<FilterByText shouldAutoFocus placeholder={t('Search_Users')} onChange={handleSearchTextChange} />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>{isLoading && <GenericTableLoadingTable headerCells={5} />}</GenericTableBody>
<GenericTableBody>
<GenericTableLoadingTable headerCells={5} />
</GenericTableBody>
</GenericTable>
)}
{data?.users && data.count > 0 && isSuccess && (
{isError && (
<States>
<StatesIcon name='warning' variation='danger' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesActions>
<StatesAction onClick={onReload}>{t('Reload_page')}</StatesAction>
</StatesActions>
</States>
)}
{isSuccess && data.users.length === 0 && <GenericNoResults />}
{isSuccess && !!data?.users && (
<>
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data?.users.map((user) => (
<UsersTableRow key={user._id} onClick={handleClick} mediaQuery={mediaQuery} user={user} />
{data.users.map((user) => (
<UsersTableRow key={user._id} onClick={handleClickOrKeyDown} mediaQuery={mediaQuery} user={user} tab={tab} />
))}
</GenericTableBody>
</GenericTable>
@ -172,7 +175,6 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => {
/>
</>
)}
{isSuccess && data?.count === 0 && <GenericNoResults />}
</>
);
};

@ -1,26 +1,41 @@
import type { IRole, IUser, Serialized } from '@rocket.chat/core-typings';
import { UserStatus as Status } from '@rocket.chat/core-typings';
import type { IAdminUserTabs, IRole, IUser, Serialized } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import type { DefaultUserInfo } from '@rocket.chat/rest-typings';
import { capitalize } from '@rocket.chat/string-helpers';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
import React, { useMemo } from 'react';
import { Roles } from '../../../../../app/models/client';
import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable';
import { UserStatus } from '../../../../components/UserStatus';
type UsersTableRowProps = {
user: Serialized<DefaultUserInfo>;
onClick: (id: IUser['_id']) => void;
onClick: (id: IUser['_id'], e: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => void;
mediaQuery: boolean;
tab: IAdminUserTabs;
};
const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): ReactElement => {
const UsersTableRow = ({ user, onClick, mediaQuery, tab }: UsersTableRowProps): ReactElement => {
const t = useTranslation();
const { _id, emails, username, name, roles, status, active, avatarETag } = user;
const statusText = active ? t(capitalize(status as string) as TranslationKey) : t('Disabled');
const { _id, emails, username, name, roles, status, active, avatarETag, lastLogin, type } = user;
const registrationStatusText = useMemo(() => {
const usersExcludedFromPending = ['bot', 'app'];
if (!lastLogin && !usersExcludedFromPending.includes(type)) {
return t('Pending');
}
if (active && lastLogin) {
return t('Active');
}
if (!active && lastLogin) {
return t('Deactivated');
}
}, [active, lastLogin, t, type]);
const roleNames = (roles || [])
.map((roleId) => (Roles.findOne(roleId, { fields: { name: 1 } }) as IRole | undefined)?.name)
@ -29,8 +44,8 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React
return (
<GenericTableRow
onKeyDown={(): void => onClick(_id)}
onClick={(): void => onClick(_id)}
onKeyDown={(e): void => onClick(_id, e)}
onClick={(e): void => onClick(_id, e)}
tabIndex={0}
role='link'
action
@ -42,12 +57,14 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React
<Box display='flex' mi={8} withTruncatedText>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' color='default' withTruncatedText>
<Box display='inline' mie='x8'>
<UserStatus status={status || Status.OFFLINE} />
</Box>
{name || username}
</Box>
{!mediaQuery && name && (
<Box fontScale='p2' color='hint' withTruncatedText>
{' '}
{`@${username}`}{' '}
{`@${username}`}
</Box>
)}
</Box>
@ -58,15 +75,17 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React
<GenericTableCell>
<Box fontScale='p2m' color='hint' withTruncatedText>
{username}
</Box>{' '}
</Box>
<Box mi={4} />
</GenericTableCell>
)}
<GenericTableCell withTruncatedText>{emails?.length && emails[0].address}</GenericTableCell>
{mediaQuery && <GenericTableCell withTruncatedText>{emails?.length && emails[0].address}</GenericTableCell>}
{mediaQuery && <GenericTableCell withTruncatedText>{roleNames}</GenericTableCell>}
<GenericTableCell fontScale='p2' color='hint' withTruncatedText>
{statusText}
</GenericTableCell>
{tab === 'all' && (
<GenericTableCell fontScale='p2' color='hint' withTruncatedText>
{registrationStatusText}
</GenericTableCell>
)}
</GenericTableRow>
);
};

@ -0,0 +1,62 @@
import type { IAdminUserTabs } from '@rocket.chat/core-typings';
import type { UsersListStatusParamsGET } from '@rocket.chat/rest-typings';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { MutableRefObject } from 'react';
import { useMemo } from 'react';
import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination';
import type { useSort } from '../../../../components/GenericTable/hooks/useSort';
import type { UsersTableSortingOptions } from '../AdminUsersPage';
type UseFilteredUsersOptions = {
searchTerm: string;
prevSearchTerm: MutableRefObject<string>;
tab: IAdminUserTabs;
paginationData: ReturnType<typeof usePagination>;
sortData: ReturnType<typeof useSort<UsersTableSortingOptions>>;
};
const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab }: UseFilteredUsersOptions) => {
const { setCurrent, itemsPerPage, current } = paginationData;
const { sortBy, sortDirection } = sortData;
const payload = useMemo(() => {
if (searchTerm !== prevSearchTerm.current) {
setCurrent(0);
}
const listUsersPayload: Partial<Record<IAdminUserTabs, UsersListStatusParamsGET>> = {
all: {},
pending: {
hasLoggedIn: false,
type: 'user',
},
active: {
hasLoggedIn: true,
status: 'active',
},
deactivated: {
hasLoggedIn: true,
status: 'deactivated',
},
};
return {
...listUsersPayload[tab],
searchTerm,
sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`,
count: itemsPerPage,
offset: searchTerm === prevSearchTerm.current ? current : 0,
};
}, [current, itemsPerPage, prevSearchTerm, searchTerm, setCurrent, sortBy, sortDirection, tab]);
const getUsers = useEndpoint('GET', '/v1/users.listByStatus');
const dispatchToastMessage = useToastMessageDispatch();
const usersListQueryResult = useQuery(['users.list', payload, tab], async () => getUsers(payload), {
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
return usersListQueryResult;
};
export default useFilteredUsers;

@ -229,3 +229,5 @@ export type AvatarServiceObject = {
};
export type AvatarObject = AvatarReset | AvatarUrlObj | FormData | AvatarServiceObject;
export type IAdminUserTabs = 'all' | 'active' | 'deactivated' | 'pending';

@ -1571,6 +1571,7 @@
"DDP_Rate_Limit_User_Interval_Time": "Limit by User: interval time",
"DDP_Rate_Limit_User_Requests_Allowed": "Limit by User: requests allowed",
"Deactivate": "Deactivate",
"Deactivated": "Deactivated",
"Decline": "Decline",
"Decode_Key": "Decode Key",
"default": "default",
@ -4119,6 +4120,7 @@
"pdf_success_message": "PDF Transcript successfully generated",
"pdf_error_message": "Error generating PDF Transcript",
"Peer_Password": "Peer Password",
"Pending": "Pending",
"Pending Avatars": "Pending Avatars",
"Pending Files": "Pending Files",
"People": "People",
@ -4361,6 +4363,7 @@
"register-on-cloud": "Register On Cloud",
"register-on-cloud_description": "Permission to register on cloud",
"Registration": "Registration",
"Registration_status": "Registration status",
"Registration_Succeeded": "Registration Succeeded",
"Registration_via_Admin": "Registration via Admin",
"Regular_Expressions": "Regular Expressions",

Loading…
Cancel
Save