Skip to content

Commit

Permalink
feat: New users page all tab (#31917)
Browse files Browse the repository at this point in the history
  • Loading branch information
rique223 committed May 6, 2024
1 parent 7d5bdde commit ff4e396
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 123 deletions.
7 changes: 7 additions & 0 deletions .changeset/chilly-poems-explode.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 54 additions & 8 deletions apps/meteor/client/views/admin/users/AdminUsersPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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');
Expand All @@ -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'>
Expand All @@ -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 && (
Expand Down Expand Up @@ -93,4 +139,4 @@ const UsersPage = (): ReactElement => {
);
};

export default UsersPage;
export default AdminUsersPage;
198 changes: 100 additions & 98 deletions apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 && (
Expand All @@ -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>
Expand All @@ -172,7 +175,6 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => {
/>
</>
)}
{isSuccess && data?.count === 0 && <GenericNoResults />}
</>
);
};
Expand Down
Loading

0 comments on commit ff4e396

Please sign in to comment.