Skip to content

Commit 3125965

Browse files
feat: roles and permissions tab added (#107)
Implements the Roles and Permissions tab, as defined in #80.
1 parent 8241020 commit 3125965

19 files changed

Lines changed: 1477 additions & 25 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react';
2+
import { screen } from '@testing-library/react';
3+
import { renderWrapper } from '@src/setupTest';
4+
import AuthzHome from './index';
5+
6+
jest.mock('../components/AuthZLayout', () => function MockAuthZLayout({ children }: { children: React.ReactNode }) {
7+
return <div data-testid="authz-layout">{children}</div>;
8+
});
9+
10+
jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermissions() {
11+
return <div data-testid="roles-permissions">Roles & Permissions Content</div>;
12+
});
13+
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+
}));
18+
19+
describe('AuthzHome', () => {
20+
it('renders without crashing', () => {
21+
renderWrapper(<AuthzHome />);
22+
});
23+
24+
it('renders the main layout and tabs', () => {
25+
renderWrapper(<AuthzHome />);
26+
expect(screen.getByTestId('authz-layout')).toBeInTheDocument();
27+
expect(screen.getByTestId('tabs')).toBeInTheDocument();
28+
});
29+
30+
it('renders both tab panels', () => {
31+
renderWrapper(<AuthzHome />);
32+
const tabs = screen.getAllByTestId('tab');
33+
expect(tabs).toHaveLength(2);
34+
});
35+
36+
it('renders the RolesPermissions component in the permissions tab', () => {
37+
renderWrapper(<AuthzHome />);
38+
expect(screen.getByTestId('roles-permissions')).toBeInTheDocument();
39+
});
40+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useIntl } from '@edx/frontend-platform/i18n';
2+
import { Tab, Tabs } from '@openedx/paragon';
3+
import { useLocation } from 'react-router-dom';
4+
import RolesPermissions from '../roles-permissions/RolesPermissions';
5+
import AuthZLayout from '../components/AuthZLayout';
6+
7+
import messages from './messages';
8+
9+
const AuthzHome = () => {
10+
const { hash } = useLocation();
11+
const intl = useIntl();
12+
13+
const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || '';
14+
const pageTitle = intl.formatMessage(messages['authz.manage.page.title']);
15+
16+
return (
17+
<div className="authz-libraries">
18+
<AuthZLayout
19+
context={{ id: '', title: '', org: '' }}
20+
navLinks={[{ label: rootBreadcrumb }]}
21+
activeLabel={pageTitle}
22+
pageTitle={pageTitle}
23+
pageSubtitle=""
24+
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+
// ]
30+
}
31+
>
32+
<Tabs
33+
variant="tabs"
34+
defaultActiveKey={hash ? 'permissionsRoles' : 'team'}
35+
className="bg-light-100 px-5"
36+
>
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 /> */}
40+
</Tab>
41+
<Tab id="libraries-permissions-roles-tab" eventKey="permissionsRoles" title={intl.formatMessage(messages['authz.tabs.permissionsRoles'])}>
42+
<RolesPermissions />
43+
</Tab>
44+
</Tabs>
45+
</AuthZLayout>
46+
</div>
47+
);
48+
};
49+
50+
export default AuthzHome;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
'authz.manage.page.title': {
5+
id: 'authz.manage.page.title',
6+
defaultMessage: 'Library Team Management',
7+
description: 'Libraries AuthZ page title',
8+
},
9+
'authz.breadcrumb.root': {
10+
id: 'authz.breadcrumb.root',
11+
defaultMessage: 'Manage Access',
12+
description: 'Libraries AuthZ root breadcrumb',
13+
},
14+
'authz.tabs.team': {
15+
id: 'authz.tabs.team',
16+
defaultMessage: 'Team Members',
17+
description: 'Libraries AuthZ title for the team management tab',
18+
},
19+
'authz.tabs.permissionsRoles': {
20+
id: 'authz.tabs.permissionsRoles',
21+
defaultMessage: 'Roles and Permissions',
22+
description: 'Libraries AuthZ title for the roles tab',
23+
},
24+
});
25+
26+
export default messages;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { renderWrapper } from '@src/setupTest';
2+
import { waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import AnchorButton from './AnchorButton';
5+
6+
const mockScrollTo = jest.fn();
7+
Object.defineProperty(window, 'scrollTo', {
8+
value: mockScrollTo,
9+
writable: true,
10+
});
11+
12+
describe('AnchorButton', () => {
13+
beforeEach(() => {
14+
mockScrollTo.mockClear();
15+
// Reset window.scrollY
16+
Object.defineProperty(window, 'scrollY', {
17+
value: 0,
18+
writable: true,
19+
});
20+
});
21+
22+
it('renders without crashing', () => {
23+
renderWrapper(<AnchorButton />);
24+
});
25+
26+
it('does not display button initially', () => {
27+
const { container } = renderWrapper(<AnchorButton />);
28+
expect(container.firstChild).toBeNull();
29+
});
30+
31+
it('calls window.scrollTo with correct parameters when button is clicked', async () => {
32+
const user = userEvent.setup();
33+
// Make button visible first
34+
Object.defineProperty(window, 'scrollY', {
35+
value: 400,
36+
writable: true,
37+
});
38+
39+
const { getByRole, rerender } = renderWrapper(<AnchorButton />);
40+
// Simulate scroll event by dispatching a scroll event
41+
const scrollEvent = new Event('scroll');
42+
window.dispatchEvent(scrollEvent);
43+
rerender(<AnchorButton />);
44+
45+
await waitFor(async () => {
46+
const button = getByRole('button');
47+
expect(button).toBeInTheDocument();
48+
await user.click(button);
49+
expect(mockScrollTo).toHaveBeenCalledWith({
50+
top: 0,
51+
behavior: 'smooth',
52+
});
53+
});
54+
});
55+
56+
it('removes event listener on unmount', () => {
57+
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
58+
const { unmount } = renderWrapper(<AnchorButton />);
59+
unmount();
60+
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
61+
removeEventListenerSpy.mockRestore();
62+
});
63+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useIntl } from '@edx/frontend-platform/i18n';
2+
import { ArrowUpward } from '@openedx/paragon/icons';
3+
import { IconButton } from '@openedx/paragon';
4+
import { useState, useEffect } from 'react';
5+
import messages from './messages';
6+
7+
const AnchorButton = () => {
8+
const [isVisible, setIsVisible] = useState(false);
9+
const intl = useIntl();
10+
const scrollToTopButton = () => {
11+
window.scrollTo({
12+
top: 0,
13+
behavior: 'smooth',
14+
});
15+
};
16+
17+
useEffect(() => {
18+
const handleScroll = () => {
19+
const scrollTop = window.scrollY;
20+
setIsVisible(scrollTop > 300);
21+
};
22+
23+
window.addEventListener('scroll', handleScroll);
24+
return () => {
25+
window.removeEventListener('scroll', handleScroll);
26+
};
27+
}, []);
28+
29+
if (!isVisible) {
30+
return null;
31+
}
32+
33+
return (
34+
<IconButton
35+
isActive
36+
src={ArrowUpward}
37+
alt={intl.formatMessage(messages['authz.anchor.button.alt'])}
38+
onClick={scrollToTopButton}
39+
variant="primary"
40+
className="mr-2 mb-2 fixed-bottom float-right"
41+
style={{
42+
bottom: '20px',
43+
left: 'calc(100% - 70px)',
44+
}}
45+
/>
46+
);
47+
};
48+
49+
export default AnchorButton;

src/authz-module/components/PermissionTable.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,14 @@ describe('PermissionTable', () => {
165165
it('renders Close icons for denied permissions', () => {
166166
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
167167

168-
const deniedIcons = screen.getAllByLabelText(/Permission denied in/);
168+
const deniedIcons = screen.getAllByLabelText(/Permission not granted in/);
169169
expect(deniedIcons.length).toBeGreaterThan(0);
170170
});
171171

172172
it('applies text-danger class to denied permission icons', () => {
173173
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
174174

175-
const deniedIcons = screen.getAllByLabelText(/Permission denied in/);
175+
const deniedIcons = screen.getAllByLabelText(/Permission not granted in/);
176176
deniedIcons.forEach(icon => {
177177
expect(icon).toHaveClass('text-danger');
178178
});
@@ -208,7 +208,7 @@ describe('PermissionTable', () => {
208208
it('renders correct aria-labels for denied permissions', () => {
209209
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
210210

211-
const deniedLabel = 'Permission denied in Viewer role';
211+
const deniedLabel = 'Permission not granted in Viewer role';
212212
expect(screen.getAllByLabelText(deniedLabel)).toHaveLength(2);
213213
});
214214

src/authz-module/components/PermissionTable.tsx

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import React from 'react';
12
import { useIntl } from '@edx/frontend-platform/i18n';
23
import { Check, Close } from '@openedx/paragon/icons';
3-
import { Card, Icon } from '@openedx/paragon';
4+
import {
5+
Card, Icon, OverlayTrigger, Tooltip,
6+
} from '@openedx/paragon';
47
import { PermissionsResourceGrouped, Role } from '@src/types';
58
import { actionsDictionary } from './RoleCard/constants';
69
import ResourceTooltip from './ResourceTooltip';
@@ -9,28 +12,55 @@ import messages from './messages';
912
type PermissionTableProps = {
1013
roles: Role[];
1114
permissionsTable: PermissionsResourceGrouped[];
15+
title?: string;
1216
};
1317

14-
const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
18+
const PermissionTable = ({ permissionsTable, roles, title }: PermissionTableProps) => {
1519
const { formatMessage } = useIntl();
1620
return (
1721
<Card>
1822
<table className="permission-table w-100">
1923
<thead>
2024
<tr>
21-
<th className="" aria-hidden="true" />
25+
<th className="sticky-top bg-white px-4 py-3">
26+
{title}
27+
</th>
2228
{roles.map(role => (
23-
<th key={role.name} className="text-center py-3">{role.name}</th>
29+
<th
30+
key={role.name}
31+
className={`text-center py-3 sticky-top bg-white ${role.disabled ? 'text-gray-200' : ''}`}
32+
>
33+
{role.disabled ? (
34+
<OverlayTrigger
35+
placement="top"
36+
overlay={(
37+
<Tooltip
38+
id={`tooltip-${role.name}`}
39+
variant="light"
40+
>
41+
{formatMessage(messages['authz.role.card.permission.for.role.status.disabled'])}
42+
</Tooltip>
43+
)}
44+
>
45+
<span style={{ cursor: 'help' }}>{role.name}</span>
46+
</OverlayTrigger>
47+
) : (
48+
role.name
49+
)}
50+
</th>
2451
))}
2552
</tr>
2653
</thead>
2754
<tbody>
2855
{permissionsTable.map(resourceGroup => (
29-
<>
56+
<React.Fragment key={resourceGroup.key}>
3057
<tr className="bg-info-100 text-primary">
3158
<td colSpan={roles.length + 1} className="text-start py-3 px-4">
32-
<strong>{resourceGroup.label}</strong>
33-
<ResourceTooltip resourceGroup={resourceGroup} />
59+
<div className="d-flex align-items-center">
60+
{resourceGroup.icon && <Icon className="d-inline-block mr-2" size="xs" src={resourceGroup.icon} />}
61+
<strong>{resourceGroup.label}</strong>
62+
<ResourceTooltip resourceGroup={resourceGroup} />
63+
</div>
3464
</td>
3565
</tr>
3666
{resourceGroup.permissions.map(permission => (
@@ -40,12 +70,12 @@ const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
4070
{permission.label}
4171
</td>
4272
{roles.map(role => (
43-
<td key={role.name} className="text-center">
73+
<td key={role.name} className={`text-center ${role.disabled ? 'text-gray-200' : ''}`}>
4474
{
4575
permission.roles[role.name]
4676
? (
4777
<Icon
48-
className="d-inline-block"
78+
className={`d-inline-block ${role.disabled ? 'text-gray-200' : 'text-success'}`}
4979
src={Check}
5080
aria-label={formatMessage(messages['authz.role.card.permission.for.role.status.granted'], {
5181
roleName: role.name,
@@ -57,12 +87,12 @@ const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
5787
)
5888
: (
5989
<Icon
60-
className="text-danger d-inline-block"
90+
className={`d-inline-block ${role.disabled ? 'text-gray-200' : 'text-danger'}`}
6191
src={Close}
62-
aria-label={formatMessage(messages['authz.role.card.permission.for.role.status.denied'], {
92+
aria-label={formatMessage(messages['authz.role.card.permission.for.role.status.not.granted'], {
6393
roleName: role.name,
6494
})}
65-
screenReaderText={formatMessage(messages['authz.role.card.permission.for.role.status.denied'], {
95+
screenReaderText={formatMessage(messages['authz.role.card.permission.for.role.status.not.granted'], {
6696
roleName: role.name,
6797
})}
6898
/>
@@ -72,7 +102,7 @@ const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
72102
))}
73103
</tr>
74104
))}
75-
</>
105+
</React.Fragment>
76106
))}
77107
</tbody>
78108
</table>

src/authz-module/components/RoleCard/constants.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import {
2-
Add, Delete, DownloadDone, Edit, ManageAccounts, Sync, Tag, Visibility,
2+
Add, Delete, DownloadDone, Edit, ManageAccounts, Sync, FileUpload, Visibility, FileDownload, Settings,
33
} from '@openedx/paragon/icons';
44

55
export const actionsDictionary = {
66
create: Add,
77
edit: Edit,
88
delete: Delete,
9-
import: Sync,
9+
import: FileDownload,
1010
publish: DownloadDone,
1111
view: Visibility,
1212
reuse: Sync,
13-
tag: Tag,
1413
team: ManageAccounts,
14+
export: FileUpload,
15+
manage: Settings,
1516
};
1617

1718
export type ActionKey = keyof typeof actionsDictionary;

0 commit comments

Comments
 (0)