Compare commits
4 Commits
dependabot
...
release/ul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a77878cc39 | ||
|
|
3a1cafa9f4 | ||
|
|
d865d3d3ce | ||
|
|
97800bb685 |
3479
package-lock.json
generated
3479
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-header": "^6.4.0",
|
||||
"@edx/frontend-platform": "^8.5.5",
|
||||
"@edx/frontend-platform": "^8.3.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.15.1",
|
||||
@@ -47,7 +47,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-router-dom": "^6.30.3"
|
||||
"react-router-dom": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('AuthZTitle', () => {
|
||||
});
|
||||
|
||||
it('renders page subtitle as ReactNode', () => {
|
||||
const subtitleNode = <div>Custom Subtitle</div>;
|
||||
const subtitleNode = <div data-testid="custom-subtitle">Custom Subtitle</div>;
|
||||
render(<AuthZTitle {...defaultProps} pageSubtitle={subtitleNode} />);
|
||||
expect(screen.getByText('Custom Subtitle')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-subtitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action buttons and triggers onClick', () => {
|
||||
@@ -69,7 +69,7 @@ describe('AuthZTitle', () => {
|
||||
});
|
||||
|
||||
it('renders action buttons with icons', () => {
|
||||
const mockIcon = () => <span role="img" aria-label="save icon">Icon</span>;
|
||||
const mockIcon = () => <span data-testid="mock-icon">Icon</span>;
|
||||
const onClick = jest.fn();
|
||||
const actions = [
|
||||
{ label: 'Save', icon: mockIcon, onClick },
|
||||
@@ -77,14 +77,14 @@ describe('AuthZTitle', () => {
|
||||
|
||||
render(<AuthZTitle {...defaultProps} actions={actions} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'save icon Save' });
|
||||
const button = screen.getByRole('button', { name: 'Icon Save' });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'save icon' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ReactNode actions alongside button actions', () => {
|
||||
const onClick = jest.fn();
|
||||
const customAction = <div role="region" aria-label="custom action area">Custom Action</div>;
|
||||
const customAction = <div data-testid="custom-action">Custom Action</div>;
|
||||
const actions = [
|
||||
{ label: 'Save', onClick },
|
||||
customAction,
|
||||
@@ -93,6 +93,6 @@ describe('AuthZTitle', () => {
|
||||
render(<AuthZTitle {...defaultProps} actions={actions} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'custom action area' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-action')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { Role, PermissionsResourceGrouped } from '@src/types';
|
||||
import { renderWrapper } from '@src/setupTest';
|
||||
import PermissionTable from './PermissionTable';
|
||||
|
||||
const mockRoles: Role[] = [
|
||||
{
|
||||
name: 'Admin',
|
||||
description: 'Administrator role',
|
||||
userCount: 0,
|
||||
permissions: [],
|
||||
role: '',
|
||||
},
|
||||
{
|
||||
name: 'Editor',
|
||||
description: 'Editor role',
|
||||
userCount: 0,
|
||||
permissions: [],
|
||||
role: '',
|
||||
},
|
||||
{
|
||||
name: 'Viewer',
|
||||
description: 'Viewer role',
|
||||
userCount: 0,
|
||||
permissions: [],
|
||||
role: '',
|
||||
},
|
||||
];
|
||||
|
||||
const mockPermissionsTable: PermissionsResourceGrouped[] = [
|
||||
{
|
||||
key: 'users',
|
||||
label: 'User Management',
|
||||
description: 'Manage user accounts',
|
||||
permissions: [
|
||||
{
|
||||
key: 'users.read',
|
||||
resource: 'users',
|
||||
label: 'View Users',
|
||||
actionKey: 'read',
|
||||
roles: {
|
||||
Admin: true,
|
||||
Editor: true,
|
||||
Viewer: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'users.write',
|
||||
resource: 'users',
|
||||
label: 'Edit Users',
|
||||
actionKey: 'write',
|
||||
roles: {
|
||||
Admin: true,
|
||||
Editor: true,
|
||||
Viewer: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'courses',
|
||||
label: 'Course Management',
|
||||
description: 'Manage courses',
|
||||
permissions: [
|
||||
{
|
||||
key: 'courses.delete',
|
||||
resource: 'courses',
|
||||
label: 'Delete Courses',
|
||||
actionKey: 'delete',
|
||||
roles: {
|
||||
Admin: true,
|
||||
Editor: false,
|
||||
Viewer: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('PermissionTable', () => {
|
||||
it('renders within a Card component', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
expect(document.querySelector('.card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders table with correct class', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
expect(table).toHaveClass('permission-table', 'w-100');
|
||||
});
|
||||
|
||||
it('renders table headers for all roles', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
mockRoles.forEach(role => {
|
||||
expect(screen.getByRole('columnheader', { name: role.name })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies correct classes to role headers', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
mockRoles.forEach(role => {
|
||||
const header = screen.getByRole('columnheader', { name: role.name });
|
||||
expect(header).toHaveClass('text-center', 'py-3');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders resource group headers', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
expect(screen.getByText('User Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Course Management')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct classes to resource group headers', () => {
|
||||
const { container } = renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const resourceRows = container.querySelectorAll('.bg-info-100.text-primary');
|
||||
expect(resourceRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders resource group headers with correct colspan', () => {
|
||||
const { container } = renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const resourceCells = container.querySelectorAll('td[colspan]');
|
||||
resourceCells.forEach(cell => {
|
||||
expect(cell).toHaveAttribute('colspan', '4');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders permission labels with icons', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
expect(screen.getByText('View Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edit Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete Courses')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct classes to permission label cells', () => {
|
||||
const { container } = renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const labelCells = container.querySelectorAll('td.text-start.d-flex');
|
||||
labelCells.forEach(cell => {
|
||||
expect(cell).toHaveClass('align-items-center', 'small', 'px-4', 'py-3');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders permission row borders', () => {
|
||||
const { container } = renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const borderRows = container.querySelectorAll('tr.border-top');
|
||||
expect(borderRows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders Check icons for granted permissions', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const grantedIcons = screen.getAllByLabelText(/Permission granted in/);
|
||||
expect(grantedIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders Close icons for denied permissions', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const deniedIcons = screen.getAllByLabelText(/Permission denied 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/);
|
||||
deniedIcons.forEach(icon => {
|
||||
expect(icon).toHaveClass('text-danger');
|
||||
});
|
||||
});
|
||||
|
||||
it('applies correct classes to granted permission icons', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const grantedIcons = screen.getAllByLabelText(/Permission granted in/);
|
||||
grantedIcons.forEach(icon => {
|
||||
expect(icon).toHaveClass('d-inline-block');
|
||||
expect(icon).not.toHaveClass('text-danger');
|
||||
});
|
||||
});
|
||||
|
||||
it('centers permission status cells', () => {
|
||||
const { container } = renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const statusCells = container.querySelectorAll('tbody td.text-center');
|
||||
expect(statusCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders correct aria-labels for granted permissions', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
mockRoles.forEach(role => {
|
||||
const grantedLabel = `Permission granted in ${role.name} role`;
|
||||
const icons = screen.queryAllByLabelText(grantedLabel);
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders correct aria-labels for denied permissions', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const deniedLabel = 'Permission denied in Viewer role';
|
||||
expect(screen.getAllByLabelText(deniedLabel)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty roles array', () => {
|
||||
renderWrapper(<PermissionTable roles={[]} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getByText('User Management')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty permissions table', () => {
|
||||
renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={[]} />);
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
mockRoles.forEach(role => {
|
||||
expect(screen.getByText(role.name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies correct margin to permission icons', () => {
|
||||
const { container } = renderWrapper(<PermissionTable roles={mockRoles} permissionsTable={mockPermissionsTable} />);
|
||||
|
||||
const permissionIcons = container.querySelectorAll('td.text-start .paragon-icon');
|
||||
permissionIcons.forEach(icon => {
|
||||
expect(icon).toHaveClass('d-inline-block', 'mr-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +1,52 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Check, Close } from '@openedx/paragon/icons';
|
||||
import { Card, Icon } from '@openedx/paragon';
|
||||
import { PermissionsResourceGrouped, Role } from '@src/types';
|
||||
import { actionsDictionary } from './RoleCard/constants';
|
||||
import ResourceTooltip from './ResourceTooltip';
|
||||
import messages from './messages';
|
||||
|
||||
type PermissionTableProps = {
|
||||
roles: Role[];
|
||||
permissionsTable: PermissionsResourceGrouped[];
|
||||
};
|
||||
|
||||
const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<Card>
|
||||
<table className="permission-table w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="" aria-hidden="true" />
|
||||
{roles.map(role => (
|
||||
<th key={role.name} className="text-center py-3">{role.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissionsTable.map(resourceGroup => (
|
||||
<>
|
||||
<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} />
|
||||
</td>
|
||||
</tr>
|
||||
{resourceGroup.permissions.map(permission => (
|
||||
<tr key={permission.key} className="border-top">
|
||||
<td className="text-start d-flex align-items-center small px-4 py-3">
|
||||
<Icon className="d-inline-block mr-2" size="sm" src={actionsDictionary[permission.actionKey]} />
|
||||
{permission.label}
|
||||
</td>
|
||||
{roles.map(role => (
|
||||
<td key={role.name} className="text-center">
|
||||
{
|
||||
permission.roles[role.name]
|
||||
? (
|
||||
<Icon
|
||||
className="d-inline-block"
|
||||
src={Check}
|
||||
aria-label={formatMessage(messages['authz.role.card.permission.for.role.status.granted'], {
|
||||
roleName: role.name,
|
||||
})}
|
||||
screenReaderText={formatMessage(messages['authz.role.card.permission.for.role.status.granted'], {
|
||||
roleName: role.name,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Icon
|
||||
className="text-danger d-inline-block"
|
||||
src={Close}
|
||||
aria-label={formatMessage(messages['authz.role.card.permission.for.role.status.denied'], {
|
||||
roleName: role.name,
|
||||
})}
|
||||
screenReaderText={formatMessage(messages['authz.role.card.permission.for.role.status.denied'], {
|
||||
roleName: role.name,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => (
|
||||
<Card>
|
||||
<table className="permission-table w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="" aria-hidden="true" />
|
||||
{roles.map(role => (
|
||||
<th key={role.name} className="text-center py-3">{role.name}</th>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissionsTable.map(resourceGroup => (
|
||||
<>
|
||||
<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} />
|
||||
</td>
|
||||
</tr>
|
||||
{resourceGroup.permissions.map(permission => (
|
||||
<tr key={permission.key} className="border-top">
|
||||
<td className="text-start d-flex align-items-center small px-4 py-3">
|
||||
<Icon className="d-inline-block mr-2" size="sm" src={actionsDictionary[permission.actionKey]} />
|
||||
{permission.label}
|
||||
</td>
|
||||
{roles.map(role => (
|
||||
<td key={role.name} className="text-center">
|
||||
{permission.roles[role.name] ? <Icon className="d-inline-block" src={Check} /> : <Icon className="text-danger d-inline-block" src={Close} />}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default PermissionTable;
|
||||
|
||||
@@ -1,50 +1,41 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Chip, Col, Row,
|
||||
} from '@openedx/paragon';
|
||||
import { RoleResourceGroup } from '@src/types';
|
||||
import { actionsDictionary, ActionKey } from './constants';
|
||||
import ResourceTooltip from '../ResourceTooltip';
|
||||
import messages from './messages';
|
||||
|
||||
type PermissionRowProps = {
|
||||
resource: RoleResourceGroup;
|
||||
};
|
||||
|
||||
const PermissionRow = ({ resource }: PermissionRowProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<Row className="row align-items-center border px-2 py-2">
|
||||
<Col md={2}>
|
||||
<span className="small font-weight-bold">{resource.label}</span>
|
||||
<ResourceTooltip resourceGroup={resource} />
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="w-100 d-flex flex-wrap align-items-center">
|
||||
{resource.permissions.map((action, index) => (
|
||||
<>
|
||||
<Chip
|
||||
key={action.key}
|
||||
iconBefore={actionsDictionary[action.actionKey as ActionKey] as ComponentType}
|
||||
disabled={action.disabled}
|
||||
className="mx-3 my-2 px-3 bg-primary-100 border-0 permission-chip"
|
||||
variant="light"
|
||||
aria-label={formatMessage(messages['authz.role.card.permissions.ariaLabel'], {
|
||||
permissionName: action.label,
|
||||
permissionStatus: formatMessage(messages[`authz.role.card.permissions.status.${action.disabled ? 'denied' : 'granted'}`]),
|
||||
})}
|
||||
>
|
||||
{action.label}
|
||||
</Chip>
|
||||
{(index === resource.permissions.length - 1) ? null
|
||||
: (<hr className="border-right mx-2" style={{ height: '24px' }} />)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
const PermissionRow = ({ resource }: PermissionRowProps) => (
|
||||
<Row className="row align-items-center border px-2 py-2">
|
||||
<Col md={2}>
|
||||
<span className="small font-weight-bold">{resource.label}</span>
|
||||
<ResourceTooltip resourceGroup={resource} />
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="w-100 d-flex flex-wrap align-items-center">
|
||||
{resource.permissions.map((action, index) => (
|
||||
<>
|
||||
<Chip
|
||||
key={action.key}
|
||||
iconBefore={actionsDictionary[action.actionKey as ActionKey] as ComponentType}
|
||||
disabled={action.disabled}
|
||||
className="mx-3 my-2 px-3 bg-primary-100 border-0 permission-chip"
|
||||
variant="light"
|
||||
>
|
||||
{action.label}
|
||||
</Chip>
|
||||
{(index === resource.permissions.length - 1) ? null
|
||||
: (<hr className="border-right mx-2" style={{ height: '24px' }} />)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
|
||||
export default PermissionRow;
|
||||
|
||||
@@ -4,14 +4,14 @@ import userEvent from '@testing-library/user-event';
|
||||
import RoleCard from '.';
|
||||
|
||||
jest.mock('@openedx/paragon/icons', () => ({
|
||||
Delete: () => <svg role="img" aria-label="delete icon" />,
|
||||
Person: () => <svg role="img" aria-label="person icon" />,
|
||||
Delete: () => <svg data-testid="delete-icon" />,
|
||||
Person: () => <svg data-testid="person-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('./constants', () => ({
|
||||
actionsDictionary: {
|
||||
view: () => <svg role="img" aria-label="view action icon" />,
|
||||
manage: () => <svg role="img" aria-label="manage action icon" />,
|
||||
view: () => <svg data-testid="view-icon" />,
|
||||
manage: () => <svg data-testid="manage-icon" />,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('RoleCard', () => {
|
||||
|
||||
// User counter with icon
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'person icon' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('person-icon')).toBeInTheDocument();
|
||||
|
||||
// Subtitle (object name)
|
||||
expect(screen.getByText('Test Library')).toBeInTheDocument();
|
||||
@@ -71,8 +71,8 @@ describe('RoleCard', () => {
|
||||
expect(screen.getByText('Manage')).toBeInTheDocument();
|
||||
|
||||
// Action icons
|
||||
expect(screen.getByRole('img', { name: 'view action icon' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'manage action icon' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('view-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('manage-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show delete button when handleDelete is not passed', () => {
|
||||
@@ -82,7 +82,7 @@ describe('RoleCard', () => {
|
||||
|
||||
it('handles no userCounter gracefully', () => {
|
||||
renderWrapper(<RoleCard {...defaultProps} userCounter={null} />);
|
||||
expect(screen.queryByRole('img', { name: 'person icon' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('person-icon')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,26 +18,17 @@ interface RoleCardProps extends CardTitleProps {
|
||||
permissionsByResource: any[];
|
||||
}
|
||||
|
||||
const CardTitle = ({ title, userCounter = null }: CardTitleProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="mr-4 text-primary">{title}</span>
|
||||
{userCounter !== null && (
|
||||
<span className="d-flex align-items-center font-weight-normal">
|
||||
<Icon
|
||||
src={Person}
|
||||
className="mr-1"
|
||||
aria-label={formatMessage(messages['authz.role.card.userCounter'])}
|
||||
screenReaderText={formatMessage(messages['authz.role.card.userCounter'])}
|
||||
/>
|
||||
{userCounter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const CardTitle = ({ title, userCounter = null }: CardTitleProps) => (
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="mr-4 text-primary">{title}</span>
|
||||
{userCounter !== null && (
|
||||
<span className="d-flex align-items-center font-weight-normal">
|
||||
<Icon src={Person} className="mr-1" />
|
||||
{userCounter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const RoleCard = ({
|
||||
title, objectName, description, handleDelete, permissionsByResource, userCounter,
|
||||
|
||||
@@ -51,26 +51,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete role action',
|
||||
description: 'Alt description for delete button',
|
||||
},
|
||||
'authz.role.card.userCounter': {
|
||||
id: 'authz.role.card.userCounter',
|
||||
defaultMessage: 'Number of users with this role',
|
||||
description: 'Screen reader text for the user counter icon in the role card header',
|
||||
},
|
||||
'authz.role.card.permissions.ariaLabel': {
|
||||
id: 'authz.role.card.permissions.ariaLabel',
|
||||
defaultMessage: '{permissionName} permission is {permissionStatus}',
|
||||
description: 'Aria label for permission chips in the role card',
|
||||
},
|
||||
'authz.role.card.permissions.status.denied': {
|
||||
id: 'authz.role.card.permissions.status.denied',
|
||||
defaultMessage: 'denied',
|
||||
description: 'Label for denied status of a permission in the role card',
|
||||
},
|
||||
'authz.role.card.permissions.status.granted': {
|
||||
id: 'authz.role.card.permissions.status.granted',
|
||||
defaultMessage: 'granted',
|
||||
description: 'Label for granted status of a permission in the role card',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'authz.role.card.permission.for.role.status.granted': {
|
||||
id: 'authz.role.card.permission.for.role.status.granted',
|
||||
defaultMessage: 'Permission granted in {roleName} role',
|
||||
description: 'Label for granted status of a permission in the permissions table',
|
||||
},
|
||||
'authz.role.card.permission.for.role.status.denied': {
|
||||
id: 'authz.role.card.permission.for.role.status.denied',
|
||||
defaultMessage: 'Permission denied in {roleName} role',
|
||||
description: 'Label for denied status of a permission in the permissions table',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -83,11 +83,9 @@ export const useAssignTeamMembersRole = () => {
|
||||
mutationFn: async ({ data }: {
|
||||
data: AssignTeamMembersRoleRequest
|
||||
}) => assignTeamMembersRole(data),
|
||||
onSettled: (_data, error, { data: { scope } }) => {
|
||||
if (!error) {
|
||||
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) });
|
||||
queryClient.invalidateQueries({ queryKey: authzQueryKeys.permissionsByRole(scope) });
|
||||
}
|
||||
onSettled: (_data, _error, { data: { scope } }) => {
|
||||
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) });
|
||||
queryClient.invalidateQueries({ queryKey: authzQueryKeys.permissionsByRole(scope) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,12 +8,9 @@ import AuthZModule from './index';
|
||||
|
||||
jest.mock('./libraries-manager', () => ({
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
LibrariesLayout: lazy(() => new Promise<{ default: ComponentType<any> }>(resolve => setTimeout(
|
||||
() => resolve({ default: () => <div><Outlet /></div> }),
|
||||
100,
|
||||
))),
|
||||
LibrariesTeamManager: () => <div>Libraries Team Page</div>,
|
||||
LibrariesUserManager: () => <div>Libraries User Page</div>,
|
||||
LibrariesLayout: lazy(() => new Promise<{ default: ComponentType<any> }>(resolve => setTimeout(() => resolve({ default: () => <div data-testid="layout"><Outlet /></div> }), 100))),
|
||||
LibrariesTeamManager: () => <div data-testid="libraries-manager">Libraries Team Page</div>,
|
||||
LibrariesUserManager: () => <div data-testid="libraries-user-manager">Libraries User Page</div>,
|
||||
}));
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
@@ -45,10 +42,10 @@ describe('AuthZModule', () => {
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.spinner-border')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loading-page')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Libraries Team Page')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('libraries-manager')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +63,7 @@ describe('AuthZModule', () => {
|
||||
</IntlProvider>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Libraries User Page')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('libraries-user-manager')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const ThrowError = ({ error }: { error:Error }) => {
|
||||
|
||||
describe('LibrariesErrorFallback', () => {
|
||||
it('renders Access Denied for 401', () => {
|
||||
const error = { name: '', message: 'NO_ACCESS', customAttributes: { httpErrorStatus: 401 } };
|
||||
const error = { name: '', message: 'NO_ACCESS', customAtributtes: { httpErrorStatus: 401 } };
|
||||
renderWrapper(
|
||||
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
|
||||
<ThrowError error={error} />
|
||||
@@ -21,19 +21,8 @@ describe('LibrariesErrorFallback', () => {
|
||||
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Not Found for 400 error', () => {
|
||||
const error = { name: '', message: 'Axios Error (Response): 400', customAttributes: { httpErrorStatus: 400 } };
|
||||
renderWrapper(
|
||||
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
|
||||
<ThrowError error={error} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText(/Page Not Found/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Not Found for 404', () => {
|
||||
const error = { name: '', message: 'NOT_FOUND', customAttributes: { httpErrorStatus: 404 } };
|
||||
const error = { name: '', message: 'NOT_FOUND', customAtributtes: { httpErrorStatus: 404 } };
|
||||
renderWrapper(
|
||||
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
|
||||
<ThrowError error={error} />
|
||||
@@ -44,7 +33,7 @@ describe('LibrariesErrorFallback', () => {
|
||||
});
|
||||
|
||||
it('renders Server Error for 500 and shows reload', async () => {
|
||||
const error = { name: '', message: 'SERVER_ERROR', customAttributes: { httpErrorStatus: 500 } };
|
||||
const error = { name: '', message: 'SERVER_ERROR', customAtributtes: { httpErrorStatus: 500 } };
|
||||
renderWrapper(
|
||||
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
|
||||
<ThrowError error={error} />
|
||||
@@ -56,7 +45,7 @@ describe('LibrariesErrorFallback', () => {
|
||||
});
|
||||
|
||||
it('renders generic error for other error error', () => {
|
||||
const error = { name: '', message: 'SOMETHING_ELSE', customAttributes: { httpErrorStatus: 418 } };
|
||||
const error = { name: '', message: 'SOMETHING_ELSE', customAtributtes: { httpErrorStatus: 418 } };
|
||||
renderWrapper(
|
||||
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
|
||||
<ThrowError error={error} />
|
||||
@@ -70,7 +59,7 @@ describe('LibrariesErrorFallback', () => {
|
||||
// Simulate error with a refetch function
|
||||
const refetch = jest.fn();
|
||||
const error = {
|
||||
name: '', message: 'SERVER_ERROR', customAttributes: { httpErrorStatus: 500 }, refetch,
|
||||
name: '', message: 'SERVER_ERROR', customAtributtes: { httpErrorStatus: 500 }, refetch,
|
||||
};
|
||||
renderWrapper(
|
||||
<ErrorBoundary FallbackComponent={LibrariesErrorFallback} onReset={refetch}>
|
||||
|
||||
@@ -5,9 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Container, Hyperlink, Row,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
CustomErrors, ERROR_STATUS, STATUS_400, STATUS_404,
|
||||
} from '@src/constants';
|
||||
import { CustomErrors, ERROR_STATUS } from '@src/constants';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -20,14 +18,11 @@ const getErrorConfig = ({ errorMessage, errorStatus }) => {
|
||||
showBackButton: true,
|
||||
});
|
||||
}
|
||||
// 400 errors are handled as 404 Not Found to avoid exposing potential sensitive information
|
||||
// about the existence of resources and handling malformed library ids in the URL
|
||||
if (errorMessage === CustomErrors.NOT_FOUND || ERROR_STATUS.NOT_FOUND.includes(errorStatus)) {
|
||||
const statusCode = errorStatus === STATUS_400 ? STATUS_404 : errorStatus;
|
||||
return ({
|
||||
title: messages['error.page.title.notFound'],
|
||||
description: messages['error.page.message.notFound'],
|
||||
statusCode: statusCode || STATUS_404,
|
||||
statusCode: errorStatus || ERROR_STATUS.NOT_FOUND[0],
|
||||
showBackButton: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ jest.mock('@src/authz-module/data/hooks', () => ({
|
||||
|
||||
jest.mock('./components/TeamTable', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div role="table" aria-label="Team Members Table">Team member list</div>,
|
||||
default: () => <div data-testid="team-table">MockTeamTable</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./components/AddNewTeamMemberModal', () => ({
|
||||
__esModule: true,
|
||||
AddNewTeamMemberTrigger: () => <button type="button">Add Team Member</button>,
|
||||
AddNewTeamMemberTrigger: () => <div data-testid="add-team-member-trigger">MockAddNewTeamMemberTrigger</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../components/RoleCard', () => ({
|
||||
@@ -41,10 +41,10 @@ jest.mock('../components/RoleCard', () => ({
|
||||
description: string,
|
||||
permissionsByResource: any[]
|
||||
}) => (
|
||||
<div role="article" aria-label={`Role: ${title}`}>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
<span>{permissionsByResource.length} permissions</span>
|
||||
<div data-testid="role-card">
|
||||
<div>{title}</div>
|
||||
<div>{description}</div>
|
||||
<div>{permissionsByResource.length} permissions</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -109,10 +109,10 @@ describe('LibrariesTeamManager', () => {
|
||||
expect(screen.getByText('lib-001')).toBeInTheDocument(); // subtitle
|
||||
|
||||
// TeamTable is rendered
|
||||
expect(screen.getByRole('table', { name: 'Team Members Table' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('team-table')).toBeInTheDocument();
|
||||
|
||||
// AddNewTeamMemberTrigger is rendered
|
||||
expect(screen.getByRole('button', { name: 'Add Team Member' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-team-member-trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders role cards when "Roles" tab is selected', async () => {
|
||||
@@ -124,10 +124,10 @@ describe('LibrariesTeamManager', () => {
|
||||
const rolesTab = await screen.findByRole('tab', { name: /roles/i });
|
||||
await user.click(rolesTab);
|
||||
|
||||
const roleCards = await screen.findAllByRole('article', { name: /Role:/ });
|
||||
const roleCards = await screen.findAllByTestId('role-card');
|
||||
const rolesScope = within(roleCards[0]);
|
||||
expect(roleCards.length).toBe(1);
|
||||
expect(rolesScope.getByRole('heading', { name: 'Instructor' })).toBeInTheDocument();
|
||||
expect(rolesScope.getByText('Instructor')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Can manage content/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 permissions/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -213,44 +213,4 @@ describe('ToastManagerContext', () => {
|
||||
expect(screen.queryByText('Default delay toast')).not.toBeInTheDocument();
|
||||
}, { timeout: 5050 });
|
||||
}, 5100);
|
||||
|
||||
it('uses longer delay for error toasts with retry functionality', async () => {
|
||||
const user = userEvent.setup();
|
||||
const retryFn = jest.fn();
|
||||
|
||||
const RetryErrorDelayTestComponent = () => {
|
||||
const { showErrorToast } = useToastManager();
|
||||
|
||||
const handleShowRetryErrorToast = () => showErrorToast(
|
||||
{ customAttributes: { httpErrorStatus: 500 } },
|
||||
retryFn,
|
||||
);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleShowRetryErrorToast}>
|
||||
Show Retry Error Toast
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<RetryErrorDelayTestComponent />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
const showButton = screen.getByText('Show Retry Error Toast');
|
||||
await user.click(showButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Retry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
}, { timeout: 5050 });
|
||||
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { logError } from '@edx/frontend-platform/logging';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Toast } from '@openedx/paragon';
|
||||
import messages from './messages';
|
||||
import { DEFAULT_TOAST_DELAY, RETRY_TOAST_DELAY } from './constants';
|
||||
import { DEFAULT_TOAST_DELAY } from './constants';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'error-retry';
|
||||
|
||||
@@ -68,19 +68,11 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) =>
|
||||
const errorStatus = error?.customAttributes?.httpErrorStatus;
|
||||
const toastConfig = ERROR_TOAST_MAP[errorStatus] || ERROR_TOAST_MAP.DEFAULT;
|
||||
const message = intl.formatMessage(messages[toastConfig.messageId], { Bold, Br });
|
||||
/**
|
||||
* For retryable errors, we set a longer delay to give users more time to read the message
|
||||
* and decide to retry, while for non-retryable errors we use the default delay.
|
||||
* Since current toast implementation does not allow disabling the autohide prop,
|
||||
* we use a longer delay for retryable errors to give users more time to read the message.
|
||||
*/
|
||||
const delay = toastConfig.type === 'error-retry' && retryFn ? RETRY_TOAST_DELAY : DEFAULT_TOAST_DELAY;
|
||||
|
||||
showToast({
|
||||
message,
|
||||
type: toastConfig.type,
|
||||
onRetry: toastConfig.type === 'error-retry' && retryFn ? retryFn : undefined,
|
||||
delay,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from 'react';
|
||||
import { FC, useRef } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Form, Hyperlink, Icon, IconButton, ModalDialog,
|
||||
@@ -24,9 +24,9 @@ interface AddNewTeamMemberModalProps {
|
||||
handleChangeForm: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => void;
|
||||
}
|
||||
|
||||
const AddNewTeamMemberModal = ({
|
||||
const AddNewTeamMemberModal: FC<AddNewTeamMemberModalProps> = ({
|
||||
isOpen, isError, isLoading, formValues, close, onSave, handleChangeForm,
|
||||
}: AddNewTeamMemberModalProps) => {
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { roles } = useLibraryAuthZ();
|
||||
const [isOpenRolesPopUp, openRolesPopUp, closeRolesPopUp] = useToggle(false);
|
||||
|
||||
@@ -21,13 +21,14 @@ jest.mock('./AddNewTeamMemberModal', () => {
|
||||
isOpen, close, onSave, isLoading, formValues, handleChangeForm,
|
||||
}) => (
|
||||
isOpen ? (
|
||||
<div role="dialog" aria-label="Add New Team Member">
|
||||
<button type="button" onClick={close} aria-label="Close modal">Close</button>
|
||||
<button type="button" onClick={onSave} aria-label="Save team member">Save</button>
|
||||
<div data-testid="add-team-member-modal" role="dialog" aria-label="Add New Team Member">
|
||||
<button type="button" onClick={close} aria-label="Close modal" data-testid="close-modal">Close</button>
|
||||
<button type="button" onClick={onSave} aria-label="Save team member" data-testid="save-modal">Save</button>
|
||||
<textarea
|
||||
name="users"
|
||||
value={formValues?.users || ''}
|
||||
onChange={handleChangeForm}
|
||||
data-testid="users-input"
|
||||
aria-label="Enter user emails or usernames"
|
||||
placeholder="Enter emails or usernames"
|
||||
/>
|
||||
@@ -35,13 +36,14 @@ jest.mock('./AddNewTeamMemberModal', () => {
|
||||
name="role"
|
||||
value={formValues?.role || ''}
|
||||
onChange={handleChangeForm}
|
||||
data-testid="role-select"
|
||||
aria-label="Select role"
|
||||
>
|
||||
<option value="">Select role</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
{isLoading && <div role="status" aria-label="Adding team member loader">Loading...</div>}
|
||||
{isLoading && <div data-testid="loading-indicator">Loading...</div>}
|
||||
</div>
|
||||
) : null
|
||||
);
|
||||
@@ -76,7 +78,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Add New Team Member' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes modal when close button is clicked', async () => {
|
||||
@@ -86,12 +88,12 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Add New Team Member' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close modal' });
|
||||
const closeButton = screen.getByTestId('close-modal');
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Add New Team Member' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('add-team-member-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls addTeamMember with correct data when save is clicked', async () => {
|
||||
@@ -101,9 +103,9 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const usersInput = screen.getByRole('textbox', { name: 'Enter user emails or usernames' });
|
||||
const roleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const saveButton = screen.getByRole('button', { name: 'Save team member' });
|
||||
const usersInput = screen.getByTestId('users-input');
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
|
||||
await user.type(usersInput, 'alice@example.com, bob@example.com');
|
||||
await user.selectOptions(roleSelect, 'editor');
|
||||
@@ -130,7 +132,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save team member' });
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
await user.click(saveButton);
|
||||
|
||||
// Simulate successful response with no errors
|
||||
@@ -144,7 +146,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Add New Team Member' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('add-team-member-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/2 team members added successfully/)).toBeInTheDocument();
|
||||
@@ -157,7 +159,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save team member' });
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
await user.click(saveButton);
|
||||
|
||||
// Simulate partial success response
|
||||
@@ -177,7 +179,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
});
|
||||
|
||||
// Modal should remain open when there are errors
|
||||
expect(screen.getByRole('dialog', { name: 'Add New Team Member' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters out successfully added users from error users list', async () => {
|
||||
@@ -231,7 +233,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save team member' });
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
await user.click(saveButton);
|
||||
|
||||
// Simulate all failed response
|
||||
@@ -249,7 +251,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
});
|
||||
|
||||
// Modal should remain open when there are errors
|
||||
expect(screen.getByRole('dialog', { name: 'Add New Team Member' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays different error toast when different errors happen', async () => {
|
||||
@@ -286,9 +288,9 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const usersInput = screen.getByRole('textbox', { name: 'Enter user emails or usernames' });
|
||||
const roleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const saveButton = screen.getByRole('button', { name: 'Save team member' });
|
||||
const usersInput = screen.getByTestId('users-input');
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
|
||||
await user.type(usersInput, 'alice@example.com');
|
||||
await user.selectOptions(roleSelect, 'editor');
|
||||
@@ -304,8 +306,8 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
// Open modal again to check if form is reset
|
||||
await user.click(triggerButton);
|
||||
|
||||
const newUsersInput = screen.getByRole('textbox', { name: 'Enter user emails or usernames' });
|
||||
const newRoleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const newUsersInput = screen.getByTestId('users-input');
|
||||
const newRoleSelect = screen.getByTestId('role-select');
|
||||
|
||||
expect(newUsersInput).toHaveValue('');
|
||||
expect(newRoleSelect).toHaveValue('');
|
||||
@@ -318,7 +320,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save team member' });
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
await user.click(saveButton);
|
||||
|
||||
// Simulate successful response
|
||||
@@ -361,7 +363,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save team member' });
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -423,12 +425,12 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const userInput = screen.getByRole('textbox', { name: 'Enter user emails or usernames' });
|
||||
const roleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const userInput = screen.getByTestId('users-input');
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
await user.type(userInput, 'alice@example.com');
|
||||
await user.selectOptions(roleSelect, 'editor');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save team member' });
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
await user.click(saveButton);
|
||||
|
||||
// should now reflect isPending = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, useToggle } from '@openedx/paragon';
|
||||
import { Plus } from '@openedx/paragon/icons';
|
||||
@@ -21,7 +21,7 @@ const DEFAULT_FORM_VALUES = {
|
||||
role: '',
|
||||
};
|
||||
|
||||
const AddNewTeamMemberTrigger = ({ libraryId }: AddNewTeamMemberTriggerProps) => {
|
||||
const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({ libraryId }) => {
|
||||
const intl = useIntl();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [formValues, setFormValues] = useState(DEFAULT_FORM_VALUES);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Button, Form, ModalDialog,
|
||||
@@ -15,9 +16,9 @@ interface AssignNewRoleModalProps {
|
||||
handleChangeSelectedRole: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => void;
|
||||
}
|
||||
|
||||
const AssignNewRoleModal = ({
|
||||
const AssignNewRoleModal: FC<AssignNewRoleModalProps> = ({
|
||||
isOpen, isLoading, selectedRole, roleOptions, close, onSave, handleChangeSelectedRole,
|
||||
}: AssignNewRoleModalProps) => {
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<ModalDialog
|
||||
|
||||
@@ -26,9 +26,9 @@ jest.mock('./AssignNewRoleModal', () => {
|
||||
selectedRole,
|
||||
handleChangeSelectedRole,
|
||||
}: any) => (isOpen ? (
|
||||
<div role="dialog" aria-label="Assign New Role">
|
||||
<div data-testid="assign-new-role-modal">
|
||||
<h2>Add New Role</h2>
|
||||
<select value={selectedRole} onChange={handleChangeSelectedRole} aria-label="Select role">
|
||||
<select data-testid="role-select" value={selectedRole} onChange={handleChangeSelectedRole}>
|
||||
<option value="">Select a role</option>
|
||||
{roleOptions.map((role: any) => (
|
||||
<option key={role.role} value={role.role}>
|
||||
@@ -36,10 +36,10 @@ jest.mock('./AssignNewRoleModal', () => {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={onSave} disabled={isLoading} aria-label="Save role assignment">
|
||||
<button type="button" onClick={onSave} disabled={isLoading} data-testid="save-button">
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={close} aria-label="Cancel role assignment">
|
||||
<button type="button" onClick={close} data-testid="cancel-button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -106,7 +106,7 @@ describe('AssignNewRoleTrigger', () => {
|
||||
it('does not show modal initially', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Assign New Role' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show toast initially', () => {
|
||||
@@ -124,7 +124,7 @@ describe('AssignNewRoleTrigger', () => {
|
||||
const triggerButton = screen.getByRole('button', { name: /add new role/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Assign New Role' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('assign-new-role-modal')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /add new role/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -134,11 +134,11 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
// Open modal
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
expect(screen.getByRole('dialog', { name: 'Assign New Role' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('assign-new-role-modal')).toBeInTheDocument();
|
||||
|
||||
// Close modal
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel role assignment' }));
|
||||
expect(screen.queryByRole('dialog', { name: 'Assign New Role' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('cancel-button'));
|
||||
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
|
||||
const roleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
await user.selectOptions(roleSelect, 'admin');
|
||||
|
||||
expect(roleSelect).toHaveValue('admin');
|
||||
@@ -163,11 +163,11 @@ describe('AssignNewRoleTrigger', () => {
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
|
||||
// Select a role
|
||||
const roleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
await user.selectOptions(roleSelect, choosenRole);
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByRole('button', { name: 'Save role assignment' }));
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -191,15 +191,15 @@ describe('AssignNewRoleTrigger', () => {
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
|
||||
// Select a role that user already has
|
||||
const roleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
await user.selectOptions(roleSelect, choosenRole);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save role assignment' }));
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
// Should not call assignTeamMembersRole
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
// Modal should be closed
|
||||
expect(screen.queryByRole('dialog', { name: 'Assign New Role' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save role assignment' })).toBeDisabled();
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -227,10 +227,10 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
|
||||
const roleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
await user.selectOptions(roleSelect, 'admin');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save role assignment' }));
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
// Simulate successful API call
|
||||
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
||||
@@ -247,22 +247,22 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
|
||||
const roleSelect = screen.getByRole('combobox', { name: 'Select role' });
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
await user.selectOptions(roleSelect, 'admin');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save role assignment' }));
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
// Simulate successful API call
|
||||
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback({ errors: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Assign New Role' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open modal again to check if role is reset
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
expect(screen.getByRole('combobox', { name: 'Select role' })).toHaveValue('');
|
||||
expect(screen.getByTestId('role-select')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,15 +273,15 @@ describe('AssignNewRoleTrigger', () => {
|
||||
renderComponent();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Select role' }), 'admin');
|
||||
await user.click(screen.getByRole('button', { name: 'Save role assignment' }));
|
||||
await user.selectOptions(screen.getByTestId('role-select'), 'admin');
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
const { onSuccess } = mockMutate.mock.calls[0][1];
|
||||
onSuccess({ errors: [{ error: 'role_assignment_error' }] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: 'Select role' })).toHaveValue(''); // role reset
|
||||
expect(screen.getByTestId('role-select')).toHaveValue(''); // role reset
|
||||
});
|
||||
});
|
||||
|
||||
@@ -306,8 +306,8 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
// Open modal and select a role
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Select role' }), 'admin');
|
||||
await user.click(screen.getByRole('button', { name: 'Save role assignment' }));
|
||||
await user.selectOptions(screen.getByTestId('role-select'), 'admin');
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
// Wait for the error toast to appear with a retry button
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { FC, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, useToggle } from '@openedx/paragon';
|
||||
import { Plus } from '@openedx/paragon/icons';
|
||||
@@ -17,11 +17,11 @@ interface AssignNewRoleTriggerProps {
|
||||
currentUserRoles: string[];
|
||||
}
|
||||
|
||||
const AssignNewRoleTrigger = ({
|
||||
const AssignNewRoleTrigger: FC<AssignNewRoleTriggerProps> = ({
|
||||
username,
|
||||
libraryId,
|
||||
currentUserRoles,
|
||||
}: AssignNewRoleTriggerProps) => {
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const { roles } = useLibraryAuthZ();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import {
|
||||
ActionRow, AlertModal, Icon, ModalDialog, Stack,
|
||||
StatefulButton,
|
||||
@@ -20,9 +21,9 @@ interface ConfirmDeletionModalProps {
|
||||
}
|
||||
}
|
||||
|
||||
const ConfirmDeletionModal = ({
|
||||
const ConfirmDeletionModal: FC<ConfirmDeletionModalProps> = ({
|
||||
isOpen, close, onSave, isDeleting, context,
|
||||
}: ConfirmDeletionModalProps) => {
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AlertModal
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
|
||||
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
||||
import {
|
||||
EmailCell,
|
||||
NameCell,
|
||||
ActionCell,
|
||||
RolesCell,
|
||||
} from './Cells';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useNavigate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@src/authz-module/libraries-manager/context', () => ({
|
||||
useLibraryAuthZ: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@src/authz-module/data/hooks', () => ({
|
||||
useTeamMembers: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useQuerySettings', () => ({
|
||||
useQuerySettings: jest.fn(() => ({
|
||||
querySettings: { page: 1, limit: 10 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockNavigate = useNavigate as jest.Mock;
|
||||
const mockUseLibraryAuthZ = useLibraryAuthZ as jest.Mock;
|
||||
const mockUseTeamMembers = useTeamMembers as jest.Mock;
|
||||
|
||||
const renderWithIntl = (component: React.ReactElement) => render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
{component}
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const mockTeamMember = {
|
||||
username: 'john.doe',
|
||||
fullName: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
roles: ['instructor', 'author'],
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockSkeletonMember = {
|
||||
username: 'skeleton',
|
||||
fullName: '',
|
||||
email: '',
|
||||
roles: [],
|
||||
createdAt: '',
|
||||
};
|
||||
|
||||
const mockCellProps = {
|
||||
row: { original: mockTeamMember },
|
||||
};
|
||||
|
||||
const mockSkeletonCellProps = {
|
||||
row: { original: mockSkeletonMember },
|
||||
};
|
||||
|
||||
describe('Table Cells', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseLibraryAuthZ.mockReturnValue({
|
||||
username: 'current.user',
|
||||
libraryId: 'lib123',
|
||||
canManageTeam: true,
|
||||
roles: [
|
||||
{ role: 'instructor', name: 'Instructor' },
|
||||
{ role: 'author', name: 'Author' },
|
||||
],
|
||||
});
|
||||
mockUseTeamMembers.mockReturnValue({ isLoading: false });
|
||||
mockNavigate.mockReturnValue(jest.fn());
|
||||
});
|
||||
|
||||
describe('EmailCell', () => {
|
||||
it('displays user email', () => {
|
||||
renderWithIntl(<EmailCell {...mockCellProps} />);
|
||||
expect(screen.getByText('john.doe@example.com')).toBeInTheDocument();
|
||||
});
|
||||
it('shows loading skeleton for loading state', () => {
|
||||
renderWithIntl(<EmailCell {...mockSkeletonCellProps} />);
|
||||
expect(document.querySelector('.react-loading-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NameCell', () => {
|
||||
it('displays username for regular user', () => {
|
||||
renderWithIntl(<NameCell {...mockCellProps} />);
|
||||
expect(screen.getByText('john.doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays current user indicator for logged in user', () => {
|
||||
const currentUserProps = {
|
||||
...mockCellProps,
|
||||
row: { original: { ...mockTeamMember, username: 'current.user' } },
|
||||
};
|
||||
renderWithIntl(<NameCell {...currentUserProps} />);
|
||||
expect(screen.getByText('current.user')).toBeInTheDocument();
|
||||
expect(screen.getByText('current.user').parentElement).toBeInTheDocument();
|
||||
});
|
||||
it('shows loading skeleton for loading state', () => {
|
||||
renderWithIntl(<NameCell {...mockSkeletonCellProps} />);
|
||||
expect(document.querySelector('.react-loading-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActionCell', () => {
|
||||
it('renders edit button for manageable team member', () => {
|
||||
renderWithIntl(<ActionCell {...mockCellProps} />);
|
||||
const editButton = screen.getByRole('button');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(document.querySelector('.pgn__icon')).toBeInTheDocument();
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to user page when edit button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const navigateMock = jest.fn();
|
||||
mockNavigate.mockReturnValue(navigateMock);
|
||||
renderWithIntl(<ActionCell {...mockCellProps} />);
|
||||
const editButton = screen.getByRole('button');
|
||||
await user.click(editButton);
|
||||
expect(navigateMock).toHaveBeenCalledWith('/authz/libraries/lib123/john.doe');
|
||||
});
|
||||
|
||||
it('does not render edit button for current user', () => {
|
||||
const currentUserProps = {
|
||||
...mockCellProps,
|
||||
row: { original: { ...mockTeamMember, username: 'current.user' } },
|
||||
};
|
||||
renderWithIntl(<ActionCell {...currentUserProps} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render edit button when user cannot manage team', () => {
|
||||
mockUseLibraryAuthZ.mockReturnValue({
|
||||
username: 'current.user',
|
||||
libraryId: 'lib123',
|
||||
canManageTeam: false,
|
||||
roles: [],
|
||||
});
|
||||
renderWithIntl(<ActionCell {...mockCellProps} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render edit button during loading', () => {
|
||||
mockUseTeamMembers.mockReturnValue({ isLoading: true });
|
||||
|
||||
renderWithIntl(<ActionCell {...mockCellProps} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RolesCell', () => {
|
||||
it('displays role chips for user roles', () => {
|
||||
renderWithIntl(<RolesCell {...mockCellProps} />);
|
||||
expect(screen.getByText('Instructor')).toBeInTheDocument();
|
||||
expect(screen.getByText('Author')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading skeleton for loading state', () => {
|
||||
renderWithIntl(<RolesCell {...mockSkeletonCellProps} />);
|
||||
expect(document.querySelector('.react-loading-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles user with no roles', () => {
|
||||
const noRolesProps = {
|
||||
...mockCellProps,
|
||||
row: { original: { ...mockTeamMember, roles: [] } },
|
||||
};
|
||||
renderWithIntl(<RolesCell {...noRolesProps} />);
|
||||
expect(screen.queryByText('Instructor')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Author')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Chip, Skeleton } from '@openedx/paragon';
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
import { TableCellValue, TeamMember } from '@src/types';
|
||||
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
||||
import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants';
|
||||
import { useQuerySettings } from '../hooks/useQuerySettings';
|
||||
import messages from '../messages';
|
||||
|
||||
type CellProps = TableCellValue<TeamMember>;
|
||||
|
||||
const EmailCell = ({ row }: CellProps) => (row.original?.username === SKELETON_ROWS[0].username ? (
|
||||
<Skeleton width="180px" />
|
||||
) : (
|
||||
row.original.email
|
||||
));
|
||||
|
||||
const NameCell = ({ row }: CellProps) => {
|
||||
const intl = useIntl();
|
||||
const { username } = useLibraryAuthZ();
|
||||
|
||||
if (row.original.username === SKELETON_ROWS[0].username) {
|
||||
return <Skeleton width="180px" />;
|
||||
}
|
||||
|
||||
if (row.original.username === username) {
|
||||
return (
|
||||
<span>
|
||||
{username}
|
||||
<span className="text-gray-500">{intl.formatMessage(messages['library.authz.team.table.username.current'])}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return row.original.username;
|
||||
};
|
||||
|
||||
const ActionCell = ({ row }: CellProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
libraryId, canManageTeam, username,
|
||||
} = useLibraryAuthZ();
|
||||
const navigate = useNavigate();
|
||||
const { querySettings } = useQuerySettings();
|
||||
const { isLoading } = useTeamMembers(libraryId, querySettings);
|
||||
return (
|
||||
canManageTeam && row.original.username !== username && !isLoading ? (
|
||||
<Button
|
||||
iconBefore={Edit}
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/authz/libraries/${libraryId}/${row.original.username}`)}
|
||||
>
|
||||
{intl.formatMessage(messages['authz.libraries.team.table.edit.action'])}
|
||||
</Button>
|
||||
) : null);
|
||||
};
|
||||
|
||||
const RolesCell = ({ row }: CellProps) => {
|
||||
const { roles } = useLibraryAuthZ();
|
||||
const roleLabels = roles.reduce((acc, role) => ({ ...acc, [role.role]: role.name }), {} as Record<string, string>);
|
||||
return (row.original.username === SKELETON_ROWS[0].username ? (
|
||||
<Skeleton width="80px" />
|
||||
) : (
|
||||
row.original.roles.map((role) => (
|
||||
<Chip key={`${row.original.username}-role-${role}`}>{roleLabels[role]}</Chip>
|
||||
))
|
||||
));
|
||||
};
|
||||
|
||||
export {
|
||||
EmailCell, NameCell, ActionCell, RolesCell,
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import {
|
||||
Dropdown, Form, Icon, Stack,
|
||||
} from '@openedx/paragon';
|
||||
@@ -10,9 +11,9 @@ interface MultipleChoiceFilterProps {
|
||||
setFilter: (value: string[]) => void;
|
||||
}
|
||||
|
||||
const MultipleChoiceFilter = ({
|
||||
const MultipleChoiceFilter: FC<MultipleChoiceFilterProps> = ({
|
||||
Header, filterChoices, filterValue, setFilter,
|
||||
}: MultipleChoiceFilterProps) => {
|
||||
}) => {
|
||||
const checkedBoxes = filterValue || [];
|
||||
|
||||
const changeCheckbox = (value) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Icon,
|
||||
@@ -10,9 +11,9 @@ interface SearchFilterProps {
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const SearchFilter = ({
|
||||
const SearchFilter: FC<SearchFilterProps> = ({
|
||||
filterValue, setFilter, placeholder,
|
||||
}: SearchFilterProps) => (
|
||||
}) => (
|
||||
<Form.Control
|
||||
className="mw-xs mr-0"
|
||||
trailingElement={<Icon src={Search} />}
|
||||
|
||||
@@ -7,8 +7,10 @@ import userEvent from '@testing-library/user-event';
|
||||
import TableControlBar from './TableControlBar';
|
||||
|
||||
jest.mock('./MultipleChoiceFilter', () => {
|
||||
const MockMultipleChoiceFilter = (props: { id?: string; accessor?: string }) => (
|
||||
<div role="group" aria-label={`Filter by ${props.id || props.accessor}`}>
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const MockMultipleChoiceFilter = (props) => (
|
||||
// eslint-disable-next-line react/prop-types
|
||||
<div data-testid="multiple-choice-filter" data-column-id={props.id || props.accessor}>
|
||||
Multiple Choice Filter
|
||||
</div>
|
||||
);
|
||||
@@ -18,7 +20,7 @@ jest.mock('./MultipleChoiceFilter', () => {
|
||||
|
||||
jest.mock('./SortDropdown', () => {
|
||||
const MockSortDropdown = () => (
|
||||
<div role="group" aria-label="Sort options">
|
||||
<div data-testid="sort-dropdown">
|
||||
Sort Dropdown
|
||||
</div>
|
||||
);
|
||||
@@ -29,7 +31,7 @@ jest.mock('./SortDropdown', () => {
|
||||
jest.mock('./SearchFilter', () => {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const MockSearchFilter = (props) => (
|
||||
<div role="search" aria-label="Search filter">
|
||||
<div data-testid="search-filter">
|
||||
<input
|
||||
// eslint-disable-next-line react/prop-types
|
||||
placeholder={props.placeholder}
|
||||
@@ -37,7 +39,7 @@ jest.mock('./SearchFilter', () => {
|
||||
value={props.filterValue || ''}
|
||||
// eslint-disable-next-line react/prop-types
|
||||
onChange={(e) => props.setFilter(e.target.value)}
|
||||
aria-label="Search input"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -72,7 +74,7 @@ describe('TableControlBar', () => {
|
||||
it('should render basic structure with SortDropdown and RowStatus', () => {
|
||||
renderWithContext();
|
||||
|
||||
expect(screen.getByRole('group', { name: 'Sort options' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument();
|
||||
const container = screen.getByText('Sort Dropdown').closest('.pgn__data-table-status-bar');
|
||||
expect(container).toHaveClass('pgn__data-table-status-bar', 'mb-3', 'flex-wrap');
|
||||
});
|
||||
@@ -129,9 +131,9 @@ describe('TableControlBar', () => {
|
||||
|
||||
renderWithContext(contextWithCheckboxColumn);
|
||||
|
||||
const multipleChoiceFilter = screen.getByRole('group', { name: 'Filter by roles' });
|
||||
const multipleChoiceFilter = screen.getByTestId('multiple-choice-filter');
|
||||
expect(multipleChoiceFilter).toBeInTheDocument();
|
||||
expect(screen.getByText('Multiple Choice Filter')).toBeInTheDocument();
|
||||
expect(multipleChoiceFilter).toHaveAttribute('data-column-id', 'roles');
|
||||
});
|
||||
|
||||
it('should render SearchFilter for columns with TextFilter', () => {
|
||||
@@ -152,8 +154,8 @@ describe('TableControlBar', () => {
|
||||
|
||||
renderWithContext(contextWithTextColumn);
|
||||
|
||||
expect(screen.getByRole('search', { name: 'Search filter' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Search input' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('search-filter')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render any filter for unsupported Filter types', () => {
|
||||
@@ -174,8 +176,8 @@ describe('TableControlBar', () => {
|
||||
renderWithContext(contextWithCustomFilter);
|
||||
|
||||
// Only SortDropdown should be present, no filter components
|
||||
expect(screen.getByRole('group', { name: 'Sort options' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('search', { name: 'Search filter' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('group', { name: /Filter by/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('search-filter')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('multiple-choice-filter')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,70 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
DataTable,
|
||||
DataTable, Button, Chip, Skeleton,
|
||||
TextFilter,
|
||||
CheckboxFilter,
|
||||
TableFooter,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
import { TableCellValue, TeamMember } from '@src/types';
|
||||
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
||||
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
|
||||
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
|
||||
import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants';
|
||||
import { useQuerySettings } from './hooks/useQuerySettings';
|
||||
import TableControlBar from './components/TableControlBar';
|
||||
import messages from './messages';
|
||||
import {
|
||||
ActionCell, EmailCell, NameCell, RolesCell,
|
||||
} from './components/Cells';
|
||||
|
||||
const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
|
||||
username: 'skeleton',
|
||||
name: '',
|
||||
email: '',
|
||||
roles: [],
|
||||
}));
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
type CellProps = TableCellValue<TeamMember>;
|
||||
|
||||
const EmailCell = ({ row }: CellProps) => (row.original?.username === SKELETON_ROWS[0].username ? (
|
||||
<Skeleton width="180px" />
|
||||
) : (
|
||||
row.original.email
|
||||
));
|
||||
|
||||
const NameCell = ({ row }: CellProps) => {
|
||||
const intl = useIntl();
|
||||
const { username } = useLibraryAuthZ();
|
||||
|
||||
if (row.original.username === SKELETON_ROWS[0].username) {
|
||||
return <Skeleton width="180px" />;
|
||||
}
|
||||
|
||||
if (row.original.username === username) {
|
||||
return (
|
||||
<span>
|
||||
{username}
|
||||
<span className="text-gray-500">{intl.formatMessage(messages['library.authz.team.table.username.current'])}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return row.original.username;
|
||||
};
|
||||
|
||||
const TeamTable = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
libraryId, roles,
|
||||
libraryId, canManageTeam, username, roles,
|
||||
} = useLibraryAuthZ();
|
||||
const roleLabels = roles.reduce((acc, role) => ({ ...acc, [role.role]: role.name }), {} as Record<string, string>);
|
||||
const { showErrorToast } = useToastManager();
|
||||
|
||||
const { querySettings, handleTableFetch } = useQuerySettings();
|
||||
|
||||
const {
|
||||
data: teamMembers, isError, error, refetch,
|
||||
data: teamMembers, isLoading, isError, error, refetch,
|
||||
} = useTeamMembers(libraryId, querySettings);
|
||||
|
||||
if (error) {
|
||||
@@ -41,6 +74,8 @@ const TeamTable = () => {
|
||||
const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS);
|
||||
const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / DEFAULT_PAGE_SIZE) : 1;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const adaptedFilterChoices = useMemo(
|
||||
() => roles.map((role) => ({
|
||||
name: role.name,
|
||||
@@ -73,7 +108,18 @@ const TeamTable = () => {
|
||||
{
|
||||
id: 'action',
|
||||
Header: intl.formatMessage(messages['library.authz.team.table.action']),
|
||||
Cell: ActionCell,
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
Cell: ({ row }: CellProps) => (
|
||||
canManageTeam && row.original.username !== username && !isLoading ? (
|
||||
<Button
|
||||
iconBefore={Edit}
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/authz/libraries/${libraryId}/${row.original.username}`)}
|
||||
>
|
||||
{intl.formatMessage(messages['authz.libraries.team.table.edit.action'])}
|
||||
</Button>
|
||||
) : null),
|
||||
},
|
||||
]}
|
||||
columns={
|
||||
@@ -94,7 +140,14 @@ const TeamTable = () => {
|
||||
{
|
||||
Header: intl.formatMessage(messages['library.authz.team.table.roles']),
|
||||
accessor: 'roles',
|
||||
Cell: RolesCell,
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
Cell: ({ row }: CellProps) => (row.original.username === SKELETON_ROWS[0].username ? (
|
||||
<Skeleton width="80px" />
|
||||
) : (
|
||||
row.original.roles.map((role) => (
|
||||
<Chip key={`${row.original.username}-role-${role}`}>{roleLabels[role]}</Chip>
|
||||
))
|
||||
)),
|
||||
Filter: CheckboxFilter,
|
||||
filter: 'includesValue',
|
||||
filterChoices: Object.values(adaptedFilterChoices),
|
||||
|
||||
@@ -51,10 +51,3 @@ export const libraryPermissions: PermissionMetadata[] = [
|
||||
];
|
||||
|
||||
export const DEFAULT_TOAST_DELAY = 5000;
|
||||
export const RETRY_TOAST_DELAY = 120_000; // 2 minutes
|
||||
export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
|
||||
username: 'skeleton',
|
||||
name: '',
|
||||
email: '',
|
||||
roles: [],
|
||||
}));
|
||||
|
||||
@@ -44,12 +44,14 @@ const TestComponent = () => {
|
||||
const context = useLibraryAuthZ();
|
||||
return (
|
||||
<div>
|
||||
<div>Username: {context.username}</div>
|
||||
<div>Library ID: {context.libraryId}</div>
|
||||
<div>Can manage team: {context.canManageTeam ? 'Yes' : 'No'}</div>
|
||||
<div>Roles count: {Array.isArray(context.roles) ? context.roles.length : 'undefined'}</div>
|
||||
<div>Permissions count: {Array.isArray(context.permissions) ? context.permissions.length : 'undefined'}</div>
|
||||
<div>Resources count: {Array.isArray(context.resources) ? context.resources.length : 'undefined'}</div>
|
||||
<div data-testid="username">{context.username}</div>
|
||||
<div data-testid="libraryId">{context.libraryId}</div>
|
||||
<div data-testid="canManageTeam">{context.canManageTeam ? 'true' : 'false'}</div>
|
||||
<div data-testid="roles">{Array.isArray(context.roles) ? context.roles.length : 'undefined'}</div>
|
||||
<div data-testid="permissions">
|
||||
{Array.isArray(context.permissions) ? context.permissions.length : 'undefined'}
|
||||
</div>
|
||||
<div data-testid="resources">{Array.isArray(context.resources) ? context.resources.length : 'undefined'}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -110,12 +112,12 @@ describe('LibraryAuthZProvider', () => {
|
||||
</LibraryAuthZProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Username: testuser/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Library ID: lib123/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Can manage team: Yes/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Roles count: \d+/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Permissions count: \d+/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Resources count: \d+/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('username')).toHaveTextContent('testuser');
|
||||
expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123');
|
||||
expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true');
|
||||
expect(Number(screen.getByTestId('roles').textContent)).not.toBeNaN();
|
||||
expect(Number(screen.getByTestId('permissions').textContent)).not.toBeNaN();
|
||||
expect(Number(screen.getByTestId('resources').textContent)).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('throws error when user lacks both view and manage permissions', () => {
|
||||
@@ -149,7 +151,7 @@ describe('LibraryAuthZProvider', () => {
|
||||
</LibraryAuthZProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Can manage team: No/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('canManageTeam')).toHaveTextContent('false');
|
||||
});
|
||||
|
||||
it('throws error when libraryId is missing', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ type AuthZProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const LibraryAuthZProvider = ({ children }: AuthZProviderProps) => {
|
||||
export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }:AuthZProviderProps) => {
|
||||
const { libraryId } = useParams<{ libraryId: string }>();
|
||||
const { authenticatedUser } = useContext(AppContext) as AppContextType;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Spinner, Container } from '@openedx/paragon';
|
||||
|
||||
const LoadingPage = () => (
|
||||
<Container className="d-flex vh-100">
|
||||
<Container className="d-flex vh-100" data-testid="loading-page">
|
||||
<Spinner
|
||||
variant="primary"
|
||||
animation="border"
|
||||
|
||||
@@ -10,11 +10,8 @@ type ErrorStatusCode = {
|
||||
[key in CustomErrors]: number[];
|
||||
};
|
||||
|
||||
export const STATUS_400 = 400;
|
||||
export const STATUS_404 = 404;
|
||||
|
||||
export const ERROR_STATUS: ErrorStatusCode = {
|
||||
[CustomErrors.NO_ACCESS]: [403, 401],
|
||||
[CustomErrors.NOT_FOUND]: [400, 404],
|
||||
[CustomErrors.NOT_FOUND]: [404],
|
||||
[CustomErrors.SERVER_ERROR]: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user