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>
This commit is contained in:
Brayan Cerón
2025-10-21 19:19:48 -05:00
committed by GitHub
parent 52fbb7ea9d
commit 2259a453ce
8 changed files with 684 additions and 3 deletions

View File

@@ -24,7 +24,6 @@ export interface AssignTeamMembersRoleRequest {
scope: string; scope: string;
} }
// TODO: replece api path once is created
export const getTeamMembers = async (object: string): Promise<TeamMember[]> => { export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
return camelCaseObject(data.results); return camelCaseObject(data.results);

View File

@@ -6,6 +6,7 @@ import { ROUTES } from '@src/authz-module/constants';
import AuthZLayout from '../components/AuthZLayout'; import AuthZLayout from '../components/AuthZLayout';
import { useLibraryAuthZ } from './context'; import { useLibraryAuthZ } from './context';
import RoleCard from '../components/RoleCard'; import RoleCard from '../components/RoleCard';
import { AssignNewRoleTrigger } from './components/AssignNewRoleModal';
import { useLibrary, useTeamMembers } from '../data/hooks'; import { useLibrary, useTeamMembers } from '../data/hooks';
import { buildPermissionsByRoleMatrix } from './utils'; import { buildPermissionsByRoleMatrix } from './utils';
@@ -15,7 +16,7 @@ const LibrariesUserManager = () => {
const intl = useIntl(); const intl = useIntl();
const { username } = useParams(); const { username } = useParams();
const { const {
libraryId, permissions, roles, resources, libraryId, permissions, roles, resources, canManageTeam,
} = useLibraryAuthZ(); } = useLibraryAuthZ();
const { data: library } = useLibrary(libraryId); const { data: library } = useLibrary(libraryId);
const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
@@ -42,7 +43,13 @@ const LibrariesUserManager = () => {
activeLabel={user?.username || ''} activeLabel={user?.username || ''}
pageTitle={user?.username || ''} pageTitle={user?.username || ''}
pageSubtitle={<p>{user?.email}</p>} pageSubtitle={<p>{user?.email}</p>}
actions={[]} actions={user && canManageTeam
? [<AssignNewRoleTrigger
username={user.username}
libraryId={libraryId}
currentUserRoles={userRoles.map(role => role.role)}
/>]
: []}
> >
<Container className="bg-light-200 p-5"> <Container className="bg-light-200 p-5">
{isLoading ? <Skeleton count={2} height={200} /> : null} {isLoading ? <Skeleton count={2} height={200} /> : null}

View File

@@ -0,0 +1,219 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWrapper } from '@src/setupTest';
import { Role } from 'types';
import AssignNewRoleModal from './AssignNewRoleModal';
describe('AssignNewRoleModal', () => {
const defaultProps = {
isOpen: true,
isLoading: false,
roleOptions: [
{
role: 'instructor',
name: 'Instructor',
description: 'Can create and edit content',
userCount: 5,
permissions: ['view', 'edit'],
},
{
role: 'admin',
name: 'Administrator',
description: 'Full access to the library',
userCount: 2,
permissions: ['view', 'edit', 'delete', 'manage'],
},
{
role: 'viewer',
name: 'Viewer',
description: 'Can only view content',
userCount: 10,
permissions: ['view'],
},
] as Role[],
selectedRole: '',
close: jest.fn(),
onSave: jest.fn(),
handleChangeSelectedRole: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
const renderComponent = (props = {}) => {
const finalProps = { ...defaultProps, ...props };
return renderWrapper(<AssignNewRoleModal {...finalProps} />);
};
describe('Modal Visibility', () => {
it('renders modal when isOpen is true', () => {
renderComponent({ isOpen: true });
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Add New Role')).toBeInTheDocument();
});
it('does not render modal when isOpen is false', () => {
renderComponent({ isOpen: false });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Add New Role')).not.toBeInTheDocument();
});
});
describe('Modal Structure', () => {
it('renders modal header with correct title', () => {
renderComponent({ isOpen: true });
expect(screen.getByText('Add New Role')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('renders close button in header', () => {
renderComponent();
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
});
describe('Role Selection Form', () => {
it('renders role selection form with correct label', () => {
renderComponent();
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('renders default option', () => {
renderComponent();
expect(screen.getByText('Select a role')).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Select a role' })).toBeDisabled();
});
it('renders all role options', () => {
renderComponent();
defaultProps.roleOptions.forEach((role) => {
expect(screen.getByRole('option', { name: role.name })).toBeInTheDocument();
});
});
it('displays selected role correctly', () => {
renderComponent({ selectedRole: 'instructor' });
const selectElement = screen.getByRole('combobox');
expect(selectElement).toHaveValue('instructor');
});
it('calls handleChangeSelectedRole when role selection changes', async () => {
const user = userEvent.setup();
renderComponent();
const selectElement = screen.getByRole('combobox');
await user.selectOptions(selectElement, 'admin');
expect(defaultProps.handleChangeSelectedRole).toHaveBeenCalled();
});
});
describe('Action Buttons', () => {
it('renders Cancel button with correct text', () => {
renderComponent();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('renders Save button with correct text when not loading', () => {
renderComponent({ isLoading: false });
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('renders Save button with loading text when loading', () => {
renderComponent({ isLoading: true });
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument();
});
it('calls close when Cancel button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(defaultProps.close).toHaveBeenCalledTimes(1);
});
it('calls onSave when Save button is clicked', async () => {
const user = userEvent.setup();
renderComponent({ selectedRole: 'instructor' });
const saveButton = screen.getByRole('button', { name: /save/i });
await user.click(saveButton);
expect(defaultProps.onSave).toHaveBeenCalledTimes(1);
});
});
describe('Button States', () => {
it('disables Save button when no role is selected', () => {
renderComponent({ selectedRole: '' });
const saveButton = screen.getByRole('button', { name: /save/i });
expect(saveButton).toBeDisabled();
});
it('enables Save button when role is selected and not loading', () => {
renderComponent({ selectedRole: 'instructor', isLoading: false });
const saveButton = screen.getByRole('button', { name: /save/i });
expect(saveButton).not.toBeDisabled();
});
it('disables Save button when loading', () => {
renderComponent({ selectedRole: 'instructor', isLoading: true });
const saveButton = screen.getByRole('button', { name: /saving/i });
expect(saveButton).toBeDisabled();
});
it('disables Cancel button when loading', () => {
renderComponent({ isLoading: true });
const cancelButton = screen.getByRole('button', { name: /cancel/i });
expect(cancelButton).toBeDisabled();
});
it('enables Cancel button when not loading', () => {
renderComponent({ isLoading: false });
const cancelButton = screen.getByRole('button', { name: /cancel/i });
expect(cancelButton).not.toBeDisabled();
});
});
describe('Modal Close Behavior', () => {
it('does not call close when modal header close is clicked during loading', async () => {
const user = userEvent.setup();
renderComponent({ isLoading: true });
const headerCloseButton = screen.getByRole('button', { name: /close/i });
await user.click(headerCloseButton);
expect(defaultProps.close).not.toHaveBeenCalled();
});
it('calls close when modal header close is clicked and not loading', async () => {
const user = userEvent.setup();
renderComponent({ isLoading: false });
const headerCloseButton = screen.getByRole('button', { name: /close/i });
await user.click(headerCloseButton);
expect(defaultProps.close).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,70 @@
import { FC } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Button, Form, ModalDialog,
} from '@openedx/paragon';
import { Role } from 'types';
import messages from '../messages';
interface AssignNewRoleModalProps {
isOpen: boolean;
isLoading: boolean;
roleOptions: Role[];
selectedRole: string;
close: () => void;
onSave: () => void;
handleChangeSelectedRole: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => void;
}
const AssignNewRoleModal: FC<AssignNewRoleModalProps> = ({
isOpen, isLoading, selectedRole, roleOptions, close, onSave, handleChangeSelectedRole,
}) => {
const intl = useIntl();
return (
<ModalDialog
title={intl.formatMessage(messages['libraries.authz.manage.assign.new.role.title'])}
isOpen={isOpen}
onClose={isLoading ? () => {} : close}
size="lg"
variant="dark"
hasCloseButton
isOverflowVisible={false}
zIndex={5}
>
<ModalDialog.Header className="bg-primary-500 text-light-100">
<ModalDialog.Title>
{intl.formatMessage(messages['libraries.authz.manage.assign.new.role.title'])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="my-4">
<Form.Group controlId="role_options">
<Form.Label>{intl.formatMessage(messages['library.authz.team.table.roles'])}</Form.Label>
<Form.Control as="select" name="role" value={selectedRole} onChange={handleChangeSelectedRole}>
<option value="" disabled>Select a role</option>
{roleOptions.map((role) => <option key={role.role} value={role.role}>{role.name}</option>)}
</Form.Control>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary" disabled={isLoading}>
{intl.formatMessage(messages['libraries.authz.manage.cancel.button'])}
</ModalDialog.CloseButton>
<Button
className="px-4"
onClick={() => onSave()}
disabled={!selectedRole || isLoading}
>
{isLoading
? intl.formatMessage(messages['libraries.authz.manage.saving.button'])
: intl.formatMessage(messages['libraries.authz.manage.save.button'])}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
export default AssignNewRoleModal;

View File

@@ -0,0 +1,265 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWrapper } from '@src/setupTest';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
import AssignNewRoleTrigger from './AssignNewRoleTrigger';
jest.mock('@src/authz-module/libraries-manager/context', () => ({
useLibraryAuthZ: jest.fn(),
}));
jest.mock('@src/authz-module/data/hooks', () => ({
useAssignTeamMembersRole: jest.fn(),
}));
jest.mock('./AssignNewRoleModal', () => {
const MockAssignNewRoleModal = ({
isOpen,
close,
onSave,
isLoading,
roleOptions,
selectedRole,
handleChangeSelectedRole,
}: any) => (isOpen ? (
<div data-testid="assign-new-role-modal">
<h2>Add New Role</h2>
<select data-testid="role-select" value={selectedRole} onChange={handleChangeSelectedRole}>
<option value="">Select a role</option>
{roleOptions.map((role: any) => (
<option key={role.role} value={role.role}>
{role.name}
</option>
))}
</select>
<button type="button" onClick={onSave} disabled={isLoading} data-testid="save-button">
{isLoading ? 'Saving...' : 'Save'}
</button>
<button type="button" onClick={close} data-testid="cancel-button">
Cancel
</button>
</div>
) : null);
MockAssignNewRoleModal.displayName = 'AssignNewRoleModal';
return MockAssignNewRoleModal;
});
const mockUseLibraryAuthZ = useLibraryAuthZ as jest.Mock;
const mockUseAssignTeamMembersRole = useAssignTeamMembersRole as jest.Mock;
describe('AssignNewRoleTrigger', () => {
const defaultProps = {
username: 'testuser',
libraryId: 'lib:test-library',
currentUserRoles: ['instructor'],
};
const mockRoles = [
{
role: 'instructor',
name: 'Instructor',
description: 'Can create and edit content',
},
{
role: 'admin',
name: 'Administrator',
description: 'Full access to the library',
},
{
role: 'viewer',
name: 'Viewer',
description: 'Can only view content',
},
];
const mockMutate = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseLibraryAuthZ.mockReturnValue({
roles: mockRoles,
});
mockUseAssignTeamMembersRole.mockReturnValue({
mutate: mockMutate,
isPending: false,
});
});
const renderComponent = (props = {}) => {
const finalProps = { ...defaultProps, ...props };
return renderWrapper(<AssignNewRoleTrigger {...finalProps} />);
};
describe('Initial Render', () => {
it('renders the trigger button with correct text', () => {
renderComponent();
expect(screen.getByRole('button', { name: /add new role/i })).toBeInTheDocument();
});
it('does not show modal initially', () => {
renderComponent();
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
});
it('does not show toast initially', () => {
renderComponent();
expect(screen.queryByText(/role added successfully/i)).not.toBeInTheDocument();
});
});
describe('Modal Interaction', () => {
it('opens modal when trigger button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const triggerButton = screen.getByRole('button', { name: /add new role/i });
await user.click(triggerButton);
expect(screen.getByTestId('assign-new-role-modal')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /add new role/i })).toBeInTheDocument();
});
it('closes modal when cancel button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
// Open modal
await user.click(screen.getByRole('button', { name: /add new role/i }));
expect(screen.getByTestId('assign-new-role-modal')).toBeInTheDocument();
// Close modal
await user.click(screen.getByTestId('cancel-button'));
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
});
});
describe('Role Selection and Assignment', () => {
it('updates selected role when role select changes', async () => {
const user = userEvent.setup();
renderComponent();
await user.click(screen.getByRole('button', { name: /add new role/i }));
const roleSelect = screen.getByTestId('role-select');
await user.selectOptions(roleSelect, 'admin');
expect(roleSelect).toHaveValue('admin');
});
it('calls assignTeamMembersRole with correct data when save is clicked', async () => {
const choosenRole = mockRoles[1].role; // 'admin'
const user = userEvent.setup();
renderComponent();
await user.click(screen.getByRole('button', { name: /add new role/i }));
// Select a role
const roleSelect = screen.getByTestId('role-select');
await user.selectOptions(roleSelect, choosenRole);
// Click save
await user.click(screen.getByTestId('save-button'));
expect(mockMutate).toHaveBeenCalledWith(
{
data: {
users: [defaultProps.username],
role: choosenRole,
scope: defaultProps.libraryId,
},
},
expect.objectContaining({
onSuccess: expect.any(Function),
}),
);
});
it('does not call assignTeamMembersRole if user already has the selected role', async () => {
const choosenRole = mockRoles[1].role; // 'admin'
const user = userEvent.setup();
renderComponent({ currentUserRoles: ['instructor', choosenRole] });
await user.click(screen.getByRole('button', { name: /add new role/i }));
// Select a role that user already has
const roleSelect = screen.getByTestId('role-select');
await user.selectOptions(roleSelect, choosenRole);
await user.click(screen.getByTestId('save-button'));
// Should not call assignTeamMembersRole
expect(mockMutate).not.toHaveBeenCalled();
// Modal should be closed
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
});
});
describe('Loading State', () => {
it('shows loading state in modal when assignment is pending', async () => {
mockUseAssignTeamMembersRole.mockReturnValue({
mutate: mockMutate,
isPending: true,
});
const user = userEvent.setup();
renderComponent();
await user.click(screen.getByRole('button', { name: /add new role/i }));
expect(screen.getByTestId('save-button')).toBeDisabled();
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
});
describe('Success Handling', () => {
it('shows success toast after successful role assignment', async () => {
const user = userEvent.setup();
renderComponent();
await user.click(screen.getByRole('button', { name: /add new role/i }));
const roleSelect = screen.getByTestId('role-select');
await user.selectOptions(roleSelect, 'admin');
await user.click(screen.getByTestId('save-button'));
// Simulate successful API call
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
onSuccessCallback();
await waitFor(() => {
expect(screen.getByText(/role added successfully/i)).toBeInTheDocument();
});
});
it('closes modal and resets role after successful assignment', async () => {
const user = userEvent.setup();
renderComponent();
await user.click(screen.getByRole('button', { name: /add new role/i }));
const roleSelect = screen.getByTestId('role-select');
await user.selectOptions(roleSelect, 'admin');
await user.click(screen.getByTestId('save-button'));
// Simulate successful API call
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
onSuccessCallback();
await waitFor(() => {
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
});
// Open modal again to check if role is reset
await user.click(screen.getByRole('button', { name: /add new role/i }));
expect(screen.getByTestId('role-select')).toHaveValue('');
});
});
});

View File

@@ -0,0 +1,92 @@
import React, { FC, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Toast, useToggle } from '@openedx/paragon';
import { Plus } from '@openedx/paragon/icons';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
import messages from '../messages';
import AssignNewRoleModal from './AssignNewRoleModal';
interface AssignNewRoleTriggerProps {
username: string;
libraryId: string;
currentUserRoles: string[];
}
const AssignNewRoleTrigger: FC<AssignNewRoleTriggerProps> = ({
username,
libraryId,
currentUserRoles,
}) => {
const intl = useIntl();
const [isOpen, open, close] = useToggle(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const { roles } = useLibraryAuthZ();
const [newRole, setNewRole] = useState<string>('');
const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole();
const handleAddRole = () => {
const data = {
users: [username],
role: newRole,
scope: libraryId,
};
if (currentUserRoles.includes(newRole)) {
close();
setNewRole('');
return;
}
assignTeamMembersRole({ data }, {
onSuccess: () => {
setToastMessage(
intl.formatMessage(
messages['libraries.authz.manage.assign.role.success'],
),
);
close();
setNewRole('');
},
});
};
return (
<>
<Button
key="authz-header-action-new-team-member"
iconBefore={Plus}
onClick={open}
>
{intl.formatMessage(messages['libraries.authz.manage.assign.new.role.title'])}
</Button>
{isOpen && (
<AssignNewRoleModal
isOpen={isOpen}
close={close}
onSave={handleAddRole}
isLoading={isAssignTeamMembersRolePending}
roleOptions={roles}
selectedRole={newRole}
handleChangeSelectedRole={(e) => setNewRole(e.target.value)}
/>
)}
{toastMessage && (
<Toast
onClose={() => setToastMessage(null)}
show={!!toastMessage}
>
{toastMessage}
</Toast>
)}
</>
);
};
export default AssignNewRoleTrigger;

View File

@@ -0,0 +1,4 @@
import AssignNewRoleModal from './AssignNewRoleModal';
import AssignNewRoleTrigger from './AssignNewRoleTrigger';
export { AssignNewRoleModal, AssignNewRoleTrigger };

View File

@@ -31,6 +31,31 @@ const messages = defineMessages({
defaultMessage: 'Edit', defaultMessage: 'Edit',
description: 'Edit action', description: 'Edit action',
}, },
'libraries.authz.manage.assign.new.role.title': {
id: 'libraries.authz.manage.assign.new.role.title',
defaultMessage: 'Add New Role',
description: 'Libraries AuthZ assign a new role to a user button title',
},
'libraries.authz.manage.cancel.button': {
id: 'libraries.authz.manage.cancel.button',
defaultMessage: 'Cancel',
description: 'Libraries AuthZ cancel button title',
},
'libraries.authz.manage.saving.button': {
id: 'libraries.authz.manage.saving.button',
defaultMessage: 'Saving...',
description: 'Libraries AuthZ saving button title',
},
'libraries.authz.manage.save.button': {
id: 'libraries.authz.manage.save.button',
defaultMessage: 'Save',
description: 'Libraries AuthZ save button title',
},
'libraries.authz.manage.assign.role.success': {
id: 'libraries.authz.manage.assign.role.success',
defaultMessage: 'Role added successfully.',
description: 'Libraries AuthZ assign role success message',
},
}); });
export default messages; export default messages;