diff --git a/.changeset/rotten-eggs-end.md b/.changeset/rotten-eggs-end.md new file mode 100644 index 0000000000000000000000000000000000000000..7d0ad6ee5047e0cdddea642ceed95b39616aa62c --- /dev/null +++ b/.changeset/rotten-eggs-end.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": patch +"@rocket.chat/ui-client": patch +--- + +Implemented a new tab to the users page called 'Active', this tab lists all users who have logged in for the first time and are active. diff --git a/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx b/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx index 1ed21c1234a9abb0f4b29276b8926027b49966ef..d52d45415c8a768dba6dbd1ae6baeae9f0f59fac 100644 --- a/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx +++ b/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx @@ -9,7 +9,6 @@ const initialRoomTypeFilterStructure = [ { id: 'filter_by_room', text: 'Filter_by_room', - isGroupTitle: true, }, { id: 'd', @@ -71,7 +70,7 @@ const RoomsTableFilters = ({ setFilters }: { setFilters: Dispatch<SetStateAction setRoomTypeSelectedOptions(options); }, [text, setFilters], - ) as Dispatch<SetStateAction<OptionProp[]>>; + ); return ( <Box diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index d49f3bdafa7fe7a15137080e08180086316b1101..b7243af14739e3776fb804329dc535d0bff77e51 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -1,8 +1,10 @@ import type { IAdminUserTabs, LicenseInfo } from '@rocket.chat/core-typings'; import { Button, ButtonGroup, Callout, ContextualbarIcon, Skeleton, Tabs, TabsItem } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import type { OptionProp } from '@rocket.chat/ui-client'; import { ExternalLink } from '@rocket.chat/ui-client'; -import { usePermission, useRouteParameter, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import { usePermission, useRouteParameter, useTranslation, useRouter, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Trans } from 'react-i18next'; @@ -33,6 +35,7 @@ import { useSeatsCap } from './useSeatsCap'; export type UsersFilters = { text: string; + roles: OptionProp[]; }; export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active'; @@ -55,11 +58,14 @@ const AdminUsersPage = (): ReactElement => { const isCreateUserDisabled = useShouldPreventAction('activeUsers'); + const getRoles = useEndpoint('GET', '/v1/roles.list'); + const { data } = useQuery(['roles'], async () => getRoles()); + const paginationData = usePagination(); const sortData = useSort<UsersTableSortingOptions>('name'); const [tab, setTab] = useState<IAdminUserTabs>('all'); - const [userFilters, setUserFilters] = useState<UsersFilters>({ text: '' }); + const [userFilters, setUserFilters] = useState<UsersFilters>({ text: '', roles: [] }); const searchTerm = useDebouncedValue(userFilters.text, 500); const prevSearchTerm = useRef(''); @@ -70,6 +76,7 @@ const AdminUsersPage = (): ReactElement => { sortData, paginationData, tab, + selectedRoles: useMemo(() => userFilters.roles.map((role) => role.id), [userFilters.roles]), }); const pendingUsersCount = usePendingUsersCount(filteredUsersQueryResult.data?.users); @@ -153,6 +160,7 @@ const AdminUsersPage = (): ReactElement => { sortData={sortData} tab={tab} isSeatsCapExceeded={isSeatsCapExceeded} + roleData={data} /> </PageContent> </Page> diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index fa35df715fc579196298dcb8e19d29b33efd8329..01d7007561eb70b8636397cdb5a992df7651c30d 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -1,13 +1,12 @@ -import type { IAdminUserTabs, Serialized } from '@rocket.chat/core-typings'; +import type { IAdminUserTabs, IRole, Serialized } from '@rocket.chat/core-typings'; import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; import { useEffectEvent, useBreakpoints } 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 React, { useMemo } from 'react'; -import FilterByText from '../../../../components/FilterByText'; import GenericNoResults from '../../../../components/GenericNoResults'; import { GenericTable, @@ -19,10 +18,12 @@ import { import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; import type { UsersFilters, UsersTableSortingOptions } from '../AdminUsersPage'; +import UsersTableFilters from './UsersTableFilters'; import UsersTableRow from './UsersTableRow'; type UsersTableProps = { tab: IAdminUserTabs; + roleData: { roles: IRole[] } | undefined; onReload: () => void; setUserFilters: Dispatch<SetStateAction<UsersFilters>>; filteredUsersQueryResult: UseQueryResult<PaginatedResult<{ users: Serialized<DefaultUserInfo>[] }>>; @@ -34,6 +35,7 @@ type UsersTableProps = { const UsersTable = ({ filteredUsersQueryResult, setUserFilters, + roleData, tab, onReload, paginationData, @@ -113,15 +115,10 @@ const UsersTable = ({ [isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab], ); - const handleSearchTextChange = useCallback( - ({ text }) => { - setUserFilters({ text }); - }, - [setUserFilters], - ); return ( <> - <FilterByText shouldAutoFocus placeholder={t('Search_Users')} onChange={handleSearchTextChange} /> + <UsersTableFilters roleData={roleData} setUsersFilters={setUserFilters} /> + {isLoading && ( <GenericTable> <GenericTableHeader>{headers}</GenericTableHeader> diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx new file mode 100644 index 0000000000000000000000000000000000000000..28508ac94ac5cc2fae35201e60c75313844fdc62 --- /dev/null +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx @@ -0,0 +1,78 @@ +import type { IRole } from '@rocket.chat/core-typings'; +import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; +import type { OptionProp } from '@rocket.chat/ui-client'; +import { MultiSelectCustom } from '@rocket.chat/ui-client'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import FilterByText from '../../../../components/FilterByText'; +import type { UsersFilters } from '../AdminUsersPage'; + +type UsersTableFiltersProps = { + setUsersFilters: React.Dispatch<React.SetStateAction<UsersFilters>>; + roleData: { roles: IRole[] } | undefined; +}; + +const UsersTableFilters = ({ roleData, setUsersFilters }: UsersTableFiltersProps) => { + const { t } = useTranslation(); + + const [selectedRoles, setSelectedRoles] = useState<OptionProp[]>([]); + const [text, setText] = useState(''); + + const handleSearchTextChange = useCallback( + ({ text }) => { + setUsersFilters({ text, roles: selectedRoles }); + setText(text); + }, + [selectedRoles, setUsersFilters], + ); + + const handleRolesChange = useCallback( + (roles: OptionProp[]) => { + setUsersFilters({ text, roles }); + setSelectedRoles(roles); + }, + [setUsersFilters, text], + ); + + const userRolesFilterStructure = useMemo( + () => [ + { + id: 'filter_by_role', + text: 'Filter_by_role', + }, + { + id: 'all', + text: 'All_roles', + checked: false, + }, + ...(roleData + ? roleData.roles.map((role) => ({ + id: role._id, + text: role.description || role.name || role._id, + checked: false, + })) + : []), + ], + [roleData], + ); + + const breakpoints = useBreakpoints(); + const fixFiltersSize = breakpoints.includes('lg') ? { maxWidth: 'x224', minWidth: 'x224' } : null; + + return ( + <FilterByText shouldAutoFocus placeholder={t('Search_Users')} onChange={handleSearchTextChange}> + <MultiSelectCustom + dropdownOptions={userRolesFilterStructure} + defaultTitle='All_roles' + selectedOptionsTitle='Roles' + setSelectedOptions={handleRolesChange} + selectedOptions={selectedRoles} + searchBarText='Search_roles' + {...fixFiltersSize} + /> + </FilterByText> + ); +}; + +export default UsersTableFilters; diff --git a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts index f8ea02a34d8267cd8960c5bb9473752e700f6b18..9a592d5e449fd9f40ba2ab65ac60086ba5ed475f 100644 --- a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts +++ b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts @@ -15,9 +15,10 @@ type UseFilteredUsersOptions = { tab: IAdminUserTabs; paginationData: ReturnType<typeof usePagination>; sortData: ReturnType<typeof useSort<UsersTableSortingOptions>>; + selectedRoles: string[]; }; -const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab }: UseFilteredUsersOptions) => { +const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab, selectedRoles }: UseFilteredUsersOptions) => { const { setCurrent, itemsPerPage, current } = paginationData; const { sortBy, sortDirection } = sortData; @@ -45,11 +46,12 @@ const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData return { ...listUsersPayload[tab], searchTerm, + roles: selectedRoles, sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, count: itemsPerPage, offset: searchTerm === prevSearchTerm.current ? current : 0, }; - }, [current, itemsPerPage, prevSearchTerm, searchTerm, setCurrent, sortBy, sortDirection, tab]); + }, [current, itemsPerPage, prevSearchTerm, searchTerm, selectedRoles, setCurrent, sortBy, sortDirection, tab]); const getUsers = useEndpoint('GET', '/v1/users.listByStatus'); const dispatchToastMessage = useToastMessageDispatch(); const usersListQueryResult = useQuery(['users.list', payload, tab], async () => getUsers(payload), { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index f9fcf0163cafb5a88729e6e2fb15d05a9ded3ca4..0b569b4ee56445f98964f36b893bc499201a598a 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -410,6 +410,7 @@ "AutoLinker_UrlsRegExp": "AutoLinker URL Regular Expression", "All_messages": "All messages", "All_Prices": "All prices", + "All_roles": "All roles", "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", @@ -2433,6 +2434,7 @@ "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_Status": "Filter by status", "Filters": "Filters", "Filters_applied": "Filters applied", @@ -4763,6 +4765,7 @@ "Search_Page_Size": "Page Size", "Search_Private_Groups": "Search Private Groups", "Search_Provider": "Search Provider", + "Search_roles": "Search roles", "Search_rooms": "Search rooms", "Search_Rooms": "Search Rooms", "Search_Users": "Search Users", diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx index affe467cd9659941732d9b895bbdf3fde41bbb19..317f56899d05af49aaf814c959425fe541bb36b6 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx @@ -1,7 +1,7 @@ -import { Box } from '@rocket.chat/fuselage'; +import { Box, Button } from '@rocket.chat/fuselage'; import { useOutsideClick, useToggle } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import type { Dispatch, FormEvent, ReactElement, RefObject, SetStateAction } from 'react'; +import type { ComponentProps, FormEvent, ReactElement, RefObject } from 'react'; import { useCallback, useRef } from 'react'; import MultiSelectCustomAnchor from './MultiSelectCustomAnchor'; @@ -21,22 +21,12 @@ const onMouseEventPreventSideEffects = (e: MouseEvent): void => { e.stopImmediatePropagation(); }; -type TitleOptionProp = { +export type OptionProp = { id: string; text: string; - isGroupTitle: boolean; - checked: never; + checked?: boolean; }; -type CheckboxOptionProp = { - id: string; - text: string; - isGroupTitle: never; - checked: boolean; -}; - -export type OptionProp = TitleOptionProp | CheckboxOptionProp; - /** * @param dropdownOptions options available for the multiselect dropdown list * @param defaultTitle dropdown text before selecting any options (or all of them). For example: 'All rooms' @@ -56,9 +46,9 @@ type DropDownProps = { defaultTitle: TranslationKey; selectedOptionsTitle: TranslationKey; selectedOptions: OptionProp[]; - setSelectedOptions: Dispatch<SetStateAction<OptionProp[]>>; + setSelectedOptions: (roles: OptionProp[]) => void; searchBarText?: TranslationKey; -}; +} & ComponentProps<typeof Button>; export const MultiSelectCustom = ({ dropdownOptions, @@ -67,6 +57,7 @@ export const MultiSelectCustom = ({ selectedOptions, setSelectedOptions, searchBarText, + ...props }: DropDownProps): ReactElement => { const reference = useRef<HTMLInputElement>(null); const target = useRef<HTMLElement>(null); @@ -102,7 +93,7 @@ export const MultiSelectCustom = ({ const count = dropdownOptions.filter((option) => option.checked).length; return ( - <Box display='flex' flexGrow={1} position='relative'> + <Box display='flex' position='relative'> <MultiSelectCustomAnchor ref={reference} collapsed={collapsed} @@ -112,6 +103,7 @@ export const MultiSelectCustom = ({ selectedOptionsTitle={selectedOptionsTitle} selectedOptionsCount={count} maxCount={dropdownOptions.length} + {...props} /> {collapsed && ( <MultiSelectCustomListWrapper ref={target}> diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx index 0a8aee69344bdb42cd005dac474c21d118a702d9..3a03673bc701d17b5f452ffa74d653a819e6cbb9 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx @@ -14,7 +14,7 @@ type MultiSelectCustomAnchorProps = { } & ComponentProps<typeof Box>; const MultiSelectCustomAnchor = forwardRef<HTMLElement, MultiSelectCustomAnchorProps>(function MultiSelectCustomAnchor( - { collapsed, selectedOptionsCount, selectedOptionsTitle, defaultTitle, maxCount, ...props }, + { className, collapsed, selectedOptionsCount, selectedOptionsTitle, defaultTitle, maxCount, ...props }, ref, ) { const t = useTranslation(); @@ -34,7 +34,7 @@ const MultiSelectCustomAnchor = forwardRef<HTMLElement, MultiSelectCustomAnchorP justifyContent='space-between' alignItems='center' h='x40' - className={['rcx-input-box__wrapper', customStyle].filter(Boolean)} + className={['rcx-input-box__wrapper', customStyle, ...(Array.isArray(className) ? className : [className])].filter(Boolean)} {...props} > {isDirty ? `${t(selectedOptionsTitle)} (${selectedOptionsCount})` : t(defaultTitle)} diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx index d8f8d60d8096189abd1f3ad2cc08b6b4c7f9168a..71cb54f81aa57e48ba869bf34145eca8ebe2a80b 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx @@ -40,11 +40,7 @@ const MultiSelectCustomList = ({ )} {filteredOptions.map((option) => ( <Fragment key={option.id}> - {option.isGroupTitle ? ( - <Box mi='x12' mb='x4' fontScale='p2b' color='default'> - {t(option.text as TranslationKey)} - </Box> - ) : ( + {option.hasOwnProperty('checked') ? ( <Option key={option.id}> <Box pis='x4' pb='x4' w='full' display='flex' justifyContent='space-between' is='label'> {t(option.text as TranslationKey)} @@ -52,6 +48,10 @@ const MultiSelectCustomList = ({ <CheckBox checked={option.checked} pi={0} name={option.text} id={option.id} onChange={() => onSelected(option)} /> </Box> </Option> + ) : ( + <Box mi='x12' mb='x4' fontScale='p2b' color='default'> + {t(option.text as TranslationKey)} + </Box> )} </Fragment> ))}