Skip to content

Commit 067b971

Browse files
feat(authz): add team members table with sorting, pagination and filtering. (#124)
* feat: roles and permissions tab added * feat: refactor to create new path for roles permissions tab * feat: icons added to roles * chore: lint fix * chore: fix texts and removed unnecessary validations * chore: aria labels updated * chore: missing i18n added, tests updated * chore: unnecessary path removed * feat: disable columns and tooltip added * feat: creating the team members tab with the new ui * feat: integrating backend apis into team members table * test: adding new ut for new team members table an its components * feat: adding show more text on filters and description text * fix: addressing pr comments * chore: unnecessary test deleted --------- Co-authored-by: Jesus Balderrama <[email protected]>
1 parent b0c457c commit 067b971

45 files changed

Lines changed: 4048 additions & 81 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,74 @@
11
import React from 'react';
22
import { screen } from '@testing-library/react';
3-
import { renderWrapper } from '@src/setupTest';
3+
import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks';
4+
import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
5+
import { renderWithAllProviders } from '@src/setupTest';
6+
import userEvent from '@testing-library/user-event';
47
import AuthzHome from './index';
8+
import messages from './messages';
59

6-
jest.mock('../components/AuthZLayout', () => function MockAuthZLayout({ children }: { children: React.ReactNode }) {
7-
return <div data-testid="authz-layout">{children}</div>;
8-
});
10+
jest.mock('@src/authz-module/data/hooks', () => ({
11+
useAllRoleAssignments: jest.fn(),
12+
useOrgs: jest.fn(),
13+
useScopes: jest.fn(),
14+
}));
915

10-
jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermissions() {
11-
return <div data-testid="roles-permissions">Roles & Permissions Content</div>;
12-
});
16+
const emptyResponse = {
17+
data: {
18+
results: [], count: 0, next: null, previous: null,
19+
},
20+
error: null,
21+
isLoading: false,
22+
refetch: jest.fn(),
23+
};
1324

14-
jest.mock('@openedx/paragon', () => ({
15-
Tab: ({ children, title } : { children: React.ReactNode, title: string }) => <div data-testid="tab" role="tabpanel">{title}: {children}</div>,
16-
Tabs: ({ children }: { children: React.ReactNode }) => <div data-testid="tabs">{children}</div>,
17-
}));
25+
const renderAuthzHome = () => renderWithAllProviders(
26+
<ToastManagerProvider>
27+
<AuthzHome />
28+
</ToastManagerProvider>,
29+
);
1830

1931
describe('AuthzHome', () => {
32+
beforeEach(() => {
33+
(useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse);
34+
(useOrgs as jest.Mock).mockReturnValue(emptyResponse);
35+
(useScopes as jest.Mock).mockReturnValue(emptyResponse);
36+
});
37+
2038
it('renders without crashing', () => {
21-
renderWrapper(<AuthzHome />);
39+
renderAuthzHome();
2240
});
2341

2442
it('renders the main layout and tabs', () => {
25-
renderWrapper(<AuthzHome />);
26-
expect(screen.getByTestId('authz-layout')).toBeInTheDocument();
27-
expect(screen.getByTestId('tabs')).toBeInTheDocument();
43+
renderAuthzHome();
44+
expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument();
45+
expect(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)).toBeInTheDocument();
46+
expect(screen.getByText(messages['authz.tabs.team'].defaultMessage)).toBeInTheDocument();
2847
});
2948

3049
it('renders both tab panels', () => {
31-
renderWrapper(<AuthzHome />);
32-
const tabs = screen.getAllByTestId('tab');
33-
expect(tabs).toHaveLength(2);
50+
renderAuthzHome();
51+
expect(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)).toBeInTheDocument();
52+
expect(screen.getByText(messages['authz.tabs.team'].defaultMessage)).toBeInTheDocument();
53+
expect(screen.getAllByRole('tab')).toHaveLength(3); // 2 + tab invisible for more...
54+
});
55+
56+
it('renders the RolesPermissions component in the permissions tab', async () => {
57+
const user = userEvent.setup();
58+
renderAuthzHome();
59+
await user.click(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage));
60+
expect(screen.getByRole('button', { name: 'Courses' })).toBeInTheDocument();
61+
expect(screen.getByRole('button', { name: 'Libraries' })).toBeInTheDocument();
3462
});
3563

36-
it('renders the RolesPermissions component in the permissions tab', () => {
37-
renderWrapper(<AuthzHome />);
38-
expect(screen.getByTestId('roles-permissions')).toBeInTheDocument();
64+
it('renders the TeamMembersTable component in the team members tab', () => {
65+
renderAuthzHome();
66+
expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument();
67+
expect(screen.getByText('Name')).toBeInTheDocument();
68+
expect(screen.getByText('Email')).toBeInTheDocument();
69+
expect(screen.getAllByText('Organization').length).toBe(2); // Header and org filter;
70+
expect(screen.getAllByText('Scope').length).toBe(2); // Header and scope filter;
71+
expect(screen.getAllByText('Role').length).toBe(2); // Header and role filter;
72+
expect(screen.getByText('Actions')).toBeInTheDocument();
3973
});
4074
});

src/authz-module/authz-home/index.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useIntl } from '@edx/frontend-platform/i18n';
22
import { Tab, Tabs } from '@openedx/paragon';
3-
import { useLocation } from 'react-router-dom';
3+
import { useLocation, useSearchParams } from 'react-router-dom';
4+
import TeamMembersTable from '@src/authz-module/team-members/TeamMembersTable';
5+
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
46
import RolesPermissions from '../roles-permissions/RolesPermissions';
57
import AuthZLayout from '../components/AuthZLayout';
68

@@ -9,34 +11,28 @@ import messages from './messages';
911
const AuthzHome = () => {
1012
const { hash } = useLocation();
1113
const intl = useIntl();
14+
const [searchParams] = useSearchParams();
15+
const presetScope = searchParams.get('scope') || undefined;
1216

13-
const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || '';
1417
const pageTitle = intl.formatMessage(messages['authz.manage.page.title']);
1518

1619
return (
17-
<div className="authz-libraries">
20+
<div className="authz-module">
1821
<AuthZLayout
1922
context={{ id: '', title: '', org: '' }}
20-
navLinks={[{ label: rootBreadcrumb }]}
21-
activeLabel={pageTitle}
2223
pageTitle={pageTitle}
2324
pageSubtitle=""
2425
actions={
25-
[]
26-
// this needs to be enable again once is refactored to be used outside of library context
27-
// [
28-
// <AddNewTeamMemberTrigger libraryId="" key="add-new-member" />,
29-
// ]
26+
[<AddRoleButton key="add-role-button" />]
3027
}
3128
>
3229
<Tabs
3330
variant="tabs"
3431
defaultActiveKey={hash ? 'permissionsRoles' : 'team'}
3532
className="bg-light-100 px-5"
3633
>
37-
<Tab eventKey="team" title={intl.formatMessage(messages['authz.tabs.team'])} className="p-5">
38-
{/* TODO: once TeamTable is refactored we can call it here. For now, this tab will be empty. */}
39-
{/* <TeamTable /> */}
34+
<Tab eventKey="team" title={intl.formatMessage(messages['authz.tabs.team'])} className="p-5 bg-light-200">
35+
<TeamMembersTable presetScope={presetScope} />
4036
</Tab>
4137
<Tab id="libraries-permissions-roles-tab" eventKey="permissionsRoles" title={intl.formatMessage(messages['authz.tabs.permissionsRoles'])}>
4238
<RolesPermissions />

src/authz-module/authz-home/messages.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,23 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
33
const messages = defineMessages({
44
'authz.manage.page.title': {
55
id: 'authz.manage.page.title',
6-
defaultMessage: 'Library Team Management',
7-
description: 'Libraries AuthZ page title',
6+
defaultMessage: 'Roles and Permissions Management',
7+
description: 'AuthZ home page title',
88
},
99
'authz.breadcrumb.root': {
1010
id: 'authz.breadcrumb.root',
1111
defaultMessage: 'Manage Access',
12-
description: 'Libraries AuthZ root breadcrumb',
12+
description: 'AuthZ root breadcrumb',
1313
},
1414
'authz.tabs.team': {
1515
id: 'authz.tabs.team',
1616
defaultMessage: 'Team Members',
17-
description: 'Libraries AuthZ title for the team management tab',
17+
description: 'AuthZ title for the team management tab',
1818
},
1919
'authz.tabs.permissionsRoles': {
2020
id: 'authz.tabs.permissionsRoles',
2121
defaultMessage: 'Roles and Permissions',
22-
description: 'Libraries AuthZ title for the roles tab',
22+
description: 'AuthZ title for the roles tab',
2323
},
2424
});
2525

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { useNavigate } from 'react-router-dom';
4+
import { initializeMockApp } from '@edx/frontend-platform/testing';
5+
import { renderWrapper } from '@src/setupTest';
6+
import AddRoleButton from './AddRoleButton';
7+
8+
// Mock react-router-dom navigation
9+
jest.mock('react-router-dom', () => ({
10+
...jest.requireActual('react-router-dom'),
11+
useNavigate: jest.fn(),
12+
}));
13+
14+
describe('AddRoleButton', () => {
15+
const mockNavigate = jest.fn();
16+
17+
beforeAll(() => {
18+
initializeMockApp({
19+
authenticatedUser: {
20+
userId: 1,
21+
username: 'testuser',
22+
23+
},
24+
});
25+
});
26+
27+
beforeEach(() => {
28+
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
29+
});
30+
31+
afterEach(() => {
32+
jest.clearAllMocks();
33+
});
34+
35+
describe('rendering', () => {
36+
it('renders the assign role button with correct text', () => {
37+
renderWrapper(<AddRoleButton />);
38+
39+
const button = screen.getByRole('button', { name: /assign role/i });
40+
expect(button).toBeInTheDocument();
41+
});
42+
43+
it('displays the plus icon', () => {
44+
renderWrapper(<AddRoleButton />);
45+
46+
const button = screen.getByRole('button', { name: /assign role/i });
47+
expect(button.querySelector('svg')).toBeInTheDocument();
48+
});
49+
50+
it('renders correctly when presetUsername is provided', () => {
51+
renderWrapper(<AddRoleButton presetUsername="testuser123" />);
52+
53+
const button = screen.getByRole('button', { name: /assign role/i });
54+
expect(button).toBeInTheDocument();
55+
});
56+
});
57+
58+
describe('navigation behavior', () => {
59+
it('navigates to assign role page without username when clicked', async () => {
60+
const user = userEvent.setup();
61+
renderWrapper(<AddRoleButton />);
62+
63+
const button = screen.getByRole('button', { name: /assign role/i });
64+
await user.click(button);
65+
66+
expect(mockNavigate).toHaveBeenCalledTimes(1);
67+
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
68+
});
69+
70+
it('navigates to assign role page with username query parameter when presetUsername is provided', async () => {
71+
const user = userEvent.setup();
72+
const presetUsername = 'john.doe';
73+
renderWrapper(<AddRoleButton presetUsername={presetUsername} />);
74+
75+
const button = screen.getByRole('button', { name: /assign role/i });
76+
await user.click(button);
77+
78+
expect(mockNavigate).toHaveBeenCalledTimes(1);
79+
expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`);
80+
});
81+
82+
it('handles special characters in presetUsername correctly', async () => {
83+
const user = userEvent.setup();
84+
const presetUsername = '[email protected]';
85+
renderWrapper(<AddRoleButton presetUsername={presetUsername} />);
86+
87+
const button = screen.getByRole('button', { name: /assign role/i });
88+
await user.click(button);
89+
90+
expect(mockNavigate).toHaveBeenCalledTimes(1);
91+
expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`);
92+
});
93+
});
94+
95+
describe('user interactions', () => {
96+
it('responds to keyboard navigation', async () => {
97+
const user = userEvent.setup();
98+
renderWrapper(<AddRoleButton />);
99+
100+
const button = screen.getByRole('button', { name: /assign role/i });
101+
102+
await user.tab();
103+
expect(button).toHaveFocus();
104+
105+
await user.keyboard('{Enter}');
106+
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
107+
});
108+
109+
it('responds to spacebar activation', async () => {
110+
const user = userEvent.setup();
111+
renderWrapper(<AddRoleButton />);
112+
113+
const button = screen.getByRole('button', { name: /assign role/i });
114+
button.focus();
115+
116+
await user.keyboard(' ');
117+
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
118+
});
119+
120+
it('handles multiple clicks gracefully', async () => {
121+
const user = userEvent.setup();
122+
renderWrapper(<AddRoleButton presetUsername="testuser" />);
123+
124+
const button = screen.getByRole('button', { name: /assign role/i });
125+
126+
await user.click(button);
127+
await user.click(button);
128+
await user.click(button);
129+
130+
expect(mockNavigate).toHaveBeenCalledTimes(3);
131+
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?username=testuser');
132+
});
133+
});
134+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { Button } from '@openedx/paragon';
4+
import { Plus } from '@openedx/paragon/icons';
5+
6+
import baseMessages from '@src/authz-module/messages';
7+
import { useNavigate } from 'react-router-dom';
8+
9+
interface AddRoleButtonProps {
10+
presetUsername?: string;
11+
}
12+
13+
const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => {
14+
const intl = useIntl();
15+
const navigate = useNavigate();
16+
17+
const handleClick = () => {
18+
const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`;
19+
navigate(assignRolePath);
20+
};
21+
22+
return (
23+
<Button
24+
iconBefore={Plus}
25+
onClick={handleClick}
26+
>
27+
{intl.formatMessage(baseMessages['authz.management.assign.role.title'])}
28+
</Button>
29+
);
30+
};
31+
32+
export default AddRoleButton;

src/authz-module/components/AuthZLayout.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ interface AuthZLayoutProps extends AuthZTitleProps {
1414
const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => (
1515
<>
1616
<StudioHeader
17-
number={context.id}
18-
org={context.org}
19-
title={context.title}
17+
number={context?.id || null}
18+
org={context?.org || null}
19+
title={context?.title || null}
20+
isHiddenMainMenu
2021
/>
2122
<AuthZTitle {...props} />
2223
{children}

src/authz-module/components/AuthZTitle.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface Action {
2121
}
2222

2323
export interface AuthZTitleProps {
24-
activeLabel: string;
24+
activeLabel?: string;
2525
pageTitle: string;
2626
pageSubtitle: string | ReactNode;
2727
navLinks?: BreadcrumbLink[];
@@ -53,8 +53,8 @@ const AuthZTitle = ({
5353
<div className="d-flex align-items-center">
5454
<h2 className="text-primary mb-0">{pageTitle}</h2>
5555
{typeof pageSubtitle === 'string'
56-
? <><hr className="mx-lg-3" /><h3 className="mb-0 py-2 font-weight-light text-gray-700">{pageSubtitle}</h3></>
57-
: <><hr className="mx-lg-3" /> <div className="mb-0">{pageSubtitle}</div></>}
56+
? <> { pageSubtitle !== '' && <hr className="mx-lg-3" /> }<h3 className="mb-0 py-2 font-weight-light text-gray-700">{pageSubtitle}</h3></>
57+
: <>{ pageSubtitle !== '' && <hr className="mx-lg-3" /> } <div className="mb-0">{pageSubtitle}</div></>}
5858

5959
</div>
6060
</Col>

0 commit comments

Comments
 (0)