fix: a11y for screen readers on roles and permissions tabs (#69)
This commit is contained in:
239
src/authz-module/components/PermissionTable.test.tsx
Normal file
239
src/authz-module/components/PermissionTable.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
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,52 +1,83 @@
|
||||
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) => (
|
||||
<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} /> : <Icon className="text-danger d-inline-block" src={Close} />}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</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}
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionTable;
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
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) => (
|
||||
<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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionRow;
|
||||
|
||||
@@ -18,17 +18,26 @@ interface RoleCardProps extends CardTitleProps {
|
||||
permissionsByResource: any[];
|
||||
}
|
||||
|
||||
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 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 RoleCard = ({
|
||||
title, objectName, description, handleDelete, permissionsByResource, userCounter,
|
||||
|
||||
@@ -51,6 +51,26 @@ 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;
|
||||
|
||||
16
src/authz-module/components/messages.ts
Normal file
16
src/authz-module/components/messages.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user