diff --git a/.changeset/chilly-poems-explode.md b/.changeset/chilly-poems-explode.md new file mode 100644 index 000000000000..17acf3c5ba85 --- /dev/null +++ b/.changeset/chilly-poems-explode.md @@ -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. diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 115d4f93f3c5..d72c7051cf6c 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -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('all'); + const [userFilters, setUserFilters] = useState({ 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 ( @@ -65,7 +99,19 @@ const UsersPage = (): ReactElement => { )} - + + setTab('all')}> + {t('All')} + + + {context && ( @@ -93,4 +139,4 @@ const UsersPage = (): ReactElement => { ); }; -export default UsersPage; +export default AdminUsersPage; diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 1af9c67c9dcc..10ba211f27e0 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -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>; + filteredUsersQueryResult: UseQueryResult[] }>>; + paginationData: ReturnType; + sortData: ReturnType>; }; // 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(''); + 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 | React.KeyboardEvent, + ): event is React.KeyboardEvent => { + return (event as React.KeyboardEvent).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 | React.KeyboardEvent): 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( () => [ - + {t('Name')} , mediaQuery && ( @@ -116,48 +91,76 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => { {t('Username')} ), - - {t('Email')} - , + mediaQuery && ( + + {t('Email')} + + ), mediaQuery && ( {t('Roles')} ), - - {t('Status')} - , + tab === 'all' && ( + + {t('Registration_status')} + + ), ], - [mediaQuery, setSort, sortBy, sortDirection, t], + [mediaQuery, setSort, sortBy, sortDirection, t, tab], ); - if (error) { - return null; - } - + const handleSearchTextChange = useCallback( + ({ text }) => { + setUserFilters({ text }); + }, + [setUserFilters], + ); return ( <> - setText(text)} /> + {isLoading && ( {headers} - {isLoading && } + + + )} - {data?.users && data.count > 0 && isSuccess && ( + + {isError && ( + + + {t('Something_went_wrong')} + + {t('Reload_page')} + + + )} + + {isSuccess && data.users.length === 0 && } + + {isSuccess && !!data?.users && ( <> {headers} - {data?.users.map((user) => ( - + {data.users.map((user) => ( + ))} @@ -172,7 +175,6 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => { /> )} - {isSuccess && data?.count === 0 && } ); }; diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index e0d46c2ba6fb..86a69dda1dbb 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -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; - onClick: (id: IUser['_id']) => void; + onClick: (id: IUser['_id'], e: React.MouseEvent | React.KeyboardEvent) => 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 ( 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 + + + {name || username} {!mediaQuery && name && ( - {' '} - {`@${username}`}{' '} + {`@${username}`} )} @@ -58,15 +75,17 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React {username} - {' '} + )} - {emails?.length && emails[0].address} + {mediaQuery && {emails?.length && emails[0].address}} {mediaQuery && {roleNames}} - - {statusText} - + {tab === 'all' && ( + + {registrationStatusText} + + )} ); }; diff --git a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts new file mode 100644 index 000000000000..f8ea02a34d82 --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts @@ -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; + tab: IAdminUserTabs; + paginationData: ReturnType; + sortData: ReturnType>; +}; + +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> = { + 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; diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 29864ae81ed1..453259ef0b3a 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -229,3 +229,5 @@ export type AvatarServiceObject = { }; export type AvatarObject = AvatarReset | AvatarUrlObj | FormData | AvatarServiceObject; + +export type IAdminUserTabs = 'all' | 'active' | 'deactivated' | 'pending'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c8897b988c82..44c142ba6e06 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -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",