Skip to content

Commit

Permalink
feat: VoIP freeswitch UI admin (#33004)
Browse files Browse the repository at this point in the history
* feat: Add options to view and assign voice call extensions on the admin's user's list

* chore: changed Roles import to not use index

* chore: added ee/views to unit test build

* test: added VoIP unit tests for UsersTable

* test: added VoIP unit tests for UsersPageHeader

* test: added VoIP unit tests for AssignExtensionModal

* test: added legacyRoot to tests

* test: added await to userEvent.click

* chore: added meteor imports coming from global namespace

* test: added mock for meteor methods

* test: removed module mocks from UsersTable

* test: get by role instead of testId on AssignExtensionModal

* chore: imported Meteor.users not compatible with global Meteor.users type

* Use fake data helpers in unit tests

* feat: improved the user interface

* test: adjusted UserTable unit tests

* Adjust type names

* Replace `voiceCall` with `voip`

---------

Co-authored-by: Tasso <[email protected]>
  • Loading branch information
aleksandernsilva and tassoevan authored Sep 23, 2024
1 parent ae54a18 commit 8389ec1
Show file tree
Hide file tree
Showing 22 changed files with 719 additions and 29 deletions.
3 changes: 2 additions & 1 deletion apps/meteor/app/models/client/models/Users.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IRole, IUser } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';

class UsersCollection extends Mongo.Collection<IUser> {
Expand Down Expand Up @@ -39,4 +40,4 @@ Object.assign(Meteor.users, {
});

/** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */
export const Users = Meteor.users as UsersCollection;
export const Users = Meteor.users as unknown as UsersCollection;
1 change: 1 addition & 0 deletions apps/meteor/app/utils/client/lib/SDKClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RestClientInterface } from '@rocket.chat/api-client';
import type { SDK, ClientStream, StreamKeys, StreamNames, StreamerCallbackArgs, ServerMethods } from '@rocket.chat/ddp-client';
import { Emitter } from '@rocket.chat/emitter';
import { Accounts } from 'meteor/accounts-base';
import { DDPCommon } from 'meteor/ddp-common';
import { Meteor } from 'meteor/meteor';

Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import React, { useCallback, useMemo } from 'react';

import { UserInfoAction } from '../../../components/UserInfo';
import { useActionSpread } from '../../hooks/useActionSpread';
import type { AdminUserTab } from './AdminUsersPage';
import type { AdminUsersTab } from './AdminUsersPage';
import { useChangeAdminStatusAction } from './hooks/useChangeAdminStatusAction';
import { useChangeUserStatusAction } from './hooks/useChangeUserStatusAction';
import { useDeleteUserAction } from './hooks/useDeleteUserAction';
Expand All @@ -19,7 +19,7 @@ type AdminUserInfoActionsProps = {
isFederatedUser: IUser['federated'];
isActive: boolean;
isAdmin: boolean;
tab: AdminUserTab;
tab: AdminUsersTab;
onChange: () => void;
onReload: () => void;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import { UserInfo } from '../../../components/UserInfo';
import { UserStatus } from '../../../components/UserStatus';
import { getUserEmailVerified } from '../../../lib/utils/getUserEmailVerified';
import AdminUserInfoActions from './AdminUserInfoActions';
import type { AdminUserTab } from './AdminUsersPage';
import type { AdminUsersTab } from './AdminUsersPage';

type AdminUserInfoWithDataProps = {
uid: IUser['_id'];
onReload: () => void;
tab: AdminUserTab;
tab: AdminUsersTab;
};

const AdminUserInfoWithData = ({ uid, onReload, tab }: AdminUserInfoWithDataProps): ReactElement => {
Expand Down
10 changes: 5 additions & 5 deletions apps/meteor/client/views/admin/users/AdminUsersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ export type UsersFilters = {
roles: OptionProp[];
};

export type AdminUserTab = 'all' | 'active' | 'deactivated' | 'pending';
export type AdminUsersTab = 'all' | 'active' | 'deactivated' | 'pending';

export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active';
export type UsersTableSortingOption = 'name' | 'username' | 'emails.address' | 'status' | 'active' | 'freeSwitchExtension';

const AdminUsersPage = (): ReactElement => {
const t = useTranslation();
Expand All @@ -65,9 +65,9 @@ const AdminUsersPage = (): ReactElement => {
const { data, error } = useQuery(['roles'], async () => getRoles());

const paginationData = usePagination();
const sortData = useSort<UsersTableSortingOptions>('name');
const sortData = useSort<UsersTableSortingOption>('name');

const [tab, setTab] = useState<AdminUserTab>('all');
const [tab, setTab] = useState<AdminUsersTab>('all');
const [userFilters, setUserFilters] = useState<UsersFilters>({ text: '', roles: [] });

const searchTerm = useDebouncedValue(userFilters.text, 500);
Expand All @@ -89,7 +89,7 @@ const AdminUsersPage = (): ReactElement => {
filteredUsersQueryResult?.refetch();
};

const handleTabChange = (tab: AdminUserTab) => {
const handleTabChange = (tab: AdminUsersTab) => {
setTab(tab);

paginationData.setCurrent(0);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';

import UserPageHeaderContent from './UserPageHeaderContentWithSeatsCap';

it('should render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is enabled', async () => {
render(<UserPageHeaderContent activeUsers={1} maxActiveUsers={1} isSeatsCapExceeded={false} />, {
legacyRoot: true,
wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', true).build(),
});

expect(screen.getByRole('button', { name: 'Assign_extension' })).toBeEnabled();
});

it('should not render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is disabled', async () => {
render(<UserPageHeaderContent activeUsers={1} maxActiveUsers={1} isSeatsCapExceeded={false} />, {
legacyRoot: true,
wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', false).build(),
});

expect(screen.queryByRole('button', { name: 'Assign_extension' })).not.toBeInTheDocument();
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Button, ButtonGroup, Margins } from '@rocket.chat/fuselage';
import { useTranslation, useRouter } from '@rocket.chat/ui-contexts';
import { useSetModal, useTranslation, useRouter, useSetting } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';

import { useExternalLink } from '../../../hooks/useExternalLink';
import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl';
import SeatsCapUsage from './SeatsCapUsage';
import AssignExtensionModal from './voip/AssignExtensionModal';

type UserPageHeaderContentWithSeatsCapProps = {
activeUsers: number;
Expand All @@ -20,6 +21,9 @@ const UserPageHeaderContentWithSeatsCap = ({
}: UserPageHeaderContentWithSeatsCapProps): ReactElement => {
const t = useTranslation();
const router = useRouter();
const setModal = useSetModal();

const canRegisterExtension = useSetting('VoIP_TeamCollab_Enabled');

const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' });
const openExternalLink = useExternalLink();
Expand All @@ -38,6 +42,11 @@ const UserPageHeaderContentWithSeatsCap = ({
<SeatsCapUsage members={activeUsers} limit={maxActiveUsers} />
</Margins>
<ButtonGroup>
{canRegisterExtension && (
<Button icon='phone' onClick={(): void => setModal(<AssignExtensionModal onClose={(): void => setModal(null)} />)}>
{t('Assign_extension')}
</Button>
)}
<Button icon='mail' onClick={handleInviteButtonClick} disabled={isSeatsCapExceeded}>
{t('Invite')}
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import React from 'react';

import { createFakeUser } from '../../../../../tests/mocks/data';
import UsersTable from './UsersTable';

const createFakeAdminUser = (freeSwitchExtension?: string) =>
createFakeUser({
active: true,
roles: ['admin'],
type: 'user',
freeSwitchExtension,
});

it('should not render "Voice call extension" column when voice call is disabled', async () => {
const user = createFakeAdminUser('1000');

render(
<UsersTable
filteredUsersQueryResult={{ isSuccess: true, data: { users: [user], count: 1, offset: 1, total: 1 } } as any}
setUserFilters={() => undefined}
tab='all'
onReload={() => undefined}
paginationData={{} as any}
sortData={{} as any}
isSeatsCapExceeded={false}
roleData={undefined}
/>,
{
legacyRoot: true,
wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', false).build(),
},
);

expect(screen.queryByText('Voice_call_extension')).not.toBeInTheDocument();

screen.getByRole('button', { name: 'More_actions' }).click();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument();
});

it('should render "Unassign_extension" button when user has a associated extension', async () => {
const user = createFakeAdminUser('1000');

render(
<UsersTable
filteredUsersQueryResult={{ isSuccess: true, data: { users: [user], count: 1, offset: 1, total: 1 } } as any}
setUserFilters={() => undefined}
tab='all'
onReload={() => undefined}
paginationData={{} as any}
sortData={{} as any}
isSeatsCapExceeded={false}
roleData={undefined}
/>,
{
legacyRoot: true,
wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', true).build(),
},
);

expect(screen.getByText('Voice_call_extension')).toBeInTheDocument();

screen.getByRole('button', { name: 'More_actions' }).click();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument();
expect(screen.getByRole('option', { name: /Unassign_extension/ })).toBeInTheDocument();
});

it('should render "Assign_extension" button when user has no associated extension', async () => {
const user = createFakeAdminUser();

render(
<UsersTable
filteredUsersQueryResult={{ isSuccess: true, data: { users: [user], count: 1, offset: 1, total: 1 } } as any}
setUserFilters={() => undefined}
tab='all'
onReload={() => undefined}
paginationData={{} as any}
sortData={{} as any}
isSeatsCapExceeded={false}
roleData={undefined}
/>,
{
legacyRoot: true,
wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', true).build(),
},
);

expect(screen.getByText('Voice_call_extension')).toBeInTheDocument();

screen.getByRole('button', { name: 'More_actions' }).click();
expect(await screen.findByRole('listbox')).toBeInTheDocument();
expect(screen.getByRole('option', { name: /Assign_extension/ })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument();
});
26 changes: 20 additions & 6 deletions apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Pagination } from '@rocket.chat/fuselage';
import { useEffectEvent, useBreakpoints } from '@rocket.chat/fuselage-hooks';
import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useRouter, useTranslation } from '@rocket.chat/ui-contexts';
import { useRouter, useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import type { ReactElement, Dispatch, SetStateAction } from 'react';
import React, { useMemo } from 'react';
Expand All @@ -18,18 +18,18 @@ import {
} from '../../../../components/GenericTable';
import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination';
import type { useSort } from '../../../../components/GenericTable/hooks/useSort';
import type { AdminUserTab, UsersFilters, UsersTableSortingOptions } from '../AdminUsersPage';
import type { AdminUsersTab, UsersFilters, UsersTableSortingOption } from '../AdminUsersPage';
import UsersTableFilters from './UsersTableFilters';
import UsersTableRow from './UsersTableRow';

type UsersTableProps = {
tab: AdminUserTab;
tab: AdminUsersTab;
roleData: { roles: IRole[] } | undefined;
onReload: () => void;
setUserFilters: Dispatch<SetStateAction<UsersFilters>>;
filteredUsersQueryResult: UseQueryResult<PaginatedResult<{ users: Serialized<DefaultUserInfo>[] }>>;
paginationData: ReturnType<typeof usePagination>;
sortData: ReturnType<typeof useSort<UsersTableSortingOptions>>;
sortData: ReturnType<typeof useSort<UsersTableSortingOption>>;
isSeatsCapExceeded: boolean;
};

Expand All @@ -49,6 +49,7 @@ const UsersTable = ({

const isMobile = !breakpoints.includes('xl');
const isLaptop = !breakpoints.includes('xxl');
const isVoIPEnabled = useSetting<boolean>('VoIP_TeamCollab_Enabled') || false;

const { data, isLoading, isError, isSuccess } = filteredUsersQueryResult;

Expand Down Expand Up @@ -111,9 +112,21 @@ const UsersTable = ({
{t('Pending_action')}
</GenericTableHeaderCell>
),
<GenericTableHeaderCell key='actions' w={tab === 'pending' ? 'x204' : ''} />,
tab === 'all' && isVoIPEnabled && (
<GenericTableHeaderCell
w='x180'
key='freeSwitchExtension'
direction={sortDirection}
active={sortBy === 'freeSwitchExtension'}
onClick={setSort}
sort='freeSwitchExtension'
>
{t('Voice_call_extension')}
</GenericTableHeaderCell>
),
<GenericTableHeaderCell key='actions' w={tab === 'pending' ? 'x204' : 'x50'} />,
],
[isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab],
[isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab, isVoIPEnabled],
);

return (
Expand Down Expand Up @@ -156,6 +169,7 @@ const UsersTable = ({
onReload={onReload}
tab={tab}
isSeatsCapExceeded={isSeatsCapExceeded}
showVoipExtension={isVoIPEnabled}
/>
))}
</GenericTableBody>
Expand Down
Loading

0 comments on commit 8389ec1

Please sign in to comment.