diff --git a/src/courseTeam/CourseTeamPage.test.tsx b/src/courseTeam/CourseTeamPage.test.tsx new file mode 100644 index 00000000..4817ee9f --- /dev/null +++ b/src/courseTeam/CourseTeamPage.test.tsx @@ -0,0 +1,60 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithIntl } from '@src/testUtils'; +import CourseTeamPage from './CourseTeamPage'; + +// 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
; + }; +}); + +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('has primary variant on add button', () => { + renderWithIntl(); + const button = screen.getByRole('button', { name: /add team member/i }); + expect(button).toHaveClass('btn-primary'); + }); + + 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..7449abf1 100644 --- a/src/courseTeam/CourseTeamPage.tsx +++ b/src/courseTeam/CourseTeamPage.tsx @@ -1,8 +1,27 @@ +import { useIntl } from '@openedx/frontend-base'; +import messages from './messages'; +import { Button, Tab, Tabs } from '@openedx/paragon'; +import MembersContent from './components/MembersContent'; +import RolesContent from './components/RolesContent'; + const CourseTeamPage = () => { + const intl = useIntl(); + return ( -
-

Course Team

-
+ <> +
+

{intl.formatMessage(messages.courseTeamTitle)}

+ +
+ + + + + + + + + ); }; 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.tsx b/src/courseTeam/components/RolesContent.tsx new file mode 100644 index 00000000..0d46b418 --- /dev/null +++ b/src/courseTeam/components/RolesContent.tsx @@ -0,0 +1,9 @@ +const RolesContent = () => { + return ( +
+ Roles content goes here. +
+ ); +}; + +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..f813a043 --- /dev/null +++ b/src/courseTeam/data/api.ts @@ -0,0 +1,34 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; +import { DataList } from '@src/types'; +import { CourseTeamMember, CourseTeamMemberQueryParams } 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; +}; diff --git a/src/courseTeam/data/apiHook.test.tsx b/src/courseTeam/data/apiHook.test.tsx new file mode 100644 index 00000000..642dad9d --- /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 = ['instructor', 'staff', '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..bd7c925c --- /dev/null +++ b/src/courseTeam/data/apiHook.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { 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, + }) +); 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..2f7365a9 --- /dev/null +++ b/src/courseTeam/messages.ts @@ -0,0 +1,56 @@ +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', + }, +}); + +export default messages; diff --git a/src/courseTeam/types.ts b/src/courseTeam/types.ts new file mode 100644 index 00000000..e016469e --- /dev/null +++ b/src/courseTeam/types.ts @@ -0,0 +1,12 @@ +export interface CourseTeamMember { + username: string, + email: string, + role: string, +}; + +export interface CourseTeamMemberQueryParams { + page: number, + pageSize: number, + emailOrUsername?: string, + role?: string, +};