Skip to content

Commit

Permalink
feat: New users page active tab (#32024)
Browse files Browse the repository at this point in the history
Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>
  • Loading branch information
rique223 and tassoevan authored Jul 16, 2024
1 parent fa82159 commit 3ffe4a2
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 40 deletions.
7 changes: 7 additions & 0 deletions .changeset/rotten-eggs-end.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const initialRoomTypeFilterStructure = [
{
id: 'filter_by_room',
text: 'Filter_by_room',
isGroupTitle: true,
},
{
id: 'd',
Expand Down Expand Up @@ -71,7 +70,7 @@ const RoomsTableFilters = ({ setFilters }: { setFilters: Dispatch<SetStateAction
setRoomTypeSelectedOptions(options);
},
[text, setFilters],
) as Dispatch<SetStateAction<OptionProp[]>>;
);

return (
<Box
Expand Down
12 changes: 10 additions & 2 deletions apps/meteor/client/views/admin/users/AdminUsersPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,6 +35,7 @@ import { useSeatsCap } from './useSeatsCap';

export type UsersFilters = {
text: string;
roles: OptionProp[];
};

export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active';
Expand All @@ -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('');
Expand All @@ -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);
Expand Down Expand Up @@ -153,6 +160,7 @@ const AdminUsersPage = (): ReactElement => {
sortData={sortData}
tab={tab}
isSeatsCapExceeded={isSeatsCapExceeded}
roleData={data}
/>
</PageContent>
</Page>
Expand Down
17 changes: 7 additions & 10 deletions apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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>[] }>>;
Expand All @@ -34,6 +35,7 @@ type UsersTableProps = {
const UsersTable = ({
filteredUsersQueryResult,
setUserFilters,
roleData,
tab,
onReload,
paginationData,
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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), {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'
Expand All @@ -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,
Expand All @@ -67,6 +57,7 @@ export const MultiSelectCustom = ({
selectedOptions,
setSelectedOptions,
searchBarText,
...props
}: DropDownProps): ReactElement => {
const reference = useRef<HTMLInputElement>(null);
const target = useRef<HTMLElement>(null);
Expand Down Expand Up @@ -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}
Expand All @@ -112,6 +103,7 @@ export const MultiSelectCustom = ({
selectedOptionsTitle={selectedOptionsTitle}
selectedOptionsCount={count}
maxCount={dropdownOptions.length}
{...props}
/>
{collapsed && (
<MultiSelectCustomListWrapper ref={target}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,18 @@ 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)}

<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>
))}
Expand Down

0 comments on commit 3ffe4a2

Please sign in to comment.