Skip to content

Commit 3401263

Browse files
feat: add/unenroll learners
1 parent 82c29e5 commit 3401263

File tree

10 files changed

+430
-55
lines changed

10 files changed

+430
-55
lines changed

src/enrollments/EnrollmentsPage.test.tsx

Lines changed: 41 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1-
import React from 'react';
2-
import { render, screen, within } from '@testing-library/react';
3-
import { IntlProvider } from '@openedx/frontend-base';
1+
import { screen, within } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
43
import EnrollmentsPage from './EnrollmentsPage';
54
import { Learner } from './types';
6-
import userEvent from '@testing-library/user-event';
75
import messages from './messages';
6+
import { useEnrollmentByUserId, useEnrollments, useEnrollLearners } from './data/apiHook';
7+
import { renderWithAlertAndIntl } from '@src/testUtils';
8+
9+
jest.mock('react-router-dom', () => ({
10+
...jest.requireActual('react-router-dom'),
11+
useParams: () => ({ courseId: 'test-course-id' }),
12+
}));
13+
14+
jest.mock('./data/apiHook', () => ({
15+
useEnrollments: jest.fn(),
16+
useEnrollmentByUserId: jest.fn(),
17+
useEnrollLearners: jest.fn(),
18+
}));
819

920
// Mock the child components
1021
jest.mock('./components/EnrollmentsList', () => {
@@ -26,55 +37,42 @@ jest.mock('./components/EnrollmentsList', () => {
2637
};
2738
});
2839

29-
jest.mock('./components/EnrollmentStatusModal', () => {
30-
return function MockEnrollmentStatusModal({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) {
31-
return isOpen ? (
32-
<div role="dialog">
33-
<button onClick={onClose}>Close Modal</button>
34-
</div>
35-
) : null;
36-
};
37-
});
38-
39-
jest.mock('./components/UnenrollModal', () => {
40-
return function MockUnenrollModal({ isOpen, learner, onClose }: { isOpen: boolean, learner: Learner | null, onClose: () => void }) {
41-
return isOpen ? (
42-
<div role="dialog">
43-
<span>Unenroll {learner?.fullName}</span>
44-
<button onClick={onClose}>Close Unenroll Modal</button>
45-
</div>
46-
) : null;
47-
};
48-
});
49-
50-
const renderWithIntl = (component: React.ReactElement) => {
51-
return render(
52-
<IntlProvider locale="en">
53-
{component}
54-
</IntlProvider>
55-
);
56-
};
57-
5840
describe('EnrollmentsPage', () => {
41+
beforeAll(() => {
42+
(useEnrollments as jest.Mock).mockReturnValue({
43+
data: { count: 1, numPages: 1, results: [{ username: 'testuser', fullName: 'Test User', email: 'test@example.com', mode: 'audit', isBetaTester: false }] },
44+
isLoading: false,
45+
});
46+
(useEnrollmentByUserId as jest.Mock).mockReturnValue({
47+
data: { enrollmentStatus: 'enrolled' },
48+
refetch: jest.fn(),
49+
});
50+
(useEnrollLearners as jest.Mock).mockReturnValue({
51+
mutate: jest.fn(),
52+
isLoading: false,
53+
error: null,
54+
});
55+
});
56+
5957
it('renders the page title', () => {
60-
renderWithIntl(<EnrollmentsPage />);
58+
renderWithAlertAndIntl(<EnrollmentsPage />);
6159
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
6260
});
6361

6462
it('renders action buttons', () => {
65-
renderWithIntl(<EnrollmentsPage />);
63+
renderWithAlertAndIntl(<EnrollmentsPage />);
6664
expect(screen.getByRole('button', { name: messages.checkEnrollmentStatus.defaultMessage })).toBeInTheDocument();
6765
expect(screen.getByRole('button', { name: new RegExp(messages.addBetaTesters.defaultMessage) })).toBeInTheDocument();
6866
expect(screen.getByRole('button', { name: new RegExp(messages.enrollLearners.defaultMessage) })).toBeInTheDocument();
6967
});
7068

7169
it('renders EnrollmentsList component', () => {
72-
renderWithIntl(<EnrollmentsPage />);
70+
renderWithAlertAndIntl(<EnrollmentsPage />);
7371
expect(screen.getByRole('table')).toBeInTheDocument();
7472
});
7573

7674
it('opens enrollment status modal when more button is clicked', async () => {
77-
renderWithIntl(<EnrollmentsPage />);
75+
renderWithAlertAndIntl(<EnrollmentsPage />);
7876

7977
const moreButton = screen.getByRole('button', { name: messages.checkEnrollmentStatus.defaultMessage });
8078
const user = userEvent.setup();
@@ -84,20 +82,20 @@ describe('EnrollmentsPage', () => {
8482
});
8583

8684
it('closes enrollment status modal', async () => {
87-
renderWithIntl(<EnrollmentsPage />);
85+
renderWithAlertAndIntl(<EnrollmentsPage />);
8886

8987
const moreButton = screen.getByRole('button', { name: messages.checkEnrollmentStatus.defaultMessage });
9088
const user = userEvent.setup();
9189
await user.click(moreButton);
9290

93-
const closeButton = screen.getByText('Close Modal');
91+
const closeButton = screen.getByText('Close');
9492
await user.click(closeButton);
9593

9694
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
9795
});
9896

9997
it('opens unenroll modal when unenroll is triggered', async () => {
100-
renderWithIntl(<EnrollmentsPage />);
98+
renderWithAlertAndIntl(<EnrollmentsPage />);
10199

102100
const unenrollButton = screen.getByText('Unenroll Test Learner');
103101
const user = userEvent.setup();
@@ -110,20 +108,20 @@ describe('EnrollmentsPage', () => {
110108
});
111109

112110
it('closes unenroll modal and clears selected learner', async () => {
113-
renderWithIntl(<EnrollmentsPage />);
111+
renderWithAlertAndIntl(<EnrollmentsPage />);
114112

115113
const unenrollButton = screen.getByText('Unenroll Test Learner');
116114
const user = userEvent.setup();
117115
await user.click(unenrollButton);
118116

119-
const closeUnenrollButton = screen.getByText('Close Unenroll Modal');
117+
const closeUnenrollButton = screen.getByText('Cancel');
120118
await user.click(closeUnenrollButton);
121119

122120
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
123121
});
124122

125123
it('modals are closed by default', () => {
126-
renderWithIntl(<EnrollmentsPage />);
124+
renderWithAlertAndIntl(<EnrollmentsPage />);
127125

128126
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
129127
});

src/enrollments/EnrollmentsPage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import messages from './messages';
66
import EnrollmentsList from './components/EnrollmentsList';
77
import EnrollmentStatusModal from './components/EnrollmentStatusModal';
88
import UnenrollModal from './components/UnenrollModal';
9+
import EnrollLearnersModal from './components/EnrollLearnersModal';
910
import { Learner } from './types';
1011

1112
const EnrollmentsPage = () => {
1213
const intl = useIntl();
1314
const [isEnrollmentStatusModalOpen, setIsEnrollmentStatusModalOpen] = useState(false);
15+
const [isEnrollLearnersModalOpen, setIsEnrollLearnersModalOpen] = useState(false);
1416
const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false);
1517
const [selectedLearner, setSelectedLearner] = useState<Learner | null>(null);
1618

@@ -32,6 +34,10 @@ const EnrollmentsPage = () => {
3234
setIsEnrollmentStatusModalOpen(false);
3335
};
3436

37+
const handleEnrollLearners = () => {
38+
setIsEnrollLearnersModalOpen(true);
39+
};
40+
3541
return (
3642
<>
3743
<div className="d-flex justify-content-between align-items-center">
@@ -44,12 +50,13 @@ const EnrollmentsPage = () => {
4450
onClick={handleMoreButton}
4551
/>
4652
<Button variant="outline-primary">+ {intl.formatMessage(messages.addBetaTesters)}</Button>
47-
<Button>+ {intl.formatMessage(messages.enrollLearners)}</Button>
53+
<Button onClick={handleEnrollLearners}>+ {intl.formatMessage(messages.enrollLearners)}</Button>
4854
</ActionRow>
4955
</div>
5056
<EnrollmentsList onUnenroll={handleUnenroll} />
5157
<EnrollmentStatusModal isOpen={isEnrollmentStatusModalOpen} onClose={handleCloseEnrollmentStatusModal} />
5258
{selectedLearner && <UnenrollModal isOpen={isUnenrollModalOpen} learner={selectedLearner} onClose={handleUnenrollModalClose} />}
59+
<EnrollLearnersModal isOpen={isEnrollLearnersModalOpen} onClose={() => setIsEnrollLearnersModalOpen(false)} onSuccess={() => {}} />
5360
</>
5461
);
5562
};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import userEvent from '@testing-library/user-event';
2+
import { screen } from '@testing-library/react';
3+
import EnrollLearnersModal, { EnrollLearnersModalProps } from './EnrollLearnersModal';
4+
import messages from '../messages';
5+
import { renderWithIntl } from '@src/testUtils';
6+
7+
const defaultProps: EnrollLearnersModalProps = {
8+
instructions: 'Enter emails separated by commas.',
9+
isOpen: true,
10+
title: 'Add Users',
11+
onClose: jest.fn(),
12+
onSuccess: jest.fn(),
13+
};
14+
15+
const renderComponent = (props = {}) =>
16+
renderWithIntl(<EnrollLearnersModal {...defaultProps} {...props} />);
17+
18+
describe('EnrollLearnersModal', () => {
19+
afterEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
it('renders modal with title and instructions', () => {
24+
renderComponent();
25+
expect(screen.getByText(defaultProps.title)).toBeInTheDocument();
26+
expect(screen.getByText(defaultProps.instructions)).toBeInTheDocument();
27+
});
28+
29+
it('renders textarea with placeholder', () => {
30+
renderComponent();
31+
expect(
32+
screen.getByPlaceholderText(messages.userIdentifierPlaceholder.defaultMessage)
33+
).toBeInTheDocument();
34+
});
35+
36+
it('renders checkboxes with correct labels', () => {
37+
renderComponent();
38+
expect(
39+
screen.getByLabelText(messages.autoEnrollCheckbox.defaultMessage)
40+
).toBeInTheDocument();
41+
expect(
42+
screen.getByLabelText(messages.notifyUsersCheckbox.defaultMessage)
43+
).toBeInTheDocument();
44+
});
45+
46+
it('calls onClose when Cancel button is clicked', async () => {
47+
renderComponent();
48+
const cancelBtn = screen.getByRole('button', {
49+
name: messages.cancelButton.defaultMessage,
50+
});
51+
const user = userEvent.setup();
52+
await user.click(cancelBtn);
53+
expect(defaultProps.onClose).toHaveBeenCalled();
54+
});
55+
56+
it('Save button is disabled when textarea is empty', () => {
57+
renderComponent();
58+
const saveBtn = screen.getByRole('button', {
59+
name: messages.saveButton.defaultMessage,
60+
});
61+
expect(saveBtn).toBeDisabled();
62+
});
63+
64+
it('Save button is enabled when textarea has input', async () => {
65+
renderComponent();
66+
const textarea = screen.getByPlaceholderText(
67+
messages.userIdentifierPlaceholder.defaultMessage
68+
);
69+
const user = userEvent.setup();
70+
await user.type(textarea, 'test@example.com');
71+
const saveBtn = screen.getByRole('button', {
72+
name: messages.saveButton.defaultMessage,
73+
});
74+
expect(saveBtn).toBeEnabled();
75+
});
76+
77+
it('calls onSave with trimmed email list when Save is clicked', async () => {
78+
renderComponent();
79+
const textarea = screen.getByPlaceholderText(
80+
messages.userIdentifierPlaceholder.defaultMessage
81+
);
82+
const user = userEvent.setup();
83+
await user.type(textarea, ' alice@example.com, bob@example.com ');
84+
const saveBtn = screen.getByRole('button', {
85+
name: messages.saveButton.defaultMessage,
86+
});
87+
await user.click(saveBtn);
88+
expect(defaultProps.onSuccess).toHaveBeenCalledWith([
89+
'alice@example.com',
90+
'bob@example.com',
91+
]);
92+
});
93+
94+
it('splits emails by comma and trims whitespace', async () => {
95+
renderComponent();
96+
const textarea = screen.getByPlaceholderText(
97+
messages.userIdentifierPlaceholder.defaultMessage
98+
);
99+
const user = userEvent.setup();
100+
await user.type(textarea, 'a@a.com, b@b.com ,c@c.com');
101+
const saveBtn = screen.getByRole('button', {
102+
name: messages.saveButton.defaultMessage,
103+
});
104+
await user.click(saveBtn);
105+
expect(defaultProps.onSuccess).toHaveBeenCalledWith([
106+
'a@a.com',
107+
'b@b.com',
108+
'c@c.com',
109+
]);
110+
});
111+
112+
it('does not call onSuccess if textarea is empty', async () => {
113+
renderComponent();
114+
const saveBtn = screen.getByRole('button', {
115+
name: messages.saveButton.defaultMessage,
116+
});
117+
const user = userEvent.setup();
118+
expect(saveBtn).toBeDisabled();
119+
await user.click(saveBtn);
120+
expect(defaultProps.onSuccess).not.toHaveBeenCalled();
121+
});
122+
123+
it('does not render modal when isOpen is false', () => {
124+
renderComponent({ isOpen: false });
125+
expect(screen.queryByText(defaultProps.title)).not.toBeInTheDocument();
126+
});
127+
});

0 commit comments

Comments
 (0)