Skip to content

Commit 2259a45

Browse files
bra-i-amdcoa
andauthored
feat: [FC-0099] Add a new role to a library member (#9)
* refactor: support components as action & connect API to add roles to users * fix: the value of LIBRARY_AUTHZ_SCOPE was misspelled * feat: add role assignment functionality with modal and trigger components * feat: add success toast message for role assignment and update user management logic * refactor: update handleAddRole to display all roles * refactor: group under AssignNewRoleModal * test: add unit tests for AssignNewRoleModal and AssignNewRoleTrigger components * fix: remove duplicated exports after rebase --------- Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
1 parent 52fbb7e commit 2259a45

File tree

8 files changed

+684
-3
lines changed

8 files changed

+684
-3
lines changed

src/authz-module/data/api.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export interface AssignTeamMembersRoleRequest {
2424
scope: string;
2525
}
2626

27-
// TODO: replece api path once is created
2827
export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
2928
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
3029
return camelCaseObject(data.results);

src/authz-module/libraries-manager/LibrariesUserManager.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ROUTES } from '@src/authz-module/constants';
66
import AuthZLayout from '../components/AuthZLayout';
77
import { useLibraryAuthZ } from './context';
88
import RoleCard from '../components/RoleCard';
9+
import { AssignNewRoleTrigger } from './components/AssignNewRoleModal';
910
import { useLibrary, useTeamMembers } from '../data/hooks';
1011
import { buildPermissionsByRoleMatrix } from './utils';
1112

@@ -15,7 +16,7 @@ const LibrariesUserManager = () => {
1516
const intl = useIntl();
1617
const { username } = useParams();
1718
const {
18-
libraryId, permissions, roles, resources,
19+
libraryId, permissions, roles, resources, canManageTeam,
1920
} = useLibraryAuthZ();
2021
const { data: library } = useLibrary(libraryId);
2122
const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
@@ -42,7 +43,13 @@ const LibrariesUserManager = () => {
4243
activeLabel={user?.username || ''}
4344
pageTitle={user?.username || ''}
4445
pageSubtitle={<p>{user?.email}</p>}
45-
actions={[]}
46+
actions={user && canManageTeam
47+
? [<AssignNewRoleTrigger
48+
username={user.username}
49+
libraryId={libraryId}
50+
currentUserRoles={userRoles.map(role => role.role)}
51+
/>]
52+
: []}
4653
>
4754
<Container className="bg-light-200 p-5">
4855
{isLoading ? <Skeleton count={2} height={200} /> : null}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { renderWrapper } from '@src/setupTest';
4+
import { Role } from 'types';
5+
import AssignNewRoleModal from './AssignNewRoleModal';
6+
7+
describe('AssignNewRoleModal', () => {
8+
const defaultProps = {
9+
isOpen: true,
10+
isLoading: false,
11+
roleOptions: [
12+
{
13+
role: 'instructor',
14+
name: 'Instructor',
15+
description: 'Can create and edit content',
16+
userCount: 5,
17+
permissions: ['view', 'edit'],
18+
},
19+
{
20+
role: 'admin',
21+
name: 'Administrator',
22+
description: 'Full access to the library',
23+
userCount: 2,
24+
permissions: ['view', 'edit', 'delete', 'manage'],
25+
},
26+
{
27+
role: 'viewer',
28+
name: 'Viewer',
29+
description: 'Can only view content',
30+
userCount: 10,
31+
permissions: ['view'],
32+
},
33+
] as Role[],
34+
selectedRole: '',
35+
close: jest.fn(),
36+
onSave: jest.fn(),
37+
handleChangeSelectedRole: jest.fn(),
38+
};
39+
40+
beforeEach(() => {
41+
jest.clearAllMocks();
42+
});
43+
44+
const renderComponent = (props = {}) => {
45+
const finalProps = { ...defaultProps, ...props };
46+
return renderWrapper(<AssignNewRoleModal {...finalProps} />);
47+
};
48+
49+
describe('Modal Visibility', () => {
50+
it('renders modal when isOpen is true', () => {
51+
renderComponent({ isOpen: true });
52+
53+
expect(screen.getByRole('dialog')).toBeInTheDocument();
54+
expect(screen.getByText('Add New Role')).toBeInTheDocument();
55+
});
56+
57+
it('does not render modal when isOpen is false', () => {
58+
renderComponent({ isOpen: false });
59+
60+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
61+
expect(screen.queryByText('Add New Role')).not.toBeInTheDocument();
62+
});
63+
});
64+
65+
describe('Modal Structure', () => {
66+
it('renders modal header with correct title', () => {
67+
renderComponent({ isOpen: true });
68+
69+
expect(screen.getByText('Add New Role')).toBeInTheDocument();
70+
expect(screen.getByRole('dialog')).toBeInTheDocument();
71+
});
72+
73+
it('renders close button in header', () => {
74+
renderComponent();
75+
76+
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
77+
});
78+
});
79+
80+
describe('Role Selection Form', () => {
81+
it('renders role selection form with correct label', () => {
82+
renderComponent();
83+
84+
expect(screen.getByText('Roles')).toBeInTheDocument();
85+
expect(screen.getByRole('combobox')).toBeInTheDocument();
86+
});
87+
88+
it('renders default option', () => {
89+
renderComponent();
90+
91+
expect(screen.getByText('Select a role')).toBeInTheDocument();
92+
expect(screen.getByRole('option', { name: 'Select a role' })).toBeDisabled();
93+
});
94+
95+
it('renders all role options', () => {
96+
renderComponent();
97+
98+
defaultProps.roleOptions.forEach((role) => {
99+
expect(screen.getByRole('option', { name: role.name })).toBeInTheDocument();
100+
});
101+
});
102+
103+
it('displays selected role correctly', () => {
104+
renderComponent({ selectedRole: 'instructor' });
105+
106+
const selectElement = screen.getByRole('combobox');
107+
expect(selectElement).toHaveValue('instructor');
108+
});
109+
110+
it('calls handleChangeSelectedRole when role selection changes', async () => {
111+
const user = userEvent.setup();
112+
renderComponent();
113+
114+
const selectElement = screen.getByRole('combobox');
115+
await user.selectOptions(selectElement, 'admin');
116+
117+
expect(defaultProps.handleChangeSelectedRole).toHaveBeenCalled();
118+
});
119+
});
120+
121+
describe('Action Buttons', () => {
122+
it('renders Cancel button with correct text', () => {
123+
renderComponent();
124+
125+
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
126+
});
127+
128+
it('renders Save button with correct text when not loading', () => {
129+
renderComponent({ isLoading: false });
130+
131+
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
132+
});
133+
134+
it('renders Save button with loading text when loading', () => {
135+
renderComponent({ isLoading: true });
136+
137+
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument();
138+
});
139+
140+
it('calls close when Cancel button is clicked', async () => {
141+
const user = userEvent.setup();
142+
renderComponent();
143+
144+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
145+
await user.click(cancelButton);
146+
147+
expect(defaultProps.close).toHaveBeenCalledTimes(1);
148+
});
149+
150+
it('calls onSave when Save button is clicked', async () => {
151+
const user = userEvent.setup();
152+
renderComponent({ selectedRole: 'instructor' });
153+
154+
const saveButton = screen.getByRole('button', { name: /save/i });
155+
await user.click(saveButton);
156+
157+
expect(defaultProps.onSave).toHaveBeenCalledTimes(1);
158+
});
159+
});
160+
161+
describe('Button States', () => {
162+
it('disables Save button when no role is selected', () => {
163+
renderComponent({ selectedRole: '' });
164+
165+
const saveButton = screen.getByRole('button', { name: /save/i });
166+
expect(saveButton).toBeDisabled();
167+
});
168+
169+
it('enables Save button when role is selected and not loading', () => {
170+
renderComponent({ selectedRole: 'instructor', isLoading: false });
171+
172+
const saveButton = screen.getByRole('button', { name: /save/i });
173+
expect(saveButton).not.toBeDisabled();
174+
});
175+
176+
it('disables Save button when loading', () => {
177+
renderComponent({ selectedRole: 'instructor', isLoading: true });
178+
179+
const saveButton = screen.getByRole('button', { name: /saving/i });
180+
expect(saveButton).toBeDisabled();
181+
});
182+
183+
it('disables Cancel button when loading', () => {
184+
renderComponent({ isLoading: true });
185+
186+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
187+
expect(cancelButton).toBeDisabled();
188+
});
189+
190+
it('enables Cancel button when not loading', () => {
191+
renderComponent({ isLoading: false });
192+
193+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
194+
expect(cancelButton).not.toBeDisabled();
195+
});
196+
});
197+
198+
describe('Modal Close Behavior', () => {
199+
it('does not call close when modal header close is clicked during loading', async () => {
200+
const user = userEvent.setup();
201+
renderComponent({ isLoading: true });
202+
203+
const headerCloseButton = screen.getByRole('button', { name: /close/i });
204+
await user.click(headerCloseButton);
205+
206+
expect(defaultProps.close).not.toHaveBeenCalled();
207+
});
208+
209+
it('calls close when modal header close is clicked and not loading', async () => {
210+
const user = userEvent.setup();
211+
renderComponent({ isLoading: false });
212+
213+
const headerCloseButton = screen.getByRole('button', { name: /close/i });
214+
await user.click(headerCloseButton);
215+
216+
expect(defaultProps.close).toHaveBeenCalledTimes(1);
217+
});
218+
});
219+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { FC } from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import {
4+
ActionRow, Button, Form, ModalDialog,
5+
} from '@openedx/paragon';
6+
import { Role } from 'types';
7+
import messages from '../messages';
8+
9+
interface AssignNewRoleModalProps {
10+
isOpen: boolean;
11+
isLoading: boolean;
12+
roleOptions: Role[];
13+
selectedRole: string;
14+
close: () => void;
15+
onSave: () => void;
16+
handleChangeSelectedRole: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => void;
17+
}
18+
19+
const AssignNewRoleModal: FC<AssignNewRoleModalProps> = ({
20+
isOpen, isLoading, selectedRole, roleOptions, close, onSave, handleChangeSelectedRole,
21+
}) => {
22+
const intl = useIntl();
23+
return (
24+
<ModalDialog
25+
title={intl.formatMessage(messages['libraries.authz.manage.assign.new.role.title'])}
26+
isOpen={isOpen}
27+
onClose={isLoading ? () => {} : close}
28+
size="lg"
29+
variant="dark"
30+
hasCloseButton
31+
isOverflowVisible={false}
32+
zIndex={5}
33+
>
34+
<ModalDialog.Header className="bg-primary-500 text-light-100">
35+
<ModalDialog.Title>
36+
{intl.formatMessage(messages['libraries.authz.manage.assign.new.role.title'])}
37+
</ModalDialog.Title>
38+
</ModalDialog.Header>
39+
40+
<ModalDialog.Body className="my-4">
41+
<Form.Group controlId="role_options">
42+
<Form.Label>{intl.formatMessage(messages['library.authz.team.table.roles'])}</Form.Label>
43+
<Form.Control as="select" name="role" value={selectedRole} onChange={handleChangeSelectedRole}>
44+
<option value="" disabled>Select a role</option>
45+
{roleOptions.map((role) => <option key={role.role} value={role.role}>{role.name}</option>)}
46+
</Form.Control>
47+
</Form.Group>
48+
</ModalDialog.Body>
49+
50+
<ModalDialog.Footer>
51+
<ActionRow>
52+
<ModalDialog.CloseButton variant="tertiary" disabled={isLoading}>
53+
{intl.formatMessage(messages['libraries.authz.manage.cancel.button'])}
54+
</ModalDialog.CloseButton>
55+
<Button
56+
className="px-4"
57+
onClick={() => onSave()}
58+
disabled={!selectedRole || isLoading}
59+
>
60+
{isLoading
61+
? intl.formatMessage(messages['libraries.authz.manage.saving.button'])
62+
: intl.formatMessage(messages['libraries.authz.manage.save.button'])}
63+
</Button>
64+
</ActionRow>
65+
</ModalDialog.Footer>
66+
</ModalDialog>
67+
);
68+
};
69+
70+
export default AssignNewRoleModal;

0 commit comments

Comments
 (0)