fix: a11y for screen readers on roles and permissions tabs (#69)

This commit is contained in:
Jacobo Dominguez
2026-03-03 02:05:52 -07:00
committed by GitHub
parent f10cbf53a3
commit 2ac818f4be
6 changed files with 400 additions and 76 deletions

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

View File

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

View File

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

View File

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

View File

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

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