Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/authz-module/authz-home/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderWrapper } from '@src/setupTest';
import AuthzHome from './index';

jest.mock('../components/AuthZLayout', () => function MockAuthZLayout({ children }: { children: React.ReactNode }) {
return <div data-testid="authz-layout">{children}</div>;
});

jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermissions() {
return <div data-testid="roles-permissions">Roles & Permissions Content</div>;
});

jest.mock('@openedx/paragon', () => ({
Tab: ({ children, title } : { children: React.ReactNode, title: string }) => <div data-testid="tab" role="tabpanel">{title}: {children}</div>,
Tabs: ({ children }: { children: React.ReactNode }) => <div data-testid="tabs">{children}</div>,
}));

describe('AuthzHome', () => {
it('renders without crashing', () => {
renderWrapper(<AuthzHome />);
});

it('renders the main layout and tabs', () => {
renderWrapper(<AuthzHome />);
expect(screen.getByTestId('authz-layout')).toBeInTheDocument();
expect(screen.getByTestId('tabs')).toBeInTheDocument();
});

it('renders both tab panels', () => {
renderWrapper(<AuthzHome />);
const tabs = screen.getAllByTestId('tab');
expect(tabs).toHaveLength(2);
});

it('renders the RolesPermissions component in the permissions tab', () => {
renderWrapper(<AuthzHome />);
expect(screen.getByTestId('roles-permissions')).toBeInTheDocument();
});
});
49 changes: 49 additions & 0 deletions src/authz-module/authz-home/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tab, Tabs } from '@openedx/paragon';
import { useLocation, useSearchParams } from 'react-router-dom';
import TeamMembersTable from 'authz-module/team-members/TeamMembersTable';
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import RolesPermissions from '../roles-permissions/RolesPermissions';
import AuthZLayout from '../components/AuthZLayout';

import messages from '../libraries-manager/messages';

const AuthzHome = () => {
const { hash } = useLocation();
const intl = useIntl();
const [searchParams] = useSearchParams();
const presetScope = searchParams.get('scope') || undefined;

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

return (
<div className="authz-libraries">
<AuthZLayout
context={{ id: '', title: '', org: '' }}
navLinks={[{ label: rootBreadcrumb }]}
activeLabel={pageTitle}
pageTitle={pageTitle}
pageSubtitle=""
actions={
[<AddRoleButton key="add-role-button" />]
}
>
<Tabs
variant="tabs"
defaultActiveKey={hash ? 'permissionsRoles' : 'team'}
className="bg-light-100 px-5"
>
<Tab eventKey="team" title={intl.formatMessage(messages['library.authz.tabs.team'])} className="p-5">
<TeamMembersTable presetScope={presetScope} />
</Tab>
<Tab id="libraries-permissions-roles-tab" eventKey="permissionsRoles" title={intl.formatMessage(messages['library.authz.tabs.permissionsRoles'])}>
<RolesPermissions />
</Tab>
</Tabs>
</AuthZLayout>
</div>
);
};

export default AuthzHome;
32 changes: 32 additions & 0 deletions src/authz-module/components/AddRoleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Plus } from '@openedx/paragon/icons';

import baseMessages from '@src/authz-module/messages';
import { useNavigate } from 'react-router-dom';

interface AddRoleButtonProps {
presetUsername?: string;
}

const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => {
const intl = useIntl();
const navigate = useNavigate();

const handleClick = () => {
const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`;
navigate(assignRolePath);
};

return (
<Button
iconBefore={Plus}
onClick={handleClick}
>
{intl.formatMessage(baseMessages['authz.management.assign.role.title'])}
</Button>
);
};

export default AddRoleButton;
63 changes: 63 additions & 0 deletions src/authz-module/components/AnchorButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { renderWrapper } from '@src/setupTest';
import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AnchorButton from './AnchorButton';

const mockScrollTo = jest.fn();
Object.defineProperty(window, 'scrollTo', {
value: mockScrollTo,
writable: true,
});

describe('AnchorButton', () => {
beforeEach(() => {
mockScrollTo.mockClear();
// Reset window.scrollY
Object.defineProperty(window, 'scrollY', {
value: 0,
writable: true,
});
});

it('renders without crashing', () => {
renderWrapper(<AnchorButton />);
});

it('does not display button initially', () => {
const { container } = renderWrapper(<AnchorButton />);
expect(container.firstChild).toBeNull();
});

it('calls window.scrollTo with correct parameters when button is clicked', async () => {
const user = userEvent.setup();
// Make button visible first
Object.defineProperty(window, 'scrollY', {
value: 400,
writable: true,
});

const { getByRole, rerender } = renderWrapper(<AnchorButton />);
// Simulate scroll event by dispatching a scroll event
const scrollEvent = new Event('scroll');
window.dispatchEvent(scrollEvent);
rerender(<AnchorButton />);

await waitFor(async () => {
const button = getByRole('button');
expect(button).toBeInTheDocument();
await user.click(button);
expect(mockScrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'smooth',
});
});
});

it('removes event listener on unmount', () => {
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderWrapper(<AnchorButton />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
removeEventListenerSpy.mockRestore();
});
});
49 changes: 49 additions & 0 deletions src/authz-module/components/AnchorButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { ArrowUpward } from '@openedx/paragon/icons';
import { IconButton } from '@openedx/paragon';
import { useState, useEffect } from 'react';
import messages from './messages';

const AnchorButton = () => {
const [isVisible, setIsVisible] = useState(false);
const intl = useIntl();
const scrollToTopButton = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};

useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
setIsVisible(scrollTop > 300);
};

window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);

if (!isVisible) {
return null;
}

return (
<IconButton
isActive
src={ArrowUpward}
alt={intl.formatMessage(messages['authz.anchor.button.alt'])}
onClick={scrollToTopButton}
variant="primary"
className="mr-2 mb-2 fixed-bottom float-right"
style={{
bottom: '20px',
left: 'calc(100% - 70px)',
}}
/>
);
};

export default AnchorButton;
7 changes: 4 additions & 3 deletions src/authz-module/components/AuthZLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ interface AuthZLayoutProps extends AuthZTitleProps {
const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => (
<>
<StudioHeader
number={context.id}
org={context.org}
title={context.title}
number={context?.id || null}
org={context?.org || null}
title={context?.title || null}
isHiddenMainMenu
/>
<AuthZTitle {...props} />
{children}
Expand Down
6 changes: 3 additions & 3 deletions src/authz-module/components/PermissionTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,14 @@ describe('PermissionTable', () => {
it('renders Close icons for denied permissions', () => {
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);

const deniedIcons = screen.getAllByLabelText(/Permission denied in/);
const deniedIcons = screen.getAllByLabelText(/Permission not granted in/);
expect(deniedIcons.length).toBeGreaterThan(0);
});

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

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

const deniedLabel = 'Permission denied in Viewer role';
const deniedLabel = 'Permission not granted in Viewer role';
expect(screen.getAllByLabelText(deniedLabel)).toHaveLength(2);
});

Expand Down
51 changes: 40 additions & 11 deletions src/authz-module/components/PermissionTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Check, Close } from '@openedx/paragon/icons';
import { Card, Icon } from '@openedx/paragon';
import {
Card, Icon, OverlayTrigger, Tooltip,
} from '@openedx/paragon';
import { PermissionsResourceGrouped, Role } from '@src/types';
import { actionsDictionary } from './RoleCard/constants';
import ResourceTooltip from './ResourceTooltip';
Expand All @@ -9,18 +11,42 @@ import messages from './messages';
type PermissionTableProps = {
roles: Role[];
permissionsTable: PermissionsResourceGrouped[];
title?: string;
};

const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
const PermissionTable = ({ permissionsTable, roles, title }: PermissionTableProps) => {
const { formatMessage } = useIntl();
return (
<Card>
<table className="permission-table w-100">
<thead>
<tr>
<th className="" aria-hidden="true" />
<th className="sticky-top bg-white px-4 py-3">
{title}
</th>
{roles.map(role => (
<th key={role.name} className="text-center py-3">{role.name}</th>
<th
key={role.name}
className={`text-center py-3 sticky-top bg-white ${role.disable && 'text-muted opacity-50'}`}
>
{role.disable ? (
<OverlayTrigger
placement="top"
overlay={(
<Tooltip
id={`tooltip-${role.name}`}
variant="light"
>
{formatMessage(messages['authz.role.card.permission.for.role.status.disabled'])}
</Tooltip>
)}
>
<span style={{ cursor: 'help' }}>{role.name}</span>
</OverlayTrigger>
) : (
role.name
)}
</th>
))}
</tr>
</thead>
Expand All @@ -29,8 +55,11 @@ const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
<>
<tr className="bg-info-100 text-primary">
<td colSpan={roles.length + 1} className="text-start py-3 px-4">
<strong>{resourceGroup.label}</strong>
<ResourceTooltip resourceGroup={resourceGroup} />
<div className="d-flex align-items-center">
{resourceGroup.icon && <Icon className="d-inline-block mr-2" size="xs" src={resourceGroup.icon} />}
<strong>{resourceGroup.label}</strong>
<ResourceTooltip resourceGroup={resourceGroup} />
</div>
</td>
</tr>
{resourceGroup.permissions.map(permission => (
Expand All @@ -40,12 +69,12 @@ const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
{permission.label}
</td>
{roles.map(role => (
<td key={role.name} className="text-center">
<td key={role.name} className={`text-center ${role.disable && 'opacity-50'}`}>
{
permission.roles[role.name]
? (
<Icon
className="d-inline-block"
className={`d-inline-block ${role.disable && 'text-muted'}`}
src={Check}
aria-label={formatMessage(messages['authz.role.card.permission.for.role.status.granted'], {
roleName: role.name,
Expand All @@ -57,12 +86,12 @@ const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
)
: (
<Icon
className="text-danger d-inline-block"
className={`d-inline-block ${role.disable ? 'text-muted' : 'text-danger'}`}
src={Close}
aria-label={formatMessage(messages['authz.role.card.permission.for.role.status.denied'], {
aria-label={formatMessage(messages['authz.role.card.permission.for.role.status.not.granted'], {
roleName: role.name,
})}
screenReaderText={formatMessage(messages['authz.role.card.permission.for.role.status.denied'], {
screenReaderText={formatMessage(messages['authz.role.card.permission.for.role.status.not.granted'], {
roleName: role.name,
})}
/>
Expand Down
Loading
Loading