Skip to content

Commit ba5478d

Browse files
committed
feat: create RoleCard component
This is a reusable component, that display a card for each role with a collapsible showing the associated permissions. The permissions, are organized by resource and enable/disable.
1 parent 387bb98 commit ba5478d

5 files changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ComponentType } from 'react';
2+
import {
3+
Chip, Col, Row,
4+
} from '@openedx/paragon';
5+
import { actionsDictionary, ActionKey } from './constants';
6+
7+
interface Action {
8+
key: string;
9+
label?: string;
10+
disabled?: boolean;
11+
}
12+
13+
interface PermissionRowProps {
14+
resourceLabel: string;
15+
actions: Action[];
16+
}
17+
18+
const PermissionRow = ({ resourceLabel, actions }: PermissionRowProps) => (
19+
<Row className="row align-items-center border px-2 py-2">
20+
<Col md={3}>
21+
<span className="small font-weight-bold">{resourceLabel}</span>
22+
</Col>
23+
<Col>
24+
<div className="w-100 d-flex flex-wrap">
25+
{actions.map(action => (
26+
<Chip
27+
key={action.key}
28+
iconBefore={actionsDictionary[action.key as ActionKey] as ComponentType}
29+
disabled={action.disabled}
30+
className="mr-4 my-2 px-3 bg-primary-100 border-0 permission-chip"
31+
variant="light"
32+
>
33+
{action.label}
34+
</Chip>
35+
))}
36+
</div>
37+
</Col>
38+
</Row>
39+
);
40+
41+
export default PermissionRow;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {
2+
Add, Delete, DownloadDone, Edit, ManageAccounts, Sync, Tag, Visibility,
3+
} from '@openedx/paragon/icons';
4+
5+
export const actionsDictionary = {
6+
create: Add,
7+
edit: Edit,
8+
delete: Delete,
9+
import: Sync,
10+
publish: DownloadDone,
11+
view: Visibility,
12+
reuse: Sync,
13+
tag: Tag,
14+
team: ManageAccounts,
15+
};
16+
17+
export type ActionKey = keyof typeof actionsDictionary;
18+
export const actionKeys = Object.keys(actionsDictionary);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { fireEvent, screen } from '@testing-library/react';
2+
import { renderWrapper } from '@src/setupTest';
3+
import RoleCard from '.';
4+
5+
jest.mock('@openedx/paragon/icons', () => ({
6+
Delete: () => <svg data-testid="delete-icon" />,
7+
Person: () => <svg data-testid="person-icon" />,
8+
}));
9+
10+
jest.mock('./constants', () => ({
11+
actionsDictionary: {
12+
view: () => <svg data-testid="view-icon" />,
13+
manage: () => <svg data-testid="manage-icon" />,
14+
},
15+
}));
16+
17+
describe('RoleCard', () => {
18+
const defaultProps = {
19+
title: 'Admin',
20+
objectName: 'Test Library',
21+
description: 'Can manage everything',
22+
showDelete: true,
23+
userCounter: 2,
24+
permissions: [
25+
{
26+
key: 'library',
27+
label: 'Library Resource',
28+
actions: [
29+
{ key: 'view', label: 'View' },
30+
{ key: 'manage', label: 'Manage', disabled: true },
31+
],
32+
},
33+
],
34+
};
35+
36+
it('renders all role card sections correctly', () => {
37+
renderWrapper(<RoleCard {...defaultProps} />);
38+
39+
// Title
40+
expect(screen.getByText('Admin')).toBeInTheDocument();
41+
42+
// User counter with icon
43+
expect(screen.getByText('2')).toBeInTheDocument();
44+
expect(screen.getByTestId('person-icon')).toBeInTheDocument();
45+
46+
// Subtitle (object name)
47+
expect(screen.getByText('Test Library')).toBeInTheDocument();
48+
49+
// Description
50+
expect(screen.getByText('Can manage everything')).toBeInTheDocument();
51+
52+
// Delete button
53+
expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument();
54+
55+
// Collapsible title
56+
expect(screen.getByText('Permissions')).toBeInTheDocument();
57+
58+
fireEvent.click(screen.getByText('Permissions'));
59+
60+
// Resource label
61+
expect(screen.getByText('Library Resource')).toBeInTheDocument();
62+
63+
// Action chips
64+
expect(screen.getByText('View')).toBeInTheDocument();
65+
expect(screen.getByText('Manage')).toBeInTheDocument();
66+
67+
// Action icons
68+
expect(screen.getByTestId('view-icon')).toBeInTheDocument();
69+
expect(screen.getByTestId('manage-icon')).toBeInTheDocument();
70+
});
71+
72+
it('does not show delete button when showDelete is false', () => {
73+
renderWrapper(<RoleCard {...defaultProps} showDelete={false} />);
74+
expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument();
75+
});
76+
77+
it('handles no userCounter gracefully', () => {
78+
renderWrapper(<RoleCard {...defaultProps} userCounter={null} />);
79+
expect(screen.queryByTestId('person-icon')).not.toBeInTheDocument();
80+
expect(screen.queryByText('2')).not.toBeInTheDocument();
81+
});
82+
83+
it('handles empty permissions gracefully', () => {
84+
renderWrapper(<RoleCard {...defaultProps} permissions={[]} />);
85+
expect(screen.queryByText('Library Resource')).not.toBeInTheDocument();
86+
});
87+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useIntl } from '@edx/frontend-platform/i18n';
2+
import {
3+
Card, Collapsible, Container, Icon, IconButton,
4+
} from '@openedx/paragon';
5+
import { Delete, Person } from '@openedx/paragon/icons';
6+
import PermissionRow from './PermissionsRow';
7+
import messages from './messages';
8+
9+
interface CardTitleProps {
10+
title: string;
11+
userCounter?: number | null;
12+
}
13+
interface RoleCardProps extends CardTitleProps {
14+
objectName?: string | null;
15+
description: string;
16+
showDelete?: boolean;
17+
permissions: any[];
18+
}
19+
20+
const CardTitle = ({ title, userCounter }: CardTitleProps) => (
21+
<div className="d-flex align-items-center">
22+
<span className="mr-4 text-primary">{title}</span>
23+
{userCounter !== null && userCounter !== undefined && (
24+
<span className="d-flex align-items-center font-weight-normal">
25+
<Icon src={Person} className="mr-1" />
26+
{userCounter}
27+
</span>
28+
)}
29+
</div>
30+
);
31+
32+
const RoleCard = ({
33+
title, objectName, description, showDelete, permissions, userCounter,
34+
}: RoleCardProps) => {
35+
const intl = useIntl();
36+
37+
return (
38+
<Card className="container-mw-lg mx-auto mb-4">
39+
<Card.Header
40+
title={<CardTitle title={title} userCounter={userCounter} />}
41+
subtitle={(objectName && <span className="text-info-400 lead">{objectName}</span>) || ''}
42+
actions={
43+
showDelete && <IconButton variant="danger" alt="Delete role action" src={Delete} />
44+
}
45+
/>
46+
<Card.Section>
47+
{description}
48+
</Card.Section>
49+
<Collapsible
50+
title={intl.formatMessage(messages['authz.permissions.title'])}
51+
>
52+
<Container>
53+
{permissions.map(({ key, label, actions }) => (
54+
<PermissionRow
55+
key={`${title}-${key}`}
56+
resourceLabel={label}
57+
actions={actions}
58+
/>
59+
60+
))}
61+
</Container>
62+
</Collapsible>
63+
</Card>
64+
);
65+
};
66+
67+
export default RoleCard;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
'authz.permissions.title': {
5+
id: 'authz.permissions.title',
6+
defaultMessage: 'Permissions',
7+
description: 'Title for the permissions section in the role card',
8+
},
9+
'authz.permissions.actions.create': {
10+
id: 'authz.permissions.actions.create',
11+
defaultMessage: 'Create {resource}',
12+
description: 'Default label for the create action',
13+
},
14+
'authz.permissions.actions.edit': {
15+
id: 'authz.permissions.actions.edit',
16+
defaultMessage: 'Edit {resource}',
17+
description: 'Default label for the edit action',
18+
},
19+
'authz.permissions.actions.import': {
20+
id: 'authz.permissions.actions.import',
21+
defaultMessage: 'Import {resource}',
22+
description: 'Default label for the import action',
23+
},
24+
'authz.permissions.actions.delete': {
25+
id: 'authz.permissions.actions.delete',
26+
defaultMessage: 'Delete {resource}',
27+
description: 'Default label for the delete action',
28+
},
29+
'authz.permissions.actions.manage': {
30+
id: 'authz.permissions.actions.manage',
31+
defaultMessage: 'Manage {resource}',
32+
description: 'Default label for the manage action',
33+
},
34+
'authz.permissions.actions.publish': {
35+
id: 'authz.permissions.actions.publish',
36+
defaultMessage: 'Publish {resource}',
37+
description: 'Default label for the publish action',
38+
},
39+
'authz.permissions.actions.view': {
40+
id: 'authz.permissions.actions.view',
41+
defaultMessage: 'View {resource}',
42+
description: 'Default label for the view action',
43+
},
44+
'authz.permissions.actions.reuse': {
45+
id: 'authz.permissions.actions.reuse',
46+
defaultMessage: 'Reuse {resource}',
47+
description: 'Default label for the reuse action',
48+
},
49+
});
50+
51+
export default messages;

0 commit comments

Comments
 (0)