diff --git a/src/enrollments/EnrollmentsPage.test.tsx b/src/enrollments/EnrollmentsPage.test.tsx new file mode 100644 index 00000000..6f738509 --- /dev/null +++ b/src/enrollments/EnrollmentsPage.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import { IntlProvider } from '@openedx/frontend-base'; +import EnrollmentsPage from './EnrollmentsPage'; +import { Learner } from './types'; +import userEvent from '@testing-library/user-event'; +import messages from './messages'; + +// Mock the child components +jest.mock('./components/EnrollmentsList', () => { + return function MockEnrollmentsList({ onUnenroll }: { onUnenroll: (learner: Learner) => void }) { + return ( +
+ +
+ ); + }; +}); + +jest.mock('./components/EnrollmentStatusModal', () => { + return function MockEnrollmentStatusModal({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) { + return isOpen ? ( +
+ +
+ ) : null; + }; +}); + +jest.mock('./components/UnenrollModal', () => { + return function MockUnenrollModal({ isOpen, learner, onClose }: { isOpen: boolean, learner: Learner | null, onClose: () => void }) { + return isOpen ? ( +
+ Unenroll {learner?.fullName} + +
+ ) : null; + }; +}); + +const renderWithIntl = (component: React.ReactElement) => { + return render( + + {component} + + ); +}; + +describe('EnrollmentsPage', () => { + it('renders the page title', () => { + renderWithIntl(); + expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument(); + }); + + it('renders action buttons', () => { + renderWithIntl(); + expect(screen.getByRole('button', { name: messages.checkEnrollmentStatus.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: new RegExp(messages.addBetaTesters.defaultMessage) })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: new RegExp(messages.enrollLearners.defaultMessage) })).toBeInTheDocument(); + }); + + it('renders EnrollmentsList component', () => { + renderWithIntl(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('opens enrollment status modal when more button is clicked', async () => { + renderWithIntl(); + + const moreButton = screen.getByRole('button', { name: messages.checkEnrollmentStatus.defaultMessage }); + const user = userEvent.setup(); + await user.click(moreButton); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('closes enrollment status modal', async () => { + renderWithIntl(); + + const moreButton = screen.getByRole('button', { name: messages.checkEnrollmentStatus.defaultMessage }); + const user = userEvent.setup(); + await user.click(moreButton); + + const closeButton = screen.getByText('Close Modal'); + await user.click(closeButton); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('opens unenroll modal when unenroll is triggered', async () => { + renderWithIntl(); + + const unenrollButton = screen.getByText('Unenroll Test Learner'); + const user = userEvent.setup(); + await user.click(unenrollButton); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText(/Tester/)).toBeInTheDocument(); + }); + + it('closes unenroll modal and clears selected learner', async () => { + renderWithIntl(); + + const unenrollButton = screen.getByText('Unenroll Test Learner'); + const user = userEvent.setup(); + await user.click(unenrollButton); + + const closeUnenrollButton = screen.getByText('Close Unenroll Modal'); + await user.click(closeUnenrollButton); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('modals are closed by default', () => { + renderWithIntl(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/src/enrollments/EnrollmentsPage.tsx b/src/enrollments/EnrollmentsPage.tsx index 37af31db..6d79cafb 100644 --- a/src/enrollments/EnrollmentsPage.tsx +++ b/src/enrollments/EnrollmentsPage.tsx @@ -1,8 +1,56 @@ +import { useState } from 'react'; +import { useIntl } from '@openedx/frontend-base'; +import { ActionRow, Button, IconButton } from '@openedx/paragon'; +import { MoreVert } from '@openedx/paragon/icons'; +import messages from './messages'; +import EnrollmentsList from './components/EnrollmentsList'; +import EnrollmentStatusModal from './components/EnrollmentStatusModal'; +import UnenrollModal from './components/UnenrollModal'; +import { Learner } from './types'; + const EnrollmentsPage = () => { + const intl = useIntl(); + const [isEnrollmentStatusModalOpen, setIsEnrollmentStatusModalOpen] = useState(false); + const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [selectedLearner, setSelectedLearner] = useState(null); + + const handleMoreButton = () => { + setIsEnrollmentStatusModalOpen(true); + }; + + const handleUnenroll = (learner: Learner) => { + setIsUnenrollModalOpen(true); + setSelectedLearner(learner); + }; + + const handleUnenrollModalClose = () => { + setIsUnenrollModalOpen(false); + setSelectedLearner(null); + }; + + const handleCloseEnrollmentStatusModal = () => { + setIsEnrollmentStatusModalOpen(false); + }; + return ( -
-

Enrollments

-
+ <> +
+

{intl.formatMessage(messages.enrollmentsPageTitle)}

+ + + + + +
+ + + + ); }; diff --git a/src/enrollments/components/EnrollmentStatusModal.test.tsx b/src/enrollments/components/EnrollmentStatusModal.test.tsx new file mode 100644 index 00000000..11925190 --- /dev/null +++ b/src/enrollments/components/EnrollmentStatusModal.test.tsx @@ -0,0 +1,134 @@ +import { screen, waitFor } from '@testing-library/react'; +import EnrollmentStatusModal from './EnrollmentStatusModal'; +import { useEnrollmentByUserId } from '../data/apiHook'; +import { renderWithIntl } from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../data/apiHook', () => ({ + useEnrollmentByUserId: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ courseId: 'test-course-id' }), +})); + +const renderComponent = (props = {}) => { + const defaultProps = { + isOpen: true, + onClose: jest.fn(), + ...props, + }; + + return renderWithIntl(); +}; + +describe('EnrollmentStatusModal', () => { + const mockRefetch = jest.fn(); + + beforeEach(() => { + (useEnrollmentByUserId as jest.Mock).mockReturnValue({ + data: { status: '' }, + refetch: mockRefetch, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal when isOpen is true', () => { + renderComponent(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('does not render modal when isOpen is false', () => { + renderComponent({ isOpen: false }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', async () => { + const onClose = jest.fn(); + renderComponent({ onClose }); + + const user = userEvent.setup(); + await user.click(screen.getByText('Close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('updates learner identifier input value', async () => { + renderComponent(); + const input = screen.getByRole('textbox'); + + const user = userEvent.setup(); + await user.type(input, 'test@example.com'); + expect(input).toHaveValue('test@example.com'); + }); + + it('disables search button when input is empty', () => { + renderComponent(); + const searchButton = screen.getByRole('button', { name: /check enrollment status/i }); + + expect(searchButton).toBeDisabled(); + }); + + it('enables search button when input has value', async () => { + renderComponent(); + const input = screen.getByRole('textbox'); + const searchButton = screen.getByRole('button', { name: /check enrollment status/i }); + + const user = userEvent.setup(); + await user.type(input, 'test@example.com'); + expect(searchButton).toBeEnabled(); + }); + + it('calls refetch when search button is clicked', async () => { + renderComponent(); + const input = screen.getByRole('textbox'); + const searchButton = screen.getByRole('button', { name: /check enrollment status/i }); + + const user = userEvent.setup(); + await user.type(input, 'test@example.com'); + await user.click(searchButton); + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + }); + + it('displays status message when data and learner identifier exist', async () => { + (useEnrollmentByUserId as jest.Mock).mockReturnValue({ + data: { status: 'enrolled' }, + refetch: mockRefetch, + }); + + renderComponent(); + const input = screen.getByRole('textbox'); + + const user = userEvent.setup(); + await user.type(input, 'test@example.com'); + + expect(screen.getByText(/test@example.com.*enrolled/)).toBeInTheDocument(); + }); + + it('does not display status message when learner identifier is empty', () => { + (useEnrollmentByUserId as jest.Mock).mockReturnValue({ + data: { status: 'enrolled' }, + refetch: mockRefetch, + }); + + renderComponent(); + + expect(screen.queryByText(/enrolled/)).not.toBeInTheDocument(); + }); + + it('does not display status message when status is empty', async () => { + renderComponent(); + const input = screen.getByRole('textbox'); + + const user = userEvent.setup(); + await user.type(input, 'test@example.com'); + + expect(screen.queryByText(/test@example.com/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/enrollments/components/EnrollmentStatusModal.tsx b/src/enrollments/components/EnrollmentStatusModal.tsx new file mode 100644 index 00000000..c1957426 --- /dev/null +++ b/src/enrollments/components/EnrollmentStatusModal.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { Button, FormControl, ModalDialog } from '@openedx/paragon'; +import { useEnrollmentByUserId } from '../data/apiHook'; +import messages from '../messages'; + +interface EnrollmentStatusModalProps { + isOpen: boolean, + onClose: () => void, +} + +const EnrollmentStatusModal = ({ isOpen, onClose }: EnrollmentStatusModalProps) => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [learnerIdentifier, setLearnerIdentifier] = useState(''); + const { data = { status: '' }, refetch } = useEnrollmentByUserId(courseId, learnerIdentifier); + + const handleSearch = async () => { + refetch(); + }; + + return ( + +

{intl.formatMessage(messages.checkEnrollmentStatus)}

+ +

{intl.formatMessage(messages.addLearnerInstructions)}

+ setLearnerIdentifier(e.target.value)} + /> + + + {data.status && learnerIdentifier && ( +

{intl.formatMessage(messages.statusResponseMessage, { learnerIdentifier, status: data.status })}

+ )} +
+ + + +
+ ); +}; + +export default EnrollmentStatusModal; diff --git a/src/enrollments/components/EnrollmentsList.test.tsx b/src/enrollments/components/EnrollmentsList.test.tsx new file mode 100644 index 00000000..c6c14067 --- /dev/null +++ b/src/enrollments/components/EnrollmentsList.test.tsx @@ -0,0 +1,114 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import EnrollmentsList from './EnrollmentsList'; +import { useEnrollments } from '../data/apiHook'; +import { renderWithIntl } from '@src/testUtils'; + +jest.mock('../data/apiHook', () => ({ + useEnrollments: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ courseId: 'test-course-id' }), +})); + +const mockLearners = [ + { + id: '1', + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + track: 'Verified', + betaTester: true, + }, + { + id: '2', + username: 'janedoe', + fullName: 'Jane Doe', + email: 'janedoe@example.com', + track: 'Audit', + betaTester: false, + }, +]; + +const renderComponent = (onUnenroll = jest.fn()) => { + return renderWithIntl( + + ); +}; + +describe('EnrollmentsList', () => { + beforeEach(() => { + (useEnrollments as jest.Mock).mockReturnValue({ + data: { count: 2, results: mockLearners }, + isLoading: false, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders table with enrollments data', () => { + renderComponent(); + + expect(screen.getByText(mockLearners[0].username)).toBeInTheDocument(); + expect(screen.getByText(mockLearners[0].fullName)).toBeInTheDocument(); + expect(screen.getByText(mockLearners[0].email)).toBeInTheDocument(); + expect(screen.getByText(mockLearners[0].track)).toBeInTheDocument(); + }); + + test('displays beta tester status correctly', () => { + renderComponent(); + + const rows = screen.getAllByRole('row'); + expect(rows[1]).toHaveTextContent('True'); // First learner is beta tester + expect(rows[2]).not.toHaveTextContent('True'); // Second learner is not beta tester + }); + + test('calls onUnenroll when unenroll button is clicked', async () => { + const mockOnUnenroll = jest.fn(); + renderComponent(mockOnUnenroll); + + const unenrollButtons = screen.getAllByRole('button', { name: /unenroll/i }); + const user = userEvent.setup(); + await user.click(unenrollButtons[0]); + + expect(mockOnUnenroll).toHaveBeenCalledWith(mockLearners[0]); + }); + + test('handles loading state', () => { + (useEnrollments as jest.Mock).mockReturnValue({ + data: { count: 0, results: [] }, + isLoading: true, + }); + + renderComponent(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + test('handles empty data', () => { + (useEnrollments as jest.Mock).mockReturnValue({ + data: { count: 0, results: [] }, + isLoading: false, + }); + + renderComponent(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + test('displays N/A for missing track', () => { + const learnersWithoutTrack = [ + { ...mockLearners[0], track: null }, + ]; + + (useEnrollments as jest.Mock).mockReturnValue({ + data: { count: 1, results: learnersWithoutTrack }, + isLoading: false, + }); + + renderComponent(); + expect(screen.getByText('N/A')).toBeInTheDocument(); + }); +}); diff --git a/src/enrollments/components/EnrollmentsList.tsx b/src/enrollments/components/EnrollmentsList.tsx new file mode 100644 index 00000000..c81cd99b --- /dev/null +++ b/src/enrollments/components/EnrollmentsList.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { ActionRow, Button, DataTable, IconButton } from '@openedx/paragon'; +import { useIntl } from '@openedx/frontend-base'; +import { MoreVert } from '@openedx/paragon/icons'; +import messages from '../messages'; +import { useEnrollments } from '../data/apiHook'; +import { Learner } from '../types'; + +const ENROLLMENTS_PAGE_SIZE = 25; + +const demoEnrollments = [ + { + id: '1', + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + track: 'Audit', + betaTester: true, + actions: , + }, +]; + +interface EnrollmentsListProps { + onUnenroll: (learner: Learner) => void, +} + +const EnrollmentsList = ({ onUnenroll }: EnrollmentsListProps) => { + const intl = useIntl(); + const { courseId } = useParams(); + const [page, setPage] = useState(0); + const { data = { count: 0, results: demoEnrollments }, isLoading } = useEnrollments(courseId ?? '', { + page, + pageSize: ENROLLMENTS_PAGE_SIZE + }); + + const pageCount = Math.ceil(data.count / ENROLLMENTS_PAGE_SIZE); + + const handleFetchData = (state: any) => { + setPage(state.pageIndex); + }; + + const handleMoreButton = () => { + // Handle more button click + console.log('More button clicked'); + }; + + const tableColumns = [ + { accessor: 'username', Header: intl.formatMessage(messages.username) }, + { accessor: 'fullName', Header: intl.formatMessage(messages.fullName) }, + { accessor: 'email', Header: intl.formatMessage(messages.email) }, + { accessor: 'track', Header: intl.formatMessage(messages.track) }, + { accessor: 'betaTester', Header: intl.formatMessage(messages.betaTester) }, + { accessor: 'actions', Header: intl.formatMessage(messages.actions) }, + ]; + + const tableData = data.results.map((learner: Learner) => ({ + id: learner.id, + username: learner.username, + fullName: learner.fullName, + email: learner.email, + track: learner.track ?? 'N/A', + betaTester: learner.betaTester ? 'True' : '', + actions: ( + + + + + ), + })); + + return ( + + ); +}; + +export default EnrollmentsList; diff --git a/src/enrollments/components/UnenrollModal.tsx b/src/enrollments/components/UnenrollModal.tsx new file mode 100644 index 00000000..029a58de --- /dev/null +++ b/src/enrollments/components/UnenrollModal.tsx @@ -0,0 +1,19 @@ +import { Learner } from '../types'; +interface UnenrollModalProps { + learner: Learner | null, + isOpen: boolean, + onClose: () => void, +} + +const UnenrollModal = ({ learner, isOpen, onClose }: UnenrollModalProps) => { + console.log(learner, isOpen); + + if (!isOpen || learner === null) { + onClose(); + return null; + } + + return
Unenroll Modal
; +}; + +export default UnenrollModal; diff --git a/src/enrollments/data/api.test.ts b/src/enrollments/data/api.test.ts new file mode 100644 index 00000000..47e4b845 --- /dev/null +++ b/src/enrollments/data/api.test.ts @@ -0,0 +1,246 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; +import { getEnrollments, getEnrollmentStatus, PaginationParams } from './api'; +import { EnrollmentsResponse, EnrollmentStatusResponse } from '../types'; + +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + camelCaseObject: jest.fn((obj) => obj), + getAuthenticatedHttpClient: jest.fn(), +})); + +jest.mock('../../data/api', () => ({ + getApiBaseUrl: jest.fn(), +})); + +const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction; +const mockCamelCaseObject = camelCaseObject as jest.MockedFunction; +const mockGetApiBaseUrl = getApiBaseUrl as jest.MockedFunction; + +describe('enrollments api', () => { + const mockHttpClient = { + get: jest.fn(), + }; + + beforeEach(() => { + mockGetApiBaseUrl.mockReturnValue('https://test-lms.com'); + mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getEnrollments', () => { + const mockEnrollmentsData = { + count: 2, + results: [ + { + id: '1', + username: 'student1', + full_name: 'Student One', + email: 'student1@example.com', + track: 'verified', + beta_tester: false, + }, + { + id: '2', + username: 'student2', + full_name: 'Student Two', + email: 'student2@example.com', + track: 'audit', + beta_tester: true, + }, + ], + }; + + const mockCamelCaseData: EnrollmentsResponse = { + count: 2, + results: [ + { + id: '1', + username: 'student1', + fullName: 'Student One', + email: 'student1@example.com', + track: 'verified', + betaTester: false, + }, + { + id: '2', + username: 'student2', + fullName: 'Student Two', + email: 'student2@example.com', + track: 'audit', + betaTester: true, + }, + ], + }; + + const pagination: PaginationParams = { page: 1, pageSize: 20 }; + + beforeEach(() => { + mockHttpClient.get.mockResolvedValue({ data: mockEnrollmentsData }); + mockCamelCaseObject.mockReturnValue(mockCamelCaseData); + }); + + it('fetches enrollments successfully', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const result = await getEnrollments(courseId, pagination); + + expect(mockGetApiBaseUrl).toHaveBeenCalled(); + expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled(); + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://test-lms.com/api/instructor/v2/courses/course-v1:edX+Test+2023/enrollments/?page=1&page_size=20' + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockEnrollmentsData); + expect(result).toBe(mockCamelCaseData); + }); + + it('handles different pagination parameters', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const customPagination: PaginationParams = { page: 3, pageSize: 50 }; + + await getEnrollments(courseId, customPagination); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://test-lms.com/api/instructor/v2/courses/course-v1:edX+Test+2023/enrollments/?page=3&page_size=50' + ); + }); + + it('handles special characters in course ID', async () => { + const courseId = 'course-v1:edX+Test+Course+2023'; + + await getEnrollments(courseId, pagination); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://test-lms.com/api/instructor/v2/courses/course-v1:edX+Test+Course+2023/enrollments/?page=1&page_size=20' + ); + }); + + it('throws error when API call fails', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const error = new Error('Network error'); + mockHttpClient.get.mockRejectedValue(error); + + await expect(getEnrollments(courseId, pagination)).rejects.toThrow('Network error'); + expect(mockCamelCaseObject).not.toHaveBeenCalled(); + }); + + it('throws error when HTTP client returns error status', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const error = { + response: { + status: 404, + data: { error: 'Course not found' }, + }, + }; + mockHttpClient.get.mockRejectedValue(error); + + await expect(getEnrollments(courseId, pagination)).rejects.toEqual(error); + }); + }); + + describe('getEnrollmentStatus', () => { + const mockEnrollmentStatusData = { + status: 'enrolled', + }; + + const mockCamelCaseStatusData: EnrollmentStatusResponse = { + status: 'enrolled', + }; + + beforeEach(() => { + mockHttpClient.get.mockResolvedValue({ data: mockEnrollmentStatusData }); + mockCamelCaseObject.mockReturnValue(mockCamelCaseStatusData); + }); + + it('fetches enrollment status by email successfully', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const userIdentifier = 'student@example.com'; + + const result = await getEnrollmentStatus(courseId, userIdentifier); + + expect(mockGetApiBaseUrl).toHaveBeenCalled(); + expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled(); + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://test-lms.com/api/instructor/v2/courses/course-v1:edX+Test+2023/enrollments/?email_or_username=student@example.com' + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockEnrollmentStatusData); + expect(result).toBe(mockCamelCaseStatusData); + }); + + it('fetches enrollment status by username successfully', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const userIdentifier = 'student123'; + + await getEnrollmentStatus(courseId, userIdentifier); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://test-lms.com/api/instructor/v2/courses/course-v1:edX+Test+2023/enrollments/?email_or_username=student123' + ); + }); + + it('handles special characters in user identifier', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const userIdentifier = 'test+user@example.com'; + + await getEnrollmentStatus(courseId, userIdentifier); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://test-lms.com/api/instructor/v2/courses/course-v1:edX+Test+2023/enrollments/?email_or_username=test+user@example.com' + ); + }); + + it('handles special characters in course ID', async () => { + const courseId = 'course-v1:edX+Advanced+Course+2023'; + const userIdentifier = 'student@example.com'; + + await getEnrollmentStatus(courseId, userIdentifier); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://test-lms.com/api/instructor/v2/courses/course-v1:edX+Advanced+Course+2023/enrollments/?email_or_username=student@example.com' + ); + }); + + it('throws error when API call fails', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const userIdentifier = 'student@example.com'; + const error = new Error('Network error'); + mockHttpClient.get.mockRejectedValue(error); + + await expect(getEnrollmentStatus(courseId, userIdentifier)).rejects.toThrow('Network error'); + expect(mockCamelCaseObject).not.toHaveBeenCalled(); + }); + + it('throws error when user not found', async () => { + const courseId = 'course-v1:edX+Test+2023'; + const userIdentifier = 'nonexistent@example.com'; + const error = { + response: { + status: 404, + data: { error: 'User not found' }, + }, + }; + mockHttpClient.get.mockRejectedValue(error); + + await expect(getEnrollmentStatus(courseId, userIdentifier)).rejects.toEqual(error); + }); + + it('handles different enrollment statuses', async () => { + const statuses = ['enrolled', 'unenrolled', 'pending']; + + for (const status of statuses) { + const mockStatusData = { status }; + const mockCamelCaseStatus = { status }; + + mockHttpClient.get.mockResolvedValue({ data: mockStatusData }); + mockCamelCaseObject.mockReturnValue(mockCamelCaseStatus); + + const result = await getEnrollmentStatus('course-v1:edX+Test+2023', 'test@example.com'); + + expect(result.status).toBe(status); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockStatusData); + } + }); + }); +}); diff --git a/src/enrollments/data/api.ts b/src/enrollments/data/api.ts new file mode 100644 index 00000000..48a027c2 --- /dev/null +++ b/src/enrollments/data/api.ts @@ -0,0 +1,28 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; +import { EnrollmentsResponse, EnrollmentStatusResponse } from '../types'; + +export interface PaginationParams { + page: number, + pageSize: number, +} + +export const getEnrollments = async ( + courseId: string, + pagination: PaginationParams +): Promise => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/enrollments/?page=${pagination.page}&page_size=${pagination.pageSize}` + ); + return camelCaseObject(data); +}; + +export const getEnrollmentStatus = async ( + courseId: string, + userIdentifier: string +): Promise => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/enrollments/?email_or_username=${userIdentifier}` + ); + return camelCaseObject(data); +}; diff --git a/src/enrollments/data/apiHook.test.tsx b/src/enrollments/data/apiHook.test.tsx new file mode 100644 index 00000000..5a439e4e --- /dev/null +++ b/src/enrollments/data/apiHook.test.tsx @@ -0,0 +1,300 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useEnrollments, useEnrollmentByUserId } from './apiHook'; +import { getEnrollments, getEnrollmentStatus, PaginationParams } from './api'; + +jest.mock('./api'); + +const mockGetEnrollments = getEnrollments as jest.MockedFunction; +const mockGetEnrollmentStatus = getEnrollmentStatus as jest.MockedFunction; + +const mockEnrollmentsData = { + count: 2, + results: [ + { + id: '1', + username: 'student1', + fullName: 'Student One', + email: 'student1@example.com', + track: 'verified', + betaTester: false, + }, + { + id: '2', + username: 'student2', + fullName: 'Student Two', + email: 'student2@example.com', + track: 'audit', + betaTester: true, + }, + ], +}; + +const mockEnrollmentStatusData = { + status: 'enrolled', +}; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + Wrapper.displayName = 'TestWrapper'; + return Wrapper; +}; + +describe('enrollments api hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useEnrollments', () => { + const courseId = 'course-v1:edX+Test+2023'; + const pagination: PaginationParams = { page: 1, pageSize: 20 }; + + it('fetches enrollments successfully', async () => { + mockGetEnrollments.mockResolvedValue(mockEnrollmentsData); + + const { result } = renderHook(() => useEnrollments(courseId, pagination), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetEnrollments).toHaveBeenCalledWith(courseId, pagination); + expect(result.current.data).toBe(mockEnrollmentsData); + expect(result.current.error).toBe(null); + }); + + it('handles API error', async () => { + const mockError = new Error('Network error'); + mockGetEnrollments.mockRejectedValue(mockError); + + const { result } = renderHook(() => useEnrollments(courseId, pagination), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(mockGetEnrollments).toHaveBeenCalledWith(courseId, pagination); + expect(result.current.error).toBe(mockError); + expect(result.current.data).toBe(undefined); + }); + + it('handles different pagination parameters', async () => { + const customPagination: PaginationParams = { page: 3, pageSize: 50 }; + mockGetEnrollments.mockResolvedValue(mockEnrollmentsData); + + const { result } = renderHook(() => useEnrollments(courseId, customPagination), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetEnrollments).toHaveBeenCalledWith(courseId, customPagination); + expect(result.current.data).toBe(mockEnrollmentsData); + }); + + it('handles empty results', async () => { + const emptyResults = { count: 0, results: [] }; + mockGetEnrollments.mockResolvedValue(emptyResults); + + const { result } = renderHook(() => useEnrollments(courseId, pagination), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBe(emptyResults); + expect(result.current.data?.count).toBe(0); + expect(result.current.data?.results).toHaveLength(0); + }); + + it('handles HTTP error responses', async () => { + const httpError = { + response: { + status: 404, + data: { error: 'Course not found' }, + }, + }; + mockGetEnrollments.mockRejectedValue(httpError); + + const { result } = renderHook(() => useEnrollments(courseId, pagination), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe(httpError); + }); + }); + + describe('useEnrollmentByUserId', () => { + const courseId = 'course-v1:edX+Test+2023'; + const userIdentifier = 'student@example.com'; + + it('fetches enrollment status successfully when enabled', async () => { + mockGetEnrollmentStatus.mockResolvedValue(mockEnrollmentStatusData); + + const { result } = renderHook(() => useEnrollmentByUserId(courseId, userIdentifier), { + wrapper: createWrapper(), + }); + + // Initially should not fetch because enabled is false + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBe(undefined); + + // Manually trigger the query + result.current.refetch(); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetEnrollmentStatus).toHaveBeenCalledWith(courseId, userIdentifier); + expect(result.current.data).toBe(mockEnrollmentStatusData); + expect(result.current.error).toBe(null); + }); + + it('is disabled by default', async () => { + mockGetEnrollmentStatus.mockResolvedValue(mockEnrollmentStatusData); + + const { result } = renderHook(() => useEnrollmentByUserId(courseId, userIdentifier), { + wrapper: createWrapper(), + }); + + // Should not automatically fetch + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.isFetched).toBe(false); + + // API should not have been called + expect(mockGetEnrollmentStatus).not.toHaveBeenCalled(); + }); + + it('handles API error when manually triggered', async () => { + const mockError = new Error('User not found'); + mockGetEnrollmentStatus.mockRejectedValue(mockError); + + const { result } = renderHook(() => useEnrollmentByUserId(courseId, userIdentifier), { + wrapper: createWrapper(), + }); + + // Manually trigger the query + result.current.refetch(); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(mockGetEnrollmentStatus).toHaveBeenCalledWith(courseId, userIdentifier); + expect(result.current.error).toBe(mockError); + expect(result.current.data).toBe(undefined); + }); + + it('uses correct query key', async () => { + mockGetEnrollmentStatus.mockResolvedValue(mockEnrollmentStatusData); + + const { result } = renderHook(() => useEnrollmentByUserId(courseId, userIdentifier), { + wrapper: createWrapper(), + }); + + // Manually trigger the query + result.current.refetch(); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Verify the query was called with correct parameters + expect(mockGetEnrollmentStatus).toHaveBeenCalledWith(courseId, userIdentifier); + expect(result.current.data).toBe(mockEnrollmentStatusData); + }); + + it('handles different user identifiers', async () => { + const username = 'student123'; + mockGetEnrollmentStatus.mockResolvedValue(mockEnrollmentStatusData); + + const { result } = renderHook(() => useEnrollmentByUserId(courseId, username), { + wrapper: createWrapper(), + }); + + // Manually trigger the query + result.current.refetch(); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetEnrollmentStatus).toHaveBeenCalledWith(courseId, username); + expect(result.current.data).toBe(mockEnrollmentStatusData); + }); + + it('handles different enrollment statuses', async () => { + const statuses = ['enrolled', 'unenrolled', 'pending']; + + for (const status of statuses) { + const statusData = { status }; + mockGetEnrollmentStatus.mockResolvedValue(statusData); + + const { result } = renderHook(() => useEnrollmentByUserId(courseId, userIdentifier), { + wrapper: createWrapper(), + }); + + // Manually trigger the query + result.current.refetch(); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.status).toBe(status); + + // Clear mock for next iteration + jest.clearAllMocks(); + } + }); + + it('handles HTTP error responses', async () => { + const httpError = { + response: { + status: 404, + data: { error: 'User not found in course' }, + }, + }; + mockGetEnrollmentStatus.mockRejectedValue(httpError); + + const { result } = renderHook(() => useEnrollmentByUserId(courseId, userIdentifier), { + wrapper: createWrapper(), + }); + + // Manually trigger the query + result.current.refetch(); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe(httpError); + }); + }); +}); diff --git a/src/enrollments/data/apiHook.ts b/src/enrollments/data/apiHook.ts new file mode 100644 index 00000000..f9a00ffb --- /dev/null +++ b/src/enrollments/data/apiHook.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { getEnrollments, getEnrollmentStatus, PaginationParams } from './api'; +import { enrollmentsQueryKeys } from './queryKeys'; + +export const useEnrollments = (courseId: string, pagination: PaginationParams) => ( + useQuery({ + queryKey: enrollmentsQueryKeys.byCoursePaginated(courseId, pagination), + queryFn: () => getEnrollments(courseId, pagination), + }) +); + +export const useEnrollmentByUserId = (courseId: string, userIdentifier: string) => ( + useQuery({ + queryKey: enrollmentsQueryKeys.byUserId(courseId, userIdentifier), + queryFn: () => getEnrollmentStatus(courseId, userIdentifier), + enabled: false, + }) +); diff --git a/src/enrollments/data/queryKeys.ts b/src/enrollments/data/queryKeys.ts new file mode 100644 index 00000000..25689268 --- /dev/null +++ b/src/enrollments/data/queryKeys.ts @@ -0,0 +1,9 @@ +import { appId } from '../../constants'; +import { PaginationParams } from './api'; + +export const enrollmentsQueryKeys = { + all: [appId, 'enrollments'] as const, + byCourse: (courseId: string) => [...enrollmentsQueryKeys.all, courseId] as const, + byCoursePaginated: (courseId: string, pagination: PaginationParams) => [...enrollmentsQueryKeys.byCourse(courseId), pagination.page] as const, + byUserId: (courseId: string, userIdentifier: string) => [...enrollmentsQueryKeys.byCourse(courseId), 'enrollment', userIdentifier] as const, +}; diff --git a/src/enrollments/messages.ts b/src/enrollments/messages.ts new file mode 100644 index 00000000..b1baa1ef --- /dev/null +++ b/src/enrollments/messages.ts @@ -0,0 +1,86 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + enrollmentsPageTitle: { + id: 'instruct.enrollments.page.title', + defaultMessage: 'Enrollment Management', + description: 'Title for the enrollments page', + }, + addBetaTesters: { + id: 'instruct.enrollments.addBetaTesters', + defaultMessage: 'Add Beta Testers', + description: 'Button label for adding beta testers', + }, + enrollLearners: { + id: 'instruct.enrollments.enrollLearners', + defaultMessage: 'Enroll Learners', + description: 'Button label for enrolling learners', + }, + checkEnrollmentStatus: { + id: 'instruct.enrollments.checkEnrollmentStatus', + defaultMessage: 'Check Enrollment Status', + description: 'Check enrollment status modal title and alt for icon button', + }, + username: { + id: 'instruct.enrollments.username', + defaultMessage: 'Username', + description: 'Column header for username in enrollments list', + }, + fullName: { + id: 'instruct.enrollments.fullName', + defaultMessage: 'Name', + description: 'Column header for full name in enrollments list', + }, + email: { + id: 'instruct.enrollments.email', + defaultMessage: 'Email', + description: 'Column header for email in enrollments list', + }, + track: { + id: 'instruct.enrollments.track', + defaultMessage: 'Track', + description: 'Column header for track in enrollments list', + }, + betaTester: { + id: 'instruct.enrollments.betaTester', + defaultMessage: 'Beta Tester', + description: 'Column header for beta tester status in enrollments list', + }, + actions: { + id: 'instruct.enrollments.actions', + defaultMessage: 'Actions', + description: 'Column header for actions in enrollments list', + }, + unenrollButton: { + id: 'instruct.enrollments.unenrollButton', + defaultMessage: 'Unenroll', + description: 'Button label for unenrolling a learner', + }, + trueLabel: { + id: 'instruct.enrollments.trueLabel', + defaultMessage: 'True', + description: 'Label for true boolean value', + }, + addLearnerInstructions: { + id: 'instruct.enrollments.checkEnrollmentStatusModal.addLearnerInstructions', + defaultMessage: 'Learner’s My Open edX email address or username', + description: 'Instructions for enroll learners to the course', + }, + enrollLearnersPlaceholder: { + id: 'instruct.enrollments.checkEnrollmentStatusModal.enrollLearnersPlaceholder', + defaultMessage: 'Learner email address or username', + description: 'Placeholder text for enrolling learners textarea', + }, + closeButton: { + id: 'instruct.enrollments.checkEnrollmentStatusModal.closeButton', + defaultMessage: 'Close', + description: 'Label for close button in modals', + }, + statusResponseMessage: { + id: 'instruct.enrollments.checkEnrollmentStatusModal.statusResponseMessage', + defaultMessage: 'Enrollment status for {learnerIdentifier}: {status}', + description: 'Message displaying the enrollment status for a learner', + } +}); + +export default messages; diff --git a/src/enrollments/types.ts b/src/enrollments/types.ts new file mode 100644 index 00000000..e073492d --- /dev/null +++ b/src/enrollments/types.ts @@ -0,0 +1,17 @@ +export interface EnrollmentsResponse { + count: number, + results: Learner[], +} + +export interface EnrollmentStatusResponse { + status: string, +} + +export interface Learner { + id: string, + username: string, + fullName: string, + email: string, + track: string, + betaTester: boolean, +};