Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
76 changes: 55 additions & 21 deletions src/authz-module/authz-home/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,74 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderWrapper } from '@src/setupTest';
import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks';
import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
import { renderWithAllProviders } from '@src/setupTest';
import userEvent from '@testing-library/user-event';
import AuthzHome from './index';
import messages from './messages';

jest.mock('../components/AuthZLayout', () => function MockAuthZLayout({ children }: { children: React.ReactNode }) {
return <div data-testid="authz-layout">{children}</div>;
});
jest.mock('@src/authz-module/data/hooks', () => ({
useAllRoleAssignments: jest.fn(),
useOrgs: jest.fn(),
useScopes: jest.fn(),
}));

jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermissions() {
return <div data-testid="roles-permissions">Roles & Permissions Content</div>;
});
const emptyResponse = {
data: {
results: [], count: 0, next: null, previous: null,
},
error: null,
isLoading: false,
refetch: jest.fn(),
};

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>,
}));
const renderAuthzHome = () => renderWithAllProviders(
<ToastManagerProvider>
<AuthzHome />
</ToastManagerProvider>,
);

describe('AuthzHome', () => {
beforeEach(() => {
(useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse);
(useOrgs as jest.Mock).mockReturnValue(emptyResponse);
(useScopes as jest.Mock).mockReturnValue(emptyResponse);
});

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

it('renders the main layout and tabs', () => {
renderWrapper(<AuthzHome />);
expect(screen.getByTestId('authz-layout')).toBeInTheDocument();
expect(screen.getByTestId('tabs')).toBeInTheDocument();
renderAuthzHome();
expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages['authz.tabs.team'].defaultMessage)).toBeInTheDocument();
});

it('renders both tab panels', () => {
renderWrapper(<AuthzHome />);
const tabs = screen.getAllByTestId('tab');
expect(tabs).toHaveLength(2);
renderAuthzHome();
expect(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages['authz.tabs.team'].defaultMessage)).toBeInTheDocument();
expect(screen.getAllByRole('tab')).toHaveLength(3); // 2 + tab invisible for more...
});

it('renders the RolesPermissions component in the permissions tab', async () => {
const user = userEvent.setup();
renderAuthzHome();
await user.click(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage));
expect(screen.getByRole('button', { name: 'Courses' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Libraries' })).toBeInTheDocument();
});

it('renders the RolesPermissions component in the permissions tab', () => {
renderWrapper(<AuthzHome />);
expect(screen.getByTestId('roles-permissions')).toBeInTheDocument();
it('renders the TeamMembersTable component in the team members tab', () => {
renderAuthzHome();
expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Email')).toBeInTheDocument();
expect(screen.getAllByText('Organization').length).toBe(2); // Header and org filter;
expect(screen.getAllByText('Scope').length).toBe(2); // Header and scope filter;
expect(screen.getAllByText('Role').length).toBe(2); // Header and role filter;
expect(screen.getByText('Actions')).toBeInTheDocument();
});
});
22 changes: 9 additions & 13 deletions src/authz-module/authz-home/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tab, Tabs } from '@openedx/paragon';
import { useLocation } from 'react-router-dom';
import { useLocation, useSearchParams } from 'react-router-dom';
import TeamMembersTable from '@src/authz-module/team-members/TeamMembersTable';
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import RolesPermissions from '../roles-permissions/RolesPermissions';
import AuthZLayout from '../components/AuthZLayout';

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

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

return (
<div className="authz-libraries">
<div className="authz-module">
<AuthZLayout
context={{ id: '', title: '', org: '' }}
navLinks={[{ label: rootBreadcrumb }]}
activeLabel={pageTitle}
pageTitle={pageTitle}
pageSubtitle=""
actions={
[]
// this needs to be enable again once is refactored to be used outside of library context
// [
// <AddNewTeamMemberTrigger libraryId="" key="add-new-member" />,
// ]
[<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['authz.tabs.team'])} className="p-5">
{/* TODO: once TeamTable is refactored we can call it here. For now, this tab will be empty. */}
{/* <TeamTable /> */}
<Tab eventKey="team" title={intl.formatMessage(messages['authz.tabs.team'])} className="p-5 bg-light-200">
<TeamMembersTable presetScope={presetScope} />
</Tab>
<Tab id="libraries-permissions-roles-tab" eventKey="permissionsRoles" title={intl.formatMessage(messages['authz.tabs.permissionsRoles'])}>
<RolesPermissions />
Expand Down
10 changes: 5 additions & 5 deletions src/authz-module/authz-home/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authz.manage.page.title': {
id: 'authz.manage.page.title',
defaultMessage: 'Library Team Management',
description: 'Libraries AuthZ page title',
defaultMessage: 'Roles and Permissions Management',
description: 'AuthZ home page title',
},
'authz.breadcrumb.root': {
id: 'authz.breadcrumb.root',
defaultMessage: 'Manage Access',
description: 'Libraries AuthZ root breadcrumb',
description: 'AuthZ root breadcrumb',
},
'authz.tabs.team': {
id: 'authz.tabs.team',
defaultMessage: 'Team Members',
description: 'Libraries AuthZ title for the team management tab',
description: 'AuthZ title for the team management tab',
},
'authz.tabs.permissionsRoles': {
id: 'authz.tabs.permissionsRoles',
defaultMessage: 'Roles and Permissions',
description: 'Libraries AuthZ title for the roles tab',
description: 'AuthZ title for the roles tab',
},
});

Expand Down
134 changes: 134 additions & 0 deletions src/authz-module/components/AddRoleButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useNavigate } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { renderWrapper } from '@src/setupTest';
import AddRoleButton from './AddRoleButton';

// Mock react-router-dom navigation
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));

describe('AddRoleButton', () => {
const mockNavigate = jest.fn();

beforeAll(() => {
initializeMockApp({
authenticatedUser: {
userId: 1,
username: 'testuser',
email: 'test@example.com',
},
});
});

beforeEach(() => {
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('rendering', () => {
it('renders the assign role button with correct text', () => {
renderWrapper(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });
expect(button).toBeInTheDocument();
});

it('displays the plus icon', () => {
renderWrapper(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });
expect(button.querySelector('svg')).toBeInTheDocument();
});

it('renders correctly when presetUsername is provided', () => {
renderWrapper(<AddRoleButton presetUsername="testuser123" />);

const button = screen.getByRole('button', { name: /assign role/i });
expect(button).toBeInTheDocument();
});
});

describe('navigation behavior', () => {
it('navigates to assign role page without username when clicked', async () => {
const user = userEvent.setup();
renderWrapper(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);

expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
});

it('navigates to assign role page with username query parameter when presetUsername is provided', async () => {
const user = userEvent.setup();
const presetUsername = 'john.doe';
renderWrapper(<AddRoleButton presetUsername={presetUsername} />);

const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);

expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`);
});

it('handles special characters in presetUsername correctly', async () => {
const user = userEvent.setup();
const presetUsername = 'user@example.com';
renderWrapper(<AddRoleButton presetUsername={presetUsername} />);

const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);

expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`);
});
});

describe('user interactions', () => {
it('responds to keyboard navigation', async () => {
const user = userEvent.setup();
renderWrapper(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });

await user.tab();
expect(button).toHaveFocus();

await user.keyboard('{Enter}');
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
});

it('responds to spacebar activation', async () => {
const user = userEvent.setup();
renderWrapper(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });
button.focus();

await user.keyboard(' ');
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
});

it('handles multiple clicks gracefully', async () => {
const user = userEvent.setup();
renderWrapper(<AddRoleButton presetUsername="testuser" />);

const button = screen.getByRole('button', { name: /assign role/i });

await user.click(button);
await user.click(button);
await user.click(button);

expect(mockNavigate).toHaveBeenCalledTimes(3);
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?username=testuser');
});
});
});
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;
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/AuthZTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface Action {
}

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

</div>
</Col>
Expand Down
Loading