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,
+};