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..c412087e 100644
--- a/src/courseTeam/CourseTeamPage.tsx
+++ b/src/courseTeam/CourseTeamPage.tsx
@@ -1,8 +1,50 @@
+import { useState } from 'react';
+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 EditTeamMemberModal from './components/EditTeamMemberModal';
+import { useAddTeamMember } from './data/apiHook';
+import { CourseTeamMember } from './types';
+
const CourseTeamPage = () => {
+ const intl = useIntl();
+ const { courseId = '' } = useParams<{ courseId: string }>();
+ const [isOpenAddModal, openAddModal, closeAddModal] = useToggle(false);
+ const [isOpenEditModal, openEditModal, closeEditModal] = useToggle(false);
+ const [selectedUser, setSelectedUser] = useState(null);
+ const { mutate: addTeamMember } = useAddTeamMember(courseId);
+
+ const handleAdd = ({ users, role }: { users: string[], role: string }) => {
+ addTeamMember({ users, role });
+ closeAddModal();
+ };
+
+ const handleEdit = (user: CourseTeamMember) => {
+ setSelectedUser(user);
+ openEditModal();
+ };
+
return (
-
-
Course Team
-
+ <>
+
+
{intl.formatMessage(messages.courseTeamTitle)}
+
+
+
+
+
+
+
+
+
+
+ {isOpenAddModal && }
+ {isOpenEditModal && selectedUser && }
+ >
);
};
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/EditTeamMemberModal.test.tsx b/src/courseTeam/components/EditTeamMemberModal.test.tsx
new file mode 100644
index 00000000..a30222ee
--- /dev/null
+++ b/src/courseTeam/components/EditTeamMemberModal.test.tsx
@@ -0,0 +1,210 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithIntl } from '@src/testUtils';
+import EditTeamMemberModal from './EditTeamMemberModal';
+import messages from '../messages';
+import { CourseTeamMember } from '../types';
+import { useRoles } from '../data/apiHook';
+
+// Mocks
+jest.mock('react-router-dom', () => ({
+ useParams: () => ({ courseId: 'course-v1:test+course+run' }),
+}));
+
+jest.mock('../data/apiHook', () => ({
+ useRoles: jest.fn(),
+}));
+
+const mockUser: CourseTeamMember = {
+ username: 'test_user',
+ fullName: 'Test User',
+ email: 'test@example.com',
+ roles: ['Staff', 'Admin'],
+};
+
+const mockRoles = [
+ { id: 'instructor', name: 'Instructor' },
+ { id: 'staff', name: 'Staff' },
+ { id: 'admin', name: 'Admin' },
+ { id: 'beta_testers', name: 'Beta Testers' },
+ { id: 'data_researcher', name: 'Data Researcher' },
+];
+
+describe('EditTeamMemberModal', () => {
+ const defaultProps = {
+ isOpen: true,
+ user: mockUser,
+ onClose: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useRoles as jest.Mock).mockReturnValue({ data: mockRoles });
+ });
+
+ it('renders modal with correct title', () => {
+ renderWithIntl();
+
+ const expectedTitle = messages.editTeamTitle.defaultMessage.replace('{username}', mockUser.username);
+ expect(screen.getByText(expectedTitle)).toBeInTheDocument();
+ });
+
+ it('renders modal header and body correctly', () => {
+ renderWithIntl();
+
+ const expectedTitle = messages.editTeamTitle.defaultMessage.replace('{username}', mockUser.username);
+ expect(screen.getByText(expectedTitle)).toBeInTheDocument();
+
+ // Check that header has correct styling
+ const headerElement = screen.getByText(expectedTitle);
+ expect(headerElement).toHaveClass('text-white');
+ });
+
+ it('renders edit instructions with username', () => {
+ renderWithIntl();
+
+ const expectedInstructions = messages.editInstructions.defaultMessage.replace('{username}', mockUser.username);
+ expect(screen.getByText(expectedInstructions)).toBeInTheDocument();
+ });
+
+ it('renders current user roles as checkboxes', () => {
+ renderWithIntl();
+
+ mockUser.roles.forEach((role) => {
+ expect(screen.getByRole('checkbox', { name: role })).toBeInTheDocument();
+ });
+ });
+
+ it('renders add role label', () => {
+ renderWithIntl();
+
+ expect(screen.getByText(messages.addRole.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders role selection dropdown with filtered roles', () => {
+ renderWithIntl();
+
+ const selectElement = screen.getByRole('combobox');
+ expect(selectElement).toBeInTheDocument();
+
+ // Verify placeholder is present
+ expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
+
+ // Verify only roles not already assigned to user are available
+ const availableRoles = mockRoles.filter(role => !mockUser.roles.includes(role.name));
+ availableRoles.forEach((role) => {
+ expect(screen.getByRole('option', { name: role.name })).toBeInTheDocument();
+ });
+
+ // Verify user's current roles are not in the dropdown options
+ mockUser.roles.forEach((roleName) => {
+ const roleInMockData = mockRoles.find(role => role.name === roleName);
+ if (roleInMockData) {
+ expect(screen.queryByRole('option', { name: roleName })).not.toBeInTheDocument();
+ }
+ });
+ });
+
+ it('renders cancel and save buttons', () => {
+ renderWithIntl();
+
+ expect(screen.getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
+ });
+
+ it('calls onClose when cancel button is clicked', async () => {
+ const mockOnClose = jest.fn();
+ renderWithIntl();
+
+ const user = userEvent.setup();
+ const cancelButton = screen.getByRole('button', { name: messages.cancelButton.defaultMessage });
+ await user.click(cancelButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose when save button is clicked', async () => {
+ const mockOnClose = jest.fn();
+ renderWithIntl();
+
+ const user = userEvent.setup();
+ const saveButton = screen.getByRole('button', { name: messages.saveButton.defaultMessage });
+ await user.click(saveButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render when isOpen is false', () => {
+ renderWithIntl();
+
+ const expectedTitle = messages.editTeamTitle.defaultMessage.replace('{username}', mockUser.username);
+ expect(screen.queryByText(expectedTitle)).not.toBeInTheDocument();
+ });
+
+ it('renders correctly when no roles data is available', () => {
+ (useRoles as jest.Mock).mockReturnValue({ data: [] });
+ renderWithIntl();
+
+ // Should only show placeholder in dropdown
+ expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
+
+ // Select should be disabled when no roles are available
+ const selectElement = screen.getByRole('combobox');
+ expect(selectElement).toBeDisabled();
+
+ // Should still show current user roles as checkboxes
+ mockUser.roles.forEach((role) => {
+ expect(screen.getByRole('checkbox', { name: role })).toBeInTheDocument();
+ });
+ });
+
+ it('renders correctly when useRoles returns undefined data', () => {
+ (useRoles as jest.Mock).mockReturnValue({ data: undefined });
+ renderWithIntl();
+
+ // Should only show placeholder in dropdown
+ expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
+
+ // Select should be disabled when no roles are available
+ const selectElement = screen.getByRole('combobox');
+ expect(selectElement).toBeDisabled();
+
+ // Should still show current user roles as checkboxes
+ mockUser.roles.forEach((role) => {
+ expect(screen.getByRole('checkbox', { name: role })).toBeInTheDocument();
+ });
+ });
+
+ it('handles user with all available roles assigned', () => {
+ const userWithAllRoles = {
+ ...mockUser,
+ roles: mockRoles.map(role => role.name),
+ };
+ renderWithIntl();
+
+ // Should show all roles as checkboxes
+ userWithAllRoles.roles.forEach((role) => {
+ expect(screen.getByRole('checkbox', { name: role })).toBeInTheDocument();
+ });
+
+ // Dropdown should only have placeholder since all roles are assigned
+ expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(1); // Only placeholder option
+ });
+
+ it('shows select role placeholder in dropdown', () => {
+ renderWithIntl();
+
+ const selectElement = screen.getByRole('combobox');
+ expect(selectElement).toHaveAttribute('placeholder', messages.rolePlaceholder.defaultMessage);
+ });
+
+ it('enables select when roles are available for assignment', () => {
+ renderWithIntl();
+
+ // Should be enabled when there are roles available to assign
+ const selectElement = screen.getByRole('combobox');
+ expect(selectElement).not.toBeDisabled();
+ });
+});
diff --git a/src/courseTeam/components/EditTeamMemberModal.tsx b/src/courseTeam/components/EditTeamMemberModal.tsx
new file mode 100644
index 00000000..72739449
--- /dev/null
+++ b/src/courseTeam/components/EditTeamMemberModal.tsx
@@ -0,0 +1,65 @@
+import { useParams } from 'react-router-dom';
+import { ActionRow, Button, FormControl, FormLabel, ModalDialog } from '@openedx/paragon';
+import { useIntl } from '@openedx/frontend-base';
+import messages from '../messages';
+import { CourseTeamMember } from '../types';
+import { useRoles } from '../data/apiHook';
+import { FormCheckboxSet, FormCheckbox } from '@openedx/paragon/dist/Form';
+
+interface EditTeamMemberModalProps {
+ isOpen: boolean,
+ user: CourseTeamMember,
+ onClose: () => void,
+}
+
+const EditTeamMemberModal = ({ isOpen, user, onClose }: EditTeamMemberModalProps) => {
+ const intl = useIntl();
+ const { courseId = '' } = useParams<{ courseId: string }>();
+
+ const { data = [] } = useRoles(courseId);
+
+ const filteredRoles = data?.filter(role => !user.roles.includes(role.name)) || [];
+
+ const roles = [{ id: '', name: intl.formatMessage(messages.rolePlaceholder) }, ...filteredRoles];
+
+ return (
+
+
+ {intl.formatMessage(messages.editTeamTitle, { username: user.username })}
+
+
+ {intl.formatMessage(messages.editInstructions, { username: user.username })}
+ {
+ user.roles.map((role) => (
+
+ {role}
+
+ ))
+ }
+ {intl.formatMessage(messages.addRole)}
+
+ {
+ roles.map((role) => (
+
+ ))
+ }
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default EditTeamMemberModal;
diff --git a/src/courseTeam/components/MembersContent.test.tsx b/src/courseTeam/components/MembersContent.test.tsx
new file mode 100644
index 00000000..36d27782
--- /dev/null
+++ b/src/courseTeam/components/MembersContent.test.tsx
@@ -0,0 +1,130 @@
+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', fullName: 'User One', email: 'user1@example.com', roles: ['Admin'] },
+ { username: 'user2', fullName: 'User Two', email: 'user2@example.com', roles: ['Staff'] },
+];
+
+const mockOnEdit = jest.fn();
+
+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].roles.join(', '))).toBeInTheDocument();
+ expect(screen.getByText(mockTeamMembers[1].username)).toBeInTheDocument();
+ expect(screen.getByText(mockTeamMembers[1].email)).toBeInTheDocument();
+ expect(screen.getByText(mockTeamMembers[1].roles.join(', '))).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..d588fb14
--- /dev/null
+++ b/src/courseTeam/components/MembersContent.tsx
@@ -0,0 +1,78 @@
+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';
+import { CourseTeamMember } from '../types';
+
+const TEAM_MEMBERS_PAGE_SIZE = 25;
+
+interface MembersContentProps {
+ onEdit: (user: CourseTeamMember) => void,
+}
+
+const MembersContent = ({ onEdit }: MembersContentProps) => {
+ 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: 'roles', Header: intl.formatMessage(messages.role), Cell: ({ cell: { value } }: { cell: { value: string[] } }) => value.join(', ') },
+ ], [intl]);
+
+ const additionalColumns = useMemo(() => [{
+ id: 'actions',
+ Header: intl.formatMessage(messages.actions),
+ Cell: ({ row }: { row: { original: any } }) => (
+
+ )
+ }], [intl, onEdit]);
+
+ 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