Skip to content

Commit

Permalink
feat: [FC-0044] Certificates page
Browse files Browse the repository at this point in the history
  • Loading branch information
khudym authored and Kyrylo Hudym-Levkovych committed Mar 12, 2024
1 parent 1fdddfb commit 75e7835
Show file tree
Hide file tree
Showing 83 changed files with 4,820 additions and 45 deletions.
5 changes: 5 additions & 0 deletions src/CourseAuthoringRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
Expand Down Expand Up @@ -115,6 +116,10 @@ const CourseAuthoringRoutes = () => {
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
<Route
path="certificates"
element={<PageWrap><Certificates courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);
Expand Down
53 changes: 53 additions & 0 deletions src/certificates/Certificates.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import PropTypes from 'prop-types';
import Placeholder from '@edx/frontend-lib-content-components';

import { RequestStatus } from '../data/constants';
import Loading from '../generic/Loading';
import useCertificates from './hooks/useCertificates';
import CertificateWithoutModes from './certificate-without-modes/CertificateWithoutModes';
import EmptyCertificatesWithModes from './empty-certificates-with-modes/EmptyCertificatesWithModes';
import CertificatesList from './certificates-list/CertificatesList';
import CertificateCreateForm from './certificate-create-form/CertificateCreateForm';
import CertificateEditForm from './certificate-edit-form/CertificateEditForm';
import { MODE_STATES } from './data/constants';
import MainLayout from './layout/MainLayout';

const MODE_COMPONENTS = {
[MODE_STATES.noModes]: CertificateWithoutModes,
[MODE_STATES.noCertificates]: EmptyCertificatesWithModes,
[MODE_STATES.create]: CertificateCreateForm,
[MODE_STATES.view]: CertificatesList,
[MODE_STATES.editAll]: CertificateEditForm,
};

const Certificates = ({ courseId }) => {
const {
certificates, componentMode, isLoading, loadingStatus,
} = useCertificates({ courseId });

if (isLoading) {
return <Loading />;
}

if (loadingStatus === RequestStatus.DENIED) {
return (

Check warning on line 33 in src/certificates/Certificates.jsx

View check run for this annotation

Codecov / codecov/patch

src/certificates/Certificates.jsx#L33

Added line #L33 was not covered by tests
<div className="row justify-content-center m-6">
<Placeholder />
</div>
);
}

const ModeComponent = MODE_COMPONENTS[componentMode] || MODE_COMPONENTS[MODE_STATES.noModes];

return (
<MainLayout courseId={courseId} showHeaderButtons={certificates?.length > 0}>
<ModeComponent courseId={courseId} />
</MainLayout>
);
};

Certificates.propTypes = {
courseId: PropTypes.string.isRequired,
};

export default Certificates;
144 changes: 144 additions & 0 deletions src/certificates/Certificates.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

import { executeThunk } from '../utils';
import initializeStore from '../store';
import { getCertificatesApiUrl } from './data/api';
import { fetchCertificates } from './data/thunks';
import { certificatesDataMock } from './__mocks__';
import Certificates from './Certificates';
import messages from './messages';

let axiosMock;
let store;
const courseId = 'course-123';

const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<IntlProvider locale="en">
<Certificates courseId={courseId} {...props} />
</IntlProvider>
</AppProvider>,
);

describe('Certificates', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});

it('renders WithoutModes when there are no certificate modes', async () => {
const noModesMock = {
...certificatesDataMock,
certificates: [],
courseModes: [],
hasCertificateModes: false,
};

axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, noModesMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);

const { getByText, queryByText } = renderComponent();

await waitFor(() => {
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.noCertificatesText.defaultMessage)).not.toBeInTheDocument();
});
});

it('renders WithModesWithoutCertificates when there are modes but no certificates', async () => {
const noCertificatesMock = {
...certificatesDataMock,
certificates: [],
};

axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, noCertificatesMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);

const { getByText, queryByText } = renderComponent();

await waitFor(() => {
expect(getByText(messages.noCertificatesText.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument();
});
});

it('renders CertificatesList when there are modes and certificates', async () => {
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, certificatesDataMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);

const { getByText, queryByText, getByTestId } = renderComponent();

await waitFor(() => {
expect(getByTestId('certificates-list')).toBeInTheDocument();
expect(getByText(certificatesDataMock.courseTitle)).toBeInTheDocument();
expect(getByText(certificatesDataMock.certificates[0].signatories[0].name)).toBeInTheDocument();
expect(queryByText(messages.noCertificatesText.defaultMessage)).not.toBeInTheDocument();
expect(queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument();
});
});

it('renders CertificateCreateForm when there is componentMode = MODE_STATES.create', async () => {
const noCertificatesMock = {
...certificatesDataMock,
certificates: [],
};

axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, noCertificatesMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);

const { queryByTestId, getByTestId, getByRole } = renderComponent();

await waitFor(() => {
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
userEvent.click(addCertificateButton);
});

expect(getByTestId('certificates-create-form')).toBeInTheDocument();
expect(getByTestId('certificate-details-form')).toBeInTheDocument();
expect(getByTestId('signatory-form')).toBeInTheDocument();
expect(queryByTestId('certificate-details')).not.toBeInTheDocument();
expect(queryByTestId('signatory')).not.toBeInTheDocument();
});

it('renders CertificateEditForm when there is componentMode = MODE_STATES.editAll', async () => {
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, certificatesDataMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);

const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();

await waitFor(() => {
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
userEvent.click(editCertificateButton);
});

expect(getByTestId('certificates-edit-form')).toBeInTheDocument();
expect(getByTestId('certificate-details-form')).toBeInTheDocument();
expect(getByTestId('signatory-form')).toBeInTheDocument();
expect(queryByTestId('certificate-details')).not.toBeInTheDocument();
expect(queryByTestId('signatory')).not.toBeInTheDocument();
});
});
20 changes: 20 additions & 0 deletions src/certificates/__mocks__/certificates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module.exports = [
{
id: 1,
courseTitle: 'Course Title 1',
signatories: [
{
name: 'Signatory Name 1',
title: 'Signatory Title 1',
organization: 'Signatory Organization 1',
signatureImagePath: '/path/to/signature1/image.png',
},
{
name: 'Signatory Name 2',
title: 'Signatory Title 2',
organization: 'Signatory Organization 2',
signatureImagePath: '/path/to/signature2/image.png',
},
],
},
];
32 changes: 32 additions & 0 deletions src/certificates/__mocks__/certificatesData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module.exports = {
certificateActivationHandlerUrl: '/certificates/activation/course-v1:org+101+101/',
certificateWebViewUrl: '//certificates/course/course-v1:org+101+101?preview=honor',
certificates: [
{
courseTitle: 'Course title',
description: 'Description of the certificate',
editing: false,
id: 1622146085,
isActive: false,
name: 'Name of the certificate',
signatories: [
{
id: 268550145,
name: 'name_sign',
organization: 'org',
signatureImagePath: '/asset-v1:org+101+101+type@[email protected]',
title: 'title_sign',
},
],
version: 1,
},
],
courseModes: ['honor', 'audit'],
hasCertificateModes: true,
isActive: false,
isGlobalStaff: true,
mfeProctoredExamSettingsUrl: '',
courseNumber: 'DemoX',
courseTitle: 'Demonstration Course',
courseNumberOverride: 'Course Number Display String',
};
3 changes: 3 additions & 0 deletions src/certificates/__mocks__/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as certificatesDataMock } from './certificatesData';
export { default as signatoriesMock } from './signatories';
export { default as certificatesMock } from './certificates';
8 changes: 8 additions & 0 deletions src/certificates/__mocks__/signatories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = [
{
id: '1', name: 'John Doe', title: 'CEO', organization: 'Company', signatureImagePath: '/path/to/signature1.png',
},
{
id: '2', name: 'Jane Doe', title: 'CFO', organization: 'Company 2', signatureImagePath: '/path/to/signature2.png',
},
];
70 changes: 70 additions & 0 deletions src/certificates/certificate-create-form/CertificateCreateForm.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import PropTypes from 'prop-types';
import { Card, Stack, Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik, Form, FieldArray } from 'formik';

import CertificateDetailsForm from '../certificate-details/CertificateDetailsForm';
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
import { defaultCertificate } from '../constants';
import messages from '../messages';
import useCertificateCreateForm from './hooks/useCertificateCreateForm';

const CertificateCreateForm = ({ courseId }) => {
const intl = useIntl();
const {
courseTitle, handleCertificateSubmit, handleFormCancel,
} = useCertificateCreateForm(courseId);

return (
<Formik initialValues={defaultCertificate} onSubmit={handleCertificateSubmit}>
{({
values, handleChange, handleBlur, resetForm, setFieldValue,
}) => (
<Form className="certificates-card-form" data-testid="certificates-create-form">
<Card>
<Card.Section>
<Stack gap="4">
<CertificateDetailsForm
courseTitleOverride={values.courseTitle}
detailsCourseTitle={courseTitle}
handleChange={handleChange}
handleBlur={handleBlur}
/>
<FieldArray
name="signatories"
render={arrayHelpers => (
<CertificateSignatories
isForm
signatories={values.signatories}
arrayHelpers={arrayHelpers}
handleChange={handleChange}
handleBlur={handleBlur}
setFieldValue={setFieldValue}
/>
)}
/>
</Stack>
</Card.Section>
<Card.Footer className="justify-content-start">
<Button type="submit">
{intl.formatMessage(messages.cardCreate)}
</Button>
<Button
variant="tertiary"
onClick={() => handleFormCancel(resetForm)}
>
{intl.formatMessage(messages.cardCancel)}
</Button>
</Card.Footer>
</Card>
</Form>
)}
</Formik>
);
};

CertificateCreateForm.propTypes = {
courseId: PropTypes.string.isRequired,
};

export default CertificateCreateForm;
Loading

0 comments on commit 75e7835

Please sign in to comment.