Skip to content

Commit b507311

Browse files
authored
feat(authz): [FC-0099] create LibrariesUserManager view to manage roles for a specific user (#6)
* refactor: use Link from react-router in Breadcrumb links * 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. * feat: create the user management view * feat: integrate the LibrariesUserManagement view in the application * style: add ts doc to buildPermissionsByRoleMatrix function * feat: add loading state to the cards roles * style: nit space * fix: nit extra comment and validation * test: update fireEvent with useEvent
1 parent c5cab49 commit b507311

20 files changed

Lines changed: 580 additions & 26 deletions

src/authz-module/components/AuthZTitle.test.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import React from 'react';
1+
import { ReactNode } from 'react';
22
import { render, screen, fireEvent } from '@testing-library/react';
33
import AuthZTitle, { AuthZTitleProps } from './AuthZTitle';
44

5+
jest.mock('react-router-dom', () => ({
6+
...jest.requireActual('react-router-dom'),
7+
Link: ({ children, to }:{ children:ReactNode, to:string }) => <a href={to}>{children}</a>,
8+
}));
9+
510
describe('AuthZTitle', () => {
611
const defaultProps: AuthZTitleProps = {
712
activeLabel: 'Current Page',
@@ -24,8 +29,9 @@ describe('AuthZTitle', () => {
2429

2530
render(<AuthZTitle {...defaultProps} navLinks={navLinks} />);
2631

27-
navLinks.forEach(({ label }) => {
32+
navLinks.forEach(({ label, to }) => {
2833
expect(screen.getByText(label)).toBeInTheDocument();
34+
expect(screen.getByText(label)).toHaveAttribute('href', expect.stringContaining(to));
2935
});
3036

3137
expect(screen.getByText(defaultProps.activeLabel)).toBeInTheDocument();

src/authz-module/components/AuthZTitle.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ReactNode } from 'react';
2+
import { Link } from 'react-router-dom';
23
import {
34
Breadcrumb, Col, Container, Row, Button, Badge,
45
} from '@openedx/paragon';
@@ -26,6 +27,7 @@ const AuthZTitle = ({
2627
}: AuthZTitleProps) => (
2728
<Container className="p-5 bg-light-100">
2829
<Breadcrumb
30+
linkAs={Link}
2931
links={navLinks}
3032
activeLabel={activeLabel}
3133
/>
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { screen } from '@testing-library/react';
2+
import { renderWrapper } from '@src/setupTest';
3+
import userEvent from '@testing-library/user-event';
4+
import RoleCard from '.';
5+
6+
jest.mock('@openedx/paragon/icons', () => ({
7+
Delete: () => <svg data-testid="delete-icon" />,
8+
Person: () => <svg data-testid="person-icon" />,
9+
}));
10+
11+
jest.mock('./constants', () => ({
12+
actionsDictionary: {
13+
view: () => <svg data-testid="view-icon" />,
14+
manage: () => <svg data-testid="manage-icon" />,
15+
},
16+
}));
17+
18+
describe('RoleCard', () => {
19+
const defaultProps = {
20+
title: 'Admin',
21+
objectName: 'Test Library',
22+
description: 'Can manage everything',
23+
showDelete: true,
24+
userCounter: 2,
25+
permissions: [
26+
{
27+
key: 'library',
28+
label: 'Library Resource',
29+
actions: [
30+
{ key: 'view', label: 'View' },
31+
{ key: 'manage', label: 'Manage', disabled: true },
32+
],
33+
},
34+
],
35+
};
36+
37+
it('renders all role card sections correctly', async () => {
38+
const user = userEvent.setup();
39+
renderWrapper(<RoleCard {...defaultProps} />);
40+
41+
// Title
42+
expect(screen.getByText('Admin')).toBeInTheDocument();
43+
44+
// User counter with icon
45+
expect(screen.getByText('2')).toBeInTheDocument();
46+
expect(screen.getByTestId('person-icon')).toBeInTheDocument();
47+
48+
// Subtitle (object name)
49+
expect(screen.getByText('Test Library')).toBeInTheDocument();
50+
51+
// Description
52+
expect(screen.getByText('Can manage everything')).toBeInTheDocument();
53+
54+
// Delete button
55+
expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument();
56+
57+
// Collapsible title
58+
expect(screen.getByText('Permissions')).toBeInTheDocument();
59+
60+
await user.click(screen.getByText('Permissions'));
61+
62+
// Resource label
63+
expect(screen.getByText('Library Resource')).toBeInTheDocument();
64+
65+
// Action chips
66+
expect(screen.getByText('View')).toBeInTheDocument();
67+
expect(screen.getByText('Manage')).toBeInTheDocument();
68+
69+
// Action icons
70+
expect(screen.getByTestId('view-icon')).toBeInTheDocument();
71+
expect(screen.getByTestId('manage-icon')).toBeInTheDocument();
72+
});
73+
74+
it('does not show delete button when showDelete is false', () => {
75+
renderWrapper(<RoleCard {...defaultProps} showDelete={false} />);
76+
expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument();
77+
});
78+
79+
it('handles no userCounter gracefully', () => {
80+
renderWrapper(<RoleCard {...defaultProps} userCounter={null} />);
81+
expect(screen.queryByTestId('person-icon')).not.toBeInTheDocument();
82+
expect(screen.queryByText('2')).not.toBeInTheDocument();
83+
});
84+
85+
it('handles empty permissions gracefully', () => {
86+
renderWrapper(<RoleCard {...defaultProps} permissions={[]} />);
87+
expect(screen.queryByText('Library Resource')).not.toBeInTheDocument();
88+
});
89+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
14+
interface RoleCardProps extends CardTitleProps {
15+
objectName?: string | null;
16+
description: string;
17+
showDelete?: boolean;
18+
permissions: any[];
19+
}
20+
21+
const CardTitle = ({ title, userCounter }: CardTitleProps) => (
22+
<div className="d-flex align-items-center">
23+
<span className="mr-4 text-primary">{title}</span>
24+
{userCounter !== null && (
25+
<span className="d-flex align-items-center font-weight-normal">
26+
<Icon src={Person} className="mr-1" />
27+
{userCounter}
28+
</span>
29+
)}
30+
</div>
31+
);
32+
33+
const RoleCard = ({
34+
title, objectName, description, showDelete, permissions, userCounter,
35+
}: RoleCardProps) => {
36+
const intl = useIntl();
37+
38+
return (
39+
<Card className="container-mw-lg mx-auto mb-4">
40+
<Card.Header
41+
title={<CardTitle title={title} userCounter={userCounter} />}
42+
subtitle={(objectName && <span className="text-info-400 lead">{objectName}</span>) || ''}
43+
actions={
44+
showDelete && <IconButton variant="danger" alt="Delete role action" src={Delete} />
45+
}
46+
/>
47+
<Card.Section>
48+
{description}
49+
</Card.Section>
50+
<Collapsible
51+
title={intl.formatMessage(messages['authz.permissions.title'])}
52+
>
53+
<Container>
54+
{permissions.map(({ key, label, actions }) => (
55+
<PermissionRow
56+
key={`${title}-${key}`}
57+
resourceLabel={label}
58+
actions={actions}
59+
/>
60+
61+
))}
62+
</Container>
63+
</Collapsible>
64+
</Card>
65+
);
66+
};
67+
68+
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;

src/authz-module/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export const ROUTES = {
22
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
3-
LIBRARIES_USER_PATH: '/libraries/user/:username',
3+
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
44
};

src/authz-module/index.scss

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,31 @@
77
.tab-content {
88
background-color: var(--pgn-color-light-200);
99
}
10-
}
10+
11+
.collapsible-card {
12+
border: none;
13+
14+
.collapsible-body {
15+
padding: 0;
16+
}
17+
}
18+
19+
.collapsible-trigger {
20+
background-color: var(--pgn-color-info-100);
21+
border: none;
22+
border-radius: 0 !important;
23+
color: var(--pgn-color-primary-base);
24+
padding: 1rem 2rem 1rem 1rem;
25+
}
26+
27+
.permission-chip {
28+
.pgn__chip__label {
29+
font-weight: var(--pgn-typography-font-weight-base);
30+
}
31+
32+
svg {
33+
width: var(--pgn-size-icon-xs);
34+
height: var(--pgn-size-icon-xs);
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)