Skip to content

Commit

Permalink
feat: [FC-0044] Certificates page (#872)
Browse files Browse the repository at this point in the history
* feat: [FC-0044]  Certificates page

* feat: add descriptions for details, signatories, sidebar i18n messages

---------

Co-authored-by: Kyrylo Hudym-Levkovych <kyr.hudym@kyrs-MacBook-Pro.local>
  • Loading branch information
khudym and Kyrylo Hudym-Levkovych authored Apr 4, 2024
1 parent b61cb5c commit e306b62
Show file tree
Hide file tree
Showing 68 changed files with 4,255 additions and 0 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
57 changes: 57 additions & 0 deletions src/certificates/Certificates.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Helmet } from 'react-helmet';
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, pageHeadTitle, hasCertificateModes,
} = useCertificates({ courseId });

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

if (loadingStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6" data-testid="request-denied-placeholder">
<Placeholder />
</div>
);
}

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

return (
<>
<Helmet><title>{pageHeadTitle}</title></Helmet>
<MainLayout courseId={courseId} showHeaderButtons={hasCertificateModes && certificates?.length > 0}>
<ModeComponent courseId={courseId} />
</MainLayout>
</>
);
};

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

export default Certificates;
189 changes: 189 additions & 0 deletions src/certificates/Certificates.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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 { RequestStatus } from '../data/constants';
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 certificates but no certificate modes', async () => {
const noModesMock = {
...certificatesDataMock,
courseModes: [],
hasCertificateModes: false,
};

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

const { getByText, queryByRole } = renderComponent();

await waitFor(() => {
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
expect(queryByRole('button', { name: messages.headingActionsPreview.defaultMessage })).not.toBeInTheDocument();
});
});

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();
});

it('renders placeholder if request fails', async () => {
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(403, certificatesDataMock);

const { getByTestId } = renderComponent();

await executeThunk(fetchCertificates(courseId), store.dispatch);

expect(getByTestId('request-denied-placeholder')).toBeInTheDocument();
});

it('updates loading status if request fails', async () => {
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(404, certificatesDataMock);

renderComponent();

await executeThunk(fetchCertificates(courseId), store.dispatch);

expect(store.getState().certificates.loadingStatus).toBe(RequestStatus.FAILED);
});
});
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@asset+block@camera.png',
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',
},
];
Loading

0 comments on commit e306b62

Please sign in to comment.