From 6630c192346f8b9ceba52e569b9fd9a85cf979db Mon Sep 17 00:00:00 2001
From: Syed Muhammad Dawoud Sheraz Ali
<40599381+DawoudSheraz@users.noreply.github.com>
Date: Mon, 28 Jun 2021 14:02:09 +0500
Subject: [PATCH] feat: add view certificate button (#121)
* feat: add view certificate button
---
src/users/data/api.js | 71 ++++++
src/users/data/api.test.js | 76 +++++-
src/users/data/test/certificates.js | 19 ++
src/users/data/urls.js | 12 +
src/users/enrollments/Certificates.jsx | 198 ++++++++++++++++
src/users/enrollments/Certificates.test.jsx | 242 ++++++++++++++++++++
src/users/enrollments/Enrollments.jsx | 25 +-
src/users/enrollments/Enrollments.test.jsx | 22 ++
8 files changed, 663 insertions(+), 2 deletions(-)
create mode 100644 src/users/data/test/certificates.js
create mode 100644 src/users/enrollments/Certificates.jsx
create mode 100644 src/users/enrollments/Certificates.test.jsx
diff --git a/src/users/data/api.js b/src/users/data/api.js
index 6aa0b9146..407105f23 100644
--- a/src/users/data/api.js
+++ b/src/users/data/api.js
@@ -443,3 +443,74 @@ export async function postResetPassword(email) {
);
return data;
}
+
+export async function getCertificate(username, courseKey) {
+ try {
+ const { data } = await getAuthenticatedHttpClient().get(
+ AppUrls.getCertificateUrl(username, courseKey),
+ );
+ return Array.isArray(data) && data.length > 0 ? data[0] : data;
+ } catch (error) {
+ return {
+ errors: [
+ {
+ code: null,
+ dismissible: true,
+ text: error.message,
+ type: 'danger',
+ topic: 'certificates',
+ },
+ ],
+ };
+ }
+}
+
+export async function generateCertificate(username, courseKey) {
+ const formData = new FormData();
+ formData.append('username', username);
+ formData.append('course_key', courseKey);
+ try {
+ const { data } = await getAuthenticatedHttpClient().post(
+ AppUrls.generateCertificateUrl(),
+ formData,
+ );
+ return data;
+ } catch (error) {
+ return {
+ errors: [
+ {
+ code: null,
+ dismissible: true,
+ text: error.message,
+ type: 'danger',
+ topic: 'certificates',
+ },
+ ],
+ };
+ }
+}
+
+export async function regenerateCertificate(username, courseKey) {
+ const formData = new FormData();
+ formData.append('username', username);
+ formData.append('course_key', courseKey);
+ try {
+ const { data } = await getAuthenticatedHttpClient().post(
+ AppUrls.regenerateCertificateUrl(),
+ formData,
+ );
+ return data;
+ } catch (error) {
+ return {
+ errors: [
+ {
+ code: null,
+ dismissible: true,
+ text: error.message,
+ type: 'danger',
+ topic: 'certificates',
+ },
+ ],
+ };
+ }
+}
diff --git a/src/users/data/api.test.js b/src/users/data/api.test.js
index cb49cd5ec..9524ae337 100644
--- a/src/users/data/api.test.js
+++ b/src/users/data/api.test.js
@@ -3,11 +3,14 @@ import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { enrollmentsData } from './test/enrollments';
+import { downloadableCertificate } from './test/certificates';
import * as api from './api';
+import * as urls from './urls';
describe('API', () => {
const testUsername = 'username';
const testEmail = 'email@example.com';
+ const testCourseId = 'course-v1:testX+test123+2030';
const userAccountApiBaseUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts`;
const ssoRecordsApiUrl = `${getConfig().LMS_BASE_URL}/support/sso_records/${testUsername}`;
const enrollmentsApiUrl = `${getConfig().LMS_BASE_URL}/support/enrollment/${testUsername}`;
@@ -17,6 +20,9 @@ describe('API', () => {
const verificationStatusApiUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${testUsername}/verification_status/`;
const licensesApiUrl = `${getConfig().LICENSE_MANAGER_URL}/api/v1/staff_lookup_licenses/`;
const onboardingStatusApiUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status`;
+ const certificatesUrl = urls.getCertificateUrl(testUsername, testCourseId);
+ const generateCertificateUrl = urls.generateCertificateUrl();
+ const regenerateCertificateUrl = urls.regenerateCertificateUrl();
let mockAdapter;
@@ -30,7 +36,7 @@ describe('API', () => {
};
beforeEach(() => {
- mockAdapter = new MockAdapter(getAuthenticatedHttpClient());
+ mockAdapter = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' });
});
afterEach(() => {
@@ -682,4 +688,72 @@ describe('API', () => {
});
});
});
+
+ describe('Certificate Operations', () => {
+ const successDictResponse = downloadableCertificate;
+ const successListResponse = [successDictResponse];
+ const expectedError = {
+ code: null,
+ dismissible: true,
+ text: '',
+ type: 'danger',
+ topic: 'certificates',
+ };
+
+ test.each([successDictResponse, successListResponse])('Successful Certificate Fetch', async (successResponse) => {
+ mockAdapter.onGet(certificatesUrl).reply(200, successResponse);
+ const response = await api.getCertificate(testUsername, testCourseId);
+ expect(response).toEqual(Array.isArray(successResponse) ? successResponse[0] : successResponse);
+ });
+
+ it('Unsuccessful Certificates fetch', async () => {
+ mockAdapter.onGet(certificatesUrl).reply(() => throwError(400, ''));
+ const response = await api.getCertificate(testUsername, testCourseId);
+ expect(...response.errors).toEqual(expectedError);
+ });
+
+ it('Successful generate Certificate', async () => {
+ /**
+ * No data is added in the post request check because axios-mock-adapter fails
+ * with formData in post request.
+ * See: https://github.com/ctimmerm/axios-mock-adapter/issues/253
+ */
+ mockAdapter.onPost(generateCertificateUrl).reply(200, successDictResponse);
+ const response = await api.generateCertificate(testUsername, testCourseId);
+ expect(response).toEqual(successDictResponse);
+ });
+
+ it('Unsuccessful generate Certificate', async () => {
+ /**
+ * No data is added in the post request check because axios-mock-adapter fails
+ * with formData in post request.
+ * See: https://github.com/ctimmerm/axios-mock-adapter/issues/253
+ */
+ mockAdapter.onPost(generateCertificateUrl).reply(() => throwError(400, ''));
+ const response = await api.generateCertificate(testUsername, testCourseId);
+ expect(...response.errors).toEqual(expectedError);
+ });
+
+ it('Successful regenerate Certificate', async () => {
+ /**
+ * No data is added in the post request check because axios-mock-adapter fails
+ * with formData in post request.
+ * See: https://github.com/ctimmerm/axios-mock-adapter/issues/253
+ */
+ mockAdapter.onPost(regenerateCertificateUrl).reply(200, successDictResponse);
+ const response = await api.regenerateCertificate(testUsername, testCourseId);
+ expect(response).toEqual(successDictResponse);
+ });
+
+ it('Unsuccessful regenerate Certificate', async () => {
+ /**
+ * No data is added in the post request check because axios-mock-adapter fails
+ * with formData in post request.
+ * See: https://github.com/ctimmerm/axios-mock-adapter/issues/253
+ */
+ mockAdapter.onPost(regenerateCertificateUrl).reply(() => throwError(400, ''));
+ const response = await api.regenerateCertificate(testUsername, testCourseId);
+ expect(...response.errors).toEqual(expectedError);
+ });
+ });
});
diff --git a/src/users/data/test/certificates.js b/src/users/data/test/certificates.js
new file mode 100644
index 000000000..a5e040f68
--- /dev/null
+++ b/src/users/data/test/certificates.js
@@ -0,0 +1,19 @@
+export const downloadableCertificate = {
+ courseKey: 'course-v1:testX+test123+2030',
+ type: 'verified',
+ status: 'passing',
+ grade: '60',
+ modified: new Date('2020/01/01').toLocaleString(),
+ downloadUrl: '/certificates/1234-abcd',
+ regenerate: false,
+};
+
+export const regeneratableCertificate = {
+ courseKey: 'course-v1:testX+test123+2030',
+ type: 'verified',
+ status: 'passing',
+ grade: '60',
+ modified: new Date('2020/01/01').toLocaleString(),
+ downloadUrl: null,
+ regenerate: true,
+};
diff --git a/src/users/data/urls.js b/src/users/data/urls.js
index eb68d241a..d1f490f93 100644
--- a/src/users/data/urls.js
+++ b/src/users/data/urls.js
@@ -69,3 +69,15 @@ export const getAccountActivationUrl = (activationKey) => `${
export const getOnboardingStatusUrl = (courseId, username) => `${
LMS_BASE_URL
}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}&username=${encodeURIComponent(username)}`;
+
+export const getCertificateUrl = (username, courseKey) => `${
+ LMS_BASE_URL
+}/certificates/search?user=${username}&course_id=${courseKey}`;
+
+export const generateCertificateUrl = () => `${
+ LMS_BASE_URL
+}/certificates/generate`;
+
+export const regenerateCertificateUrl = () => `${
+ LMS_BASE_URL
+}/certificates/regenerate`;
diff --git a/src/users/enrollments/Certificates.jsx b/src/users/enrollments/Certificates.jsx
new file mode 100644
index 000000000..40f3aef1b
--- /dev/null
+++ b/src/users/enrollments/Certificates.jsx
@@ -0,0 +1,198 @@
+import React, {
+ useContext,
+ useEffect,
+ useState,
+ useRef,
+ useLayoutEffect,
+} from 'react';
+import { camelCaseObject, getConfig } from '@edx/frontend-platform';
+import PropTypes from 'prop-types';
+import { Button } from '@edx/paragon';
+import PageLoading from '../../components/common/PageLoading';
+import { formatDate } from '../../utils';
+import { getCertificate, generateCertificate, regenerateCertificate } from '../data/api';
+import UserMessagesContext from '../../userMessages/UserMessagesContext';
+import AlertList from '../../userMessages/AlertList';
+
+export default function Certificates({
+ username, courseId, closeHandler,
+}) {
+ const { add, clear } = useContext(UserMessagesContext);
+ const [displayCertErrors, setDisplayCertificateErrors] = useState(false);
+ const [certificate, setCertificateData] = useState(undefined);
+ const [status, setStatus] = useState(undefined);
+ const [buttonDisabled, setButtonDisabled] = useState(false);
+ // eslint-disable-next-line no-use-before-define
+ const oldCourseId = usePrevious(courseId);
+ const certificateRef = useRef(null);
+
+ useEffect(() => {
+ if ((certificate === undefined && !displayCertErrors) || (courseId !== oldCourseId)) {
+ getCertificate(username, courseId).then((result) => {
+ const camelCaseResult = camelCaseObject(result);
+ if (camelCaseResult.errors) {
+ clear('certificates');
+ camelCaseResult.errors.forEach(error => add(error));
+ setDisplayCertificateErrors(true);
+ } else {
+ setDisplayCertificateErrors(false);
+ setCertificateData(camelCaseResult);
+ }
+ });
+ }
+ }, [certificate, courseId]);
+
+ useLayoutEffect(() => {
+ if (certificateRef != null) {
+ certificateRef.current.focus();
+ }
+ });
+
+ function usePrevious(value) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ }, [value]);
+ return ref.current;
+ }
+
+ function renderHideButton() {
+ return (
+
+
+
+ );
+ }
+
+ function postGenerateCertificate() {
+ setButtonDisabled(true);
+ setStatus('Generating New Certificate');
+ generateCertificate(username, certificate.courseKey).then((result) => {
+ if (result.errors) {
+ clear('certificates');
+ result.errors.forEach(error => add(error));
+ setDisplayCertificateErrors(true);
+ } else {
+ setDisplayCertificateErrors(false);
+ setCertificateData(undefined);
+ }
+ setButtonDisabled(false);
+ setStatus(undefined);
+ });
+ }
+
+ function postRegenerateCertificate() {
+ setButtonDisabled(true);
+ setStatus('Regenerating Certificate');
+ regenerateCertificate(username, certificate.courseKey).then((result) => {
+ if (result.errors) {
+ clear('certificates');
+ result.errors.forEach(error => add(error));
+ setDisplayCertificateErrors(true);
+ } else {
+ setDisplayCertificateErrors(false);
+ setCertificateData(undefined);
+ }
+ setButtonDisabled(false);
+ setStatus(undefined);
+ });
+ }
+
+ return (
+
+ <>
+ {renderHideButton()}
+ >
+
+ {!certificate && !displayCertErrors && }
+ {displayCertErrors && }
+
+ {certificate && !displayCertErrors && (
+
+
+
Course ID: {certificate.courseKey}
+ {status && (
Status: {status}
)}
+
+
+
+
+ Certificate Type |
+ {certificate.type ? certificate.type : 'Not Available'} |
+
+
+
+ Status |
+ {certificate.status ? certificate.status : 'Not Available'} |
+
+
+
+ Grade |
+ {certificate.grade ? certificate.grade : 'Not Available'} |
+
+
+
+ Last Updated |
+ {formatDate(certificate.modified)} |
+
+
+
+ Download URL |
+ {certificate.downloadUrl ? Download : 'Not Available'} |
+
+
+
+ Actions |
+
+ {
+ certificate.regenerate
+ ? (
+
+ )
+ : (
+
+ )
+ }
+
+ |
+
+
+
+
+
+ )}
+
+ );
+}
+
+Certificates.propTypes = {
+ username: PropTypes.string,
+ courseId: PropTypes.string,
+ closeHandler: PropTypes.func,
+};
+
+Certificates.defaultProps = {
+ username: null,
+ courseId: null,
+ closeHandler: null,
+};
diff --git a/src/users/enrollments/Certificates.test.jsx b/src/users/enrollments/Certificates.test.jsx
new file mode 100644
index 000000000..deb531323
--- /dev/null
+++ b/src/users/enrollments/Certificates.test.jsx
@@ -0,0 +1,242 @@
+import { mount } from 'enzyme';
+import React from 'react';
+
+import { waitForComponentToPaint } from '../../setupTest';
+import Certificates from './Certificates';
+import { downloadableCertificate, regeneratableCertificate } from '../data/test/certificates';
+import UserMessagesProvider from '../../userMessages/UserMessagesProvider';
+import * as api from '../data/api';
+
+const CertificateWrapper = (props) => (
+
+ ;
+
+);
+
+describe('Certificate component', () => {
+ let apiMock; let wrapper;
+ const testUser = 'testUser';
+ const testCourseId = 'course-v1:testX+test123+2030';
+
+ const props = {
+ username: testUser,
+ courseId: testCourseId,
+ closeHandler: jest.fn(() => {}),
+ };
+
+ afterEach(() => {
+ apiMock.mockRestore();
+ });
+
+ it('Downloadable Certificate', async () => {
+ apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve(downloadableCertificate));
+ wrapper = mount();
+ await waitForComponentToPaint(wrapper);
+
+ expect(wrapper.find('h2').html()).toEqual(expect.stringContaining(downloadableCertificate.courseKey));
+ const dataRows = wrapper.find('table.table tbody tr');
+ expect(dataRows.length).toEqual(6);
+
+ // Explictly check each row and verify individual piece of data
+ const certType = dataRows.at(0).find('td');
+ expect(certType.at(0).text()).toEqual('Certificate Type');
+ expect(certType.at(1).text()).toEqual('verified');
+
+ const status = dataRows.at(1).find('td');
+ expect(status.at(0).text()).toEqual('Status');
+ expect(status.at(1).text()).toEqual('passing');
+
+ const grade = dataRows.at(2).find('td');
+ expect(grade.at(0).text()).toEqual('Grade');
+ expect(grade.at(1).text()).toEqual('60');
+
+ const lastUpdated = dataRows.at(3).find('td');
+ expect(lastUpdated.at(0).text()).toEqual('Last Updated');
+ expect(lastUpdated.at(1).text()).toEqual('Jan 1, 2020 12:00 AM');
+
+ const downloadUrl = dataRows.at(4).find('td');
+ expect(downloadUrl.at(0).text()).toEqual('Download URL');
+ expect(downloadUrl.at(1).text()).toEqual('Download');
+ expect(downloadUrl.at(1).find('a').prop('href')).toEqual('http://localhost:18000/certificates/1234-abcd');
+
+ const action = dataRows.at(5).find('td');
+ expect(action.at(0).text()).toEqual('Actions');
+ expect(action.find('button#generate-certificate').length).toEqual(1);
+ });
+
+ it('Regeneratable Certificate', async () => {
+ apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve(regeneratableCertificate));
+ wrapper = mount();
+ await waitForComponentToPaint(wrapper);
+
+ expect(wrapper.find('h2').html()).toEqual(expect.stringContaining(regeneratableCertificate.courseKey));
+ const dataRows = wrapper.find('table.table tbody tr');
+ expect(dataRows.length).toEqual(6);
+
+ const action = dataRows.at(5).find('td');
+ const actionButton = action.find('button#regenerate-certificate');
+ expect(action.at(0).text()).toEqual('Actions');
+ expect(actionButton.text()).toEqual('Regenerate');
+ });
+
+ it('Missing Certificate Data', async () => {
+ apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve({ courseKey: testCourseId }));
+ wrapper = mount();
+ await waitForComponentToPaint(wrapper);
+
+ expect(wrapper.find('h2').html()).toEqual(expect.stringContaining(testCourseId));
+ const dataRows = wrapper.find('table.table tbody tr');
+ expect(dataRows.length).toEqual(6);
+
+ expect(dataRows.at(0).find('td').at(1).text()).toEqual('Not Available');
+ expect(dataRows.at(1).find('td').at(1).text()).toEqual('Not Available');
+ expect(dataRows.at(2).find('td').at(1).text()).toEqual('Not Available');
+ expect(dataRows.at(3).find('td').at(1).text()).toEqual('N/A');
+ expect(dataRows.at(4).find('td').at(1).text()).toEqual('Not Available');
+ });
+
+ it('Certificate Fetch Errors', async () => {
+ apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve({
+ errors: [
+ {
+ code: null,
+ dismissible: true,
+ text: 'No certificate found',
+ type: 'danger',
+ topic: 'certificates',
+ },
+ ],
+ }));
+ wrapper = mount();
+ await waitForComponentToPaint(wrapper);
+ const alert = wrapper.find('.alert');
+ expect(alert.text()).toEqual('No certificate found');
+ });
+
+ describe('Generate Certificates flow', () => {
+ let generateApiMock;
+
+ afterEach(() => {
+ generateApiMock.mockRestore();
+ });
+
+ it('Successful certificate generation flow', async () => {
+ generateApiMock = jest.spyOn(api, 'generateCertificate').mockImplementationOnce(() => Promise.resolve({}));
+ apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve(downloadableCertificate));
+ // 2nd call to get certificate after generaion would yield regenerate certificate data.
+ jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve(regeneratableCertificate));
+
+ wrapper = mount();
+ await waitForComponentToPaint(wrapper);
+
+ let generateButton = wrapper.find('button#generate-certificate');
+ expect(generateButton.text()).toEqual('Generate');
+ expect(generateButton.prop('disabled')).toBeFalsy();
+
+ generateButton.simulate('click');
+ generateButton = wrapper.find('button#generate-certificate');
+
+ expect(generateButton.prop('disabled')).toBeTruthy();
+ expect(generateApiMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('h3').text()).toEqual('Status: Generating New Certificate ');
+
+ // Once the generation api call is successful, the status text will revert
+ // and button will change into regenerate button.
+ await waitForComponentToPaint(wrapper);
+ const regenerateButton = wrapper.find('button#regenerate-certificate');
+ expect(wrapper.find('h3').length).toEqual(0);
+ expect(regenerateButton.text()).toEqual('Regenerate');
+ expect(regenerateButton.prop('disabled')).toBeFalsy();
+ expect(apiMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('Unsuccessful certificate generation flow', async () => {
+ generateApiMock = jest.spyOn(api, 'generateCertificate').mockImplementationOnce(() => Promise.resolve({
+ errors: [
+ {
+ code: null,
+ dismissible: true,
+ text: 'Error generating certificate',
+ type: 'danger',
+ topic: 'certificates',
+ },
+ ],
+ }));
+ apiMock = jest.spyOn(api, 'getCertificate').mockImplementation(() => Promise.resolve(downloadableCertificate));
+
+ wrapper = mount();
+ await waitForComponentToPaint(wrapper);
+
+ const generateButton = wrapper.find('button#generate-certificate');
+ expect(generateButton.text()).toEqual('Generate');
+ expect(generateButton.prop('disabled')).toBeFalsy();
+ generateButton.simulate('click');
+ expect(generateApiMock).toHaveBeenCalledTimes(1);
+
+ await waitForComponentToPaint(wrapper);
+ const alert = wrapper.find('.alert');
+ expect(alert.text()).toEqual('Error generating certificate');
+ });
+ });
+
+ describe('Regenerate Certificates flow', () => {
+ let regenerateApiMock;
+
+ afterEach(() => {
+ regenerateApiMock.mockRestore();
+ });
+
+ it('Successful certificate regeneration flow', async () => {
+ regenerateApiMock = jest.spyOn(api, 'regenerateCertificate').mockImplementationOnce(() => Promise.resolve({}));
+ apiMock = jest.spyOn(api, 'getCertificate').mockImplementation(() => Promise.resolve(regeneratableCertificate));
+
+ wrapper = mount();
+ await waitForComponentToPaint(wrapper);
+
+ let regenerateButton = wrapper.find('button#regenerate-certificate');
+ expect(regenerateButton.text()).toEqual('Regenerate');
+ expect(regenerateButton.prop('disabled')).toBeFalsy();
+
+ regenerateButton.simulate('click');
+ regenerateButton = wrapper.find('button#regenerate-certificate');
+
+ expect(regenerateButton.prop('disabled')).toBeTruthy();
+ expect(regenerateApiMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('h3').text()).toEqual('Status: Regenerating Certificate ');
+
+ await waitForComponentToPaint(wrapper);
+ regenerateButton = wrapper.find('button#regenerate-certificate');
+ expect(wrapper.find('h3').length).toEqual(0);
+ expect(regenerateButton.prop('disabled')).toBeFalsy();
+ });
+
+ it('Unsuccessful certificate regeneration flow', async () => {
+ regenerateApiMock = jest.spyOn(api, 'regenerateCertificate').mockImplementationOnce(() => Promise.resolve({
+ errors: [
+ {
+ code: null,
+ dismissible: true,
+ text: 'Error regenerating certificate',
+ type: 'danger',
+ topic: 'certificates',
+ },
+ ],
+ }));
+ apiMock = jest.spyOn(api, 'getCertificate').mockImplementation(() => Promise.resolve(regeneratableCertificate));
+
+ wrapper = mount();
+ await waitForComponentToPaint(wrapper);
+
+ const regenerateButton = wrapper.find('button#regenerate-certificate');
+ expect(regenerateButton.text()).toEqual('Regenerate');
+ expect(regenerateButton.prop('disabled')).toBeFalsy();
+
+ regenerateButton.simulate('click');
+ expect(regenerateApiMock).toHaveBeenCalledTimes(1);
+
+ await waitForComponentToPaint(wrapper);
+ const alert = wrapper.find('.alert');
+ expect(alert.text()).toEqual('Error regenerating certificate');
+ });
+ });
+});
diff --git a/src/users/enrollments/Enrollments.jsx b/src/users/enrollments/Enrollments.jsx
index 1e9d41455..e5a9e6e25 100644
--- a/src/users/enrollments/Enrollments.jsx
+++ b/src/users/enrollments/Enrollments.jsx
@@ -9,6 +9,7 @@ import React, {
import { Button, TransitionReplace, Collapsible } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
+import Certificates from './Certificates';
import EnrollmentForm from './EnrollmentForm';
import EnrollmentExtra from './EnrollmentExtra';
import { CREATE, CHANGE } from './constants';
@@ -23,6 +24,7 @@ export default function Enrollments({
const [formType, setFormType] = useState(null);
const [enrollmentToChange, setEnrollmentToChange] = useState(undefined);
const [enrollmentExtraData, setEnrollmentExtraData] = useState(undefined);
+ const [selectedCourseId, setSelectedCourseId] = useState(undefined);
const formRef = useRef(null);
const extraRef = useRef(null);
@@ -33,11 +35,12 @@ export default function Enrollments({
lastModifiedBy: enrollment.manualEnrollment && enrollment.manualEnrollment.enrolledBy ? enrollment.manualEnrollment.enrolledBy : 'N/A',
reason: enrollment.manualEnrollment && enrollment.manualEnrollment.reason ? enrollment.manualEnrollment.reason : 'N/A',
};
+ setSelectedCourseId(undefined);
setEnrollmentExtraData(extraData);
}
useLayoutEffect(() => {
- if (enrollmentExtraData !== undefined) {
+ if (enrollmentExtraData !== undefined && selectedCourseId === undefined) {
extraRef.current.focus();
}
});
@@ -103,6 +106,16 @@ export default function Enrollments({
>
Show Extra
+
),
value: 'Change',
@@ -199,6 +212,16 @@ export default function Enrollments({
/>
) : () }
+
+ {selectedCourseId !== undefined ? (
+ setSelectedCourseId(undefined)}
+ courseId={selectedCourseId}
+ username={user}
+ />
+ ) : () }
+
(
@@ -62,6 +64,26 @@ describe('Course Enrollments Listing', () => {
});
});
+ it('View Certificate action', async () => {
+ /**
+ * Testing the certificate fetch on first row only. Async painting in the loop was causing
+ * the test to pass data across the loop, causing inconsistent behavior..
+ */
+ const dataRow = wrapper.find('table.table tbody tr').at(0);
+ const courseName = dataRow.find('td').at(1).text();
+ const apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve({ courseKey: courseName }));
+ dataRow.find('button#certificate').simulate('click');
+
+ await waitForComponentToPaint(wrapper);
+ const certificates = wrapper.find('Certificates');
+ expect(certificates.html()).toEqual(expect.stringContaining(courseName));
+
+ expect(apiMock).toHaveBeenCalledTimes(1);
+ certificates.find('button.btn-outline-secondary').simulate('click');
+ expect(wrapper.find('Certificates')).toEqual({});
+ apiMock.mockReset();
+ });
+
it('Sorting Columns Button Enabled by default', () => {
const dataTable = wrapper.find('table.table');
const tableHeaders = dataTable.find('thead tr th');