feat: [FC-0099] add button & modal for adding new team members (#3)

* feat: add team roles management and update related hooks and types

* feat: implement add new team member functionality with modal and actions

* test: add some missing tests

* test: add unit tests for AddNewTeamMemberModal and update context mocks

* test: add toast close functionality and loading state handling in AddNewTeamMemberTrigger tests

* fix: update LibrariesAuthZTeamView to include canManageTeam check for AddNewTeamMemberTrigger

* fix: correct API endpoint paths and update authorization scope format

* refactor: improve error handling & address PR feedback

* refactor: group AddNewTeamMemberModal in 1 folder

* fix: reset modal values to close action

* refactor: replace useAddTeamMember with useAssignTeamMembersRole

* feat: add tooltip

* test: fix test after rebase

* refactor: enhance user intruction with placeholder

* style: remove unnecessary inline style

* fix:  remove the error style on change the textarea value

* fix: add useState to display toast

* fix: remove empty strings from the user input

* fix: validate error users to apply style

---------

Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
This commit is contained in:
Brayan Cerón
2025-10-21 17:02:21 -05:00
committed by GitHub
parent b50731187b
commit 52fbb7ea9d
16 changed files with 1119 additions and 9 deletions

View File

@@ -65,4 +65,32 @@ describe('AuthZTitle', () => {
expect(onClick).toHaveBeenCalled();
});
});
it('renders action buttons with icons', () => {
const mockIcon = () => <span data-testid="mock-icon">Icon</span>;
const onClick = jest.fn();
const actions = [
{ label: 'Save', icon: mockIcon, onClick },
];
render(<AuthZTitle {...defaultProps} actions={actions} />);
const button = screen.getByRole('button', { name: 'Icon Save' });
expect(button).toBeInTheDocument();
expect(screen.getByTestId('mock-icon')).toBeInTheDocument();
});
it('renders ReactNode actions alongside button actions', () => {
const onClick = jest.fn();
const customAction = <div data-testid="custom-action">Custom Action</div>;
const actions = [
{ label: 'Save', onClick },
customAction,
];
render(<AuthZTitle {...defaultProps} actions={actions} />);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
expect(screen.getByTestId('custom-action')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { ComponentType, isValidElement, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import {
Breadcrumb, Col, Container, Row, Button, Badge,
@@ -11,6 +11,7 @@ interface BreadcrumbLink {
interface Action {
label: string;
icon?: ComponentType;
onClick: () => void;
}
@@ -19,7 +20,7 @@ export interface AuthZTitleProps {
pageTitle: string;
pageSubtitle: string | ReactNode;
navLinks?: BreadcrumbLink[];
actions?: Action[];
actions?: (Action | ReactNode)[];
}
const AuthZTitle = ({
@@ -41,7 +42,22 @@ const AuthZTitle = ({
<Col xs={12} md={4}>
<div className="d-flex justify-content-md-end">
{
actions.map(({ label, onClick }) => <Button key={`authz-header-action-${label}`} onClick={onClick}>{label}</Button>)
actions.map((action) => {
if (isValidElement(action)) {
return action;
}
const { label, icon, onClick } = action as Action;
return (
<Button
key={`authz-header-action-${label}`}
iconBefore={icon}
onClick={onClick}
>
{label}
</Button>
);
})
}
</div>
</Col>

View File

@@ -2,3 +2,11 @@ export const ROUTES = {
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
};
export enum RoleOperationErrorStatus {
USER_NOT_FOUND = 'user_not_found',
USER_ALREADY_HAS_ROLE = 'user_already_has_role',
USER_DOES_NOT_HAVE_ROLE = 'user_does_not_have_role',
ROLE_ASSIGNMENT_ERROR = 'role_assignment_error',
ROLE_REMOVAL_ERROR = 'role_removal_error',
}

View File

@@ -13,6 +13,16 @@ export type PermissionsByRole = {
permissions: string[];
userCount: number;
};
export interface PutAssignTeamMembersRoleResponse {
completed: { user: string; status: string }[];
errors: { userIdentifier: string; error: string }[];
}
export interface AssignTeamMembersRoleRequest {
users: string[];
role: string;
scope: string;
}
// TODO: replece api path once is created
export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
@@ -20,6 +30,13 @@ export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
return camelCaseObject(data.results);
};
export const assignTeamMembersRole = async (
data: AssignTeamMembersRoleRequest,
): Promise<PutAssignTeamMembersRoleResponse> => {
const res = await getAuthenticatedHttpClient().put(getApiUrl('/api/authz/v1/roles/users/'), data);
return camelCaseObject(res.data);
};
// TODO: this should be replaced in the future with Console API
export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> => {
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));

View File

@@ -2,7 +2,9 @@ import { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useLibrary, usePermissionsByRole, useTeamMembers } from './hooks';
import {
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole,
} from './hooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
@@ -155,4 +157,74 @@ describe('usePermissionsByRole', () => {
expect(e).toEqual(new Error('Not found'));
}
});
describe('useAssignTeamMembersRole', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('successfully adds team members', async () => {
const mockResponse = {
completed: [
{
user: 'jdoe',
status: 'role_added',
},
{
user: 'alice@example.com',
status: 'already_has_role',
},
],
errors: [],
};
getAuthenticatedHttpClient.mockReturnValue({
put: jest.fn().mockResolvedValue({ data: mockResponse }),
});
const { result } = renderHook(() => useAssignTeamMembersRole(), {
wrapper: createWrapper(),
});
const addTeamMemberData = {
scope: 'lib:123',
users: ['jdoe'],
role: 'author',
};
await act(async () => {
result.current.mutate({ data: addTeamMemberData });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data).toEqual(mockResponse);
});
it('handles error when adding team members fails', async () => {
getAuthenticatedHttpClient.mockReturnValue({
put: jest.fn().mockRejectedValue(new Error('Failed to add members')),
});
const { result } = renderHook(() => useAssignTeamMembersRole(), {
wrapper: createWrapper(),
});
const addTeamMemberData = {
scope: 'lib:123',
users: ['jdoe'],
role: 'author',
};
await act(async () => {
result.current.mutate({ data: addTeamMemberData });
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.error).toEqual(new Error('Failed to add members'));
});
});
});

View File

@@ -1,7 +1,11 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import {
useMutation, useQuery, useQueryClient, useSuspenseQuery,
} from '@tanstack/react-query';
import { appId } from '@src/constants';
import { LibraryMetadata, TeamMember } from '@src/types';
import {
assignTeamMembersRole,
AssignTeamMembersRoleRequest,
getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole,
} from './api';
@@ -60,3 +64,23 @@ export const useLibrary = (libraryId: string) => useSuspenseQuery<LibraryMetadat
queryFn: () => getLibrary(libraryId),
retry: false,
});
/**
* React Query hook to add new team members to a specific scope or manage the corresponding roles.
* It provides a mutation function to add users with specified roles to the team or assign new roles.
*
* @example
* const { mutate: assignTeamMembersRole } = useAssignTeamMembersRole();
* assignTeamMembersRole({ data: { libraryId: 'lib:123', users: ['jdoe'], role: 'editor' } });
*/
export const useAssignTeamMembersRole = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ data }: {
data: AssignTeamMembersRoleRequest
}) => assignTeamMembersRole(data),
onSettled: (_data, _error, { data: { scope } }) => {
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembers(scope) });
},
});
};

View File

@@ -34,4 +34,13 @@
height: var(--pgn-size-icon-xs);
}
}
}
.toast-container {
// Ensure toast appears above modal
z-index: 1000;
// Move toast to the right
left: auto;
right: var(--pgn-spacing-toast-container-gutter-lg);
}

View File

@@ -24,6 +24,11 @@ jest.mock('./components/TeamTable', () => ({
default: () => <div data-testid="team-table">MockTeamTable</div>,
}));
jest.mock('./components/AddNewTeamMemberModal', () => ({
__esModule: true,
AddNewTeamMemberTrigger: () => <div data-testid="add-team-member-trigger">MockAddNewTeamMemberTrigger</div>,
}));
describe('LibrariesTeamManager', () => {
beforeEach(() => {
initializeMockApp({
@@ -63,5 +68,8 @@ describe('LibrariesTeamManager', () => {
// TeamTable is rendered
expect(screen.getByTestId('team-table')).toBeInTheDocument();
// AddNewTeamMemberTrigger is rendered
expect(screen.getByTestId('add-team-member-trigger')).toBeInTheDocument();
});
});

View File

@@ -1,15 +1,18 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tab, Tabs } from '@openedx/paragon';
import { useLibrary } from '@src/authz-module/data/hooks';
import { useLocation } from 'react-router-dom';
import TeamTable from './components/TeamTable';
import AuthZLayout from '../components/AuthZLayout';
import { useLibraryAuthZ } from './context';
import { AddNewTeamMemberTrigger } from './components/AddNewTeamMemberModal';
import messages from './messages';
const LibrariesTeamManager = () => {
const intl = useIntl();
const { libraryId } = useLibraryAuthZ();
const { hash } = useLocation();
const { libraryId, canManageTeam } = useLibraryAuthZ();
const { data: library } = useLibrary(libraryId);
const rootBradecrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
const pageTitle = intl.formatMessage(messages['library.authz.manage.page.title']);
@@ -21,11 +24,15 @@ const LibrariesTeamManager = () => {
activeLabel={pageTitle}
pageTitle={pageTitle}
pageSubtitle={libraryId}
actions={[]}
actions={
canManageTeam
? [<AddNewTeamMemberTrigger libraryId={libraryId} />]
: []
}
>
<Tabs
variant="tabs"
defaultActiveKey="team"
defaultActiveKey={hash ? 'permissions' : 'team'}
className="bg-light-100 px-5"
>
<Tab eventKey="team" title={intl.formatMessage(messages['library.authz.tabs.team'])} className="p-5">
@@ -34,7 +41,7 @@ const LibrariesTeamManager = () => {
<Tab eventKey="roles" title={intl.formatMessage(messages['library.authz.tabs.roles'])}>
Role tab.
</Tab>
<Tab eventKey="permissions" title={intl.formatMessage(messages['library.authz.tabs.permissions'])}>
<Tab id="libraries-permissions-tab" eventKey="permissions" title={intl.formatMessage(messages['library.authz.tabs.permissions'])}>
Permissions tab.
</Tab>
</Tabs>

View File

@@ -0,0 +1,244 @@
import { screen } 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 AddNewTeamMemberModal from './AddNewTeamMemberModal';
// Mock the context module
jest.mock('@src/authz-module/libraries-manager/context', () => {
const actual = jest.requireActual('@src/authz-module/libraries-manager/context');
return {
...actual,
useLibraryAuthZ: jest.fn(),
};
});
const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock;
jest.mock('@src/authz-module/data/hooks', () => ({
useTeamRoles: jest.fn(),
}));
const defaultProps = {
isOpen: true,
isLoading: false,
isError: false,
formValues: {
users: '',
role: '',
},
close: jest.fn(),
onSave: jest.fn(),
handleChangeForm: jest.fn(),
};
const mockRoles = [
{
role: 'instructor',
name: 'instructor',
description: 'Can create and edit content',
userCount: 3,
objects: [
{
object: 'library',
description: 'Library permissions',
actions: ['view', 'edit', 'delete'],
},
],
},
{
role: 'admin',
name: 'admin',
description: 'Full access to the library',
userCount: 1,
objects: [
{
object: 'library',
description: 'Library permissions',
actions: ['view', 'edit', 'delete', 'manage'],
},
],
},
];
describe('AddNewTeamMemberModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedUseLibraryAuthZ.mockReturnValue({
username: 'testuser',
libraryId: 'lib123',
roles: mockRoles,
canManageTeam: true,
});
});
const renderModal = (props = {}) => {
const finalProps = { ...defaultProps, ...props };
return renderWrapper(
<AddNewTeamMemberModal {...finalProps} />,
);
};
describe('Modal Rendering', () => {
it('renders the modal when isOpen is true', () => {
renderModal();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Add New Team Member')).toBeInTheDocument();
});
it('does not render the modal when isOpen is false', () => {
renderModal({ isOpen: false });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
describe('Form Elements', () => {
it('renders the users textarea with correct label', () => {
renderModal();
expect(screen.getByLabelText('Add users by username or email')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /add users by username or email/i })).toBeInTheDocument();
});
it('renders the role select dropdown with correct label', () => {
renderModal();
expect(screen.getAllByLabelText('Roles')[0]).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /roles/i })).toBeInTheDocument();
});
it('renders role options correctly', () => {
renderModal();
expect(screen.getByText('Select a role')).toBeInTheDocument();
mockRoles.forEach((role) => {
expect(screen.getByText(role.role)).toBeInTheDocument();
});
});
it('displays form values correctly', () => {
renderModal({
formValues: {
users: 'user1@example.com, user2@example.com',
role: 'instructor',
},
});
expect(screen.getByDisplayValue('user1@example.com, user2@example.com')).toBeInTheDocument();
expect(screen.getByDisplayValue('instructor')).toBeInTheDocument();
});
});
describe('Form Interactions', () => {
it('calls handleChangeForm when users textarea changes', async () => {
const user = userEvent.setup();
const handleChangeForm = jest.fn();
renderModal({ handleChangeForm });
const usersTextarea = screen.getByRole('textbox', { name: /add users by username or email/i });
await user.type(usersTextarea, 'test@example.com');
expect(handleChangeForm).toHaveBeenCalled();
});
it('calls handleChangeForm when role select changes', async () => {
const user = userEvent.setup();
const handleChangeForm = jest.fn();
renderModal({ handleChangeForm });
const roleSelect = screen.getByRole('combobox', { name: /roles/i });
await user.selectOptions(roleSelect, 'instructor');
expect(handleChangeForm).toHaveBeenCalled();
});
});
describe('Modal Actions', () => {
it('renders Cancel and Save buttons', () => {
renderModal();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('calls close function when Cancel button is clicked', async () => {
const user = userEvent.setup();
const close = jest.fn();
renderModal({ close });
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(close).toHaveBeenCalledTimes(1);
});
it('calls onSave function when Save button is clicked', async () => {
const user = userEvent.setup();
const onSave = jest.fn();
renderModal({
onSave,
formValues: {
users: 'test@example.com',
role: 'instructor',
},
});
await user.click(screen.getByRole('button', { name: /save/i }));
expect(onSave).toHaveBeenCalledTimes(1);
});
it('disables Save button when users field is empty', () => {
renderModal({
formValues: {
users: '',
role: 'instructor',
},
});
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});
it('disables Save button when role field is empty', () => {
renderModal({
formValues: {
users: 'test@example.com',
role: '',
},
});
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});
it('enables Save button when both fields are filled', () => {
renderModal({
formValues: {
users: 'test@example.com',
role: 'instructor',
},
});
expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled();
});
});
describe('Loading State', () => {
it('disables Cancel button when loading', () => {
renderModal({ isLoading: true });
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
});
it('disables Save button when loading', () => {
renderModal({
isLoading: true,
formValues: {
users: 'test@example.com',
role: 'instructor',
},
});
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,127 @@
import { FC, useRef } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Form, Hyperlink, Icon, IconButton, ModalDialog,
ModalPopup,
Stack,
StatefulButton,
useToggle,
} from '@openedx/paragon';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { Info, SpinnerSimple } from '@openedx/paragon/icons';
import messages from './messages';
interface AddNewTeamMemberModalProps {
isOpen: boolean;
isError: boolean;
isLoading: boolean;
formValues: {
users: string;
role: string;
};
close: () => void;
onSave: () => void;
handleChangeForm: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => void;
}
const AddNewTeamMemberModal: FC<AddNewTeamMemberModalProps> = ({
isOpen, isError, isLoading, formValues, close, onSave, handleChangeForm,
}) => {
const intl = useIntl();
const { roles } = useLibraryAuthZ();
const [isOpenRolesPopUp, openRolesPopUp, closeRolesPopUp] = useToggle(false);
const targetRolesPopUpRef = useRef<HTMLButtonElement | null>(null);
return (
<>
<ModalPopup
hasArrow
placement="auto"
positionRef={targetRolesPopUpRef.current}
isOpen={isOpenRolesPopUp}
onClose={closeRolesPopUp}
>
<div className="bg-white p-3 rounded shadow border x-small">
<ul>
{roles.map((role) => <li key={`role-tooltip-${role.role}`}><b>{role.name}: </b>{role.description}</li>)}
</ul>
<Hyperlink destination="#libraries-permissions-tab" target="_blank">{intl.formatMessage(messages['libraries.authz.manage.tooltip.roles.extra.info'])}</Hyperlink>
</div>
</ModalPopup>
<ModalDialog
title={intl.formatMessage(messages['libraries.authz.manage.add.member.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.add.member.title'])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="my-4">
<Stack gap={3}>
<p>
{intl.formatMessage(messages['libraries.authz.manage.add.member.description'])}
</p>
<Form.Group controlId="users_list">
<Form.Label>{intl.formatMessage(messages['libraries.authz.manage.add.member.users.label'])}</Form.Label>
<Form.Control
isInvalid={isError}
as="textarea"
name="users"
rows="3"
value={formValues.users}
onChange={(e) => handleChangeForm(e)}
placeholder={intl.formatMessage(messages['libraries.authz.manage.add.member.users.placeholder'])}
style={{ color: isError && 'var(--pgn-color-form-feedback-invalid)' }}
/>
</Form.Group>
<Form.Group controlId="role_options">
<Form.Label>
{intl.formatMessage(messages['libraries.authz.manage.add.member.roles.label'])}
<IconButton alt="tooptip-extra-info" size="inline" src={Info} onClick={openRolesPopUp} ref={targetRolesPopUpRef} />
</Form.Label>
<Form.Control as="select" name="role" value={formValues.role} onChange={(e) => handleChangeForm(e)}>
<option value="" disabled>
{intl.formatMessage(messages['libraries.authz.manage.add.member.select.default'])}
</option>
{roles.map((role) => <option key={role.role} value={role.role}>{role.name}</option>)}
</Form.Control>
</Form.Group>
</Stack>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary" disabled={isLoading}>
{intl.formatMessage(messages['libraries.authz.manage.cancel.button'])}
</ModalDialog.CloseButton>
<StatefulButton
className="px-4"
labels={{
default: intl.formatMessage(messages['libraries.authz.manage.save.button']),
pending: intl.formatMessage(messages['libraries.authz.manage.saving.button']),
}}
icons={{
pending: <Icon src={SpinnerSimple} />,
}}
state={isLoading ? 'pending' : 'default'}
onClick={() => onSave()}
disabledStates={['pending']}
disabled={isLoading || !formValues.users || !formValues.role}
/>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
</>
);
};
export default AddNewTeamMemberModal;

View File

@@ -0,0 +1,290 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWrapper } from '@src/setupTest';
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
import AddNewTeamMemberTrigger from './AddNewTeamMemberTrigger';
const mockMutate = jest.fn();
// Mock the hooks module
jest.mock('@src/authz-module/data/hooks', () => ({
useAssignTeamMembersRole: jest.fn(),
}));
jest.mock('./AddNewTeamMemberModal', () => {
/* eslint-disable react/prop-types */
const MockModal = ({
isOpen, close, onSave, isLoading, formValues, handleChangeForm,
}) => (
isOpen ? (
<div data-testid="add-team-member-modal">
<button type="button" onClick={close} data-testid="close-modal">Close</button>
<button type="button" onClick={onSave} data-testid="save-modal">Save</button>
<textarea
name="users"
value={formValues?.users || ''}
onChange={handleChangeForm}
data-testid="users-input"
/>
<select
name="role"
value={formValues?.role || ''}
onChange={handleChangeForm}
data-testid="role-select"
>
<option value="">Select role</option>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
</select>
{isLoading && <div data-testid="loading-indicator">Loading...</div>}
</div>
) : null
);
/* eslint-enable react/prop-types */
return MockModal;
});
describe('AddNewTeamMemberTrigger', () => {
const mockLibraryId = 'lib:123';
beforeEach(() => {
jest.clearAllMocks();
(useAssignTeamMembersRole as jest.Mock).mockReturnValue({
mutate: mockMutate,
isPending: false,
isError: false,
isSuccess: false,
} as any);
});
it('renders the trigger button', () => {
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const button = screen.getByRole('button', { name: /add new team member/i });
expect(button).toBeInTheDocument();
});
it('opens modal when trigger button is clicked', async () => {
const user = userEvent.setup();
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
});
it('closes modal when close button is clicked', async () => {
const user = userEvent.setup();
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
const closeButton = screen.getByTestId('close-modal');
await user.click(closeButton);
expect(screen.queryByTestId('add-team-member-modal')).not.toBeInTheDocument();
});
it('calls addTeamMember with correct data when save is clicked', async () => {
const user = userEvent.setup();
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
const usersInput = screen.getByTestId('users-input');
const roleSelect = screen.getByTestId('role-select');
const saveButton = screen.getByTestId('save-modal');
await user.type(usersInput, 'alice@example.com, bob@example.com');
await user.selectOptions(roleSelect, 'editor');
await user.click(saveButton);
expect(mockMutate).toHaveBeenCalledWith(
{
data: {
users: ['alice@example.com', 'bob@example.com'],
role: 'editor',
scope: mockLibraryId,
},
},
expect.objectContaining({
onSuccess: expect.any(Function),
}),
);
});
it('displays success toast and closes modal on successful addition with no errors', async () => {
const user = userEvent.setup();
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
const saveButton = screen.getByTestId('save-modal');
await user.click(saveButton);
// Simulate successful response with no errors
const [, { onSuccess }] = mockMutate.mock.calls[0];
onSuccess({
completed: [
{ userIdentifier: 'alice@example.com', status: 'role_added' },
{ userIdentifier: 'bob@example.com', status: 'added_to_team' },
],
errors: [],
});
await waitFor(() => {
expect(screen.queryByTestId('add-team-member-modal')).not.toBeInTheDocument();
});
expect(screen.getByText('2 team members added successfully.')).toBeInTheDocument();
});
it('displays mixed success and error toast on partial success', async () => {
const user = userEvent.setup();
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
const saveButton = screen.getByTestId('save-modal');
await user.click(saveButton);
// Simulate partial success response
const [, { onSuccess }] = mockMutate.mock.calls[0];
onSuccess({
completed: [
{ userIdentifier: 'alice@example.com', status: 'role_added' },
],
errors: [
{ userIdentifier: 'unknown@example.com', error: 'user_not_found' },
],
});
await waitFor(() => {
expect(screen.getByText(/1 team member added successfully/)).toBeInTheDocument();
expect(screen.getByText(/We couldn't find a user for 1 email address or username/)).toBeInTheDocument();
});
// Modal should remain open when there are errors
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
});
it('displays only error toast when all additions fail', async () => {
const user = userEvent.setup();
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
const saveButton = screen.getByTestId('save-modal');
await user.click(saveButton);
// Simulate all failed response
const [, { onSuccess }] = mockMutate.mock.calls[0];
onSuccess({
completed: [],
errors: [
{ userIdentifier: 'unknown1@example.com', error: 'user_not_found' },
{ userIdentifier: 'unknown2@example.com', error: 'user_not_found' },
],
});
await waitFor(() => {
expect(screen.getByText(/We couldn't find a user for 2 email addresses or usernames/)).toBeInTheDocument();
});
// Modal should remain open when there are errors
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
});
it('resets form values after successful addition with no errors', async () => {
const user = userEvent.setup();
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
const usersInput = screen.getByTestId('users-input');
const roleSelect = screen.getByTestId('role-select');
const saveButton = screen.getByTestId('save-modal');
await user.type(usersInput, 'alice@example.com');
await user.selectOptions(roleSelect, 'editor');
await user.click(saveButton);
// Simulate successful response with no errors
const [, { onSuccess }] = mockMutate.mock.calls[0];
onSuccess({
completed: [{ userIdentifier: 'alice@example.com', status: 'role_added' }],
errors: [],
});
// Open modal again to check if form is reset
await user.click(triggerButton);
const newUsersInput = screen.getByTestId('users-input');
const newRoleSelect = screen.getByTestId('role-select');
expect(newUsersInput).toHaveValue('');
expect(newRoleSelect).toHaveValue('');
});
it('allows closing the success/error toast message', async () => {
const user = userEvent.setup();
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
const saveButton = screen.getByTestId('save-modal');
await user.click(saveButton);
// Simulate successful response
const [, { onSuccess }] = mockMutate.mock.calls[0];
onSuccess({
completed: [{ userIdentifier: 'alice@example.com', status: 'role_added' }],
errors: [],
});
// Toast should be visible
await waitFor(() => {
expect(screen.getByText('1 team member added successfully.')).toBeInTheDocument();
});
// Find and close the toast
const toastCloseButton = screen.getByLabelText(/close/i);
await user.click(toastCloseButton);
// Toast should be removed
await waitFor(() => {
expect(screen.queryByText('1 team member added successfully.')).not.toBeInTheDocument();
});
});
it('displays loading state when adding team member', async () => {
const user = userEvent.setup();
// Mock loading state
(useAssignTeamMembersRole as jest.Mock).mockReturnValue({
mutate: mockMutate,
isPending: true,
isError: false,
isSuccess: false,
} as any);
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
await user.click(triggerButton);
// Loading indicator should be visible in the modal
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,156 @@
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 { PutAssignTeamMembersRoleResponse } from 'authz-module/data/api';
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
import { RoleOperationErrorStatus } from '@src/authz-module/constants';
import AddNewTeamMemberModal from './AddNewTeamMemberModal';
import messages from './messages';
interface AddNewTeamMemberTriggerProps {
libraryId: string;
}
const DEFAULT_FORM_VALUES = {
users: '',
role: '',
};
const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({
libraryId,
}) => {
const intl = useIntl();
const [isOpen, open, close] = useToggle(false);
const [showToast, setShowToast] = useState(false);
const [additionMessage, setAdditionMessage] = useState<string | null>(null);
const [formValues, setFormValues] = useState(DEFAULT_FORM_VALUES);
const [isError, setIsError] = useState(false);
const [errorValidationUsers, setNotFoundUsers] = useState<string[]>([]);
const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole();
const handleChangeForm = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
const userIds = value
.split(',')
.map(userId => userId.trim())
.filter(Boolean);
const hasErrorUser = errorValidationUsers.find((noUser) => userIds.includes(noUser));
if (hasErrorUser) {
setIsError(true);
} else {
setIsError(false);
}
setFormValues((prev) => ({
...prev,
[name]: value,
}));
};
const handleErrors = (errors: PutAssignTeamMembersRoleResponse['errors']) => {
setIsError(false);
const notFoundUsers = errors.filter(err => err.error === RoleOperationErrorStatus.USER_NOT_FOUND)
.map(err => err.userIdentifier.trim());
if (errors.length === 1 && errors[0].error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE) {
setFormValues(DEFAULT_FORM_VALUES);
close();
}
if (notFoundUsers.length) {
setNotFoundUsers(notFoundUsers);
setIsError(true);
setFormValues((prev) => ({
...prev,
users: notFoundUsers.join(', '),
}));
setAdditionMessage((prevMessage) => (
`${prevMessage ? `${prevMessage} ` : ''}${intl.formatMessage(
messages['libraries.authz.manage.add.member.failure'],
{ count: notFoundUsers.length },
)}`
));
setShowToast(true);
}
};
const handleAddTeamMember = () => {
const normalizedUsers = new Set(formValues.users.split(',').map(user => user.trim()).filter(user => user));
const data = {
users: [...normalizedUsers],
role: formValues.role,
scope: libraryId,
};
assignTeamMembersRole({ data }, {
onSuccess: (successData) => {
setAdditionMessage(null);
if (successData.completed.length) {
setAdditionMessage(
intl.formatMessage(
messages['libraries.authz.manage.add.member.success'],
{ count: successData.completed.length },
),
);
setShowToast(true);
}
if (successData.errors.length) {
handleErrors(successData.errors);
} else {
setIsError(false);
setNotFoundUsers([]);
close();
setFormValues(DEFAULT_FORM_VALUES);
}
},
});
};
const handleClose = () => {
setFormValues(DEFAULT_FORM_VALUES);
setNotFoundUsers([]);
setIsError(false);
setAdditionMessage(null);
close();
};
return (
<>
<Button
key="authz-header-action-new-team-member"
iconBefore={Plus}
onClick={open}
>
{intl.formatMessage(messages['libraries.authz.manage.add.member.title'])}
</Button>
{isOpen && (
<AddNewTeamMemberModal
isOpen={isOpen}
isError={isError}
close={handleClose}
onSave={handleAddTeamMember}
isLoading={isAssignTeamMembersRolePending}
formValues={formValues}
handleChangeForm={handleChangeForm}
/>
)}
{additionMessage && (
<Toast
onClose={() => setShowToast(false)}
show={showToast}
>
{additionMessage}
</Toast>
)}
</>
);
};
export default AddNewTeamMemberTrigger;

View File

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

View File

@@ -0,0 +1,71 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'libraries.authz.manage.add.member.title': {
id: 'libraries.authz.manage.add.member.title',
defaultMessage: 'Add New Team Member',
description: 'Title for the add new team member modal',
},
'libraries.authz.manage.add.member.users.label': {
id: 'libraries.authz.manage.add.member.users.label',
defaultMessage: 'Add users by username or email',
description: 'Label for the users input field in the add new team member modal',
},
'libraries.authz.manage.add.member.users.placeholder': {
id: 'libraries.authz.manage.add.member.users.placeholder',
defaultMessage: 'Enter one or more email addresses or usernames, comma-separated.',
description: 'Placeholder for the users input field in the add new team member modal',
},
'libraries.authz.manage.add.member.roles.label': {
id: 'libraries.authz.manage.add.member.roles.label',
defaultMessage: 'Roles',
description: 'Label for the roles select field in the add new team member modal',
},
'libraries.authz.manage.add.member.invalid.users': {
id: 'libraries.authz.manage.add.member.invalid.users',
defaultMessage: 'The following users could not be found:',
description: 'Error message for invalid users in the add new team member modal',
},
'libraries.authz.manage.add.member.select.default': {
id: 'libraries.authz.manage.add.member.select.default',
defaultMessage: 'Select a role',
description: 'Default option for the roles select field in the add new team member modal',
},
'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.add.member.description': {
id: 'libraries.authz.manage.add.member.description',
defaultMessage: 'Add new members to this library\'s team and assign them a role to define their permissions.',
description: 'Description for the add new team member modal',
},
'libraries.authz.manage.add.member.success': {
id: 'libraries.authz.manage.add.member.success',
defaultMessage: '{count, plural, one {# team member added successfully.} other {# team members added successfully.}}',
description: 'Success message when adding new team members',
},
'libraries.authz.manage.add.member.failure': {
id: 'libraries.authz.manage.add.member.failure',
defaultMessage: 'We couldn\'t find a user for {count, plural, one {# email address or username.} other {# email addresses or usernames.}} Please check the values and try again, or invite them to join your organization first.',
description: 'Error message when adding new team members',
},
'libraries.authz.manage.tooltip.roles.extra.info': {
id: 'libraries.authz.manage.tooltip.roles.extra.info',
defaultMessage: 'View detailed permissions for each role.',
description: 'Invite the user to check a detailed view of permissions',
},
});
export default messages;

View File

@@ -2,6 +2,7 @@ import { screen } from '@testing-library/react';
import { useParams } from 'react-router-dom';
import { useValidateUserPermissions } from '@src/data/hooks';
import { renderWrapper } from '@src/setupTest';
import { usePermissionsByRole } from '@src/authz-module/data/hooks';
import { LibraryAuthZProvider, useLibraryAuthZ } from './context';
jest.mock('react-router-dom', () => ({
@@ -45,6 +46,34 @@ describe('LibraryAuthZProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
(useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' });
(usePermissionsByRole as jest.Mock).mockReturnValue({
data: [
{
role: 'instructor',
description: 'Can create and edit content',
userCount: 3,
objects: [
{
object: 'library',
description: 'Library permissions',
actions: ['view', 'edit', 'delete'],
},
],
},
{
role: 'admin',
description: 'Full access to the library',
userCount: 1,
objects: [
{
object: 'library',
description: 'Library permissions',
actions: ['view', 'edit', 'delete', 'manage'],
},
],
},
],
});
});
it('provides the correct context values to consumers', () => {