Skip to content

Commit 9ffd17a

Browse files
feat: add bulk certificate exceptions CSV upload (#190)
* feat: integrate the v2 endpoints for instructor dashboard certificates tab * feat: remove exception and remove invalidation * fix: remove unused imports and vars * fix: tests * fix: tests * feat: expand test coverage * fix: linting * feat: expand test coverage * feat: test coverage * fix: mock data * fix: tests * feat: PR feedback * feat: Refactor certificates module to use absolute imports and improve data handling * fix: tests * feat: adds bulk grant exception * fix: tests * fix: tests * fix: revert config change * feat: fix tests * feat: increase test coverage * fix: restore code editor * fix: delete test file * feat: use messages for Modal error
1 parent b66e27b commit 9ffd17a

14 files changed

Lines changed: 889 additions & 198 deletions

src/certificates/CertificatesPage.test.tsx

Lines changed: 222 additions & 72 deletions
Large diffs are not rendered by default.

src/certificates/CertificatesPage.tsx

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
useRemoveException,
2222
useRemoveInvalidation,
2323
useToggleCertificateGeneration,
24+
useUploadBulkExceptionsCsv,
2425
} from '@src/certificates/data/apiHook';
2526
import { CertificateFilter } from '@src/certificates/types';
2627
import { CERTIFICATES_PAGE_SIZE, TAB_KEYS, MODAL_TITLES, ALERT_VARIANTS } from '@src/certificates/constants';
@@ -68,6 +69,7 @@ const CertificatesPage = () => {
6869
});
6970

7071
const { mutate: grantExceptions, isPending: isGrantingExceptions } = useGrantBulkExceptions(courseId);
72+
const { mutate: uploadCsvExceptions, isPending: isUploadingCsv } = useUploadBulkExceptionsCsv(courseId);
7173
const { mutate: invalidateCert, isPending: isInvalidating } = useInvalidateCertificate(courseId);
7274
const { mutate: removeExcept, isPending: isRemovingException } = useRemoveException(courseId);
7375
const { mutate: removeInval, isPending: isRemovingInvalidation } = useRemoveInvalidation(courseId);
@@ -83,7 +85,7 @@ const CertificatesPage = () => {
8385
if (data.errors && data.errors.length > 0) {
8486
const errorMessages = data.errors.map(err => `${err.learner}: ${err.message}`).join('\n');
8587
showModal({
86-
title: MODAL_TITLES.ERROR,
88+
title: intl.formatMessage(messages.errorModalTitle),
8789
message: `Some exceptions failed:\n${errorMessages}`,
8890
variant: ALERT_VARIANTS.WARNING,
8991
});
@@ -94,7 +96,7 @@ const CertificatesPage = () => {
9496
},
9597
onError: (error) => {
9698
showModal({
97-
title: MODAL_TITLES.ERROR,
99+
title: intl.formatMessage(messages.errorModalTitle),
98100
message: getErrorMessage(error, intl.formatMessage(messages.errorGrantException)),
99101
variant: ALERT_VARIANTS.DANGER,
100102
});
@@ -103,6 +105,35 @@ const CertificatesPage = () => {
103105
);
104106
}, [grantExceptions, showToast, showModal, intl]);
105107

108+
const handleUploadCsvExceptions = useCallback((file: File) => {
109+
uploadCsvExceptions(
110+
file,
111+
{
112+
onSuccess: (data) => {
113+
setIsGrantExceptionsOpen(false);
114+
if (data.errors && data.errors.length > 0) {
115+
const errorMessages = data.errors.map(err => `${err.learner}: ${err.message}`).join('\n');
116+
showModal({
117+
title: intl.formatMessage(messages.errorModalTitle),
118+
message: `Some exceptions failed:\n${errorMessages}`,
119+
variant: ALERT_VARIANTS.WARNING,
120+
});
121+
}
122+
if (data.success && data.success.length > 0) {
123+
showToast(intl.formatMessage(messages.exceptionsGrantedToast, { count: data.success.length }));
124+
}
125+
},
126+
onError: (error) => {
127+
showModal({
128+
title: intl.formatMessage(messages.errorModalTitle),
129+
message: getErrorMessage(error, intl.formatMessage(messages.errorGrantException)),
130+
variant: ALERT_VARIANTS.DANGER,
131+
});
132+
},
133+
},
134+
);
135+
}, [uploadCsvExceptions, showToast, showModal, intl]);
136+
106137
const handleInvalidateCertificate = useCallback((learners: string[], notes: string) => {
107138
invalidateCert(
108139
{ learners, notes },
@@ -112,7 +143,7 @@ const CertificatesPage = () => {
112143
if (data.errors && data.errors.length > 0) {
113144
const errorMessages = data.errors.map(err => `${err.learner}: ${err.message}`).join('\n');
114145
showModal({
115-
title: MODAL_TITLES.ERROR,
146+
title: intl.formatMessage(messages.errorModalTitle),
116147
message: `Some invalidations failed:\n${errorMessages}`,
117148
variant: ALERT_VARIANTS.WARNING,
118149
});
@@ -123,7 +154,7 @@ const CertificatesPage = () => {
123154
},
124155
onError: (error) => {
125156
showModal({
126-
title: MODAL_TITLES.ERROR,
157+
title: intl.formatMessage(messages.errorModalTitle),
127158
message: getErrorMessage(error, intl.formatMessage(messages.errorInvalidateCertificate)),
128159
variant: ALERT_VARIANTS.DANGER,
129160
});
@@ -162,7 +193,7 @@ const CertificatesPage = () => {
162193
},
163194
onError: (error) => {
164195
showModal({
165-
title: MODAL_TITLES.ERROR,
196+
title: intl.formatMessage(messages.errorModalTitle),
166197
message: getErrorMessage(error, intl.formatMessage(messages.errorRemoveException)),
167198
variant: ALERT_VARIANTS.DANGER,
168199
});
@@ -201,7 +232,7 @@ const CertificatesPage = () => {
201232
},
202233
onError: (error) => {
203234
showModal({
204-
title: MODAL_TITLES.ERROR,
235+
title: intl.formatMessage(messages.errorModalTitle),
205236
message: getErrorMessage(error, intl.formatMessage(messages.errorRemoveInvalidation)),
206237
variant: ALERT_VARIANTS.DANGER,
207238
});
@@ -263,7 +294,7 @@ const CertificatesPage = () => {
263294
<CertificatesPageHeader
264295
onGrantExceptions={() => setIsGrantExceptionsOpen(true)}
265296
onInvalidateCertificate={() => setIsInvalidateCertificateOpen(true)}
266-
onDisableCertificates={() => setIsDisableCertificatesOpen(true)}
297+
onStudentGeneratedCertificates={() => setIsDisableCertificatesOpen(true)}
267298
/>
268299

269300
<Card variant="muted" className="pt-3 pt-md-4 pb-4 pb-md-6 certificates-card">
@@ -309,7 +340,8 @@ const CertificatesPage = () => {
309340
isOpen={isGrantExceptionsOpen}
310341
onClose={() => setIsGrantExceptionsOpen(false)}
311342
onSubmit={handleGrantExceptions}
312-
isSubmitting={isGrantingExceptions}
343+
onUploadCsv={handleUploadCsvExceptions}
344+
isSubmitting={isGrantingExceptions || isUploadingCsv}
313345
/>
314346
<InvalidateCertificateModal
315347
isOpen={isInvalidateCertificateOpen}

src/certificates/components/CertificatesPageHeader.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import { Button, Dropdown, IconButton, Stack } from '@openedx/paragon';
2-
import { Add, Close, MoreVert } from '@openedx/paragon/icons';
2+
import { Add, Cancel, MoreVert } from '@openedx/paragon/icons';
33
import { useIntl } from '@openedx/frontend-base';
44
import messages from '@src/certificates/messages';
55

66
interface CertificatesPageHeaderProps {
77
onGrantExceptions: () => void,
88
onInvalidateCertificate: () => void,
9-
onDisableCertificates: () => void,
9+
onStudentGeneratedCertificates?: () => void,
1010
}
1111

1212
const CertificatesPageHeader = ({
1313
onGrantExceptions,
1414
onInvalidateCertificate,
15-
onDisableCertificates,
15+
onStudentGeneratedCertificates,
1616
}: CertificatesPageHeaderProps) => {
1717
const intl = useIntl();
1818

@@ -24,18 +24,20 @@ const CertificatesPageHeader = ({
2424
<Dropdown.Toggle
2525
as={IconButton}
2626
src={MoreVert}
27-
alt={intl.formatMessage(messages.disableCertificatesButton)}
27+
alt={intl.formatMessage(messages.moreActionsButton)}
2828
id="certificates-more-menu"
2929
/>
3030
<Dropdown.Menu>
31-
<Dropdown.Item onClick={onDisableCertificates}>
32-
{intl.formatMessage(messages.disableCertificatesButton)}
33-
</Dropdown.Item>
31+
{onStudentGeneratedCertificates && (
32+
<Dropdown.Item onClick={onStudentGeneratedCertificates}>
33+
{intl.formatMessage(messages.studentGeneratedCertificatesMenuItem)}
34+
</Dropdown.Item>
35+
)}
3436
</Dropdown.Menu>
3537
</Dropdown>
3638
<Button
3739
variant="outline-primary"
38-
iconBefore={Close}
40+
iconBefore={Cancel}
3941
onClick={onInvalidateCertificate}
4042
className="text-nowrap"
4143
>

src/certificates/components/DisableCertificatesModal.test.tsx

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,47 +33,58 @@ describe('DisableCertificatesModal', () => {
3333
expect(screen.getByRole('dialog')).toBeInTheDocument();
3434
});
3535

36-
it('renders confirm and cancel buttons', () => {
36+
it('renders save and close buttons', () => {
3737
renderWithIntl(<DisableCertificatesModal {...defaultProps} />);
3838

39-
expect(screen.getByRole('button', { name: messages.confirm.defaultMessage })).toBeInTheDocument();
40-
expect(screen.getByRole('button', { name: messages.cancel.defaultMessage })).toBeInTheDocument();
39+
const buttons = screen.getAllByRole('button');
40+
const saveButton = buttons.find(btn => btn.textContent === messages.save.defaultMessage);
41+
const closeButton = buttons.find(btn => btn.textContent === messages.close.defaultMessage);
42+
43+
expect(saveButton).toBeInTheDocument();
44+
expect(closeButton).toBeInTheDocument();
4145
});
4246

43-
it('calls onConfirm when confirm button is clicked', async () => {
44-
renderWithIntl(<DisableCertificatesModal {...defaultProps} />);
47+
it('calls onConfirm when save button is clicked and checkbox state changed', async () => {
48+
renderWithIntl(<DisableCertificatesModal {...defaultProps} isEnabled={true} />);
4549
const user = userEvent.setup();
4650

47-
const confirmButton = screen.getByRole('button', { name: messages.confirm.defaultMessage });
48-
await user.click(confirmButton);
51+
// Change the checkbox state
52+
const checkbox = screen.getByRole('checkbox');
53+
await user.click(checkbox);
54+
55+
const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage });
56+
await user.click(saveButton);
4957

5058
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
5159
});
5260

53-
it('calls onClose when cancel button is clicked', async () => {
61+
it('calls onClose when close button is clicked', async () => {
5462
renderWithIntl(<DisableCertificatesModal {...defaultProps} />);
5563
const user = userEvent.setup();
5664

57-
const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage });
58-
await user.click(cancelButton);
65+
const buttons = screen.getAllByRole('button');
66+
const closeButton = buttons.find(btn => btn.textContent === messages.close.defaultMessage);
67+
if (!closeButton) throw new Error('Close button not found');
68+
await user.click(closeButton);
5969

6070
expect(mockOnClose).toHaveBeenCalledTimes(1);
6171
});
6272

6373
it('disables buttons when isSubmitting is true', () => {
6474
renderWithIntl(<DisableCertificatesModal {...defaultProps} isSubmitting={true} />);
6575

66-
const confirmButton = screen.getByRole('button', { name: messages.confirm.defaultMessage });
67-
const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage });
76+
const buttons = screen.getAllByRole('button');
77+
const saveButton = buttons.find(btn => btn.textContent === messages.save.defaultMessage);
78+
const closeButton = buttons.find(btn => btn.textContent === messages.close.defaultMessage);
6879

69-
expect(confirmButton).toBeDisabled();
70-
expect(cancelButton).toBeDisabled();
80+
expect(saveButton).toBeDisabled();
81+
expect(closeButton).toBeDisabled();
7182
});
7283

7384
it('does not render when isOpen is false', () => {
7485
renderWithIntl(<DisableCertificatesModal {...defaultProps} isOpen={false} />);
7586

76-
expect(screen.queryByText(messages.disableCertificatesModalTitle.defaultMessage)).not.toBeInTheDocument();
87+
expect(screen.queryByText(messages.studentGeneratedCertificatesModalTitle.defaultMessage)).not.toBeInTheDocument();
7788
});
7889

7990
it('switches title and message based on isEnabled prop', () => {
@@ -95,11 +106,12 @@ describe('DisableCertificatesModal', () => {
95106
it('enables buttons when not submitting', () => {
96107
renderWithIntl(<DisableCertificatesModal {...defaultProps} isSubmitting={false} />);
97108

98-
const confirmButton = screen.getByRole('button', { name: messages.confirm.defaultMessage });
99-
const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage });
109+
const buttons = screen.getAllByRole('button');
110+
const saveButton = buttons.find(btn => btn.textContent === messages.save.defaultMessage);
111+
const closeButton = buttons.find(btn => btn.textContent === messages.close.defaultMessage);
100112

101-
expect(confirmButton).not.toBeDisabled();
102-
expect(cancelButton).not.toBeDisabled();
113+
expect(saveButton).not.toBeDisabled();
114+
expect(closeButton).not.toBeDisabled();
103115
});
104116

105117
it('renders with small size modal', () => {
@@ -109,11 +121,22 @@ describe('DisableCertificatesModal', () => {
109121
expect(modal).toBeInTheDocument();
110122
});
111123

112-
it('does not have close button in header', () => {
124+
it('has close button in header', () => {
113125
renderWithIntl(<DisableCertificatesModal {...defaultProps} />);
114126

115-
// Modal should not have the default close button (X) in header
127+
// Modal should have the default close button (X) in header
116128
const closeButtons = screen.queryAllByLabelText('Close');
117-
expect(closeButtons.length).toBe(0);
129+
expect(closeButtons.length).toBeGreaterThan(0);
130+
});
131+
132+
it('calls onClose when save button is clicked without changes', async () => {
133+
renderWithIntl(<DisableCertificatesModal {...defaultProps} />);
134+
const user = userEvent.setup();
135+
136+
const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage });
137+
await user.click(saveButton);
138+
139+
expect(mockOnClose).toHaveBeenCalled();
140+
expect(mockOnConfirm).not.toHaveBeenCalled();
118141
});
119142
});

src/certificates/components/DisableCertificatesModal.tsx

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
1+
import { useState } from 'react';
2+
import { ActionRow, Button, Form, ModalDialog } from '@openedx/paragon';
23
import { useIntl } from '@openedx/frontend-base';
34
import messages from '@src/certificates/messages';
45

@@ -18,34 +19,55 @@ const DisableCertificatesModal = ({
1819
isSubmitting,
1920
}: DisableCertificatesModalProps) => {
2021
const intl = useIntl();
22+
const [enabled, setEnabled] = useState(isEnabled);
2123

22-
const title = isEnabled
23-
? intl.formatMessage(messages.disableCertificatesModalTitle)
24-
: intl.formatMessage(messages.enableCertificatesModalTitle);
24+
const handleSave = () => {
25+
if (enabled !== isEnabled) {
26+
onConfirm();
27+
} else {
28+
onClose();
29+
}
30+
};
2531

26-
const message = isEnabled
27-
? intl.formatMessage(messages.disableCertificatesModalMessage)
28-
: intl.formatMessage(messages.enableCertificatesModalMessage);
32+
const handleClose = () => {
33+
setEnabled(isEnabled); // Reset to original value
34+
onClose();
35+
};
2936

3037
return (
3138
<ModalDialog
32-
title={title}
33-
onClose={onClose}
39+
title={intl.formatMessage(messages.studentGeneratedCertificatesModalTitle)}
40+
onClose={handleClose}
3441
isOpen={isOpen}
35-
size="sm"
36-
hasCloseButton={false}
42+
size="md"
43+
hasCloseButton
3744
isOverflowVisible={false}
3845
>
39-
<div className="mx-4 mt-4 mb-2.5">
40-
<p>{message}</p>
41-
</div>
46+
<ModalDialog.Header className="border-bottom">
47+
<ModalDialog.Title>
48+
{intl.formatMessage(messages.studentGeneratedCertificatesModalTitle)}
49+
</ModalDialog.Title>
50+
</ModalDialog.Header>
51+
<ModalDialog.Body className="px-4 py-3">
52+
<Form.Group>
53+
<Form.Checkbox
54+
checked={enabled}
55+
onChange={(e) => setEnabled(e.target.checked)}
56+
>
57+
{intl.formatMessage(messages.enableStudentGeneratedCertificates)}
58+
</Form.Checkbox>
59+
<Form.Text className="text-muted">
60+
{intl.formatMessage(messages.studentGeneratedCertificatesDescription)}
61+
</Form.Text>
62+
</Form.Group>
63+
</ModalDialog.Body>
4264
<ModalDialog.Footer>
4365
<ActionRow>
44-
<Button variant="tertiary" onClick={onClose} disabled={isSubmitting}>
45-
{intl.formatMessage(messages.cancel)}
66+
<Button variant="tertiary" onClick={handleClose} disabled={isSubmitting}>
67+
{intl.formatMessage(messages.close)}
4668
</Button>
47-
<Button variant="primary" onClick={onConfirm} disabled={isSubmitting}>
48-
{intl.formatMessage(messages.confirm)}
69+
<Button variant="primary" onClick={handleSave} disabled={isSubmitting}>
70+
{intl.formatMessage(messages.save)}
4971
</Button>
5072
</ActionRow>
5173
</ModalDialog.Footer>

0 commit comments

Comments
 (0)