From 72bacc5a2eca67827df6e5d6f123a61c65f33380 Mon Sep 17 00:00:00 2001 From: rique223 Date: Thu, 31 Aug 2023 16:51:01 -0300 Subject: [PATCH 01/23] feat: :sparkles: Filter users list by active users Implemented the necessary resources to filter the list of users and to return only those who are active, when the active tab is enabled. --- .../views/admin/users/AdminUsersPage.tsx | 12 ++-- .../admin/users/UsersTable/UsersTable.tsx | 71 +++++-------------- .../admin/users/hooks/useFilterActiveUsers.ts | 7 ++ .../views/admin/users/hooks/useListUsers.ts | 69 ++++++++++++++++++ 4 files changed, 100 insertions(+), 59 deletions(-) create mode 100644 apps/meteor/client/views/admin/users/hooks/useFilterActiveUsers.ts create mode 100644 apps/meteor/client/views/admin/users/hooks/useListUsers.ts diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 838a383d4fc9..c16e6b6a43b2 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -25,7 +25,7 @@ const UsersPage = (): ReactElement => { const canCreateUser = usePermission('create-user'); const canBulkCreateUser = usePermission('bulk-register-user'); - const [tab, setTab] = useState('all'); + const [tab, setTab] = useState<'all' | 'invited' | 'new' | 'active' | 'deactivated'>('all'); useEffect(() => { if (!context || !seatsCap) { @@ -68,20 +68,20 @@ const UsersPage = (): ReactElement => { setTab('all')}> {t('All')} - setTab('tab-invited')}> + setTab('invited')}> {t('Invited')} - setTab('tab-new')}> + setTab('new')}> {t('New_users')} - setTab('tab-active')}> + setTab('active')}> {t('Active')} - setTab('tab-deactivated')}> + setTab('deactivated')}> {t('Deactivated')} - + {context && ( diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 44175ec47de7..035f716721c5 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -1,8 +1,6 @@ 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 { useRoute, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, MutableRefObject } from 'react'; import React, { useRef, useMemo, useState, useEffect } from 'react'; @@ -17,14 +15,17 @@ import { } from '../../../../components/GenericTable'; import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../../components/GenericTable/hooks/useSort'; +import { useFilterActiveUsers } from '../hooks/useFilterActiveUsers'; +import { useListUsers } from '../hooks/useListUsers'; import UsersTableRow from './UsersTableRow'; type UsersTableProps = { reload: MutableRefObject<() => void>; + tab: string; }; // TODO: Missing error state -const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => { +const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { const t = useTranslation(); const usersRoute = useRoute('admin-users'); const mediaQuery = useMediaQuery('(min-width: 1024px)'); @@ -34,55 +35,19 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => { const { sortBy, sortDirection, setSort } = useSort<'name' | 'username' | 'emails.address' | 'status'>('name'); const searchTerm = useDebouncedValue(text, 500); - const prevSearchTerm = useRef(''); - - const query = useDebouncedValue( - useMemo(() => { - if (searchTerm !== prevSearchTerm.current) { - setCurrent(0); - } - - 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 prevSearchTerm = useRef(''); + + const { data, isLoading, isSuccess, error, refetch } = useListUsers( + searchTerm, + prevSearchTerm, + setCurrent, + sortBy, + sortDirection, + itemsPerPage, + current, ); - const getUsers = useEndpoint('GET', '/v1/users.list'); - - const dispatchToastMessage = useToastMessageDispatch(); - - const { data, isLoading, error, isSuccess, refetch } = useQuery( - ['users', query], - async () => { - const users = await getUsers(query); - return users; - }, - { - onError: (error) => { - dispatchToastMessage({ type: 'error', message: error }); - }, - }, - ); + const filteredUsers = [...useFilterActiveUsers(data?.users, tab)]; useEffect(() => { reload.current = refetch; @@ -151,12 +116,12 @@ const UsersTable = ({ reload }: UsersTableProps): ReactElement | null => { {isLoading && } )} - {data?.users && data.count > 0 && isSuccess && ( + {isSuccess && !!data && !!filteredUsers && data.count > 0 && ( <> {headers} - {data?.users.map((user) => ( + {(tab === 'all' ? data.users : filteredUsers).map((user) => ( ))} diff --git a/apps/meteor/client/views/admin/users/hooks/useFilterActiveUsers.ts b/apps/meteor/client/views/admin/users/hooks/useFilterActiveUsers.ts new file mode 100644 index 000000000000..86c97245d745 --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useFilterActiveUsers.ts @@ -0,0 +1,7 @@ +import type { UsersEndpoints } from '@rocket.chat/rest-typings'; + +export const useFilterActiveUsers = (users: ReturnType['users'] | undefined, tab: string) => { + if (!users || tab !== 'active') return []; + + return users.filter((currentUser) => currentUser.active); +}; diff --git a/apps/meteor/client/views/admin/users/hooks/useListUsers.ts b/apps/meteor/client/views/admin/users/hooks/useListUsers.ts new file mode 100644 index 000000000000..57b263f2f360 --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useListUsers.ts @@ -0,0 +1,69 @@ +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; +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'; + +export const useListUsers = ( + searchTerm: string, + prevSearchTerm: MutableRefObject, + setCurrent: ReturnType['setCurrent'], + sortBy: ReturnType['sortBy'], + sortDirection: ReturnType['sortDirection'], + itemsPerPage: ReturnType['itemsPerPage'], + current: ReturnType['current'], +) => { + const query = useDebouncedValue( + useMemo(() => { + if (searchTerm !== prevSearchTerm.current) { + setCurrent(0); + } + + 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, prevSearchTerm, sortBy, sortDirection, itemsPerPage, current, setCurrent]), + 500, + ); + + const getUsers = useEndpoint('GET', '/v1/users.list'); + + const dispatchToastMessage = useToastMessageDispatch(); + + const usersListQueryResult = useQuery( + ['users', query], + async () => { + const users = await getUsers(query); + return users; + }, + { + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }, + ); + + return usersListQueryResult; +}; From 444c93016a7310d40ad3efd74eaaf69dca67f50e Mon Sep 17 00:00:00 2001 From: rique223 Date: Tue, 5 Sep 2023 19:25:58 -0300 Subject: [PATCH 02/23] feat: :sparkles: Implement new roles filter in the admin users page Implemented a new filter on the users' page that retrieves the roles list and creates a dropdown menu. When any of its options is selected, it will filter the users' list to display only those with the selected roles. Additionally, I made some minor adjustments to the MultiSelectCustom component and introduced some new hooks. --- .../admin/users/UsersTable/UsersTable.tsx | 61 ++++++++++++++++--- .../admin/users/hooks/useFilterActiveUsers.ts | 7 ++- .../admin/users/hooks/useFilterUsersByrole.ts | 12 ++++ .../MultiSelectCustom/MultiSelectCustom.tsx | 4 +- .../MultiSelectCustomAnchor.tsx | 9 ++- .../MultiSelectCustomList.tsx | 4 +- 6 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 apps/meteor/client/views/admin/users/hooks/useFilterUsersByrole.ts diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 035f716721c5..7fe4688c9735 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -1,6 +1,9 @@ import { Pagination } from '@rocket.chat/fuselage'; import { useMediaQuery, useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useRoute, useTranslation } from '@rocket.chat/ui-contexts'; +import type { OptionProp } from '@rocket.chat/ui-client'; +import { MultiSelectCustom } from '@rocket.chat/ui-client'; +import { useEndpoint, useRoute, 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'; @@ -16,6 +19,7 @@ import { import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../../components/GenericTable/hooks/useSort'; import { useFilterActiveUsers } from '../hooks/useFilterActiveUsers'; +import { useFilterUsersByRole } from '../hooks/useFilterUsersByrole'; import { useListUsers } from '../hooks/useListUsers'; import UsersTableRow from './UsersTableRow'; @@ -29,7 +33,34 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { const t = useTranslation(); const usersRoute = useRoute('admin-users'); const mediaQuery = useMediaQuery('(min-width: 1024px)'); + const getRoles = useEndpoint('GET', '/v1/roles.list'); + const { data: roleData, isSuccess: hasRoleData } = useQuery(['roles'], async () => getRoles()); + + const roleFilterStructure = useMemo( + () => + [ + { + id: 'filter_by_role', + text: 'Filter_by_role', + isGroupTitle: true, + }, + { + id: 'all', + text: 'All_roles', + isGroupTitle: false, + }, + ...((hasRoleData && roleData.roles) || []).map((currentRole) => ({ + id: currentRole._id, + text: currentRole.name, + isGroupTitle: false, + })), + ] as OptionProp[], + [hasRoleData, roleData?.roles], + ); + const [text, setText] = useState(''); + const [roleFilterOptions, setRoleFilterOptions] = useState([]); + const [roleFilterSelectedOptions, setRoleFilterSelectedOptions] = useState([]); const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); const { sortBy, sortDirection, setSort } = useSort<'name' | 'username' | 'emails.address' | 'status'>('name'); @@ -47,15 +78,22 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { current, ); - const filteredUsers = [...useFilterActiveUsers(data?.users, tab)]; + const useAllUsers = () => (tab === 'all' && isSuccess ? data?.users : []); + + const currentTabUsers = [...useAllUsers(), ...useFilterActiveUsers(data?.users, tab)]; + const filteredUsers = useFilterUsersByRole( + currentTabUsers, + roleFilterSelectedOptions.map((currentRole) => currentRole.id), + ); useEffect(() => { reload.current = refetch; - }, [reload, refetch]); + prevSearchTerm.current = searchTerm; + }, [reload, refetch, searchTerm]); useEffect(() => { - prevSearchTerm.current = searchTerm; - }, [searchTerm]); + setRoleFilterOptions(roleFilterStructure); + }, [roleFilterStructure]); const handleClick = useMutableCallback((id): void => usersRoute.push({ @@ -109,7 +147,16 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { return ( <> - setText(text)} /> + setText(text)}> + + {isLoading && ( {headers} @@ -121,7 +168,7 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { {headers} - {(tab === 'all' ? data.users : filteredUsers).map((user) => ( + {filteredUsers.map((user) => ( ))} diff --git a/apps/meteor/client/views/admin/users/hooks/useFilterActiveUsers.ts b/apps/meteor/client/views/admin/users/hooks/useFilterActiveUsers.ts index 86c97245d745..a8dd6acfa3b7 100644 --- a/apps/meteor/client/views/admin/users/hooks/useFilterActiveUsers.ts +++ b/apps/meteor/client/views/admin/users/hooks/useFilterActiveUsers.ts @@ -1,6 +1,9 @@ -import type { UsersEndpoints } from '@rocket.chat/rest-typings'; +import type { IUser } from '@rocket.chat/core-typings'; -export const useFilterActiveUsers = (users: ReturnType['users'] | undefined, tab: string) => { +export const useFilterActiveUsers = ( + users: Pick[] | undefined, + tab: string, +) => { if (!users || tab !== 'active') return []; return users.filter((currentUser) => currentUser.active); diff --git a/apps/meteor/client/views/admin/users/hooks/useFilterUsersByrole.ts b/apps/meteor/client/views/admin/users/hooks/useFilterUsersByrole.ts new file mode 100644 index 000000000000..44b6c6ae1217 --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useFilterUsersByrole.ts @@ -0,0 +1,12 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +export const useFilterUsersByRole = ( + users: Pick[], + selectedRoles: string[], +) => { + if (!users.length || !selectedRoles.length || selectedRoles.includes('all')) return users; + + return users.filter((currentUser) => { + return currentUser.roles.some((currentRole) => selectedRoles.includes(currentRole)); + }); +}; diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx index 1420a62346d6..a81d93f8c2f5 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx @@ -53,12 +53,12 @@ export type OptionProp = TitleOptionProp | CheckboxOptionProp; */ type DropDownProps = { dropdownOptions: OptionProp[]; - defaultTitle: TranslationKey; + defaultTitle: string; selectedOptionsTitle: TranslationKey; selectedOptions: OptionProp[]; setSelectedOptions: Dispatch>; customSetSelected: Dispatch>; - searchBarText?: TranslationKey; + searchBarText?: string; }; export const MultiSelectCustom = ({ diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx index 9f6a15d38ba1..d06e6d881f92 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx @@ -1,15 +1,14 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Button, Icon, Palette } from '@rocket.chat/fuselage'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { TranslationKey, useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps } from 'react'; import { forwardRef } from 'react'; type MultiSelectCustomAnchorProps = { onClick?: (value: boolean) => void; collapsed: boolean; - defaultTitle: TranslationKey; - selectedOptionsTitle: TranslationKey; + defaultTitle: string; + selectedOptionsTitle: string; selectedOptionsCount: number; maxCount: number; } & ComponentProps; @@ -59,7 +58,7 @@ const MultiSelectCustomAnchor = forwardRef - {isDirty ? `${t(selectedOptionsTitle)} (${selectedOptionsCount})` : t(defaultTitle)} + {isDirty ? `${t(selectedOptionsTitle as TranslationKey)} (${selectedOptionsCount})` : t(defaultTitle as TranslationKey)} ); diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx index 7e6bfdb9fee1..85ee17f1751d 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx @@ -14,7 +14,7 @@ const MultiSelectCustomList = ({ }: { options: OptionProp[]; onSelected: (item: OptionProp, e?: FormEvent) => void; - searchBarText?: TranslationKey; + searchBarText?: string; }) => { const t = useTranslation(); @@ -33,7 +33,7 @@ const MultiSelectCustomList = ({ diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index 64e3f4858ffa..a59edf905854 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -11,15 +11,17 @@ import { UserStatus } from '../../../../components/UserStatus'; import UserAvatar from '../../../../components/avatar/UserAvatar'; type UsersTableRowProps = { - user: Pick; + tab: string; + user: Partial; onClick: (id: IUser['_id']) => void; mediaQuery: boolean; }; -const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): ReactElement => { +const UsersTableRow = ({ tab, user, onClick, mediaQuery }: UsersTableRowProps): ReactElement => { const t = useTranslation(); - const { _id, emails, username, name, status, roles, active, avatarETag } = user; + const { _id, emails, username, name, status, roles, active, avatarETag } = user as IUser; const registrationStatusText = active ? t('Active') : t('Deactivated'); + const pendingAction = user.active === false ? t('Activate') : t('User_first_log_in'); const roleNames = (roles || []) .map((roleId) => (Roles.findOne(roleId, { fields: { name: 1 } }) as IRole | undefined)?.name) @@ -65,10 +67,20 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React )} {emails?.length && emails[0].address} + {mediaQuery && {roleNames}} - - {registrationStatusText} - + + {(tab === 'active' || tab === 'all') && ( + + {registrationStatusText} + + )} + + {tab === 'pending' && ( + + {pendingAction} + + )} ); }; diff --git a/apps/meteor/client/views/admin/users/hooks/useFilterUsersByRole.ts b/apps/meteor/client/views/admin/users/hooks/useFilterUsersByRole.ts new file mode 100644 index 000000000000..926fce0e0c70 --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useFilterUsersByRole.ts @@ -0,0 +1,9 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +export const useFilterUsersByRole = (users: Partial[], selectedRoles: string[]) => { + if (!users.length || !selectedRoles.length || selectedRoles.includes('all')) return users; + + return users.filter((currentUser) => { + return currentUser.roles?.some((currentRole) => selectedRoles.includes(currentRole)); + }); +}; diff --git a/apps/meteor/client/views/admin/users/hooks/useFilterUsersByrole.ts b/apps/meteor/client/views/admin/users/hooks/useFilterUsersByrole.ts deleted file mode 100644 index 44b6c6ae1217..000000000000 --- a/apps/meteor/client/views/admin/users/hooks/useFilterUsersByrole.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; - -export const useFilterUsersByRole = ( - users: Pick[], - selectedRoles: string[], -) => { - if (!users.length || !selectedRoles.length || selectedRoles.includes('all')) return users; - - return users.filter((currentUser) => { - return currentUser.roles.some((currentRole) => selectedRoles.includes(currentRole)); - }); -}; From 089c66974bb6970ce17666f8e4c9b875a3dc47da Mon Sep 17 00:00:00 2001 From: Felipe <84182706+felipe-rod123@users.noreply.github.com> Date: Mon, 11 Sep 2023 10:48:06 -0300 Subject: [PATCH 05/23] add pending actions count --- .../client/views/admin/users/AdminUsersPage.tsx | 6 ++++-- .../views/admin/users/UsersTable/UsersTable.tsx | 12 ++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index dbc0c28ef7a7..78d77478d166 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -42,6 +42,8 @@ const UsersPage = (): ReactElement => { reload.current(); }; + const [pendingActionsCount, setPendingActionsCount] = useState(0); + return ( @@ -69,7 +71,7 @@ const UsersPage = (): ReactElement => { {t('All')} setTab('pending')}> - {t('Pending')} + {pendingActionsCount === 0 ? t('Pending') : `${t('Pending')} (${pendingActionsCount})`} setTab('active')}> {t('Active')} @@ -81,7 +83,7 @@ const UsersPage = (): ReactElement => { {t('Invited')} - + {context && ( diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index a9773413f0d3..d0e8fa55c157 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -4,7 +4,7 @@ import type { OptionProp } from '@rocket.chat/ui-client'; import { MultiSelectCustom } from '@rocket.chat/ui-client'; import { useEndpoint, useRoute, useTranslation } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import type { ReactElement, MutableRefObject } from 'react'; +import type { ReactElement, MutableRefObject, Dispatch, SetStateAction } from 'react'; import React, { useRef, useMemo, useState, useEffect } from 'react'; import FilterByText from '../../../../components/FilterByText'; @@ -25,12 +25,13 @@ import { useListUsers } from '../hooks/useListUsers'; import UsersTableRow from './UsersTableRow'; type UsersTableProps = { + setPendingActionsCount: Dispatch>; reload: MutableRefObject<() => void>; tab: string; }; // TODO: Missing error state -const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { +const UsersTable = ({ setPendingActionsCount, reload, tab }: UsersTableProps): ReactElement | null => { const t = useTranslation(); const usersRoute = useRoute('admin-users'); const mediaQuery = useMediaQuery('(min-width: 1024px)'); @@ -87,6 +88,13 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { roleFilterSelectedOptions.map((currentRole) => currentRole.id), ); + // TODO: how to call this function and display the count even when not clicked? + const pendingActionsCount = useFilterPendingUsers(data?.users, tab).length; + useEffect(() => { + // console.log('pendingActionsCount: ', pendingActionsCount); + setPendingActionsCount(pendingActionsCount); + }, [pendingActionsCount, setPendingActionsCount]); + useEffect(() => { reload.current = refetch; prevSearchTerm.current = searchTerm; From a1d01d5afddf1b075fe44e25ba31257596591575 Mon Sep 17 00:00:00 2001 From: rique223 Date: Tue, 12 Sep 2023 10:27:06 -0300 Subject: [PATCH 06/23] WIP: Remove filters --- .../admin/users/UsersTable/UsersTable.tsx | 81 +++++-------------- .../rocketchat-i18n/i18n/en.i18n.json | 6 +- 2 files changed, 25 insertions(+), 62 deletions(-) diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 7fe4688c9735..48ede4678d66 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -1,9 +1,6 @@ -import { Pagination } from '@rocket.chat/fuselage'; +import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; import { useMediaQuery, useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import type { OptionProp } from '@rocket.chat/ui-client'; -import { MultiSelectCustom } from '@rocket.chat/ui-client'; -import { useEndpoint, useRoute, useTranslation } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; +import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, MutableRefObject } from 'react'; import React, { useRef, useMemo, useState, useEffect } from 'react'; @@ -19,7 +16,6 @@ import { import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../../components/GenericTable/hooks/useSort'; import { useFilterActiveUsers } from '../hooks/useFilterActiveUsers'; -import { useFilterUsersByRole } from '../hooks/useFilterUsersByrole'; import { useListUsers } from '../hooks/useListUsers'; import UsersTableRow from './UsersTableRow'; @@ -31,36 +27,10 @@ type UsersTableProps = { // TODO: Missing error state const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { const t = useTranslation(); - const usersRoute = useRoute('admin-users'); + const router = useRouter(); const mediaQuery = useMediaQuery('(min-width: 1024px)'); - const getRoles = useEndpoint('GET', '/v1/roles.list'); - const { data: roleData, isSuccess: hasRoleData } = useQuery(['roles'], async () => getRoles()); - - const roleFilterStructure = useMemo( - () => - [ - { - id: 'filter_by_role', - text: 'Filter_by_role', - isGroupTitle: true, - }, - { - id: 'all', - text: 'All_roles', - isGroupTitle: false, - }, - ...((hasRoleData && roleData.roles) || []).map((currentRole) => ({ - id: currentRole._id, - text: currentRole.name, - isGroupTitle: false, - })), - ] as OptionProp[], - [hasRoleData, roleData?.roles], - ); const [text, setText] = useState(''); - const [roleFilterOptions, setRoleFilterOptions] = useState([]); - const [roleFilterSelectedOptions, setRoleFilterSelectedOptions] = useState([]); const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); const { sortBy, sortDirection, setSort } = useSort<'name' | 'username' | 'emails.address' | 'status'>('name'); @@ -68,7 +38,7 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { const searchTerm = useDebouncedValue(text, 500); const prevSearchTerm = useRef(''); - const { data, isLoading, isSuccess, error, refetch } = useListUsers( + const { data, isLoading, isSuccess, isError, refetch } = useListUsers( searchTerm, prevSearchTerm, setCurrent, @@ -80,25 +50,20 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { const useAllUsers = () => (tab === 'all' && isSuccess ? data?.users : []); - const currentTabUsers = [...useAllUsers(), ...useFilterActiveUsers(data?.users, tab)]; - const filteredUsers = useFilterUsersByRole( - currentTabUsers, - roleFilterSelectedOptions.map((currentRole) => currentRole.id), - ); + const filteredUsers = [...useAllUsers(), ...useFilterActiveUsers(data?.users, tab)]; useEffect(() => { reload.current = refetch; prevSearchTerm.current = searchTerm; }, [reload, refetch, searchTerm]); - useEffect(() => { - setRoleFilterOptions(roleFilterStructure); - }, [roleFilterStructure]); - const handleClick = useMutableCallback((id): void => - usersRoute.push({ - context: 'info', - id, + router.navigate({ + name: 'admin-users', + params: { + context: 'info', + id, + }, }), ); @@ -141,22 +106,9 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { [mediaQuery, setSort, sortBy, sortDirection, t], ); - if (error) { - return null; - } - return ( <> - setText(text)}> - - + setText(text)} /> {isLoading && ( {headers} @@ -185,6 +137,15 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { )} {isSuccess && data?.count === 0 && } + {isError && ( + + + {t('Something_went_wrong')} + + refetch()}>{t('Reload_page')} + + + )} ); }; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 756f74a3acb5..3e730ac8530f 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -396,6 +396,8 @@ "AutoLinker_UrlsRegExp": "AutoLinker URL Regular Expression", "All_messages": "All messages", "All_Prices": "All prices", + "All_roles": "All roles", + "All_rooms": "All rooms", "All_status": "All status", "All_users": "All users", "All_users_in_the_channel_can_write_new_messages": "All users in the channel can write new messages", @@ -2315,6 +2317,8 @@ "Filter_by_category": "Filter by Category", "Filter_by_Custom_Fields": "Filter by Custom Fields", "Filter_By_Price": "Filter by price", + "Filter_by_role": "Filter by role", + "Filter_by_room": "Filter by room type", "Filter_By_Status": "Filter by status", "Filters": "Filters", "Filters_applied": "Filters applied", @@ -6031,9 +6035,7 @@ "multiple_instance_solutions": "multiple instance solutions", "Uninstall_grandfathered_app": "Uninstall {{appName}}?", "App_will_lose_grandfathered_status": "**This {{context}} app will lose its grandfathered status.** \n \nWorkspaces on Community Edition can have up to {{limit}} {{context}} apps enabled. Grandfathered apps count towards the limit but the limit is not applied to them.", - "All_rooms": "All rooms", "All_visible": "All visible", - "Filter_by_room": "Filter by room type", "Filter_by_visibility": "Filter by visibility", "Registration_status": "Registration status", "Theme_Appearence": "Theme Appearence" From d3f01f7e1be062500b0c98d582e2ff19b7de9dee Mon Sep 17 00:00:00 2001 From: rique223 Date: Thu, 21 Sep 2023 17:38:36 -0300 Subject: [PATCH 07/23] feat: :sparkles: WIP: Implement new actions menu for users page table --- .../views/admin/users/AdminUsersPage.tsx | 2 +- .../admin/users/UsersTable/UsersTable.tsx | 21 ++++-- .../admin/users/UsersTable/UsersTableRow.tsx | 75 +++++++++++++++++-- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index d98364d2d6f6..e0e88bedcd90 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -81,7 +81,7 @@ const UsersPage = (): ReactElement => { {t('Deactivated')} - + {context && ( diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 48ede4678d66..55fd15a195bd 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -22,10 +22,11 @@ import UsersTableRow from './UsersTableRow'; type UsersTableProps = { reload: MutableRefObject<() => void>; tab: string; + onReload: () => void; }; // TODO: Missing error state -const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { +const UsersTable = ({ reload, tab, onReload }: UsersTableProps): ReactElement | null => { const t = useTranslation(); const router = useRouter(); const mediaQuery = useMediaQuery('(min-width: 1024px)'); @@ -57,15 +58,17 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { prevSearchTerm.current = searchTerm; }, [reload, refetch, searchTerm]); - const handleClick = useMutableCallback((id): void => + const handleClick = useMutableCallback((id, e: React.MouseEvent | React.KeyboardEvent): void => { + e.stopPropagation(); + router.navigate({ name: 'admin-users', params: { context: 'info', id, }, - }), - ); + }); + }); const headers = useMemo( () => [ @@ -102,6 +105,7 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { {t('Registration_status')} , + , ], [mediaQuery, setSort, sortBy, sortDirection, t], ); @@ -121,7 +125,14 @@ const UsersTable = ({ reload, tab }: UsersTableProps): ReactElement | null => { {headers} {filteredUsers.map((user) => ( - + ))} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index 64e3f4858ffa..495e8112ab5e 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -1,7 +1,9 @@ -import { UserStatus as Status } from '@rocket.chat/core-typings'; +import { UserStatus as Status, isUserFederated } from '@rocket.chat/core-typings'; import type { IRole, IUser } from '@rocket.chat/core-typings'; -import { Box } from '@rocket.chat/fuselage'; +import { Box, Menu, Option } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React from 'react'; @@ -9,14 +11,21 @@ import { Roles } from '../../../../../app/models/client'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; import { UserStatus } from '../../../../components/UserStatus'; import UserAvatar from '../../../../components/avatar/UserAvatar'; +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'; type UsersTableRowProps = { user: Pick; - onClick: (id: IUser['_id']) => void; + onClick: (id: IUser['_id'], e: React.MouseEvent | React.KeyboardEvent) => void; mediaQuery: boolean; + refetchUsers: ReturnType['refetch']; + onReload: () => void; }; -const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): ReactElement => { +const UsersTableRow = ({ user, onClick, mediaQuery, refetchUsers, onReload }: UsersTableRowProps): ReactElement => { const t = useTranslation(); const { _id, emails, username, name, status, roles, active, avatarETag } = user; const registrationStatusText = active ? t('Active') : t('Deactivated'); @@ -26,10 +35,54 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React .filter((roleName): roleName is string => !!roleName) .join(', '); + const userId = user._id; + const isAdmin = user.roles?.includes('admin'); + const isActive = user.active; + const isFederatedUser = isUserFederated(user); + + const onChange = useMutableCallback(() => { + onReload(); + refetchUsers(); + }); + + 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 menuOptions = { + ...(changeAdminStatusAction && + !isFederatedUser && { + makeAdmin: { + label: { label: changeAdminStatusAction.label, icon: changeAdminStatusAction.icon }, + action: changeAdminStatusAction.action, + }, + }), + ...(resetE2EKeyAction && + !isFederatedUser && { + resetE2EKey: { label: { label: resetE2EKeyAction.label, icon: resetE2EKeyAction.icon }, action: resetE2EKeyAction.action }, + }), + ...(resetTOTPAction && + !isFederatedUser && { + resetTOTP: { label: { label: resetTOTPAction.label, icon: resetTOTPAction.icon }, action: resetTOTPAction.action }, + }), + ...(deleteUserAction && { + delete: { label: { label: deleteUserAction.label, icon: deleteUserAction.icon }, action: deleteUserAction.action }, + }), + ...(changeUserStatusAction && + !isFederatedUser && { + changeActiveStatus: { + label: { label: changeUserStatusAction.label, icon: changeUserStatusAction.icon }, + action: changeUserStatusAction.action, + }, + }), + }; + return ( onClick(_id)} - onClick={(): void => onClick(_id)} + onKeyDown={(e): void => onClick(_id, e)} + onClick={(e): void => onClick(_id, e)} tabIndex={0} role='link' action @@ -69,6 +122,16 @@ const UsersTableRow = ({ user, onClick, mediaQuery }: UsersTableRowProps): React {registrationStatusText} + +