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> => { + const queryParams = new URLSearchParams({ + page: (params.page + 1).toString(), + page_size: params.pageSize.toString(), + }); + + if (params.emailOrUsername) { + queryParams.append('email_or_username', params.emailOrUsername); + } + + if (params.role) { + queryParams.append('role', params.role); + } + + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/team_members?${queryParams.toString()}` + ); + return camelCaseObject(data); +}; + +export const getRoles = async (courseId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/team_roles` + ); + return data.roles; +}; + +export const addTeamMember = async (courseId: string, users: string[], role: string): Promise => { + await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/team_members`, + { users, role } + ); +}; diff --git a/src/courseTeam/data/apiHook.test.tsx b/src/courseTeam/data/apiHook.test.tsx new file mode 100644 index 00000000..c6f4ca8f --- /dev/null +++ b/src/courseTeam/data/apiHook.test.tsx @@ -0,0 +1,142 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode } from 'react'; +import { useTeamMembers, useRoles } from './apiHook'; +import * as api from './api'; +import { CourseTeamMember } from '../types'; +import { DataList } from '../../types'; + +jest.mock('./api'); + +const mockGetTeamMembers = api.getTeamMembers as jest.MockedFunction; +const mockGetRoles = api.getRoles as jest.MockedFunction; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const WrappedComponent = ({ children }: { children: ReactNode }) => ( + {children} + ); + return WrappedComponent; +}; + +describe('apiHook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useTeamMembers', () => { + it('should fetch course team members successfully', async () => { + const mockTeamMembers: DataList = { + count: 2, + next: null, + previous: null, + numPages: 1, + results: [ + { username: 'john.doe', email: 'john@example.com', role: 'instructor' }, + { username: 'jane.smith', email: 'jane@example.com', role: 'staff' }, + ], + }; + + mockGetTeamMembers.mockResolvedValue(mockTeamMembers); + + const { result } = renderHook(() => useTeamMembers('course-v1:org+course+run', { + page: 0, + pageSize: 25, + }), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockTeamMembers); + expect(mockGetTeamMembers).toHaveBeenCalledWith('course-v1:org+course+run', { + page: 0, + pageSize: 25, + }); + }); + + it('should handle error when fetching course team fails', async () => { + const mockError = new Error('Failed to fetch course team'); + mockGetTeamMembers.mockRejectedValue(mockError); + + const { result } = renderHook(() => useTeamMembers('course-v1:org+course+run', { + page: 0, + pageSize: 25, + }), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(mockError); + }); + + it('should be disabled when courseId is empty', () => { + const { result } = renderHook(() => useTeamMembers('', { + page: 0, + pageSize: 25, + }), { + wrapper: createWrapper(), + }); + + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(mockGetTeamMembers).not.toHaveBeenCalled(); + }); + }); + + describe('useRoles', () => { + it('should fetch course roles successfully', async () => { + const mockRoles = [{ id: 'instructor', name: 'Instructor' }, { id: 'staff', name: 'Staff' }, { id: 'beta_testers', name: 'Beta Testers' }]; + + mockGetRoles.mockResolvedValue(mockRoles); + + const { result } = renderHook(() => useRoles('course-v1:org+course+run'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockRoles); + expect(mockGetRoles).toHaveBeenCalledWith('course-v1:org+course+run'); + }); + + it('should handle error when fetching roles fails', async () => { + const mockError = new Error('Failed to fetch roles'); + mockGetRoles.mockRejectedValue(mockError); + + const { result } = renderHook(() => useRoles('course-v1:org+course+run'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(mockError); + }); + + it('should be disabled when courseId is empty', () => { + const { result } = renderHook(() => useRoles(''), { + wrapper: createWrapper(), + }); + + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(mockGetRoles).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/courseTeam/data/apiHook.ts b/src/courseTeam/data/apiHook.ts new file mode 100644 index 00000000..74a0e74f --- /dev/null +++ b/src/courseTeam/data/apiHook.ts @@ -0,0 +1,31 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { addTeamMember, getRoles, getTeamMembers } from './api'; +import { CourseTeamMemberQueryParams } from '../types'; +import { courseTeamQueryKeys } from './queryKeys'; + +export const useTeamMembers = (courseId: string, params: CourseTeamMemberQueryParams) => ( + useQuery({ + queryKey: courseTeamQueryKeys.byCoursePaginated(courseId, params), + queryFn: () => getTeamMembers(courseId, params), + enabled: !!courseId, + }) +); + +export const useRoles = (courseId: string) => ( + useQuery({ + queryKey: courseTeamQueryKeys.roles(courseId), + queryFn: () => getRoles(courseId), + 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/data/queryKeys.ts b/src/courseTeam/data/queryKeys.ts new file mode 100644 index 00000000..dd3cd5e7 --- /dev/null +++ b/src/courseTeam/data/queryKeys.ts @@ -0,0 +1,18 @@ +import { appId } from '../../constants'; +import { CourseTeamMemberQueryParams } from '../types'; + +export const courseTeamQueryKeys = { + all: [appId, 'courseTeam'] as const, + byCourse: (courseId: string) => [...courseTeamQueryKeys.all, courseId] as const, + byCoursePaginated: ( + courseId: string, + params: CourseTeamMemberQueryParams + ) => [ + ...courseTeamQueryKeys.byCourse(courseId), + params.page, + params.pageSize, + params.emailOrUsername || '', + params.role || '' + ] as const, + roles: (courseId: string) => [...courseTeamQueryKeys.byCourse(courseId), 'roles'] as const, +}; diff --git a/src/courseTeam/messages.ts b/src/courseTeam/messages.ts new file mode 100644 index 00000000..06687d4c --- /dev/null +++ b/src/courseTeam/messages.ts @@ -0,0 +1,196 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + courseTeamTitle: { + id: 'instruct.courseTeam.page.title', + defaultMessage: 'Course Team Management', + description: 'Title for the course team page', + }, + addTeamMember: { + id: 'instruct.courseTeam.addTeamMember', + defaultMessage: 'Add Team Member', + description: 'Button label for adding a team member', + }, + membersTab: { + id: 'instruct.courseTeam.membersTab', + defaultMessage: 'Members', + description: 'Tab title for course team members', + }, + rolesTab: { + id: 'instruct.courseTeam.rolesTab', + defaultMessage: 'Roles', + description: 'Tab title for course team roles', + }, + username: { + id: 'instruct.courseTeam.username', + defaultMessage: 'Username', + description: 'Column header for team member username', + }, + email: { + id: 'instruct.courseTeam.email', + defaultMessage: 'Email', + description: 'Column header for team member email', + }, + role: { + id: 'instruct.courseTeam.role', + defaultMessage: 'Role', + description: 'Column header for team member role', + }, + actions: { + id: 'instruct.courseTeam.actions', + defaultMessage: 'Actions', + description: 'Column header for team member actions', + }, + edit: { + id: 'instruct.courseTeam.edit', + defaultMessage: 'Edit', + description: 'Button label for editing a team member', + }, + noTeamMembers: { + id: 'instruct.courseTeam.noTeamMembers', + defaultMessage: 'No team members found.', + description: 'Message displayed when there are no team members', + }, + staff: { + id: 'instruct.courseTeam.roles.staff', + defaultMessage: 'Staff', + description: 'Role name for staff members', + }, + staffDescription: { + id: 'instruct.courseTeam.roles.staffDescription', + defaultMessage: 'Course team members with the Staff role help you manage your course. Staff can enroll and unenroll learners, as well as modify their grades and access all course data. Staff also have access to your course in Studio and Insights. Any users not yet enrolled in the course will be automatically enrolled when added as Staff.', + description: 'Description for staff role', + }, + limitedStaff: { + id: 'instruct.courseTeam.roles.limitedStaff', + defaultMessage: 'Limited Staff', + description: 'Role name for limited staff members', + }, + limitedStaffDescription: { + id: 'instruct.courseTeam.roles.limitedStaffDescription', + defaultMessage: 'Course team members with the Limited Staff role help you manage your course. Limited Staff can enroll and unenroll learners, as well as modify their grades and access all course data. Limited Staff don\'t have access to your course in Studio. Any users not yet enrolled in the course will be automatically enrolled when added as Limited Staff.', + description: 'Description for limited staff role', + }, + admin: { + id: 'instruct.courseTeam.roles.admin', + defaultMessage: 'Admin', + description: 'Role name for admin members', + }, + adminDescription: { + id: 'instruct.courseTeam.roles.adminDescription', + defaultMessage: 'Course team members with the Admin role help you manage your course. They can do all of the tasks that Staff can do, and can also add and remove the Staff and Admin roles, discussion moderation roles, and the beta tester role to manage course team membership. Any users not yet enrolled in the course will be automatically enrolled when added as Admin.', + description: 'Description for admin role', + }, + betaTesters: { + id: 'instruct.courseTeam.roles.betaTesters', + defaultMessage: 'Beta Testers', + description: 'Role name for beta tester members', + }, + betaTestersDescription: { + id: 'instruct.courseTeam.roles.betaTestersDescription', + defaultMessage: 'Beta Testers can see course content before other learners. They can make sure that the content works, but have no additional privileges. Any users not yet enrolled in the course will be automatically enrolled when added as Beta Tester.', + description: 'Description for beta tester role', + }, + courseDataResearchers: { + id: 'instruct.courseTeam.roles.courseDataResearchers', + defaultMessage: 'Course Data Researchers', + description: 'Role name for course data researcher members', + }, + courseDataResearchersDescription: { + id: 'instruct.courseTeam.roles.courseDataResearchersDescription', + defaultMessage: 'Course Data Researchers can access the data download tab. Any users not yet enrolled in the course will be automatically enrolled when added as Course Data Researcher.', + description: 'Description for course data researcher role', + }, + discussionAdmin: { + id: 'instruct.courseTeam.roles.discussionAdmin', + defaultMessage: 'Discussion Admin', + description: 'Role name for discussion admin members', + }, + discussionAdminDescription: { + id: 'instruct.courseTeam.roles.discussionAdminDescription', + defaultMessage: 'Discussion Admins can edit or delete any post, clear misuse flags, close and re-open threads, endorse responses, and see posts from all groups. Their posts are marked as \'staff\'. They can also add and remove the discussion moderation roles to manage course team membership. Any users not yet enrolled in the course will be automatically enrolled when added as Discussion Admin.', + description: 'Description for discussion admin role', + }, + discussionModerator: { + id: 'instruct.courseTeam.roles.discussionModerator', + defaultMessage: 'Discussion Moderator', + description: 'Role name for discussion moderator members', + }, + discussionModeratorDescription: { + id: 'instruct.courseTeam.roles.discussionModeratorDescription', + defaultMessage: 'Discussion Moderators can edit or delete any post, clear misuse flags, close and re-open threads, endorse responses, and see posts from all groups. Their posts are marked as \'staff\'. They cannot manage course team membership by adding or removing discussion moderation roles. Any users not yet enrolled in the course will be automatically enrolled when added as Discussion Moderator.', + description: 'Description for discussion moderator role', + }, + groupCommunityTA: { + id: 'instruct.courseTeam.roles.groupCommunityTA', + defaultMessage: 'Group Community TA', + description: 'Role name for group community TA members', + }, + groupCommunityTADescription: { + id: 'instruct.courseTeam.roles.groupCommunityTADescription', + defaultMessage: 'Group Community TAs are members of the community who help course teams moderate discussions. Group Community TAs see only posts by learners in their assigned group. They can edit or delete posts, clear flags, close and re-open threads, and endorse responses, but only for posts by learners in their group. Their posts are marked as \'Community TA\'. Any users not yet enrolled in the course will be automatically enrolled when added as Group Community TA.', + description: 'Description for group community TA role', + }, + communityTA: { + id: 'instruct.courseTeam.roles.communityTA', + defaultMessage: 'Community TA', + description: 'Role name for community TA members', + }, + communityTADescription: { + id: 'instruct.courseTeam.roles.communityTADescription', + defaultMessage: 'Community TAs are members of the community who help course teams moderate discussions. They can see posts by learners in their assigned cohort or enrollment track, and can edit or delete posts, clear flags, close or re-open threads, and endorse responses. Their posts are marked as \'Community TA\'. Any users not yet enrolled in the course will be automatically enrolled when added as Community TA.', + description: 'Description for community TA role', + }, + ccxCoach: { + id: 'instruct.courseTeam.roles.ccxCoach', + defaultMessage: 'CCX Coach', + description: 'Role name for CCX coach members', + }, + ccxCoachDescription: { + 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; diff --git a/src/courseTeam/types.ts b/src/courseTeam/types.ts new file mode 100644 index 00000000..d9e34bba --- /dev/null +++ b/src/courseTeam/types.ts @@ -0,0 +1,17 @@ +export interface CourseTeamMember { + username: string, + email: string, + role: string, +}; + +export interface CourseTeamMemberQueryParams { + page: number, + pageSize: number, + emailOrUsername?: string, + role?: string, +}; + +export interface Role { + id: string, + name: string, +};