diff --git a/src/courseTeam/CourseTeamPage.test.tsx b/src/courseTeam/CourseTeamPage.test.tsx
new file mode 100644
index 00000000..aaa9e2ca
--- /dev/null
+++ b/src/courseTeam/CourseTeamPage.test.tsx
@@ -0,0 +1,77 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithIntl } from '@src/testUtils';
+import CourseTeamPage from './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() {
+ return
Members Content
;
+ };
+});
+
+jest.mock('./components/RolesContent', () => {
+ return function RolesContent() {
+ return Roles Content
;
+ };
+});
+
+jest.mock('./components/AddTeamMemberModal', () => {
+ return function AddTeamMemberModal() {
+ return Add Team Member Modal
;
+ };
+});
+
+describe('CourseTeamPage', () => {
+ it('renders the course team title', () => {
+ renderWithIntl();
+ expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
+ });
+
+ it('renders the add team member button', () => {
+ renderWithIntl();
+ expect(screen.getByRole('button', { name: /add team member/i })).toBeInTheDocument();
+ });
+
+ it('renders both tabs', () => {
+ renderWithIntl();
+ expect(screen.getByRole('tab', { name: /members/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /roles/i })).toBeInTheDocument();
+ });
+
+ it('renders MembersContent by default', () => {
+ renderWithIntl();
+ expect(screen.getByText('Members Content')).toBeInTheDocument();
+ });
+
+ it('has correct CSS classes on title', () => {
+ renderWithIntl();
+ const title = screen.getByRole('heading', { level: 3 });
+ expect(title).toHaveClass('text-primary-700', 'mb-0');
+ });
+
+ it('shows the AddTeamMemberModal when add button is clicked', async () => {
+ renderWithIntl();
+ const button = screen.getByRole('button', { name: /add team member/i });
+ 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 () => {
+ renderWithIntl();
+ const rolesTab = screen.getByRole('tab', { name: /roles/i });
+ const user = userEvent.setup();
+ await user.click(rolesTab);
+ expect(screen.getByText('Roles Content')).toBeInTheDocument();
+ });
+});
diff --git a/src/courseTeam/CourseTeamPage.tsx b/src/courseTeam/CourseTeamPage.tsx
index 065f294c..561d2380 100644
--- a/src/courseTeam/CourseTeamPage.tsx
+++ b/src/courseTeam/CourseTeamPage.tsx
@@ -1,8 +1,39 @@
+import { useParams } from 'react-router-dom';
+import { useIntl } from '@openedx/frontend-base';
+import { Button, Tab, Tabs, useToggle } from '@openedx/paragon';
+import messages from './messages';
+import MembersContent from './components/MembersContent';
+import RolesContent from './components/RolesContent';
+import AddTeamMemberModal from './components/AddTeamMemberModal';
+import { useAddTeamMember } from './data/apiHook';
+
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 });
+ closeAddModal();
+ };
+
return (
-
-
Course Team
-
+ <>
+
+
{intl.formatMessage(messages.courseTeamTitle)}
+
+
+
+
+
+
+
+
+
+
+ {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..4b96b022
--- /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 } = useRoles(courseId);
+
+ const roles = [{ id: '', name: intl.formatMessage(messages.rolePlaceholder) }, ...(data || [])];
+
+ 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/components/MembersContent.test.tsx b/src/courseTeam/components/MembersContent.test.tsx
new file mode 100644
index 00000000..978920d6
--- /dev/null
+++ b/src/courseTeam/components/MembersContent.test.tsx
@@ -0,0 +1,128 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithIntl } from '@src/testUtils';
+import { useTeamMembers } from '../data/apiHook';
+import MembersContent from './MembersContent';
+import messages from '../messages';
+
+const courseId = 'course-v1:edX+DemoX+Demo_Course';
+
+jest.mock('../data/apiHook', () => ({
+ useTeamMembers: jest.fn(),
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({ courseId: courseId }),
+}));
+
+const mockTeamMembers = [
+ { username: 'user1', email: 'user1@example.com', role: 'Admin' },
+ { username: 'user2', email: 'user2@example.com', role: 'Staff' },
+];
+
+const renderComponent = () => renderWithIntl();
+
+describe('MembersContent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders loading state correctly', () => {
+ (useTeamMembers as jest.Mock).mockReturnValue({
+ data: { results: [], numPages: 1, count: 0 },
+ isLoading: true,
+ });
+
+ renderComponent();
+ expect(screen.getByRole('table')).toBeInTheDocument();
+ });
+
+ it('renders team members data correctly', () => {
+ (useTeamMembers as jest.Mock).mockReturnValue({
+ data: { results: mockTeamMembers, numPages: 1, count: 2 },
+ isLoading: false,
+ });
+
+ renderComponent();
+
+ expect(screen.getByText(mockTeamMembers[0].username)).toBeInTheDocument();
+ expect(screen.getByText(mockTeamMembers[0].email)).toBeInTheDocument();
+ expect(screen.getByText(mockTeamMembers[0].role)).toBeInTheDocument();
+ expect(screen.getByText(mockTeamMembers[1].username)).toBeInTheDocument();
+ expect(screen.getByText(mockTeamMembers[1].email)).toBeInTheDocument();
+ expect(screen.getByText(mockTeamMembers[1].role)).toBeInTheDocument();
+ });
+
+ it('renders empty state when no team members', () => {
+ (useTeamMembers as jest.Mock).mockReturnValue({
+ data: { results: [], numPages: 1, count: 0 },
+ isLoading: false,
+ });
+
+ renderComponent();
+ expect(screen.getByText(messages.noTeamMembers.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('calls useTeamMembers with correct parameters', () => {
+ (useTeamMembers as jest.Mock).mockReturnValue({
+ data: { results: [], numPages: 1, count: 0 },
+ isLoading: false,
+ });
+
+ renderComponent();
+
+ expect(useTeamMembers).toHaveBeenCalledWith(courseId, {
+ page: 0,
+ emailOrUsername: '',
+ role: '',
+ pageSize: 25,
+ });
+ });
+
+ it('handles pagination correctly', async () => {
+ (useTeamMembers as jest.Mock).mockReturnValue({
+ data: { results: mockTeamMembers, numPages: 3, count: 50 },
+ isLoading: false,
+ });
+
+ renderComponent();
+
+ const nextPageButton = screen.getByLabelText(/next/i);
+ const user = userEvent.setup();
+ await user.click(nextPageButton);
+
+ expect(useTeamMembers).toHaveBeenLastCalledWith(courseId, {
+ page: 1,
+ emailOrUsername: '',
+ role: '',
+ pageSize: 25,
+ });
+ });
+
+ it('renders action buttons for each row', () => {
+ (useTeamMembers as jest.Mock).mockReturnValue({
+ data: { results: mockTeamMembers, numPages: 1, count: 2 },
+ isLoading: false,
+ });
+
+ renderComponent();
+
+ const editButtons = screen.getAllByText(messages.edit.defaultMessage);
+ expect(editButtons).toHaveLength(2);
+ });
+
+ it('renders table headers correctly', () => {
+ (useTeamMembers as jest.Mock).mockReturnValue({
+ data: { results: mockTeamMembers, numPages: 1, count: 2 },
+ isLoading: false,
+ });
+
+ renderComponent();
+
+ expect(screen.getByText(messages.username.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages.email.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages.role.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages.actions.defaultMessage)).toBeInTheDocument();
+ });
+});
diff --git a/src/courseTeam/components/MembersContent.tsx b/src/courseTeam/components/MembersContent.tsx
new file mode 100644
index 00000000..0e8e3b5e
--- /dev/null
+++ b/src/courseTeam/components/MembersContent.tsx
@@ -0,0 +1,73 @@
+import { useState, useCallback, useMemo } from 'react';
+import { useParams } from 'react-router-dom';
+import { useIntl } from '@openedx/frontend-base';
+import { Button, DataTable } from '@openedx/paragon';
+import messages from '../messages';
+import { useTeamMembers } from '../data/apiHook';
+
+const TEAM_MEMBERS_PAGE_SIZE = 25;
+
+const MembersContent = () => {
+ const intl = useIntl();
+ const { courseId = '' } = useParams<{ courseId: string }>();
+ const [filters, setFilters] = useState({ page: 0, emailOrUsername: '', role: '' });
+ const { data: { results: teamMembers = [], numPages = 1, count = 0 } = {}, isLoading = false } = useTeamMembers(courseId, { ...filters, pageSize: TEAM_MEMBERS_PAGE_SIZE });
+
+ const tableColumns = useMemo(() => [
+ { accessor: 'username', Header: intl.formatMessage(messages.username) },
+ { accessor: 'email', Header: intl.formatMessage(messages.email) },
+ { accessor: 'role', Header: intl.formatMessage(messages.role) },
+ ], [intl]);
+
+ const additionalColumns = useMemo(() => [{
+ id: 'actions',
+ Header: intl.formatMessage(messages.actions),
+ Cell: () => (
+
+ )
+ }], [intl]);
+
+ const handleFetchData = useCallback(({ pageIndex, filters: tableFilters }: { pageIndex: number, filters: { id: string, value: string }[] }) => {
+ // Filters will be handled in a future iteration, for now we will just update pagination
+ console.log(pageIndex, tableFilters);
+ if (pageIndex !== filters.page) {
+ setFilters(prevFilters => ({
+ ...prevFilters,
+ page: pageIndex,
+ }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const tableState = useMemo(() => ({
+ pageIndex: filters.page,
+ pageSize: TEAM_MEMBERS_PAGE_SIZE,
+ }), [filters.page]);
+
+ return (
+ null}
+ >
+
+
+
+
+
+ );
+};
+
+export default MembersContent;
diff --git a/src/courseTeam/components/RolesContent.test.tsx b/src/courseTeam/components/RolesContent.test.tsx
new file mode 100644
index 00000000..3edf0222
--- /dev/null
+++ b/src/courseTeam/components/RolesContent.test.tsx
@@ -0,0 +1,45 @@
+import { screen } from '@testing-library/react';
+import { renderWithIntl } from '@src/testUtils';
+import RolesContent, { rolesOrder } from './RolesContent';
+import messages from '../messages';
+import { useRoles } from '../data/apiHook';
+
+jest.mock('../data/apiHook', () => ({
+ useRoles: jest.fn(),
+}));
+
+const mockRoles = rolesOrder.map((role) => ({ id: role, name: messages[role].defaultMessage }));
+
+describe('RolesContent', () => {
+ it('renders all roles in the correct order with their descriptions', () => {
+ (useRoles as jest.Mock).mockReturnValue({ data: mockRoles });
+ renderWithIntl();
+
+ rolesOrder.forEach((role) => {
+ expect(screen.getByText(messages[role].defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages[`${role}Description`].defaultMessage)).toBeInTheDocument();
+ });
+ });
+
+ it('does not render CCX Coach role when isCCXCoachEnabled is false', () => {
+ (useRoles as jest.Mock).mockReturnValue({ data: mockRoles });
+ renderWithIntl();
+ expect(screen.queryByText(messages.ccxCoach.defaultMessage)).not.toBeInTheDocument();
+ expect(screen.queryByText(messages.ccxCoachDescription.defaultMessage)).not.toBeInTheDocument();
+ });
+
+ it('renders correct number of role sections', () => {
+ (useRoles as jest.Mock).mockReturnValue({ data: mockRoles });
+ renderWithIntl();
+ // There are 9 roles in rolesOrder
+ expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(9);
+ });
+
+ it('renders CCX Coach role when isCCXCoachEnabled is true', () => {
+ (useRoles as jest.Mock).mockReturnValue({ data: [...mockRoles, { id: 'ccxCoach', name: messages.ccxCoach.defaultMessage }] });
+
+ renderWithIntl();
+ expect(screen.getByText(messages.ccxCoach.defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages.ccxCoachDescription.defaultMessage)).toBeInTheDocument();
+ });
+});
diff --git a/src/courseTeam/components/RolesContent.tsx b/src/courseTeam/components/RolesContent.tsx
new file mode 100644
index 00000000..2df9399f
--- /dev/null
+++ b/src/courseTeam/components/RolesContent.tsx
@@ -0,0 +1,46 @@
+import { useIntl } from '@openedx/frontend-base';
+import messages from '../messages';
+import { useParams } from 'react-router-dom';
+import { useRoles } from '../data/apiHook';
+
+export const rolesOrder = [
+ 'staff',
+ 'limitedStaff',
+ 'admin',
+ 'betaTesters',
+ 'courseDataResearchers',
+ 'discussionAdmin',
+ 'discussionModerator',
+ 'groupCommunityTA',
+ 'communityTA'
+];
+
+const RolesContent = () => {
+ const intl = useIntl();
+ const { courseId = '' } = useParams<{ courseId: string }>();
+ const { data: roles = [] } = useRoles(courseId);
+ const isCCXCoachEnabled = roles.find((role) => role.id === 'ccxCoach');
+
+ return (
+
+ {
+ rolesOrder.map((role) => (
+
+
{intl.formatMessage(messages[role])}
+
{intl.formatMessage(messages[`${role}Description`])}
+
+ ))
+ }
+ {
+ isCCXCoachEnabled && (
+
+
{intl.formatMessage(messages.ccxCoach)}
+
{intl.formatMessage(messages.ccxCoachDescription)}
+
+ )
+ }
+
+ );
+};
+
+export default RolesContent;
diff --git a/src/courseTeam/data/api.test.ts b/src/courseTeam/data/api.test.ts
new file mode 100644
index 00000000..a1ee663a
--- /dev/null
+++ b/src/courseTeam/data/api.test.ts
@@ -0,0 +1,78 @@
+import { getAuthenticatedHttpClient } from '@openedx/frontend-base';
+import { getTeamMembers, getRoles } from './api';
+
+jest.mock('@openedx/frontend-base', () => ({
+ ...jest.requireActual('@openedx/frontend-base'),
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+jest.mock('../../data/api', () => ({
+ getApiBaseUrl: jest.fn().mockReturnValue(''),
+}));
+
+const httpClientMock = {
+ get: jest.fn(),
+};
+
+beforeEach(() => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue(httpClientMock);
+});
+
+describe('courseTeam API', () => {
+ describe('getTeamMembers', () => {
+ it('should call the correct endpoint to get team members', async () => {
+ const courseId = 'course-v1:edX+DemoX+Demo_Course';
+ const params = { page: 0, pageSize: 10 };
+ httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } });
+
+ await getTeamMembers(courseId, params);
+
+ const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10`;
+ expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
+ });
+
+ it('should include email_or_username in query params if provided', async () => {
+ const courseId = 'course-v1:edX+DemoX+Demo_Course';
+ const params = { page: 0, pageSize: 10, emailOrUsername: 'test@example.com' };
+ httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } });
+
+ await getTeamMembers(courseId, params);
+
+ const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10&email_or_username=test%40example.com`;
+ expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
+ });
+
+ it('should include role in query params if provided', async () => {
+ const courseId = 'course-v1:edX+DemoX+Demo_Course';
+ const params = { page: 0, pageSize: 10, role: 'instructor' };
+ httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } });
+
+ await getTeamMembers(courseId, params);
+
+ const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10&role=instructor`;
+ expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
+ });
+ });
+
+ describe('getRoles', () => {
+ it('should call the correct endpoint to get roles', async () => {
+ const courseId = 'course-v1:edX+DemoX+Demo_Course';
+ httpClientMock.get.mockResolvedValue({ data: { roles: [] } });
+
+ await getRoles(courseId);
+
+ const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_roles`;
+ expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
+ });
+
+ it('should return the roles from the response', async () => {
+ const courseId = 'course-v1:edX+DemoX+Demo_Course';
+ const roles = ['instructor', 'staff'];
+ httpClientMock.get.mockResolvedValue({ data: { roles } });
+
+ const result = await getRoles(courseId);
+
+ expect(result).toEqual(roles);
+ });
+ });
+});
diff --git a/src/courseTeam/data/api.ts b/src/courseTeam/data/api.ts
new file mode 100644
index 00000000..8a163658
--- /dev/null
+++ b/src/courseTeam/data/api.ts
@@ -0,0 +1,41 @@
+import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base';
+import { getApiBaseUrl } from '../../data/api';
+import { DataList } from '@src/types';
+import { CourseTeamMember, CourseTeamMemberQueryParams, Role } from '../types';
+
+export const getTeamMembers = async (
+ courseId: string,
+ params: CourseTeamMemberQueryParams
+): Promise