diff --git a/src/courseTeam/CourseTeamPage.test.tsx b/src/courseTeam/CourseTeamPage.test.tsx
index a36a9a54..9cbb9854 100644
--- a/src/courseTeam/CourseTeamPage.test.tsx
+++ b/src/courseTeam/CourseTeamPage.test.tsx
@@ -3,6 +3,15 @@ import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import CourseTeamPage from '@src/courseTeam/CourseTeamPage';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: jest.fn(() => ({ courseId: 'course-v1:test-course' })),
+}));
+
+jest.mock('./data/apiHook', () => ({
+ useAddTeamMember: () => ({ mutate: jest.fn() }),
+}));
+
// Mock the child components, each component should have its own test suite
jest.mock('./components/MembersContent', () => {
return function MembersContent() {
@@ -16,6 +25,12 @@ jest.mock('./components/RolesContent', () => {
};
});
+jest.mock('./components/AddTeamMemberModal', () => {
+ return function AddTeamMemberModal() {
+ return
Add Team Member Modal
;
+ };
+});
+
describe('CourseTeamPage', () => {
it('renders the course team title', () => {
renderWithIntl();
@@ -44,10 +59,12 @@ describe('CourseTeamPage', () => {
expect(title).toHaveClass('text-primary-700', 'mb-0');
});
- it('has primary variant on add button', () => {
+ it('shows the AddTeamMemberModal when add button is clicked', async () => {
renderWithIntl();
const button = screen.getByRole('button', { name: /add team member/i });
- expect(button).toHaveClass('btn-primary');
+ const user = userEvent.setup();
+ await user.click(button);
+ expect(screen.getByText('Add Team Member Modal')).toBeInTheDocument();
});
it('renders RolesContent when Roles tab is selected', async () => {
diff --git a/src/courseTeam/CourseTeamPage.tsx b/src/courseTeam/CourseTeamPage.tsx
index 2dbbd411..12df095a 100644
--- a/src/courseTeam/CourseTeamPage.tsx
+++ b/src/courseTeam/CourseTeamPage.tsx
@@ -1,18 +1,32 @@
+import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
-import { Button, Tab, Tabs } from '@openedx/paragon';
+import { Button, Tab, Tabs, useToggle } from '@openedx/paragon';
import { Plus } from '@openedx/paragon/icons';
+import AddTeamMemberModal from '@src/courseTeam/components/AddTeamMemberModal';
import MembersContent from '@src/courseTeam/components/MembersContent';
import RolesContent from '@src/courseTeam/components/RolesContent';
+import { useAddTeamMember } from '@src/courseTeam/data/apiHook';
import messages from '@src/courseTeam/messages';
const CourseTeamPage = () => {
const intl = useIntl();
+ const { courseId = '' } = useParams<{ courseId: string }>();
+ const [isOpenAddModal, openAddModal, closeAddModal] = useToggle(false);
+ const { mutate: addTeamMember } = useAddTeamMember(courseId);
+
+ const handleAdd = ({ users, role }: { users: string[], role: string }) => {
+ addTeamMember({ users, role }, {
+ onSuccess: () => {
+ closeAddModal();
+ },
+ });
+ };
return (
<>
{intl.formatMessage(messages.courseTeamTitle)}
-
+
@@ -22,6 +36,7 @@ const CourseTeamPage = () => {
+ {isOpenAddModal && }
>
);
};
diff --git a/src/courseTeam/components/AddTeamMemberModal.test.tsx b/src/courseTeam/components/AddTeamMemberModal.test.tsx
new file mode 100644
index 00000000..60fc6528
--- /dev/null
+++ b/src/courseTeam/components/AddTeamMemberModal.test.tsx
@@ -0,0 +1,72 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithIntl } from '@src/testUtils';
+import AddTeamMemberModal from './AddTeamMemberModal';
+import messages from '../messages';
+import { useRoles } from '../data/apiHook';
+
+// Mocks
+jest.mock('react-router-dom', () => ({
+ useParams: () => ({ courseId: 'course-v1:test+id' }),
+}));
+
+jest.mock('@src/data/apiHook', () => ({
+ useCourseInfo: () => ({ data: { displayName: 'Test Course' } }),
+}));
+
+jest.mock('../data/apiHook', () => ({
+ useRoles: jest.fn(),
+}));
+
+describe('AddTeamMemberModal', () => {
+ const defaultProps = {
+ isOpen: true,
+ onClose: jest.fn(),
+ onSave: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useRoles as jest.Mock).mockReturnValue({ data: [{ id: 'admin', name: 'Admin' }] });
+ });
+
+ it('renders modal with correct title and description', () => {
+ renderWithIntl();
+ expect(screen.getByText(messages.addNewTeamMember.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages.addNewTeamMemberDescription.defaultMessage.replace('{courseName}', 'Test Course'))).toBeInTheDocument();
+ });
+
+ it('renders users textarea and role select', () => {
+ renderWithIntl();
+ expect(screen.getByLabelText(messages.addUsersLabel.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByPlaceholderText(messages.usersPlaceholder.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByLabelText(messages.roleLabel.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('calls onClose when Cancel button is clicked', async () => {
+ renderWithIntl();
+ const user = userEvent.setup();
+ await user.click(screen.getByText(messages.cancelButton.defaultMessage));
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+
+ it('calls onSave when Save button is clicked', async () => {
+ renderWithIntl();
+ const user = userEvent.setup();
+ await user.click(screen.getByText(messages.saveButton.defaultMessage));
+ expect(defaultProps.onSave).toHaveBeenCalledWith({ users: [''], role: '' });
+ });
+
+ it('does not render modal when isOpen is false', () => {
+ renderWithIntl();
+ expect(screen.queryByText(messages.addNewTeamMember.defaultMessage)).not.toBeInTheDocument();
+ });
+
+ it('disables role select when only placeholder role exists', () => {
+ (useRoles as jest.Mock).mockReturnValue({ data: [] });
+ renderWithIntl();
+ expect(screen.getByLabelText(messages.roleLabel.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByPlaceholderText(messages.rolePlaceholder.defaultMessage)).toBeDisabled();
+ });
+});
diff --git a/src/courseTeam/components/AddTeamMemberModal.tsx b/src/courseTeam/components/AddTeamMemberModal.tsx
new file mode 100644
index 00000000..5919c305
--- /dev/null
+++ b/src/courseTeam/components/AddTeamMemberModal.tsx
@@ -0,0 +1,64 @@
+import { useParams } from 'react-router-dom';
+import { useIntl } from '@openedx/frontend-base';
+import { ActionRow, Button, Form, ModalDialog } from '@openedx/paragon';
+import messages from '../messages';
+import { useCourseInfo } from '@src/data/apiHook';
+import { useRoles } from '../data/apiHook';
+
+interface AddTeamMemberModalProps {
+ isOpen: boolean,
+ onClose: () => void,
+ onSave: ({ users, role }: { users: string[], role: string }) => void,
+}
+
+const AddTeamMemberModal = ({
+ isOpen,
+ onClose,
+ onSave,
+}: AddTeamMemberModalProps) => {
+ const intl = useIntl();
+ const { courseId = '' } = useParams<{ courseId: string }>();
+ const { data: { displayName } = { displayName: '' } } = useCourseInfo(courseId);
+ const { data: { results } = { results: [] } } = useRoles(courseId);
+
+ const roles = [{ role: '', displayName: intl.formatMessage(messages.rolePlaceholder) }, ...(results || [])];
+
+ const handleSave = () => {
+ onSave({ users: [''], role: '' });
+ };
+
+ return (
+
+
+ {intl.formatMessage(messages.addNewTeamMember)}
+
+
+ {intl.formatMessage(messages.addNewTeamMemberDescription, { courseName: displayName })}
+
+ {intl.formatMessage(messages.addUsersLabel)}
+
+
+
+ {intl.formatMessage(messages.roleLabel)}
+
+ {
+ roles.map((role) => (
+
+ ))
+ }
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AddTeamMemberModal;
diff --git a/src/courseTeam/data/api.test.ts b/src/courseTeam/data/api.test.ts
index 9cff3624..4b7a2562 100644
--- a/src/courseTeam/data/api.test.ts
+++ b/src/courseTeam/data/api.test.ts
@@ -1,5 +1,5 @@
import { getAuthenticatedHttpClient } from '@openedx/frontend-base';
-import { getTeamMembers, getRoles } from '@src/courseTeam/data/api';
+import { getTeamMembers, getRoles, addTeamMember } from '@src/courseTeam/data/api';
jest.mock('@openedx/frontend-base', () => ({
...jest.requireActual('@openedx/frontend-base'),
@@ -12,6 +12,7 @@ jest.mock('../../data/api', () => ({
const httpClientMock = {
get: jest.fn(),
+ post: jest.fn(),
};
beforeEach(() => {
@@ -52,6 +53,15 @@ describe('courseTeam API', () => {
const expectedUrl = `/api/instructor/v2/courses/${courseId}/team?page=1&page_size=10&role=instructor`;
expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
});
+
+ it('should handle errors when API call fails', async () => {
+ const courseId = 'course-v1:edX+DemoX+Demo_Course';
+ const params = { page: 0, pageSize: 10 };
+ const error = new Error('API error');
+ httpClientMock.get.mockRejectedValue(error);
+
+ await expect(getTeamMembers(courseId, params)).rejects.toThrow('API error');
+ });
});
describe('getRoles', () => {
@@ -76,4 +86,21 @@ describe('courseTeam API', () => {
expect(result).toEqual(data);
});
});
+
+ describe('addTeamMember', () => {
+ it('should call the correct endpoint to add a team member', async () => {
+ const courseId = 'course-v1:edX+DemoX+Demo_Course';
+ const users = ['testuser'];
+ const role = 'instructor';
+ httpClientMock.post.mockResolvedValue({ data: {
+ users,
+ role,
+ } });
+
+ await addTeamMember(courseId, users, role);
+
+ const expectedUrl = `/api/instructor/v2/courses/${courseId}/team`;
+ expect(httpClientMock.post).toHaveBeenCalledWith(expectedUrl, { users, role });
+ });
+ });
});
diff --git a/src/courseTeam/data/api.ts b/src/courseTeam/data/api.ts
index 8cbf9436..e4f12709 100644
--- a/src/courseTeam/data/api.ts
+++ b/src/courseTeam/data/api.ts
@@ -32,3 +32,10 @@ export const getRoles = async (courseId: string): Promise, '
);
return camelCaseObject(data);
};
+
+export const addTeamMember = async (courseId: string, users: string[], role: string): Promise => {
+ await getAuthenticatedHttpClient().post(
+ `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/team`,
+ { users, role }
+ );
+};
diff --git a/src/courseTeam/data/apiHook.ts b/src/courseTeam/data/apiHook.ts
index 4ce29a7b..a2e8e7b4 100644
--- a/src/courseTeam/data/apiHook.ts
+++ b/src/courseTeam/data/apiHook.ts
@@ -1,5 +1,5 @@
-import { useQuery } from '@tanstack/react-query';
-import { getRoles, getTeamMembers } from '@src/courseTeam/data/api';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { addTeamMember, getRoles, getTeamMembers } from '@src/courseTeam/data/api';
import { courseTeamQueryKeys } from '@src/courseTeam/data/queryKeys';
import { CourseTeamMemberQueryParams } from '@src/courseTeam/types';
@@ -18,3 +18,14 @@ export const useRoles = (courseId: string) => (
enabled: !!courseId,
})
);
+
+export const useAddTeamMember = (courseId: string) => {
+ const queryClient = useQueryClient();
+ return (useMutation({
+ mutationFn: ({ users, role }: { users: string[], role: string }) => addTeamMember(courseId, users, role),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.byCourse(courseId) });
+ }
+ })
+ );
+};
diff --git a/src/courseTeam/messages.ts b/src/courseTeam/messages.ts
index 4403a137..27471332 100644
--- a/src/courseTeam/messages.ts
+++ b/src/courseTeam/messages.ts
@@ -155,7 +155,47 @@ const messages = defineMessages({
id: 'instruct.courseTeam.roles.ccxCoachDescription',
defaultMessage: 'CCX Coaches are able to create their own Custom Courses based on this course, which they can use to provide personalized instruction to their own students based in this course material.',
description: 'Description for CCX coach role',
- }
+ },
+ addNewTeamMember: {
+ id: 'instruct.courseTeam.addNewTeamMember',
+ defaultMessage: 'Add New Team Member',
+ description: 'Title for add new team member form',
+ },
+ addNewTeamMemberDescription: {
+ id: 'instruct.courseTeam.addNewTeamMemberDescription',
+ defaultMessage: 'Add new members to {courseName}’s Course team and assign them a role to define their permissions.',
+ description: 'Description for add new team member form',
+ },
+ addUsersLabel: {
+ id: 'instruct.courseTeam.addUsersLabel',
+ defaultMessage: 'Add users by username or email',
+ description: 'Label for input to add users to course team',
+ },
+ usersPlaceholder: {
+ id: 'instruct.courseTeam.usersPlaceholder',
+ defaultMessage: 'Enter one or more email addresses or usernames',
+ description: 'Placeholder for input to add users to course team',
+ },
+ roleLabel: {
+ id: 'instruct.courseTeam.roleLabel',
+ defaultMessage: 'Role',
+ description: 'Label for role selection when adding users to course team',
+ },
+ rolePlaceholder: {
+ id: 'instruct.courseTeam.rolePlaceholder',
+ defaultMessage: 'Select Role',
+ description: 'Placeholder for role selection when adding users to course team',
+ },
+ cancelButton: {
+ id: 'instruct.courseTeam.cancelButton',
+ defaultMessage: 'Cancel',
+ description: 'Label for cancel button when adding users to course team',
+ },
+ saveButton: {
+ id: 'instruct.courseTeam.saveButton',
+ defaultMessage: 'Save',
+ description: 'Label for save button when adding users to course team',
+ },
});
export default messages;