Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/enrollments/EnrollmentsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="table">
<button onClick={() => onUnenroll({
id: '1', fullName: 'Tester', email: 'test@example.com',
username: '',
track: '',
betaTester: false
})}
>
Unenroll Test Learner
</button>
</div>
);
};
});

jest.mock('./components/EnrollmentStatusModal', () => {
return function MockEnrollmentStatusModal({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) {
return isOpen ? (
<div role="dialog">
<button onClick={onClose}>Close Modal</button>
</div>
) : null;
};
});

jest.mock('./components/UnenrollModal', () => {
return function MockUnenrollModal({ isOpen, learner, onClose }: { isOpen: boolean, learner: Learner | null, onClose: () => void }) {
return isOpen ? (
<div role="dialog">
<span>Unenroll {learner?.fullName}</span>
<button onClick={onClose}>Close Unenroll Modal</button>
</div>
) : null;
};
});

const renderWithIntl = (component: React.ReactElement) => {
return render(
<IntlProvider locale="en">
{component}
</IntlProvider>
);
};

describe('EnrollmentsPage', () => {
it('renders the page title', () => {
renderWithIntl(<EnrollmentsPage />);
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
});

it('renders action buttons', () => {
renderWithIntl(<EnrollmentsPage />);
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(<EnrollmentsPage />);
expect(screen.getByRole('table')).toBeInTheDocument();
});

it('opens enrollment status modal when more button is clicked', async () => {
renderWithIntl(<EnrollmentsPage />);

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(<EnrollmentsPage />);

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(<EnrollmentsPage />);

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(<EnrollmentsPage />);

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(<EnrollmentsPage />);

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
54 changes: 51 additions & 3 deletions src/enrollments/EnrollmentsPage.tsx
Original file line number Diff line number Diff line change
@@ -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<Learner | null>(null);

const handleMoreButton = () => {
setIsEnrollmentStatusModalOpen(true);
};

const handleUnenroll = (learner: Learner) => {
setIsUnenrollModalOpen(true);
setSelectedLearner(learner);
};

const handleUnenrollModalClose = () => {
setIsUnenrollModalOpen(false);
setSelectedLearner(null);
};

const handleCloseEnrollmentStatusModal = () => {
setIsEnrollmentStatusModalOpen(false);
};

return (
<div>
<h3>Enrollments</h3>
</div>
<>
<div className="d-flex justify-content-between align-items-center">
<h3>{intl.formatMessage(messages.enrollmentsPageTitle)}</h3>
<ActionRow>
<IconButton
alt={intl.formatMessage(messages.checkEnrollmentStatus)}
className="lead"
iconAs={MoreVert}
onClick={handleMoreButton}
/>
<Button variant="outline-primary">+ {intl.formatMessage(messages.addBetaTesters)}</Button>
<Button>+ {intl.formatMessage(messages.enrollLearners)}</Button>
</ActionRow>
</div>
<EnrollmentsList onUnenroll={handleUnenroll} />
<EnrollmentStatusModal isOpen={isEnrollmentStatusModalOpen} onClose={handleCloseEnrollmentStatusModal} />
<UnenrollModal isOpen={isUnenrollModalOpen} learner={selectedLearner} onClose={handleUnenrollModalClose} />
</>
);
};

Expand Down
134 changes: 134 additions & 0 deletions src/enrollments/components/EnrollmentStatusModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<EnrollmentStatusModal {...defaultProps} />);
};

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();
});
});
52 changes: 52 additions & 0 deletions src/enrollments/components/EnrollmentStatusModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('');
const { data = { status: '' }, refetch } = useEnrollmentByUserId(courseId, learnerIdentifier);

const handleSearch = async () => {
refetch();
};

return (
<ModalDialog title={intl.formatMessage(messages.checkEnrollmentStatus)} isOpen={isOpen} onClose={onClose} isOverflowVisible={false}>
<ModalDialog.Header><h3 className="text-primary-500">{intl.formatMessage(messages.checkEnrollmentStatus)}</h3></ModalDialog.Header>
<ModalDialog.Body className="py-4">
<p>{intl.formatMessage(messages.addLearnerInstructions)}</p>
<FormControl
placeholder={intl.formatMessage(messages.enrollLearnersPlaceholder)}
value={learnerIdentifier}
onChange={(e) => setLearnerIdentifier(e.target.value)}
/>
<Button
className="mt-3"
onClick={handleSearch}
disabled={!learnerIdentifier.trim()}
>
{intl.formatMessage(messages.checkEnrollmentStatus)}
</Button>

{data.status && learnerIdentifier && (
<p>{intl.formatMessage(messages.statusResponseMessage, { learnerIdentifier, status: data.status })}</p>
)}
</ModalDialog.Body>
<ModalDialog.Footer>
<Button onClick={onClose}>{intl.formatMessage(messages.closeButton)}</Button>
</ModalDialog.Footer>
</ModalDialog>
);
};

export default EnrollmentStatusModal;
Loading