4 Commits

Author SHA1 Message Date
Jacobo Dominguez
a77878cc39 fix(ulmo): backport changing how the feedback message is displayed for adding team members (#44)
* fix: changing how the feedback message is displayed for adding team members

* fix: updating feedback to consider existing roles a successful messages

* fix: already has role message update

* feat: update not found message
2026-01-02 13:15:30 +11:00
jacobo-dominguez-wgu
3a1cafa9f4 fix: base breadcrumb item with no link set 2025-11-20 16:46:55 +05:30
Diana Olarte
d865d3d3ce fix: invalidate PermissionsByRole to update number of user in a role 2025-11-19 12:53:50 -03:00
Jacobo Dominguez
97800bb685 [backport] adding namespace to permissions (#38)
Updating namespaces to permissions to use name spaced identifiers by adding the content_libraries prefix.
2025-11-18 15:58:04 -03:00
35 changed files with 2336 additions and 2324 deletions

3479
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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();
});
});

View File

@@ -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');
});
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) });
},
});
};

View File

@@ -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();
});
});
});

View File

@@ -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}>

View File

@@ -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,
});
}

View File

@@ -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();
});

View File

@@ -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();
});
});

View File

@@ -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,
});
};

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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

View File

@@ -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();
});
});
});

View File

@@ -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,
};

View File

@@ -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) => {

View File

@@ -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} />}

View File

@@ -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();
});
});

View File

@@ -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),

View File

@@ -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: [],
}));

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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"

View File

@@ -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],
};