feat: [FC-0044] Certificates page (#872)
* 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>
This commit is contained in:
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
57
src/certificates/Certificates.jsx
Normal file
57
src/certificates/Certificates.jsx
Normal file
@@ -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
src/certificates/Certificates.test.jsx
Normal file
189
src/certificates/Certificates.test.jsx
Normal file
@@ -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
src/certificates/__mocks__/certificates.js
Normal file
20
src/certificates/__mocks__/certificates.js
Normal file
@@ -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
src/certificates/__mocks__/certificatesData.js
Normal file
32
src/certificates/__mocks__/certificatesData.js
Normal file
@@ -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
src/certificates/__mocks__/index.js
Normal file
3
src/certificates/__mocks__/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as certificatesDataMock } from './certificatesData';
|
||||
export { default as signatoriesMock } from './signatories';
|
||||
export { default as certificatesMock } from './certificates';
|
||||
8
src/certificates/__mocks__/signatories.js
Normal file
8
src/certificates/__mocks__/signatories.js
Normal file
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { render, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { executeThunk } from '../../utils';
|
||||
import initializeStore from '../../store';
|
||||
import { MODE_STATES } from '../data/constants';
|
||||
import { getCertificatesApiUrl, getCertificateApiUrl } from '../data/api';
|
||||
import { fetchCertificates, createCourseCertificate } from '../data/thunks';
|
||||
import { certificatesDataMock } from '../__mocks__';
|
||||
import detailsMessages from '../certificate-details/messages';
|
||||
import signatoryMessages from '../certificate-signatories/messages';
|
||||
import messages from '../messages';
|
||||
import CertificateCreateForm from './CertificateCreateForm';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
const renderComponent = () => render(
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CertificateCreateForm courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
certificates: {
|
||||
certificatesData: {
|
||||
certificates: [],
|
||||
hasCertificateModes: true,
|
||||
},
|
||||
componentMode: MODE_STATES.create,
|
||||
},
|
||||
};
|
||||
|
||||
describe('CertificateCreateForm', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCertificatesApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...certificatesDataMock,
|
||||
certificates: [],
|
||||
});
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
it('renders with empty fields', () => {
|
||||
const { getByPlaceholderText } = renderComponent();
|
||||
|
||||
expect(getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage).value).toBe('');
|
||||
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage).value).toBe('');
|
||||
expect(getByPlaceholderText(signatoryMessages.titlePlaceholder.defaultMessage).value).toBe('');
|
||||
expect(getByPlaceholderText(signatoryMessages.organizationPlaceholder.defaultMessage).value).toBe('');
|
||||
expect(getByPlaceholderText(signatoryMessages.imagePlaceholder.defaultMessage).value).toBe('');
|
||||
});
|
||||
|
||||
it('creates a new certificate', async () => {
|
||||
const courseTitleOverrideValue = 'Create Course Title';
|
||||
const signatoryNameValue = 'Create signatory name';
|
||||
const newCertificateData = {
|
||||
...certificatesDataMock,
|
||||
courseTitle: courseTitleOverrideValue,
|
||||
certificates: [{
|
||||
...certificatesDataMock.certificates[0],
|
||||
signatories: [{
|
||||
...certificatesDataMock.certificates[0].signatories[0],
|
||||
name: signatoryNameValue,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
const { getByPlaceholderText, getByRole, getByDisplayValue } = renderComponent();
|
||||
|
||||
userEvent.type(
|
||||
getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage),
|
||||
courseTitleOverrideValue,
|
||||
);
|
||||
userEvent.type(
|
||||
getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage),
|
||||
signatoryNameValue,
|
||||
);
|
||||
userEvent.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
|
||||
|
||||
axiosMock.onPost(
|
||||
getCertificateApiUrl(courseId),
|
||||
).reply(200, newCertificateData);
|
||||
await executeThunk(createCourseCertificate(courseId, newCertificateData), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByDisplayValue(courseTitleOverrideValue)).toBeInTheDocument();
|
||||
expect(getByDisplayValue(signatoryNameValue)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('cancel certificates creation', async () => {
|
||||
const { getByRole } = renderComponent();
|
||||
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.noCertificates);
|
||||
});
|
||||
});
|
||||
|
||||
it('there is no delete signatory button if signatories length is less then 2', async () => {
|
||||
const { queryAllByRole } = renderComponent();
|
||||
const deleteIcons = queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteIcons.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('add and delete signatory', async () => {
|
||||
const {
|
||||
getAllByRole, queryAllByRole, getByText, getByRole,
|
||||
} = renderComponent();
|
||||
|
||||
const addSignatoryBtn = getByText(signatoryMessages.addSignatoryButton.defaultMessage);
|
||||
|
||||
userEvent.click(addSignatoryBtn);
|
||||
|
||||
const deleteIcons = getAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteIcons.length).toBe(2);
|
||||
});
|
||||
|
||||
userEvent.click(deleteIcons[0]);
|
||||
|
||||
const confirModal = getByRole('dialog');
|
||||
const deleteModalButton = within(confirModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
||||
|
||||
userEvent.click(deleteIcons[0]);
|
||||
userEvent.click(deleteModalButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage }).length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import { MODE_STATES } from '../../data/constants';
|
||||
import { getCourseTitle } from '../../data/selectors';
|
||||
import { setMode } from '../../data/slice';
|
||||
import { createCourseCertificate } from '../../data/thunks';
|
||||
|
||||
const useCertificateCreateForm = (courseId) => {
|
||||
const dispatch = useDispatch();
|
||||
const courseTitle = useSelector(getCourseTitle);
|
||||
|
||||
const handleCertificateSubmit = (values) => {
|
||||
const signatoriesWithoutIds = values.signatories.map(({ id, ...rest }) => rest);
|
||||
const newValues = { ...values, signatories: signatoriesWithoutIds };
|
||||
dispatch(createCourseCertificate(courseId, newValues));
|
||||
};
|
||||
|
||||
const handleFormCancel = (resetForm) => {
|
||||
dispatch(setMode(MODE_STATES.noCertificates));
|
||||
resetForm();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
return {
|
||||
courseTitle, handleCertificateSubmit, handleFormCancel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCertificateCreateForm;
|
||||
123
src/certificates/certificate-details/CertificateDetails.jsx
Normal file
123
src/certificates/certificate-details/CertificateDetails.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon, Stack, IconButtonWithTooltip,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
EditOutline as EditOutlineIcon, DeleteOutline as DeleteOutlineIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import CertificateSection from '../certificate-section/CertificateSection';
|
||||
import ModalNotification from '../../generic/modal-notification';
|
||||
import commonMessages from '../messages';
|
||||
import messages from './messages';
|
||||
import useCertificateDetails from './hooks/useCertificateDetails';
|
||||
|
||||
const CertificateDetails = ({
|
||||
certificateId,
|
||||
detailsCourseTitle,
|
||||
courseTitleOverride,
|
||||
detailsCourseNumber,
|
||||
courseNumberOverride,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
isConfirmOpen,
|
||||
confirmOpen,
|
||||
confirmClose,
|
||||
isEditModalOpen,
|
||||
editModalOpen,
|
||||
editModalClose,
|
||||
isCertificateActive,
|
||||
handleEditAll,
|
||||
handleDeleteCard,
|
||||
} = useCertificateDetails(certificateId);
|
||||
|
||||
return (
|
||||
<CertificateSection
|
||||
title={intl.formatMessage(messages.detailsSectionTitle)}
|
||||
className="certificate-details"
|
||||
data-testid="certificate-details"
|
||||
actions={(
|
||||
<Stack direction="horizontal" gap="2">
|
||||
<IconButtonWithTooltip
|
||||
src={EditOutlineIcon}
|
||||
iconAs={Icon}
|
||||
tooltipContent={<div>{intl.formatMessage(commonMessages.editTooltip)}</div>}
|
||||
alt={intl.formatMessage(commonMessages.editTooltip)}
|
||||
onClick={isCertificateActive ? editModalOpen : handleEditAll}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
src={DeleteOutlineIcon}
|
||||
iconAs={Icon}
|
||||
tooltipContent={<div>{intl.formatMessage(commonMessages.deleteTooltip)}</div>}
|
||||
alt={intl.formatMessage(commonMessages.deleteTooltip)}
|
||||
onClick={confirmOpen}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
|
||||
<p className="certificate-details__info-paragraph">
|
||||
<strong>{intl.formatMessage(messages.detailsCourseTitle)}:</strong> {detailsCourseTitle}
|
||||
</p>
|
||||
<p className="certificate-details__info-paragraph-course-number">
|
||||
<strong>{intl.formatMessage(messages.detailsCourseNumber)}:</strong> {detailsCourseNumber}
|
||||
</p>
|
||||
</Stack>
|
||||
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
|
||||
{courseTitleOverride && (
|
||||
<p className="certificate-details__info-paragraph">
|
||||
<strong>{intl.formatMessage(messages.detailsCourseTitleOverride)}:</strong> {courseTitleOverride}
|
||||
</p>
|
||||
)}
|
||||
{courseNumberOverride && (
|
||||
<p className="certificate-details__info-paragraph text-right">
|
||||
<strong>{intl.formatMessage(messages.detailsCourseNumberOverride)}:</strong> {courseNumberOverride}
|
||||
</p>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ModalNotification
|
||||
isOpen={isEditModalOpen}
|
||||
title={intl.formatMessage(messages.editCertificateConfirmationTitle)}
|
||||
message={intl.formatMessage(messages.editCertificateMessage)}
|
||||
actionButtonText={intl.formatMessage(commonMessages.editTooltip)}
|
||||
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
|
||||
handleCancel={editModalClose}
|
||||
handleAction={() => {
|
||||
editModalClose();
|
||||
handleEditAll();
|
||||
}}
|
||||
/>
|
||||
<ModalNotification
|
||||
isOpen={isConfirmOpen}
|
||||
title={intl.formatMessage(messages.deleteCertificateConfirmationTitle)}
|
||||
message={intl.formatMessage(messages.deleteCertificateMessage)}
|
||||
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
|
||||
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
|
||||
handleCancel={confirmClose}
|
||||
handleAction={() => {
|
||||
confirmClose();
|
||||
handleDeleteCard();
|
||||
}}
|
||||
/>
|
||||
</CertificateSection>
|
||||
);
|
||||
};
|
||||
|
||||
CertificateDetails.defaultProps = {
|
||||
courseTitleOverride: '',
|
||||
courseNumberOverride: '',
|
||||
};
|
||||
|
||||
CertificateDetails.propTypes = {
|
||||
certificateId: PropTypes.number.isRequired,
|
||||
courseTitleOverride: PropTypes.string,
|
||||
courseNumberOverride: PropTypes.string,
|
||||
detailsCourseTitle: PropTypes.string.isRequired,
|
||||
detailsCourseNumber: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CertificateDetails;
|
||||
118
src/certificates/certificate-details/CertificateDetails.test.jsx
Normal file
118
src/certificates/certificate-details/CertificateDetails.test.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Provider, useDispatch } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
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 initializeStore from '../../store';
|
||||
import { MODE_STATES } from '../data/constants';
|
||||
import { deleteCourseCertificate } from '../data/thunks';
|
||||
import commonMessages from '../messages';
|
||||
import messages from './messages';
|
||||
import CertificateDetails from './CertificateDetails';
|
||||
|
||||
let store;
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const certificateId = 123;
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: jest.fn(),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../data/thunks', () => ({
|
||||
deleteCourseCertificate: jest.fn(),
|
||||
}));
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CertificateDetails {...props} />
|
||||
</IntlProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const defaultProps = {
|
||||
componentMode: MODE_STATES.view,
|
||||
detailsCourseTitle: 'Course Title',
|
||||
detailsCourseNumber: 'Course Number',
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
certificates: {
|
||||
certificatesData: {
|
||||
certificates: [],
|
||||
hasCertificateModes: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('CertificateDetails', () => {
|
||||
let mockDispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
useParams.mockReturnValue({ courseId });
|
||||
mockDispatch = jest.fn();
|
||||
useDispatch.mockReturnValue(mockDispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useParams.mockClear();
|
||||
mockDispatch.mockClear();
|
||||
});
|
||||
|
||||
it('renders correctly in view mode', () => {
|
||||
const { getByText } = renderComponent(defaultProps);
|
||||
|
||||
expect(getByText(messages.detailsSectionTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(defaultProps.detailsCourseTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens confirm modal on delete button click', () => {
|
||||
const { getByRole, getByText } = renderComponent(defaultProps);
|
||||
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
userEvent.click(deleteButton);
|
||||
|
||||
expect(getByText(messages.deleteCertificateConfirmationTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dispatches delete action on confirm modal action', async () => {
|
||||
const props = { ...defaultProps, courseId, certificateId };
|
||||
const { getByRole } = renderComponent(props);
|
||||
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmActionButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
userEvent.click(confirmActionButton);
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(deleteCourseCertificate(courseId, certificateId));
|
||||
});
|
||||
|
||||
it('shows course title override in view mode', () => {
|
||||
const courseTitleOverride = 'Overridden Title';
|
||||
const props = { ...defaultProps, courseTitleOverride };
|
||||
const { getByText } = renderComponent(props);
|
||||
|
||||
expect(getByText(courseTitleOverride)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Stack, Form } from '@openedx/paragon';
|
||||
|
||||
import CertificateSection from '../certificate-section/CertificateSection';
|
||||
import messages from './messages';
|
||||
|
||||
const CertificateDetailsForm = ({
|
||||
detailsCourseTitle,
|
||||
courseTitleOverride,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<CertificateSection
|
||||
title={intl.formatMessage(messages.detailsSectionTitle)}
|
||||
className="certificate-details"
|
||||
data-testid="certificate-details-form"
|
||||
>
|
||||
<Stack>
|
||||
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
|
||||
<p className="certificate-details__info-paragraph">
|
||||
<strong>{intl.formatMessage(messages.detailsCourseTitle)}:</strong> {detailsCourseTitle}
|
||||
</p>
|
||||
</Stack>
|
||||
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
|
||||
<Form.Group className="m-0 w-100">
|
||||
<Form.Label>{intl.formatMessage(messages.detailsCourseTitleOverride)}</Form.Label>
|
||||
<Form.Control
|
||||
name="courseTitle"
|
||||
value={courseTitleOverride}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={intl.formatMessage(messages.detailsCourseTitleOverride)}
|
||||
/>
|
||||
<Form.Control.Feedback>
|
||||
<span className="x-small">{intl.formatMessage(messages.detailsCourseTitleOverrideDescription)}</span>
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CertificateSection>
|
||||
);
|
||||
};
|
||||
|
||||
CertificateDetailsForm.propTypes = {
|
||||
courseTitleOverride: PropTypes.string.isRequired,
|
||||
detailsCourseTitle: PropTypes.string.isRequired,
|
||||
handleChange: PropTypes.func.isRequired,
|
||||
handleBlur: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default CertificateDetailsForm;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Provider } from 'react-redux';
|
||||
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 initializeStore from '../../store';
|
||||
import { MODE_STATES } from '../data/constants';
|
||||
import commonMessages from '../messages';
|
||||
import messages from './messages';
|
||||
import CertificateDetailsForm from './CertificateDetailsForm';
|
||||
|
||||
let store;
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CertificateDetailsForm {...props} />
|
||||
</IntlProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const defaultProps = {
|
||||
componentMode: MODE_STATES.view,
|
||||
detailsCourseTitle: 'Course Title',
|
||||
detailsCourseNumber: 'Course Number',
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
certificates: {
|
||||
certificatesData: {
|
||||
certificates: [],
|
||||
hasCertificateModes: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('CertificateDetails', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
});
|
||||
|
||||
it('renders correctly in create mode', () => {
|
||||
const { getByText, getByPlaceholderText } = renderComponent(defaultProps);
|
||||
|
||||
expect(getByText(messages.detailsSectionTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles input change in create mode', async () => {
|
||||
const { getByPlaceholderText } = renderComponent(defaultProps);
|
||||
const input = getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage);
|
||||
const newInputValue = 'New Title';
|
||||
|
||||
userEvent.type(input, newInputValue);
|
||||
|
||||
waitFor(() => {
|
||||
expect(input.value).toBe(newInputValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show delete button in create mode', () => {
|
||||
const { queryByRole } = renderComponent(defaultProps);
|
||||
|
||||
expect(queryByRole('button', { name: commonMessages.deleteTooltip.defaultMessage })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { setMode } from '../../data/slice';
|
||||
import { deleteCourseCertificate } from '../../data/thunks';
|
||||
import { getIsCertificateActive } from '../../data/selectors';
|
||||
import { MODE_STATES } from '../../data/constants';
|
||||
|
||||
const useCertificateDetails = (certificateId) => {
|
||||
const dispatch = useDispatch();
|
||||
const { courseId } = useParams();
|
||||
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
|
||||
const [isEditModalOpen, editModalOpen, editModalClose] = useToggle(false);
|
||||
const isCertificateActive = useSelector(getIsCertificateActive);
|
||||
|
||||
const handleEditAll = () => {
|
||||
dispatch(setMode(MODE_STATES.editAll));
|
||||
};
|
||||
|
||||
const handleDeleteCard = () => {
|
||||
if (certificateId) {
|
||||
dispatch(deleteCourseCertificate(courseId, certificateId));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isConfirmOpen,
|
||||
confirmOpen,
|
||||
confirmClose,
|
||||
isEditModalOpen,
|
||||
editModalOpen,
|
||||
editModalClose,
|
||||
isCertificateActive,
|
||||
handleEditAll,
|
||||
handleDeleteCard,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCertificateDetails;
|
||||
56
src/certificates/certificate-details/messages.js
Normal file
56
src/certificates/certificate-details/messages.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
detailsSectionTitle: {
|
||||
id: 'course-authoring.certificates.details.section.title',
|
||||
defaultMessage: 'Certificate details',
|
||||
description: 'Title for the section',
|
||||
},
|
||||
detailsCourseTitle: {
|
||||
id: 'course-authoring.certificates.details.course.title',
|
||||
defaultMessage: 'Course title',
|
||||
description: 'Label for displaying the official course title in the certificate details section',
|
||||
},
|
||||
detailsCourseTitleOverride: {
|
||||
id: 'course-authoring.certificates.details.course.title.override',
|
||||
defaultMessage: 'Course title override',
|
||||
description: 'Label for the course title override input field',
|
||||
},
|
||||
detailsCourseTitleOverrideDescription: {
|
||||
id: 'course-authoring.certificates.details.course.title.override.description',
|
||||
defaultMessage: 'Specify an alternative to the official course title to display on certificates. Leave blank to use the official course title.',
|
||||
description: 'Helper text under the course title override input field',
|
||||
},
|
||||
detailsCourseNumber: {
|
||||
id: 'course-authoring.certificates.details.course.number',
|
||||
defaultMessage: 'Course number',
|
||||
description: 'Label for displaying the official course number in the certificate details section',
|
||||
},
|
||||
detailsCourseNumberOverride: {
|
||||
id: 'course-authoring.certificates.details.course.number.override',
|
||||
defaultMessage: 'Course number override',
|
||||
description: 'Label for the course number override input field',
|
||||
},
|
||||
deleteCertificateConfirmationTitle: {
|
||||
id: 'course-authoring.certificates.details.confirm-modal',
|
||||
defaultMessage: 'Delete this certificate?',
|
||||
description: 'Title for the confirmation modal when a user attempts to delete a certificate',
|
||||
},
|
||||
deleteCertificateMessage: {
|
||||
id: 'course-authoring.certificates.details.confirm-modal.message',
|
||||
defaultMessage: 'Deleting this certificate is permanent and cannot be undone.',
|
||||
description: 'Warning message within the delete confirmation modal, emphasizing the permanent nature of the action',
|
||||
},
|
||||
editCertificateConfirmationTitle: {
|
||||
id: 'course-authoring.certificates.details.confirm.edit',
|
||||
defaultMessage: 'Edit this certificate?',
|
||||
description: 'Title for the confirmation modal when a user attempts to edit an already activated (live) certificate',
|
||||
},
|
||||
editCertificateMessage: {
|
||||
id: 'course-authoring.certificates.details.confirm.edit.message',
|
||||
defaultMessage: 'This certificate has already been activated and is live. Are you sure you want to continue editing?',
|
||||
description: 'Message warning users about the implications of editing a certificate that is already live, prompting for confirmation',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
104
src/certificates/certificate-edit-form/CertificateEditForm.jsx
Normal file
104
src/certificates/certificate-edit-form/CertificateEditForm.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
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 ModalNotification from '../../generic/modal-notification';
|
||||
import CertificateDetailsForm from '../certificate-details/CertificateDetailsForm';
|
||||
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
|
||||
import commonMessages from '../messages';
|
||||
import messages from '../certificate-details/messages';
|
||||
import useCertificateEditForm from './hooks/useCertificateEditForm';
|
||||
|
||||
const CertificateEditForm = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
confirmOpen,
|
||||
courseTitle,
|
||||
certificates,
|
||||
confirmClose,
|
||||
initialValues,
|
||||
isConfirmOpen,
|
||||
handleCertificateDelete,
|
||||
handleCertificateSubmit,
|
||||
handleCertificateUpdateCancel,
|
||||
} = useCertificateEditForm(courseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{certificates.map((certificate, id) => (
|
||||
<Formik initialValues={initialValues[id]} onSubmit={handleCertificateSubmit} key={certificate.id}>
|
||||
{({
|
||||
values, handleChange, handleBlur, resetForm, setFieldValue,
|
||||
}) => (
|
||||
<>
|
||||
<Form className="certificates-card-form" data-testid="certificates-edit-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(commonMessages.saveTooltip)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={() => handleCertificateUpdateCancel(resetForm)}
|
||||
>
|
||||
{intl.formatMessage(commonMessages.cardCancel)}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
variant="tertiary"
|
||||
onClick={() => confirmOpen(certificate.id)}
|
||||
>
|
||||
{intl.formatMessage(commonMessages.deleteTooltip)}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</Form>
|
||||
<ModalNotification
|
||||
isOpen={isConfirmOpen}
|
||||
title={intl.formatMessage(messages.deleteCertificateConfirmationTitle)}
|
||||
message={intl.formatMessage(messages.deleteCertificateMessage)}
|
||||
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
|
||||
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
|
||||
handleCancel={() => confirmClose()}
|
||||
handleAction={() => {
|
||||
confirmClose();
|
||||
handleCertificateDelete(certificate.id);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CertificateEditForm.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CertificateEditForm;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
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, getUpdateCertificateApiUrl } from '../data/api';
|
||||
import { fetchCertificates, deleteCourseCertificate, updateCourseCertificate } from '../data/thunks';
|
||||
import { certificatesDataMock } from '../__mocks__';
|
||||
import { MODE_STATES } from '../data/constants';
|
||||
import messagesDetails from '../certificate-details/messages';
|
||||
import messages from '../messages';
|
||||
import CertificateEditForm from './CertificateEditForm';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const courseId = 'course-123';
|
||||
|
||||
const renderComponent = () => render(
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CertificateEditForm courseId="course-123" />
|
||||
</IntlProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
certificates: {
|
||||
certificatesData: {},
|
||||
componentMode: MODE_STATES.editAll,
|
||||
},
|
||||
};
|
||||
|
||||
describe('CertificateEditForm Component', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCertificatesApiUrl(courseId))
|
||||
.reply(200, certificatesDataMock);
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
it('submits the form with updated certificate details', async () => {
|
||||
const courseTitleOverrideValue = 'Updated Course Title';
|
||||
const signatoryNameValue = 'Updated signatory name';
|
||||
const newCertificateData = {
|
||||
...certificatesDataMock,
|
||||
courseTitle: courseTitleOverrideValue,
|
||||
certificates: [{
|
||||
...certificatesDataMock.certificates[0],
|
||||
signatories: [{
|
||||
...certificatesDataMock.certificates[0].signatories[0],
|
||||
name: signatoryNameValue,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
const { getByDisplayValue, getByRole, getByPlaceholderText } = renderComponent();
|
||||
|
||||
userEvent.type(
|
||||
getByPlaceholderText(messagesDetails.detailsCourseTitleOverride.defaultMessage),
|
||||
courseTitleOverrideValue,
|
||||
);
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
||||
|
||||
axiosMock.onPost(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
).reply(200, newCertificateData);
|
||||
await executeThunk(updateCourseCertificate(courseId, newCertificateData), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByDisplayValue(
|
||||
certificatesDataMock.certificates[0].courseTitle + courseTitleOverrideValue,
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a certificate and updates the store', async () => {
|
||||
axiosMock.onDelete(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
).reply(200);
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
|
||||
const confirmDeleteModal = getByRole('dialog');
|
||||
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
|
||||
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().certificates.certificatesData.certificates.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates loading status if delete fails', async () => {
|
||||
axiosMock.onDelete(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
).reply(404);
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
|
||||
const confirmDeleteModal = getByRole('dialog');
|
||||
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
|
||||
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().certificates.savingStatus).toBe(RequestStatus.FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
it('cancel edit form', async () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
|
||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.view);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { MODE_STATES } from '../../data/constants';
|
||||
import { getCourseTitle, getCertificates } from '../../data/selectors';
|
||||
import { setMode } from '../../data/slice';
|
||||
import { updateCourseCertificate, deleteCourseCertificate } from '../../data/thunks';
|
||||
import { defaultCertificate } from '../../constants';
|
||||
|
||||
const useCertificateEditForm = (courseId) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
|
||||
const courseTitle = useSelector(getCourseTitle);
|
||||
const certificates = useSelector(getCertificates);
|
||||
|
||||
const handleCertificateSubmit = (values) => {
|
||||
const signatoriesWithoutLocalIds = values.signatories.map(signatory => {
|
||||
if (signatory.id && typeof signatory.id === 'string' && signatory.id.startsWith('local-')) {
|
||||
const { id, ...rest } = signatory;
|
||||
return rest;
|
||||
}
|
||||
return signatory;
|
||||
});
|
||||
|
||||
const newValues = {
|
||||
...values,
|
||||
signatories: signatoriesWithoutLocalIds,
|
||||
};
|
||||
|
||||
dispatch(updateCourseCertificate(courseId, newValues));
|
||||
};
|
||||
|
||||
const handleCertificateUpdateCancel = (resetForm) => {
|
||||
dispatch(setMode(MODE_STATES.view));
|
||||
resetForm();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCertificateDelete = (certificateId) => {
|
||||
dispatch(deleteCourseCertificate(courseId, certificateId));
|
||||
};
|
||||
|
||||
const initialValues = certificates.map((certificate) => ({
|
||||
...certificate,
|
||||
courseTitle: certificate.courseTitle || defaultCertificate.courseTitle,
|
||||
signatories: certificate.signatories || defaultCertificate.signatories,
|
||||
}));
|
||||
|
||||
return {
|
||||
confirmOpen,
|
||||
courseTitle,
|
||||
certificates,
|
||||
confirmClose,
|
||||
initialValues,
|
||||
isConfirmOpen,
|
||||
handleCertificateDelete,
|
||||
handleCertificateSubmit,
|
||||
handleCertificateUpdateCancel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCertificateEditForm;
|
||||
29
src/certificates/certificate-section/CertificateSection.jsx
Normal file
29
src/certificates/certificate-section/CertificateSection.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
|
||||
const CertificateSection = ({
|
||||
title, actions, children, ...rest
|
||||
}) => (
|
||||
<section {...rest}>
|
||||
<Stack className="justify-content-between mb-2.5" direction="horizontal">
|
||||
<h2 className="lead section-title mb-0">{title}</h2>
|
||||
{actions && actions}
|
||||
</Stack>
|
||||
<hr className="mt-0 mb-4" />
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
CertificateSection.defaultProps = {
|
||||
children: null,
|
||||
actions: null,
|
||||
};
|
||||
CertificateSection.propTypes = {
|
||||
children: PropTypes.node,
|
||||
actions: PropTypes.node,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CertificateSection;
|
||||
@@ -0,0 +1,130 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Stack, Button, Form } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import CertificateSection from '../certificate-section/CertificateSection';
|
||||
import Signatory from './signatory/Signatory';
|
||||
import SignatoryForm from './signatory/SignatoryForm';
|
||||
import useEditSignatory from './hooks/useEditSignatory';
|
||||
import useCreateSignatory from './hooks/useCreateSignatory';
|
||||
import messages from './messages';
|
||||
|
||||
const CertificateSignatories = ({
|
||||
isForm,
|
||||
editModes,
|
||||
signatories,
|
||||
arrayHelpers,
|
||||
initialSignatoriesValues,
|
||||
setFieldValue,
|
||||
setEditModes,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
toggleEditSignatory,
|
||||
handleDeleteSignatory,
|
||||
handleCancelUpdateSignatory,
|
||||
} = useEditSignatory({
|
||||
arrayHelpers, editModes, setEditModes, setFieldValue, initialSignatoriesValues,
|
||||
});
|
||||
|
||||
const { handleAddSignatory } = useCreateSignatory({ arrayHelpers });
|
||||
|
||||
return (
|
||||
<CertificateSection
|
||||
title={intl.formatMessage(messages.signatoriesSectionTitle)}
|
||||
className="certificate-signatories"
|
||||
>
|
||||
<div>
|
||||
<p className="mb-4.5">
|
||||
{intl.formatMessage(messages.signatoriesRecommendation)}
|
||||
</p>
|
||||
<Stack gap="4.5">
|
||||
{signatories.map(({
|
||||
id, name, title, organization, signatureImagePath,
|
||||
}, idx) => (
|
||||
isForm || editModes[idx] ? (
|
||||
<SignatoryForm
|
||||
key={id}
|
||||
index={idx}
|
||||
isEdit={editModes[idx]}
|
||||
name={name}
|
||||
title={title}
|
||||
organization={organization}
|
||||
signatureImagePath={signatureImagePath}
|
||||
handleChange={handleChange}
|
||||
handleBlur={handleBlur}
|
||||
setFieldValue={setFieldValue}
|
||||
showDeleteButton={signatories.length > 1 && !editModes[idx]}
|
||||
handleDeleteSignatory={() => handleDeleteSignatory(idx)}
|
||||
{...(editModes[idx] && {
|
||||
handleCancelUpdateSignatory: () => handleCancelUpdateSignatory(idx),
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Signatory
|
||||
key={id}
|
||||
index={idx}
|
||||
name={name}
|
||||
title={title}
|
||||
organization={organization}
|
||||
signatureImagePath={signatureImagePath}
|
||||
handleEdit={() => toggleEditSignatory(idx)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</Stack>
|
||||
{isForm && (
|
||||
<>
|
||||
<Button variant="outline-primary" onClick={handleAddSignatory} className="w-100 mt-4">
|
||||
{intl.formatMessage(messages.addSignatoryButton)}
|
||||
</Button>
|
||||
<Form.Control.Feedback>
|
||||
<span className="x-small">{intl.formatMessage(messages.addSignatoryButtonDescription)}</span>
|
||||
</Form.Control.Feedback>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CertificateSection>
|
||||
);
|
||||
};
|
||||
|
||||
CertificateSignatories.defaultProps = {
|
||||
handleChange: null,
|
||||
handleBlur: null,
|
||||
setFieldValue: null,
|
||||
arrayHelpers: null,
|
||||
isForm: false,
|
||||
editModes: {},
|
||||
setEditModes: null,
|
||||
initialSignatoriesValues: null,
|
||||
};
|
||||
|
||||
CertificateSignatories.propTypes = {
|
||||
isForm: PropTypes.bool,
|
||||
editModes: PropTypes.objectOf(PropTypes.bool),
|
||||
initialSignatoriesValues: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
organization: PropTypes.string.isRequired,
|
||||
signatureImagePath: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
})),
|
||||
handleChange: PropTypes.func,
|
||||
handleBlur: PropTypes.func,
|
||||
setFieldValue: PropTypes.func,
|
||||
setEditModes: PropTypes.func,
|
||||
arrayHelpers: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
remove: PropTypes.func,
|
||||
}),
|
||||
signatories: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
organization: PropTypes.string.isRequired,
|
||||
signatureImagePath: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
export default CertificateSignatories;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { MODE_STATES } from '../data/constants';
|
||||
import { signatoriesMock } from '../__mocks__';
|
||||
import commonMessages from '../messages';
|
||||
import messages from './messages';
|
||||
import useEditSignatory from './hooks/useEditSignatory';
|
||||
import useCreateSignatory from './hooks/useCreateSignatory';
|
||||
import CertificateSignatories from './CertificateSignatories';
|
||||
|
||||
let store;
|
||||
|
||||
const mockArrayHelpers = {
|
||||
push: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('./hooks/useEditSignatory');
|
||||
|
||||
jest.mock('./hooks/useCreateSignatory');
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CertificateSignatories {...props} />
|
||||
</IntlProvider>,
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const defaultProps = {
|
||||
signatories: signatoriesMock,
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
setFieldValue: jest.fn(),
|
||||
arrayHelpers: mockArrayHelpers,
|
||||
isForm: true,
|
||||
resetForm: jest.fn(),
|
||||
editModes: {},
|
||||
setEditModes: jest.fn(),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
certificates: {
|
||||
certificatesData: {
|
||||
certificates: [],
|
||||
hasCertificateModes: true,
|
||||
},
|
||||
componentMode: MODE_STATES.create,
|
||||
},
|
||||
};
|
||||
|
||||
describe('CertificateSignatories', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
useEditSignatory.mockReturnValue({
|
||||
toggleEditSignatory: jest.fn(),
|
||||
handleDeleteSignatory: jest.fn(),
|
||||
handleCancelUpdateSignatory: jest.fn(),
|
||||
});
|
||||
|
||||
useCreateSignatory.mockReturnValue({
|
||||
handleAddSignatory: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders signatory components for each signatory', () => {
|
||||
const { getByText } = renderComponent({ ...defaultProps, isForm: false });
|
||||
|
||||
signatoriesMock.forEach(signatory => {
|
||||
expect(getByText(signatory.name)).toBeInTheDocument();
|
||||
expect(getByText(signatory.title)).toBeInTheDocument();
|
||||
expect(getByText(signatory.organization)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a new signatory when add button is clicked', () => {
|
||||
const { getByText } = renderComponent({ ...defaultProps, isForm: true });
|
||||
|
||||
userEvent.click(getByText(messages.addSignatoryButton.defaultMessage));
|
||||
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls remove for the correct signatory when delete icon is clicked', async () => {
|
||||
const { getAllByRole } = renderComponent(defaultProps);
|
||||
|
||||
const deleteIcons = getAllByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
expect(deleteIcons.length).toBe(signatoriesMock.length);
|
||||
|
||||
userEvent.click(deleteIcons[0]);
|
||||
|
||||
waitFor(() => {
|
||||
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const useCreateSignatory = ({ arrayHelpers }) => {
|
||||
const handleAddSignatory = () => {
|
||||
const getNewSignatory = () => ({
|
||||
id: `local-${uuid()}`, name: '', title: '', organization: '', signatureImagePath: '',
|
||||
});
|
||||
|
||||
arrayHelpers.push(getNewSignatory());
|
||||
};
|
||||
|
||||
return { handleAddSignatory };
|
||||
};
|
||||
|
||||
export default useCreateSignatory;
|
||||
@@ -0,0 +1,33 @@
|
||||
const useEditSignatory = ({
|
||||
arrayHelpers, editModes, setEditModes, setFieldValue, initialSignatoriesValues,
|
||||
}) => {
|
||||
const handleDeleteSignatory = (id) => {
|
||||
arrayHelpers.remove(id);
|
||||
|
||||
if (editModes && setEditModes) {
|
||||
const newEditModes = { ...editModes };
|
||||
delete newEditModes[id];
|
||||
setEditModes(newEditModes);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEditSignatory = (id) => {
|
||||
setEditModes(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCancelUpdateSignatory = (id) => {
|
||||
const signatoryInitialValues = initialSignatoriesValues[id];
|
||||
Object.keys(signatoryInitialValues).forEach(fieldKey => {
|
||||
const fieldName = `signatories[${id}].${fieldKey}`;
|
||||
setFieldValue(fieldName, signatoryInitialValues[fieldKey]);
|
||||
});
|
||||
toggleEditSignatory(id);
|
||||
};
|
||||
|
||||
return { toggleEditSignatory, handleDeleteSignatory, handleCancelUpdateSignatory };
|
||||
};
|
||||
|
||||
export default useEditSignatory;
|
||||
116
src/certificates/certificate-signatories/messages.js
Normal file
116
src/certificates/certificate-signatories/messages.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
signatoryTitle: {
|
||||
id: 'course-authoring.certificates.signatories.title',
|
||||
defaultMessage: 'Signatory',
|
||||
description: 'Title for a signatory',
|
||||
},
|
||||
signatoriesRecommendation: {
|
||||
id: 'course-authoring.certificates.signatories.recommendation',
|
||||
defaultMessage: 'It is strongly recommended that you include four or fewer signatories. If you include additional signatories, preview the certificate in Print View to ensure the certificate will print correctly on one page.',
|
||||
description: 'A recommendation for the number of signatories to include on a certificate, emphasizing the importance of testing the print layout',
|
||||
},
|
||||
signatoriesSectionTitle: {
|
||||
id: 'course-authoring.certificates.signatories.section.title',
|
||||
defaultMessage: 'Certificate signatories',
|
||||
description: 'Title for the section',
|
||||
},
|
||||
addSignatoryButton: {
|
||||
id: 'course-authoring.certificates.signatories.add.signatory.button',
|
||||
defaultMessage: 'Add additional signatory',
|
||||
description: 'Button text for adding a new signatory to the certificate',
|
||||
},
|
||||
addSignatoryButtonDescription: {
|
||||
id: 'course-authoring.certificates.signatories.add.signatory.button.description',
|
||||
defaultMessage: '(Add signatories for a certificate)',
|
||||
description: 'Helper text for the button used to add signatories',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'course-authoring.certificates.signatories.name.label',
|
||||
defaultMessage: 'Name',
|
||||
description: 'Label for the input field where the signatory name is entered',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'course-authoring.certificates.signatories.name.placeholder',
|
||||
defaultMessage: 'Name of the signatory',
|
||||
description: 'Placeholder text for the signatory name input field',
|
||||
},
|
||||
nameDescription: {
|
||||
id: 'course-authoring.certificates.signatories.name.description',
|
||||
defaultMessage: 'The name of this signatory as it should appear on certificates.',
|
||||
description: 'Helper text under the name input field',
|
||||
},
|
||||
titleLabel: {
|
||||
id: 'course-authoring.certificates.signatories.title.label',
|
||||
defaultMessage: 'Title',
|
||||
description: 'Label for the input field where the signatory title is entered',
|
||||
},
|
||||
titlePlaceholder: {
|
||||
id: 'course-authoring.certificates.signatories.title.placeholder',
|
||||
defaultMessage: 'Title of the signatory',
|
||||
description: 'Placeholder text for the signatory title input field',
|
||||
},
|
||||
titleDescription: {
|
||||
id: 'course-authoring.certificates.signatories.title.description',
|
||||
defaultMessage: 'Titles more than 100 characters may prevent students from printing their certificate on a single page.',
|
||||
description: 'Helper text under the title input field',
|
||||
},
|
||||
organizationLabel: {
|
||||
id: 'course-authoring.certificates.signatories.organization.label',
|
||||
defaultMessage: 'Organization',
|
||||
description: 'Label for the input field where the signatory organization is entered',
|
||||
},
|
||||
organizationPlaceholder: {
|
||||
id: 'course-authoring.certificates.signatories.organization.placeholder',
|
||||
defaultMessage: 'Organization of the signatory',
|
||||
description: 'Placeholder text for the signatory organization input field',
|
||||
},
|
||||
organizationDescription: {
|
||||
id: 'course-authoring.certificates.signatories.organization.description',
|
||||
defaultMessage: 'The organization that this signatory belongs to, as it should appear on certificates.',
|
||||
description: 'Helper text under the organization input field',
|
||||
},
|
||||
imageLabel: {
|
||||
id: 'course-authoring.certificates.signatories.image.label',
|
||||
defaultMessage: 'Signature image',
|
||||
description: 'Label for the input field where the signatory image is selected',
|
||||
},
|
||||
imagePlaceholder: {
|
||||
id: 'course-authoring.certificates.signatories.image.placeholder',
|
||||
defaultMessage: 'Path to signature image',
|
||||
description: 'Placeholder text for the signatory image input field',
|
||||
},
|
||||
imageDescription: {
|
||||
id: 'course-authoring.certificates.signatories.image.description',
|
||||
defaultMessage: 'Image must be in PNG format',
|
||||
description: 'Helper text under the image input field',
|
||||
},
|
||||
uploadImageButton: {
|
||||
id: 'course-authoring.certificates.signatories.upload.image.button',
|
||||
defaultMessage: '{uploadText} signature image',
|
||||
description: 'Button text for adding or replacing a signature image',
|
||||
},
|
||||
uploadModal: {
|
||||
id: 'course-authoring.certificates.signatories.upload.modal',
|
||||
defaultMessage: 'Upload',
|
||||
description: 'Option for button text for adding a new signature image',
|
||||
},
|
||||
uploadModalReplace: {
|
||||
id: 'course-authoring.certificates.signatories.upload.modal.replace',
|
||||
defaultMessage: 'Replace',
|
||||
description: 'Option for button text for replacing an existing signature image',
|
||||
},
|
||||
deleteSignatoryConfirmation: {
|
||||
id: 'course-authoring.certificates.signatories.confirm-modal',
|
||||
defaultMessage: 'Delete "{name}" from the list of signatories?',
|
||||
description: 'Title for the confirmation modal when a user attempts to delete a signatory, where "{name}" is the name of the signatory to be deleted',
|
||||
},
|
||||
deleteSignatoryConfirmationMessage: {
|
||||
id: 'course-authoring.certificates.signatories.confirm-modal.message',
|
||||
defaultMessage: 'This action cannot be undone.',
|
||||
description: 'A warning message that emphasizes the permanence of the delete action for a signatory',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,66 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Image, Icon, Stack, IconButtonWithTooltip,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
EditOutline as EditOutlineIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import commonMessages from '../../messages';
|
||||
import messages from '../messages';
|
||||
|
||||
const Signatory = ({
|
||||
index,
|
||||
name,
|
||||
title,
|
||||
organization,
|
||||
signatureImagePath,
|
||||
handleEdit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="bg-light-200 p-2.5 signatory" data-testid="signatory">
|
||||
<Stack className="signatory__header" gap={3}>
|
||||
<h3 className="section-title m-0">{`${intl.formatMessage(messages.signatoryTitle)} ${index + 1}`}</h3>
|
||||
<Stack className="signatory__text-fields-stack">
|
||||
<p className="signatory__text"><b>{intl.formatMessage(messages.nameLabel)}</b> {name}</p>
|
||||
<p className="signatory__text"><b>{intl.formatMessage(messages.titleLabel)}</b> {title}</p>
|
||||
<p className="signatory__text"><b>{intl.formatMessage(messages.organizationLabel)}</b> {organization}</p>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<IconButtonWithTooltip
|
||||
className="signatory__action-button"
|
||||
src={EditOutlineIcon}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(commonMessages.editTooltip)}
|
||||
tooltipContent={<div>{intl.formatMessage(commonMessages.editTooltip)}</div>}
|
||||
onClick={handleEdit}
|
||||
/>
|
||||
<div className="signatory__image-container">
|
||||
{signatureImagePath && (
|
||||
<Image
|
||||
src={`${getConfig().STUDIO_BASE_URL}${signatureImagePath}`}
|
||||
fluid
|
||||
alt={intl.formatMessage(messages.imageLabel)}
|
||||
className="signatory__image"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Signatory.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
organization: PropTypes.string.isRequired,
|
||||
signatureImagePath: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
handleEdit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Signatory;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { signatoriesMock } from '../../__mocks__';
|
||||
import commonMessages from '../../messages';
|
||||
import messages from '../messages';
|
||||
import Signatory from './Signatory';
|
||||
|
||||
const mockHandleEdit = jest.fn();
|
||||
|
||||
const renderSignatory = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<Signatory {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const defaultProps = { ...signatoriesMock[0], handleEdit: mockHandleEdit, index: 0 };
|
||||
|
||||
describe('Signatory Component', () => {
|
||||
it('renders in MODE_STATES.view mode', () => {
|
||||
const {
|
||||
getByText, queryByText, getByAltText, getByRole,
|
||||
} = renderSignatory(defaultProps);
|
||||
const signatureImage = getByAltText(messages.imageLabel.defaultMessage);
|
||||
const sectionTitle = getByRole('heading', { level: 3, name: `${messages.signatoryTitle.defaultMessage} ${defaultProps.index + 1}` });
|
||||
|
||||
expect(sectionTitle).toBeInTheDocument();
|
||||
expect(getByText(defaultProps.name)).toBeInTheDocument();
|
||||
expect(getByText(defaultProps.title)).toBeInTheDocument();
|
||||
expect(getByText(defaultProps.organization)).toBeInTheDocument();
|
||||
expect(signatureImage).toBeInTheDocument();
|
||||
expect(signatureImage).toHaveAttribute('src', expect.stringContaining(defaultProps.signatureImagePath));
|
||||
expect(queryByText(messages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleEdit when the edit button is clicked', () => {
|
||||
const { getByRole } = renderSignatory(defaultProps);
|
||||
|
||||
const editButton = getByRole('button', { name: commonMessages.editTooltip.defaultMessage });
|
||||
userEvent.click(editButton);
|
||||
|
||||
expect(mockHandleEdit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
Image, Icon, Stack, IconButtonWithTooltip, FormLabel, Form, Button, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { DeleteOutline as DeleteOutlineIcon } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import ModalDropzone from '../../../generic/modal-dropzone/ModalDropzone';
|
||||
import ModalNotification from '../../../generic/modal-notification';
|
||||
import { updateSavingImageStatus } from '../../data/slice';
|
||||
import commonMessages from '../../messages';
|
||||
import messages from '../messages';
|
||||
|
||||
const SignatoryForm = ({
|
||||
index,
|
||||
name,
|
||||
title,
|
||||
isEdit,
|
||||
handleBlur,
|
||||
organization,
|
||||
handleChange,
|
||||
setFieldValue,
|
||||
showDeleteButton,
|
||||
signatureImagePath,
|
||||
handleDeleteSignatory,
|
||||
handleCancelUpdateSignatory,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
|
||||
|
||||
const handleImageUpload = (newImagePath) => {
|
||||
setFieldValue(`signatories[${index}].signatureImagePath`, newImagePath);
|
||||
};
|
||||
|
||||
const handleSavingStatusDispatch = (status) => {
|
||||
dispatch(updateSavingImageStatus(status));
|
||||
};
|
||||
|
||||
const formData = [
|
||||
{
|
||||
labelText: intl.formatMessage(messages.nameLabel),
|
||||
value: name,
|
||||
name: `signatories[${index}].name`,
|
||||
placeholder: intl.formatMessage(messages.namePlaceholder),
|
||||
feedback: intl.formatMessage(messages.nameDescription),
|
||||
onChange: handleChange,
|
||||
onBlur: handleBlur,
|
||||
},
|
||||
{
|
||||
as: 'textarea',
|
||||
labelText: intl.formatMessage(messages.titleLabel),
|
||||
value: title,
|
||||
name: `signatories[${index}].title`,
|
||||
placeholder: intl.formatMessage(messages.titlePlaceholder),
|
||||
feedback: intl.formatMessage(messages.titleDescription),
|
||||
onChange: handleChange,
|
||||
onBlur: handleBlur,
|
||||
},
|
||||
{
|
||||
labelText: intl.formatMessage(messages.organizationLabel),
|
||||
value: organization,
|
||||
name: `signatories[${index}].organization`,
|
||||
placeholder: intl.formatMessage(messages.organizationPlaceholder),
|
||||
feedback: intl.formatMessage(messages.organizationDescription),
|
||||
onChange: handleChange,
|
||||
onBlur: handleBlur,
|
||||
},
|
||||
];
|
||||
|
||||
const uploadReplaceText = intl.formatMessage(
|
||||
messages.uploadImageButton,
|
||||
{
|
||||
uploadText: signatureImagePath
|
||||
? intl.formatMessage(messages.uploadModalReplace)
|
||||
: intl.formatMessage(messages.uploadModal),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-light-200 p-2.5 signatory-form" data-testid="signatory-form">
|
||||
<Stack className="justify-content-between mb-4" direction="horizontal">
|
||||
<h3 className="section-title">{`${intl.formatMessage(messages.signatoryTitle)} ${index + 1}`}</h3>
|
||||
<Stack direction="horizontal" gap="2">
|
||||
{showDeleteButton && (
|
||||
<IconButtonWithTooltip
|
||||
src={DeleteOutlineIcon}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(commonMessages.deleteTooltip)}
|
||||
tooltipContent={<div>{intl.formatMessage(commonMessages.deleteTooltip)}</div>}
|
||||
onClick={confirmOpen}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="4">
|
||||
{formData.map(({ labelText, feedback, ...rest }) => (
|
||||
<Form.Group className="m-0" key={labelText}>
|
||||
<FormLabel>{labelText}</FormLabel>
|
||||
<Form.Control {...rest} className="m-0" />
|
||||
<Form.Control.Feedback>
|
||||
<span className="x-small">{feedback}</span>
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
))}
|
||||
<Form.Group className="m-0">
|
||||
<FormLabel> {intl.formatMessage(messages.imageLabel)}</FormLabel>
|
||||
{signatureImagePath && (
|
||||
<Image
|
||||
src={`${getConfig().STUDIO_BASE_URL}${signatureImagePath}`}
|
||||
fluid
|
||||
alt={intl.formatMessage(messages.imageLabel)}
|
||||
className="signatory__image"
|
||||
/>
|
||||
)}
|
||||
<Stack direction="horizontal" className="align-items-baseline">
|
||||
<Stack>
|
||||
<Form.Control
|
||||
readOnly
|
||||
value={signatureImagePath}
|
||||
name={`signatories[${index}].signatureImagePath`}
|
||||
placeholder={intl.formatMessage(messages.imagePlaceholder)}
|
||||
/>
|
||||
<Form.Control.Feedback>
|
||||
<span className="x-small">{intl.formatMessage(messages.imageDescription)}</span>
|
||||
</Form.Control.Feedback>
|
||||
</Stack>
|
||||
<Button onClick={open}>{uploadReplaceText}</Button>
|
||||
</Stack>
|
||||
</Form.Group>
|
||||
</Stack>
|
||||
{isEdit && (
|
||||
<Stack direction="horizontal" gap="2" className="mt-4">
|
||||
<Button type="submit">
|
||||
{intl.formatMessage(commonMessages.saveTooltip)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={() => handleCancelUpdateSignatory()}
|
||||
>
|
||||
{intl.formatMessage(commonMessages.cardCancel)}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ModalDropzone
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
onCancel={close}
|
||||
onChange={handleImageUpload}
|
||||
fileTypes={['png']}
|
||||
onSavingStatus={handleSavingStatusDispatch}
|
||||
imageHelpText={intl.formatMessage(messages.imageDescription)}
|
||||
modalTitle={uploadReplaceText}
|
||||
/>
|
||||
<ModalNotification
|
||||
isOpen={isConfirmOpen}
|
||||
title={intl.formatMessage(messages.deleteSignatoryConfirmation, { name })}
|
||||
message={intl.formatMessage(messages.deleteSignatoryConfirmationMessage)}
|
||||
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
|
||||
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
|
||||
handleCancel={confirmClose}
|
||||
handleAction={() => {
|
||||
confirmClose();
|
||||
handleDeleteSignatory();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SignatoryForm.defaultProps = {
|
||||
isEdit: false,
|
||||
handleChange: null,
|
||||
handleBlur: null,
|
||||
handleDeleteSignatory: null,
|
||||
setFieldValue: null,
|
||||
handleCancelUpdateSignatory: null,
|
||||
};
|
||||
|
||||
SignatoryForm.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
organization: PropTypes.string.isRequired,
|
||||
showDeleteButton: PropTypes.bool.isRequired,
|
||||
signatureImagePath: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
isEdit: PropTypes.bool,
|
||||
handleChange: PropTypes.func,
|
||||
handleBlur: PropTypes.func,
|
||||
setFieldValue: PropTypes.func,
|
||||
handleDeleteSignatory: PropTypes.func,
|
||||
handleCancelUpdateSignatory: PropTypes.func,
|
||||
};
|
||||
|
||||
export default SignatoryForm;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
|
||||
import initializeStore from '../../../store';
|
||||
import { signatoriesMock } from '../../__mocks__';
|
||||
import commonMessages from '../../messages';
|
||||
import messages from '../messages';
|
||||
import SignatoryForm from './SignatoryForm';
|
||||
|
||||
let store;
|
||||
|
||||
const renderSignatory = (props) => render(
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<SignatoryForm {...props} />
|
||||
</IntlProvider>,
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
certificates: {
|
||||
certificatesData: {
|
||||
certificates: [],
|
||||
hasCertificateModes: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
...signatoriesMock[0],
|
||||
showDeleteButton: true,
|
||||
isEdit: true,
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
setFieldValue: jest.fn(),
|
||||
handleDeleteSignatory: jest.fn(),
|
||||
handleCancelUpdateSignatory: jest.fn(),
|
||||
};
|
||||
|
||||
describe('Signatory Component', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
});
|
||||
it('renders in CREATE mode', () => {
|
||||
const { queryByTestId, getByPlaceholderText } = renderSignatory(defaultProps);
|
||||
|
||||
expect(queryByTestId('signatory-view')).not.toBeInTheDocument();
|
||||
expect(getByPlaceholderText(messages.namePlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles input change', async () => {
|
||||
const handleChange = jest.fn();
|
||||
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
|
||||
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
||||
const newInputValue = 'Jane Doe';
|
||||
|
||||
userEvent.type(input, newInputValue, { name: 'signatories[0].name' });
|
||||
|
||||
waitFor(() => {
|
||||
expect(handleChange).toHaveBeenCalledWith(expect.anything());
|
||||
expect(input.value).toBe(newInputValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens image upload modal on button click', () => {
|
||||
const { getByRole, queryByRole } = renderSignatory(defaultProps);
|
||||
const replaceButton = getByRole(
|
||||
'button',
|
||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
||||
);
|
||||
|
||||
expect(queryByRole('presentation')).not.toBeInTheDocument();
|
||||
|
||||
userEvent.click(replaceButton);
|
||||
|
||||
expect(getByRole('presentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirm modal on delete icon click', async () => {
|
||||
const { getByLabelText, getByText } = renderSignatory(defaultProps);
|
||||
const deleteIcon = getByLabelText(commonMessages.deleteTooltip.defaultMessage);
|
||||
|
||||
userEvent.click(deleteIcon);
|
||||
|
||||
expect(getByText(messages.deleteSignatoryConfirmationMessage.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels deletion of a signatory', () => {
|
||||
const { getByRole } = renderSignatory(defaultProps);
|
||||
|
||||
const deleteIcon = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
userEvent.click(deleteIcon);
|
||||
|
||||
const cancelButton = getByRole('button', { name: commonMessages.cardCancel.defaultMessage });
|
||||
userEvent.click(cancelButton);
|
||||
|
||||
expect(defaultProps.handleDeleteSignatory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders without save button with isEdit=false', () => {
|
||||
const { queryByRole } = renderSignatory({ ...defaultProps, isEdit: false });
|
||||
|
||||
const deleteIcon = queryByRole('button', { name: commonMessages.saveTooltip.defaultMessage });
|
||||
|
||||
expect(deleteIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders button with Replace text if there is a signatureImagePath', () => {
|
||||
const newProps = {
|
||||
...defaultProps,
|
||||
isEdit: false,
|
||||
};
|
||||
|
||||
const { getByRole, queryByRole } = renderSignatory(newProps);
|
||||
|
||||
const replaceButton = getByRole(
|
||||
'button',
|
||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
||||
);
|
||||
const uploadButton = queryByRole(
|
||||
'button',
|
||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModal.defaultMessage) },
|
||||
);
|
||||
|
||||
expect(replaceButton).toBeInTheDocument();
|
||||
expect(uploadButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders button with Upload text if there is no signatureImagePath', () => {
|
||||
const newProps = {
|
||||
...defaultProps,
|
||||
signatureImagePath: '',
|
||||
isEdit: false,
|
||||
};
|
||||
|
||||
const { getByRole, queryByRole } = renderSignatory(newProps);
|
||||
|
||||
const uploadButton = getByRole(
|
||||
'button',
|
||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModal.defaultMessage) },
|
||||
);
|
||||
const replaceButton = queryByRole(
|
||||
'button',
|
||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
||||
);
|
||||
|
||||
expect(uploadButton).toBeInTheDocument();
|
||||
expect(replaceButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Card } from '@openedx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
const CertificateWithoutModes = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Card>
|
||||
<Card.Section className="d-flex justify-content-center">
|
||||
<span className="small">{intl.formatMessage(messages.withoutModesText)}</span>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateWithoutModes;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import messages from '../messages';
|
||||
import WithoutModes from './CertificateWithoutModes';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<WithoutModes courseId={courseId} {...props} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('CertificateWithoutModes', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
const { getByText, queryByText } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(queryByText(messages.headingActionsPreview.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(queryByText(messages.headingActionsDeactivate.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/certificates/certificates-list/CertificatesList.jsx
Normal file
69
src/certificates/certificates-list/CertificatesList.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Card, Stack } from '@openedx/paragon';
|
||||
import { Formik, Form, FieldArray } from 'formik';
|
||||
|
||||
import CertificateDetails from '../certificate-details/CertificateDetails';
|
||||
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
|
||||
import useCertificatesList from './hooks/useCertificatesList';
|
||||
|
||||
const CertificatesList = ({ courseId }) => {
|
||||
const {
|
||||
editModes,
|
||||
courseTitle,
|
||||
certificates,
|
||||
courseNumber,
|
||||
initialValues,
|
||||
courseNumberOverride,
|
||||
setEditModes,
|
||||
handleSubmit,
|
||||
} = useCertificatesList(courseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{certificates.map((certificate, idx) => (
|
||||
<Formik initialValues={initialValues[idx]} onSubmit={handleSubmit} key={certificate.id}>
|
||||
{({
|
||||
values, handleChange, handleBlur, setFieldValue,
|
||||
}) => (
|
||||
<Form className="certificates-card-form" data-testid="certificates-list">
|
||||
<Card>
|
||||
<Card.Section>
|
||||
<Stack gap="2">
|
||||
<CertificateDetails
|
||||
detailsCourseTitle={courseTitle}
|
||||
detailsCourseNumber={courseNumber}
|
||||
courseNumberOverride={courseNumberOverride}
|
||||
courseTitleOverride={certificate.courseTitle}
|
||||
certificateId={certificate.id}
|
||||
/>
|
||||
<FieldArray
|
||||
name="signatories"
|
||||
render={arrayHelpers => (
|
||||
<CertificateSignatories
|
||||
signatories={values.signatories}
|
||||
arrayHelpers={arrayHelpers}
|
||||
editModes={editModes}
|
||||
initialSignatoriesValues={initialValues[idx].signatories}
|
||||
handleChange={handleChange}
|
||||
handleBlur={handleBlur}
|
||||
setFieldValue={setFieldValue}
|
||||
setEditModes={setEditModes}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CertificatesList.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CertificatesList;
|
||||
133
src/certificates/certificates-list/CertificatesList.test.jsx
Normal file
133
src/certificates/certificates-list/CertificatesList.test.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { executeThunk } from '../../utils';
|
||||
import initializeStore from '../../store';
|
||||
import { MODE_STATES } from '../data/constants';
|
||||
import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../data/api';
|
||||
import { fetchCertificates, updateCourseCertificate } from '../data/thunks';
|
||||
import { certificatesMock, certificatesDataMock } from '../__mocks__';
|
||||
import signatoryMessages from '../certificate-signatories/messages';
|
||||
import messages from '../messages';
|
||||
import CertificatesList from './CertificatesList';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const courseId = 'course-123';
|
||||
|
||||
const renderComponent = () => render(
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CertificatesList courseId="course-123" />
|
||||
</IntlProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
describe('CertificatesList Component', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCertificatesApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...certificatesDataMock,
|
||||
certificates: certificatesMock,
|
||||
});
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
it('renders each certificate', () => {
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
certificatesMock.forEach((certificate) => {
|
||||
certificate.signatories.forEach((signatory) => {
|
||||
expect(getByText(signatory.name)).toBeInTheDocument();
|
||||
expect(getByText(signatory.title)).toBeInTheDocument();
|
||||
expect(getByText(signatory.organization)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('update certificate', async () => {
|
||||
const {
|
||||
getByText, queryByText, getByPlaceholderText, getByRole, getAllByLabelText,
|
||||
} = renderComponent();
|
||||
|
||||
const signatoryNameValue = 'Updated signatory name';
|
||||
const newCertificateData = {
|
||||
...certificatesDataMock,
|
||||
certificates: [{
|
||||
...certificatesMock[0],
|
||||
signatories: [{
|
||||
...certificatesMock[0].signatories[0],
|
||||
name: signatoryNameValue,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
|
||||
|
||||
userEvent.click(editButtons[1]);
|
||||
|
||||
const nameInput = getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage);
|
||||
userEvent.clear(nameInput);
|
||||
userEvent.type(nameInput, signatoryNameValue);
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
||||
|
||||
axiosMock
|
||||
.onPost(getUpdateCertificateApiUrl(courseId, certificatesMock.id))
|
||||
.reply(200, newCertificateData);
|
||||
await executeThunk(updateCourseCertificate(courseId, newCertificateData), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(newCertificateData.certificates[0].signatories[0].name)).toBeInTheDocument();
|
||||
expect(queryByText(certificatesDataMock.certificates[0].signatories[0].name)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggle edit signatory', async () => {
|
||||
const {
|
||||
getAllByLabelText, queryByPlaceholderText, getByTestId, getByPlaceholderText,
|
||||
} = renderComponent();
|
||||
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
|
||||
|
||||
expect(editButtons.length).toBe(3);
|
||||
|
||||
userEvent.click(editButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
userEvent.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggle certificate edit all', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
const detailsSection = getByTestId('certificate-details');
|
||||
const editButton = within(detailsSection).getByLabelText(messages.editTooltip.defaultMessage);
|
||||
userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import { MODE_STATES } from '../../data/constants';
|
||||
import {
|
||||
getCourseTitle, getCourseNumber, getCourseNumberOverride, getCertificates,
|
||||
} from '../../data/selectors';
|
||||
import { updateCourseCertificate } from '../../data/thunks';
|
||||
import { setMode } from '../../data/slice';
|
||||
import { defaultCertificate } from '../../constants';
|
||||
|
||||
const useCertificatesList = (courseId) => {
|
||||
const dispatch = useDispatch();
|
||||
const certificates = useSelector(getCertificates);
|
||||
const courseTitle = useSelector(getCourseTitle);
|
||||
const courseNumber = useSelector(getCourseNumber);
|
||||
const courseNumberOverride = useSelector(getCourseNumberOverride);
|
||||
|
||||
const [editModes, setEditModes] = useState({});
|
||||
|
||||
const initialValues = certificates.map((certificate) => ({
|
||||
...certificate,
|
||||
courseTitle: certificate.courseTitle || defaultCertificate.courseTitle,
|
||||
signatories: certificate.signatories || defaultCertificate.signatories,
|
||||
}));
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
await dispatch(updateCourseCertificate(courseId, values));
|
||||
setEditModes({});
|
||||
dispatch(setMode(MODE_STATES.view));
|
||||
};
|
||||
|
||||
return {
|
||||
editModes,
|
||||
courseTitle,
|
||||
certificates,
|
||||
courseNumber,
|
||||
initialValues,
|
||||
courseNumberOverride,
|
||||
setEditModes,
|
||||
handleSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCertificatesList;
|
||||
13
src/certificates/constants.js
Normal file
13
src/certificates/constants.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const defaultCertificate = {
|
||||
courseTitle: '',
|
||||
signatories: [{
|
||||
id: `local-${uuid()}`,
|
||||
name: '',
|
||||
title: '',
|
||||
organization: '',
|
||||
signatureImagePath: '',
|
||||
}],
|
||||
};
|
||||
89
src/certificates/data/api.js
Normal file
89
src/certificates/data/api.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { prepareCertificatePayload } from '../utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getCertificatesApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
|
||||
export const getCertificateApiUrl = (courseId) => `${getApiBaseUrl()}/certificates/${courseId}`;
|
||||
export const getUpdateCertificateApiUrl = (courseId, certificateId) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
|
||||
export const getUpdateCertificateActiveStatusApiUrl = (path) => `${getApiBaseUrl()}${path}`;
|
||||
|
||||
/**
|
||||
* Gets certificates for a course.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCertificates(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCertificatesApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create course certificate.
|
||||
* @param {string} courseId
|
||||
* @param {object} certificatesData
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
|
||||
export async function createCertificate(courseId, certificatesData) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
getCertificateApiUrl(courseId),
|
||||
prepareCertificatePayload(certificatesData),
|
||||
);
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update course certificate.
|
||||
* @param {string} courseId
|
||||
* @param {object} certificateData
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCertificate(courseId, certificateData) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
getUpdateCertificateApiUrl(courseId, certificateData.id),
|
||||
prepareCertificatePayload(certificateData),
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete course certificate.
|
||||
* @param {string} courseId
|
||||
* @param {object} certificateId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteCertificate(courseId, certificateId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(
|
||||
getUpdateCertificateApiUrl(courseId, certificateId),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate/deactivate course certificate.
|
||||
* @param {string} courseId
|
||||
* @param {object} activationStatus
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateActiveStatus(path, activationStatus) {
|
||||
const body = {
|
||||
is_active: activationStatus,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
getUpdateCertificateActiveStatusApiUrl(path),
|
||||
body,
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
12
src/certificates/data/constants.js
Normal file
12
src/certificates/data/constants.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const MODE_STATES = {
|
||||
noModes: 'no_modes',
|
||||
noCertificates: 'no_certificates',
|
||||
view: 'view',
|
||||
editAll: 'edit_all',
|
||||
create: 'create',
|
||||
};
|
||||
|
||||
export const ACTIVATION_MESSAGES = {
|
||||
activating: 'Activating',
|
||||
deactivating: 'Deactivating',
|
||||
};
|
||||
21
src/certificates/data/selectors.js
Normal file
21
src/certificates/data/selectors.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
export const getLoadingStatus = (state) => state.certificates.loadingStatus;
|
||||
export const getSavingStatus = (state) => state.certificates.savingStatus;
|
||||
export const getSavingImageStatus = (state) => state.certificates.savingImageStatus;
|
||||
export const getSendRequestErrors = (state) => state.certificates.sendRequestErrors.developer_message;
|
||||
export const getCertificates = state => state.certificates.certificatesData.certificates;
|
||||
export const getHasCertificateModes = state => state.certificates.certificatesData.hasCertificateModes;
|
||||
export const getCourseModes = state => state.certificates.certificatesData.courseModes;
|
||||
export const getCertificateActivationUrl = state => state.certificates.certificatesData.certificateActivationHandlerUrl;
|
||||
export const getCertificateWebViewUrl = state => state.certificates.certificatesData.certificateWebViewUrl;
|
||||
export const getIsCertificateActive = state => state.certificates.certificatesData.isActive;
|
||||
export const getComponentMode = state => state.certificates.componentMode;
|
||||
export const getCourseNumber = state => state.certificates.certificatesData.courseNumber;
|
||||
export const getCourseNumberOverride = state => state.certificates.certificatesData.courseNumberOverride;
|
||||
export const getCourseTitle = state => state.certificates.certificatesData.courseTitle;
|
||||
|
||||
export const getHasCertificates = createSelector(
|
||||
[getCertificates],
|
||||
(certificates) => certificates && certificates.length > 0,
|
||||
);
|
||||
59
src/certificates/data/slice.js
Normal file
59
src/certificates/data/slice.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { MODE_STATES } from './constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'certificates',
|
||||
initialState: {
|
||||
certificatesData: {},
|
||||
componentMode: MODE_STATES.noModes,
|
||||
loadingStatus: RequestStatus.PENDING,
|
||||
savingStatus: '',
|
||||
savingImageStatus: '',
|
||||
},
|
||||
reducers: {
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
updateSavingImageStatus: (state, { payload }) => {
|
||||
state.savingImageStatus = payload.status;
|
||||
},
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
fetchCertificatesSuccess: (state, { payload }) => {
|
||||
Object.assign(state.certificatesData, payload);
|
||||
},
|
||||
createCertificateSuccess: (state, action) => {
|
||||
state.certificatesData.certificates.push(action.payload);
|
||||
},
|
||||
updateCertificateSuccess: (state, action) => {
|
||||
const index = state.certificatesData.certificates.findIndex(c => c.id === action.payload.id);
|
||||
|
||||
if (index !== -1) {
|
||||
state.certificatesData.certificates[index] = action.payload;
|
||||
}
|
||||
},
|
||||
setMode: (state, action) => {
|
||||
state.componentMode = action.payload;
|
||||
},
|
||||
deleteCertificateSuccess: (state) => {
|
||||
state.certificatesData.certificates = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setMode,
|
||||
updateSavingStatus,
|
||||
updateLoadingStatus,
|
||||
updateSavingImageStatus,
|
||||
fetchCertificatesSuccess,
|
||||
createCertificateSuccess,
|
||||
updateCertificateSuccess,
|
||||
deleteCertificateSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const { reducer } = slice;
|
||||
120
src/certificates/data/thunks.js
Normal file
120
src/certificates/data/thunks.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '../../generic/processing-notification/data/slice';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import {
|
||||
getCertificates,
|
||||
createCertificate,
|
||||
updateCertificate,
|
||||
deleteCertificate,
|
||||
updateActiveStatus,
|
||||
} from './api';
|
||||
import {
|
||||
fetchCertificatesSuccess,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
createCertificateSuccess,
|
||||
updateCertificateSuccess,
|
||||
deleteCertificateSuccess,
|
||||
} from './slice';
|
||||
import { ACTIVATION_MESSAGES } from './constants';
|
||||
|
||||
export function fetchCertificates(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const certificates = await getCertificates(courseId);
|
||||
|
||||
dispatch(fetchCertificatesSuccess(certificates));
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCourseCertificate(courseId, certificate) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
const certificateValues = await createCertificate(courseId, certificate);
|
||||
dispatch(createCertificateSuccess(certificateValues));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} finally {
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseCertificate(courseId, certificate) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
const certificatesValues = await updateCertificate(courseId, certificate);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateCertificateSuccess(certificatesValues));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} finally {
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseCertificate(courseId, certificateId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
||||
|
||||
try {
|
||||
const certificatesValues = await deleteCertificate(courseId, certificateId);
|
||||
dispatch(deleteCertificateSuccess(certificatesValues));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} finally {
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCertificateActiveStatus(courseId, path, activationStatus) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
dispatch(showProcessingNotification(
|
||||
activationStatus ? ACTIVATION_MESSAGES.activating : ACTIVATION_MESSAGES.deactivating,
|
||||
));
|
||||
|
||||
try {
|
||||
await updateActiveStatus(path, activationStatus);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(fetchCertificates(courseId));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} finally {
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, ActionRow, Card } from '@openedx/paragon';
|
||||
import { Add as AddIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import { setMode } from '../data/slice';
|
||||
import { MODE_STATES } from '../data/constants';
|
||||
import messages from '../messages';
|
||||
|
||||
const EmptyCertificatesWithModes = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCreateMode = () => {
|
||||
dispatch(setMode(MODE_STATES.create));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Section>
|
||||
<ActionRow>
|
||||
<span className="small">{intl.formatMessage(messages.noCertificatesText)}</span>
|
||||
<ActionRow.Spacer />
|
||||
<Button
|
||||
iconBefore={AddIcon}
|
||||
onClick={handleCreateMode}
|
||||
>
|
||||
{intl.formatMessage(messages.setupCertificateBtn)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyCertificatesWithModes;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import messages from '../messages';
|
||||
import WithModesWithoutCertificates from './EmptyCertificatesWithModes';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<WithModesWithoutCertificates courseId={courseId} {...props} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('EmptyCertificatesWithModes', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
const { getByText, queryByText } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.noCertificatesText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.setupCertificateBtn.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(queryByText(messages.headingActionsPreview.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(queryByText(messages.headingActionsDeactivate.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/certificates/hooks/useCertificates.jsx
Normal file
49
src/certificates/hooks/useCertificates.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import getPageHeadTitle from '../../generic/utils';
|
||||
import {
|
||||
getComponentMode, getLoadingStatus, getCertificates, getHasCertificateModes, getCourseTitle,
|
||||
} from '../data/selectors';
|
||||
import { setMode } from '../data/slice';
|
||||
import { fetchCertificates } from '../data/thunks';
|
||||
import { MODE_STATES } from '../data/constants';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
const useCertificates = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const componentMode = useSelector(getComponentMode);
|
||||
const certificates = useSelector(getCertificates);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const hasCertificateModes = useSelector(getHasCertificateModes);
|
||||
const courseTitle = useSelector(getCourseTitle);
|
||||
|
||||
const isLoading = useMemo(() => loadingStatus === RequestStatus.IN_PROGRESS, [loadingStatus]);
|
||||
|
||||
const pageHeadTitle = getPageHeadTitle(courseTitle, intl.formatMessage(messages.headingTitleTabText));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCertificateModes) {
|
||||
dispatch(setMode(MODE_STATES.noModes));
|
||||
} else if (certificates.length === 0) {
|
||||
dispatch(setMode(MODE_STATES.noCertificates));
|
||||
} else {
|
||||
dispatch(setMode(MODE_STATES.view));
|
||||
}
|
||||
}, [hasCertificateModes, certificates]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCertificates(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
return {
|
||||
componentMode, isLoading, loadingStatus, certificates, pageHeadTitle, hasCertificateModes,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCertificates;
|
||||
2
src/certificates/index.js
Normal file
2
src/certificates/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as Certificates } from './Certificates';
|
||||
77
src/certificates/layout/MainLayout.jsx
Normal file
77
src/certificates/layout/MainLayout.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Container, Layout } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ProcessingNotification from '../../generic/processing-notification';
|
||||
import InternetConnectionAlert from '../../generic/internet-connection-alert';
|
||||
import SubHeader from '../../generic/sub-header/SubHeader';
|
||||
import messages from '../messages';
|
||||
import CertificatesSidebar from './certificates-sidebar/CertificatesSidebar';
|
||||
import HeaderButtons from './header-buttons/HeaderButtons';
|
||||
import useLayout from './hooks/useLayout';
|
||||
|
||||
const MainLayout = ({ courseId, showHeaderButtons, children }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isQueryPending,
|
||||
isQueryFailed,
|
||||
isShowProcessingNotification,
|
||||
processingNotificationTitle,
|
||||
} = useLayout();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="certificates px-4">
|
||||
<div className="mt-5" />
|
||||
<SubHeader
|
||||
hideBorder
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={showHeaderButtons && <HeaderButtons />}
|
||||
/>
|
||||
<section>
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
sm={[{ span: 9 }, { span: 3 }]}
|
||||
xs={[{ span: 9 }, { span: 3 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<article role="main">
|
||||
{children}
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<CertificatesSidebar courseId={courseId} />
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
</Container>
|
||||
<div className="certificates alert-toast">
|
||||
<ProcessingNotification
|
||||
isShow={isShowProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
/>
|
||||
<InternetConnectionAlert
|
||||
isFailed={isQueryFailed}
|
||||
isQueryPending={isQueryPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
MainLayout.defaultProps = {
|
||||
showHeaderButtons: false,
|
||||
children: null,
|
||||
};
|
||||
|
||||
MainLayout.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
showHeaderButtons: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,45 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Hyperlink } from '@openedx/paragon';
|
||||
|
||||
import { HelpSidebar } from '../../../generic/help-sidebar';
|
||||
import { useHelpUrls } from '../../../help-urls/hooks';
|
||||
import { getSidebarData } from './utils';
|
||||
import SidebarBlock from './sidebar-block/SidebarBlock';
|
||||
import messages from './messages';
|
||||
|
||||
const CertificatesSidebar = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const { certificates: learnMoreCertificates } = useHelpUrls(['certificates']);
|
||||
return (
|
||||
<HelpSidebar
|
||||
courseId={courseId}
|
||||
showOtherSettings
|
||||
>
|
||||
{getSidebarData({ messages, intl }).map(({ title, paragraphs }, id) => (
|
||||
<SidebarBlock
|
||||
key={title}
|
||||
title={title}
|
||||
paragraphs={paragraphs}
|
||||
isLast={id === getSidebarData({ messages, intl }).length - 1}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
as={Hyperlink}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
size="sm"
|
||||
href={learnMoreCertificates}
|
||||
variant="outline-primary"
|
||||
>
|
||||
{intl.formatMessage(messages.learnMoreBtn)}
|
||||
</Button>
|
||||
</HelpSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
CertificatesSidebar.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CertificatesSidebar;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../../store';
|
||||
import CertificatesSidebar from './CertificatesSidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<CertificatesSidebar courseId={courseId} {...props} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('CertificatesSidebar', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
const { getByText } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.workingWithCertificatesTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.workingWithCertificatesFirstParagraph.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.workingWithCertificatesSecondParagraph.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.workingWithCertificatesThirdParagraph.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(getByText(messages.issuingCertificatesTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.issuingCertificatesFirstParagraph.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.issuingCertificatesSecondParagraph.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(getByText(messages.learnMoreBtn.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
66
src/certificates/layout/certificates-sidebar/messages.js
Normal file
66
src/certificates/layout/certificates-sidebar/messages.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
workingWithCertificatesTitle: {
|
||||
id: 'course-authoring.certificates.sidebar.working-with-certificates.title',
|
||||
defaultMessage: 'Working with certificates',
|
||||
description: 'Title for the section on how to work with certificates',
|
||||
},
|
||||
workingWithCertificatesFirstParagraph: {
|
||||
id: 'course-authoring.certificates.sidebar.working-with-certificates.first-paragraph',
|
||||
defaultMessage: 'Specify a course title to use on the certificate if the course\'s official title is too long to be displayed well.',
|
||||
description: 'Instructions for specifying a course title for the certificate',
|
||||
},
|
||||
workingWithCertificatesSecondParagraph: {
|
||||
id: 'course-authoring.certificates.sidebar.working-with-certificates.second-paragraph',
|
||||
defaultMessage: 'For verified certificates, specify between one and four signatories and upload the associated images. To edit or delete a certificate before it is activated, hover over the top right corner of the form and select {strongText} or the delete icon.',
|
||||
description: 'Details on how to specify signatories for verified certificates and edit or delete certificates',
|
||||
},
|
||||
workingWithCertificatesSecondParagraph_strong: {
|
||||
id: 'course-authoring.certificates.sidebar.working-with-certificates.second-paragraph.strong',
|
||||
defaultMessage: 'Edit',
|
||||
description: 'The strong emphasis text for the edit option',
|
||||
},
|
||||
workingWithCertificatesThirdParagraph: {
|
||||
id: 'course-authoring.certificates.sidebar.working-with-certificates.third-paragraph',
|
||||
defaultMessage: 'To view a sample certificate, choose a course mode and select {strongText}.',
|
||||
description: 'Instructions on how to view a sample certificate',
|
||||
},
|
||||
workingWithCertificatesThirdParagraph_strong: {
|
||||
id: 'course-authoring.certificates.sidebar.working-with-certificates.third-paragraph.strong',
|
||||
defaultMessage: 'Preview certificate',
|
||||
description: 'The strong emphasis text for the button to preview a sample certificate',
|
||||
},
|
||||
issuingCertificatesTitle: {
|
||||
id: 'course-authoring.certificates.sidebar.issuing-certificates.title',
|
||||
defaultMessage: 'Issuing certificates to learners',
|
||||
description: 'Title for the section on issuing certificates to learners',
|
||||
},
|
||||
issuingCertificatesFirstParagraph: {
|
||||
id: 'course-authoring.certificates.sidebar.issuing-certificates.first-paragraph',
|
||||
defaultMessage: 'To begin issuing course certificates, a course team member with either the Staff or Admin role selects {strongText}. Only course team members with these roles can edit or delete an activated certificate.',
|
||||
description: 'Instructions for issuing course certificates and the roles required to edit or delete certificates',
|
||||
},
|
||||
issuingCertificatesFirstParagraph_strong: {
|
||||
id: 'course-authoring.certificates.sidebar.issuing-certificates.first-paragraph.strong',
|
||||
defaultMessage: 'Activate',
|
||||
description: 'The strong emphasis text for the activation option',
|
||||
},
|
||||
issuingCertificatesSecondParagraph: {
|
||||
id: 'course-authoring.certificates.sidebar.issuing-certificates.second-paragraph',
|
||||
defaultMessage: '{strongText} delete certificates after a course has started; learners who have already earned certificates will no longer be able to access them.',
|
||||
description: 'A warning against deleting certificates once a course has started, noting the impact on learners',
|
||||
},
|
||||
issuingCertificatesSecondParagraph_strong: {
|
||||
id: 'course-authoring.certificates.sidebar.issuing-certificates.second-paragraph.strong',
|
||||
defaultMessage: 'Do not',
|
||||
description: 'The strong emphasis text part of the warning against deleting certificates',
|
||||
},
|
||||
learnMoreBtn: {
|
||||
id: 'course-authoring.certificates.sidebar.learnmore.button',
|
||||
defaultMessage: 'Learn more about certificates',
|
||||
description: 'Text for a button that links to additional information about setting up certificates in studio',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const SidebarBlock = ({ title, paragraphs, isLast }) => (
|
||||
<React.Fragment key={title}>
|
||||
<h4 className="help-sidebar-about-title">
|
||||
{title}
|
||||
</h4>
|
||||
{paragraphs.map((text) => (
|
||||
<p key={text} className="help-sidebar-about-descriptions">
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
{!isLast && <hr />}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
SidebarBlock.defaultProps = {
|
||||
isLast: false,
|
||||
};
|
||||
|
||||
SidebarBlock.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
paragraphs: PropTypes.arrayOf(PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.node,
|
||||
])).isRequired,
|
||||
isLast: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default SidebarBlock;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import SidebarBlock from './SidebarBlock';
|
||||
|
||||
const testProps = {
|
||||
title: 'Test Title',
|
||||
paragraphs: ['Test Paragraph'],
|
||||
};
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<SidebarBlock {...props} />,
|
||||
);
|
||||
|
||||
describe('SidebarBlock', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { getByText } = renderComponent(testProps);
|
||||
|
||||
expect(getByText(testProps.title)).toBeInTheDocument();
|
||||
expect(getByText(testProps.paragraphs[0])).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders <hr> if isLast is false', () => {
|
||||
const { getByRole } = renderComponent(testProps);
|
||||
|
||||
expect(getByRole('separator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render <hr> if isLast is true', () => {
|
||||
const { queryByRole } = renderComponent({ ...testProps, isLast: true });
|
||||
|
||||
expect(queryByRole('separator')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
30
src/certificates/layout/certificates-sidebar/utils.jsx
Normal file
30
src/certificates/layout/certificates-sidebar/utils.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const getSidebarData = ({ messages, intl }) => [
|
||||
{
|
||||
title: intl.formatMessage(messages.workingWithCertificatesTitle),
|
||||
paragraphs: [
|
||||
intl.formatMessage(messages.workingWithCertificatesFirstParagraph),
|
||||
intl.formatMessage(
|
||||
messages.workingWithCertificatesSecondParagraph,
|
||||
{ strongText: <strong>{intl.formatMessage(messages.workingWithCertificatesSecondParagraph_strong)}</strong> },
|
||||
),
|
||||
intl.formatMessage(
|
||||
messages.workingWithCertificatesThirdParagraph,
|
||||
{ strongText: <strong>{intl.formatMessage(messages.workingWithCertificatesThirdParagraph_strong)}</strong> },
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage(messages.issuingCertificatesTitle),
|
||||
paragraphs: [
|
||||
intl.formatMessage(
|
||||
messages.issuingCertificatesFirstParagraph,
|
||||
{ strongText: <strong>{intl.formatMessage(messages.issuingCertificatesFirstParagraph_strong)}</strong> },
|
||||
),
|
||||
intl.formatMessage(
|
||||
messages.issuingCertificatesSecondParagraph,
|
||||
{ strongText: <strong>{intl.formatMessage(messages.issuingCertificatesSecondParagraph_strong)}</strong> },
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
46
src/certificates/layout/header-buttons/HeaderButtons.jsx
Normal file
46
src/certificates/layout/header-buttons/HeaderButtons.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Dropdown, DropdownButton, Hyperlink,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import messages from '../../messages';
|
||||
import useHeaderButtons from './hooks/useHeaderButtons';
|
||||
|
||||
const HeaderButtons = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
previewUrl,
|
||||
courseModes,
|
||||
dropdowmItem,
|
||||
isCertificateActive,
|
||||
setDropdowmItem,
|
||||
handleActivationStatus,
|
||||
} = useHeaderButtons();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownButton id="dropdown-basic-button" title={dropdowmItem} onSelect={(item) => setDropdowmItem(item)}>
|
||||
{courseModes.map((mode) => <Dropdown.Item key={mode} eventKey={mode}>{mode}</Dropdown.Item>)}
|
||||
</DropdownButton>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
as={Hyperlink}
|
||||
destination={previewUrl}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.headingActionsPreview)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={handleActivationStatus}
|
||||
>
|
||||
{isCertificateActive
|
||||
? intl.formatMessage(messages.headingActionsDeactivate)
|
||||
: intl.formatMessage(messages.headingActionsActivate)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderButtons;
|
||||
130
src/certificates/layout/header-buttons/HeaderButtons.test.jsx
Normal file
130
src/certificates/layout/header-buttons/HeaderButtons.test.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { executeThunk } from '../../../utils';
|
||||
import initializeStore from '../../../store';
|
||||
import { MODE_STATES } from '../../data/constants';
|
||||
import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../../data/api';
|
||||
import { fetchCertificates, updateCertificateActiveStatus } from '../../data/thunks';
|
||||
import { certificatesDataMock } from '../../__mocks__';
|
||||
import messages from '../../messages';
|
||||
import HeaderButtons from './HeaderButtons';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const courseId = 'course-123';
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<Provider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<HeaderButtons {...props} />
|
||||
</IntlProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
certificates: {
|
||||
certificatesData: {},
|
||||
componentMode: MODE_STATES.editAll,
|
||||
},
|
||||
};
|
||||
|
||||
describe('HeaderButtons Component', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCertificatesApiUrl(courseId))
|
||||
.reply(200, certificatesDataMock);
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
it('updates preview URL param based on selected dropdown item', async () => {
|
||||
const { getByRole } = renderComponent();
|
||||
const previewLink = getByRole('link', { name: messages.headingActionsPreview.defaultMessage });
|
||||
|
||||
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[0]));
|
||||
|
||||
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
|
||||
await userEvent.click(dropdownButton);
|
||||
|
||||
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
||||
await userEvent.click(verifiedMode);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[1]));
|
||||
});
|
||||
});
|
||||
|
||||
it('activates certificate when button is clicked', async () => {
|
||||
const newCertificateData = {
|
||||
...certificatesDataMock,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const { getByRole, queryByRole } = renderComponent();
|
||||
|
||||
const activationButton = getByRole('button', { name: messages.headingActionsActivate.defaultMessage });
|
||||
await userEvent.click(activationButton);
|
||||
|
||||
axiosMock.onPost(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
).reply(200);
|
||||
await executeThunk(updateCertificateActiveStatus(courseId), store.dispatch);
|
||||
axiosMock
|
||||
.onGet(getCertificatesApiUrl(courseId))
|
||||
.reply(200, newCertificateData);
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().certificates.certificatesData.isActive).toBe(true);
|
||||
expect(getByRole('button', { name: messages.headingActionsDeactivate.defaultMessage })).toBeInTheDocument();
|
||||
expect(queryByRole('button', { name: messages.headingActionsActivate.defaultMessage })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('deactivates certificate when button is clicked', async () => {
|
||||
axiosMock
|
||||
.onGet(getCertificatesApiUrl(courseId))
|
||||
.reply(200, { ...certificatesDataMock, isActive: true });
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
|
||||
const newCertificateData = {
|
||||
...certificatesDataMock,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
const { getByRole, queryByRole } = renderComponent();
|
||||
|
||||
const deactivateButton = getByRole('button', { name: messages.headingActionsDeactivate.defaultMessage });
|
||||
await userEvent.click(deactivateButton);
|
||||
|
||||
axiosMock.onPost(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
).reply(200);
|
||||
await executeThunk(updateCertificateActiveStatus(courseId), store.dispatch);
|
||||
axiosMock
|
||||
.onGet(getCertificatesApiUrl(courseId))
|
||||
.reply(200, newCertificateData);
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().certificates.certificatesData.isActive).toBe(false);
|
||||
expect(getByRole('button', { name: messages.headingActionsActivate.defaultMessage })).toBeInTheDocument();
|
||||
expect(queryByRole('button', { name: messages.headingActionsDeactivate.defaultMessage })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
getCourseModes, getCertificateActivationUrl, getCertificateWebViewUrl, getIsCertificateActive,
|
||||
} from '../../../data/selectors';
|
||||
import { updateCertificateActiveStatus } from '../../../data/thunks';
|
||||
|
||||
const useHeaderButtons = () => {
|
||||
const { courseId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const courseModes = useSelector(getCourseModes);
|
||||
const certificateWebViewUrl = useSelector(getCertificateWebViewUrl);
|
||||
const certificateActivationHandlerUrl = useSelector(getCertificateActivationUrl);
|
||||
const isCertificateActive = useSelector(getIsCertificateActive);
|
||||
|
||||
const [dropdowmItem, setDropdowmItem] = useState(courseModes[0]);
|
||||
|
||||
const handleActivationStatus = () => {
|
||||
const status = !isCertificateActive;
|
||||
|
||||
dispatch(updateCertificateActiveStatus(courseId, certificateActivationHandlerUrl, status));
|
||||
};
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (!certificateWebViewUrl) { return ''; }
|
||||
|
||||
const getUrl = () => new URL(certificateWebViewUrl, window.location.origin);
|
||||
const url = getUrl();
|
||||
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
searchParams.set('preview', dropdowmItem);
|
||||
url.search = searchParams.toString();
|
||||
|
||||
return url.toString();
|
||||
}, [certificateWebViewUrl, dropdowmItem]);
|
||||
|
||||
return {
|
||||
previewUrl,
|
||||
courseModes,
|
||||
dropdowmItem,
|
||||
isCertificateActive,
|
||||
setDropdowmItem,
|
||||
handleActivationStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export default useHeaderButtons;
|
||||
33
src/certificates/layout/hooks/useLayout.jsx
Normal file
33
src/certificates/layout/hooks/useLayout.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { getProcessingNotification } from '../../../generic/processing-notification/data/selectors';
|
||||
import { getSavingStatus, getSavingImageStatus } from '../../data/selectors';
|
||||
|
||||
const useLayout = () => {
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const savingImageStatus = useSelector(getSavingImageStatus);
|
||||
const {
|
||||
isShow: isShowProcessingNotification,
|
||||
title: processingNotificationTitle,
|
||||
} = useSelector(getProcessingNotification);
|
||||
|
||||
const isQueryPending = savingStatus === RequestStatus.PENDING || savingImageStatus === RequestStatus.PENDING;
|
||||
const isQueryFailed = savingStatus === RequestStatus.FAILED || savingImageStatus === RequestStatus.FAILED;
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
return {
|
||||
isQueryPending,
|
||||
isQueryFailed,
|
||||
isShowProcessingNotification,
|
||||
processingNotificationTitle,
|
||||
};
|
||||
};
|
||||
|
||||
export default useLayout;
|
||||
62
src/certificates/messages.js
Normal file
62
src/certificates/messages.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
headingTitle: {
|
||||
id: 'course-authoring.certificates.heading.title',
|
||||
defaultMessage: 'Certificates',
|
||||
},
|
||||
headingTitleTabText: {
|
||||
id: 'course-authoring.certificates.heading.title.tab.text',
|
||||
defaultMessage: 'Course certificates',
|
||||
},
|
||||
headingSubtitle: {
|
||||
id: 'course-authoring.certificates.heading.subtitle',
|
||||
defaultMessage: 'Settings',
|
||||
},
|
||||
headingActionsPreview: {
|
||||
id: 'course-authoring.certificates.heading.action.button.preview',
|
||||
defaultMessage: 'Preview certificate',
|
||||
},
|
||||
headingActionsDeactivate: {
|
||||
id: 'course-authoring.certificates.heading.action.button.deactivate',
|
||||
defaultMessage: 'Deactivate',
|
||||
},
|
||||
headingActionsActivate: {
|
||||
id: 'course-authoring.certificates.heading.action.button.activate',
|
||||
defaultMessage: 'Activate',
|
||||
},
|
||||
noCertificatesText: {
|
||||
id: 'course-authoring.certificates.nocertificate.text',
|
||||
defaultMessage: 'You haven\'t added any certificates to this course yet.',
|
||||
},
|
||||
setupCertificateBtn: {
|
||||
id: 'course-authoring.certificates.setup.certificate.button',
|
||||
defaultMessage: 'Add your first certificate',
|
||||
},
|
||||
withoutModesText: {
|
||||
id: 'course-authoring.certificates.without.modes.text',
|
||||
defaultMessage: 'This course does not use a mode that offers certificates.',
|
||||
},
|
||||
cardCreate: {
|
||||
id: 'course-authoring.certificates.create',
|
||||
defaultMessage: 'Create',
|
||||
},
|
||||
cardCancel: {
|
||||
id: 'course-authoring.certificates.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
deleteTooltip: {
|
||||
id: 'course-authoring.certificates.signatories.delete.tooltip',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
editTooltip: {
|
||||
id: 'course-authoring.certificates.signatories.edit.tooltip',
|
||||
defaultMessage: 'Edit',
|
||||
},
|
||||
saveTooltip: {
|
||||
id: 'course-authoring.certificates.signatories.save.tooltip',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
115
src/certificates/scss/Certificates.scss
Normal file
115
src/certificates/scss/Certificates.scss
Normal file
@@ -0,0 +1,115 @@
|
||||
@import "variables";
|
||||
|
||||
.certificates {
|
||||
.section-title {
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.sub-header-actions {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.certificate-details {
|
||||
.certificate-details__info {
|
||||
color: $black;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.certificate-details__info-paragraph {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.certificate-details__info-paragraph-course-number {
|
||||
flex: 1;
|
||||
color: $gray-700;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.signatory {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.signatory__header {
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
|
||||
.signatory__action-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: .75rem;
|
||||
}
|
||||
|
||||
.signatory__fields-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.signatory__text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.signatory__image-container {
|
||||
flex: 2;
|
||||
max-width: 18.75rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.signatory__image {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: map-get($grid-breakpoints, "xl")) {
|
||||
.signatory {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.signatory__header {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.signatory__text {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.signatory__image-container {
|
||||
max-width: none;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signatory__image {
|
||||
display: flex;
|
||||
margin: .625rem auto;
|
||||
max-width: 23.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.certificates-card-form {
|
||||
.pgn__form-control-description,
|
||||
.pgn__form-text {
|
||||
margin-top: .62rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.certificates.alert-toast {
|
||||
z-index: $popover;
|
||||
}
|
||||
1
src/certificates/scss/_variables.scss
Normal file
1
src/certificates/scss/_variables.scss
Normal file
@@ -0,0 +1 @@
|
||||
$popover: 9999;
|
||||
15
src/certificates/utils.js
Normal file
15
src/certificates/utils.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { convertObjectToSnakeCase } from '../utils';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const prepareCertificatePayload = (data) => convertObjectToSnakeCase(({
|
||||
...data,
|
||||
courseTitle: data.courseTitle,
|
||||
description: 'Description of the certificate',
|
||||
editing: data.editing || true,
|
||||
isActive: data.isActive || false,
|
||||
name: 'Name of the certificate',
|
||||
version: data.version || 1,
|
||||
signatories: data.signatories
|
||||
.map(signatory => convertObjectToSnakeCase(signatory, true))
|
||||
.map(signatorySnakeCase => ({ ...signatorySnakeCase, certificate: signatorySnakeCase.certificate || null })),
|
||||
}), true);
|
||||
136
src/generic/modal-dropzone/ModalDropzone.jsx
Normal file
136
src/generic/modal-dropzone/ModalDropzone.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Form,
|
||||
ModalDialog,
|
||||
Dropzone,
|
||||
ActionRow,
|
||||
Image,
|
||||
Card,
|
||||
Icon,
|
||||
Button,
|
||||
IconButton,
|
||||
Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import { FileUpload as FileUploadIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import useModalDropzone from './useModalDropzone';
|
||||
import messages from './messages';
|
||||
|
||||
const ModalDropzone = ({
|
||||
fileTypes,
|
||||
modalTitle,
|
||||
imageHelpText,
|
||||
previewComponent,
|
||||
imageDropzoneText,
|
||||
isOpen,
|
||||
onClose,
|
||||
onCancel,
|
||||
onChange,
|
||||
onSavingStatus,
|
||||
}) => {
|
||||
const {
|
||||
intl,
|
||||
accept,
|
||||
previewUrl,
|
||||
uploadProgress,
|
||||
disabledUploadBtn,
|
||||
imageValidator,
|
||||
handleUpload,
|
||||
handleCancel,
|
||||
handleSelectFile,
|
||||
} = useModalDropzone({
|
||||
onChange, onCancel, onClose, fileTypes, onSavingStatus,
|
||||
});
|
||||
|
||||
const inputComponent = previewUrl ? (
|
||||
<div>
|
||||
{previewComponent || (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt={intl.formatMessage(messages.uploadImageDropzoneAlt)}
|
||||
fluid
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<IconButton
|
||||
isActive
|
||||
src={FileUploadIcon}
|
||||
iconAs={Icon}
|
||||
variant="secondary"
|
||||
alt={intl.formatMessage(messages.uploadImageDropzoneAlt)}
|
||||
className="mb-3"
|
||||
/>
|
||||
<p>{imageDropzoneText || intl.formatMessage(messages.uploadImageDropzoneText)}</p>
|
||||
<p className="x-small text-center mt-1.5">{imageHelpText}</p>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={modalTitle}
|
||||
size="lg"
|
||||
isOpen={isOpen}
|
||||
onClose={handleCancel}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
className="modal-dropzone"
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{modalTitle}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<Form.Group className="form-group-custom w-100">
|
||||
<Card>
|
||||
<Card.Body className="image-body">
|
||||
{uploadProgress > 0 ? (
|
||||
<Spinner animation="border" variant="primary" className="mr-3" screenReaderText={uploadProgress} />
|
||||
) : (
|
||||
<Dropzone
|
||||
onProcessUpload={handleSelectFile}
|
||||
inputComponent={inputComponent}
|
||||
accept={accept}
|
||||
validator={imageValidator}
|
||||
/>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Form.Group>
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary" onClick={handleCancel}>
|
||||
{intl.formatMessage(messages.cancelModal)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button onClick={handleUpload} disabled={disabledUploadBtn}>
|
||||
{intl.formatMessage(messages.uploadModal)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
ModalDropzone.defaultProps = {
|
||||
imageHelpText: '',
|
||||
previewComponent: null,
|
||||
imageDropzoneText: '',
|
||||
};
|
||||
|
||||
ModalDropzone.propTypes = {
|
||||
imageHelpText: PropTypes.string,
|
||||
previewComponent: PropTypes.element,
|
||||
imageDropzoneText: PropTypes.string,
|
||||
modalTitle: PropTypes.string.isRequired,
|
||||
fileTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSavingStatus: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ModalDropzone;
|
||||
14
src/generic/modal-dropzone/ModalDropzone.scss
Normal file
14
src/generic/modal-dropzone/ModalDropzone.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
.modal-dropzone .image-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-height: 300px;
|
||||
|
||||
.pgn__dropzone {
|
||||
background: $white;
|
||||
height: 100%;
|
||||
min-height: 18.75rem;
|
||||
}
|
||||
}
|
||||
134
src/generic/modal-dropzone/ModalDropzone.test.jsx
Normal file
134
src/generic/modal-dropzone/ModalDropzone.test.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { uploadAssets, getUploadAssetsUrl } from './data/api';
|
||||
import initializeStore from '../../store';
|
||||
import ModalDropzone from './ModalDropzone';
|
||||
import messages from './messages';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const courseId = 'course-123';
|
||||
const file = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const fileData = new FormData();
|
||||
fileData.append('file', file);
|
||||
const baseUrl = process.env.STUDIO_BASE_URL || 'http://localhost:18010';
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
const mockOnCancel = jest.fn();
|
||||
const mockOnChange = jest.fn();
|
||||
const mockOnSavingStatus = jest.fn();
|
||||
|
||||
const RootWrapper = (props) => (
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<ModalDropzone {...props} />
|
||||
</AppProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const props = {
|
||||
isOpen: true,
|
||||
fileTypes: ['png'],
|
||||
onClose: mockOnClose,
|
||||
onCancel: mockOnCancel,
|
||||
onChange: mockOnChange,
|
||||
onSavingStatus: mockOnSavingStatus,
|
||||
};
|
||||
|
||||
describe('<ModalDropzone />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
it('renders successfully when open', () => {
|
||||
const { getByText } = render(<RootWrapper {...props} />);
|
||||
|
||||
expect(getByText(messages.uploadImageDropzoneText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
const { getByText } = render(<RootWrapper {...props} />);
|
||||
userEvent.click(getByText(messages.cancelModal.defaultMessage));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
const { getByText } = render(<RootWrapper {...props} />);
|
||||
userEvent.click(getByText(messages.cancelModal.defaultMessage));
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the upload button initially', () => {
|
||||
const { getByText } = render(<RootWrapper {...props} />);
|
||||
const uploadButton = getByText(messages.uploadModal.defaultMessage);
|
||||
|
||||
expect(uploadButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables the upload button after a file is selected', async () => {
|
||||
const { getByRole } = render(<RootWrapper {...props} />);
|
||||
const dropzoneInput = getByRole('presentation', { hidden: true }).firstChild;
|
||||
|
||||
const uploadButton = getByRole('button', { name: messages.uploadModal.defaultMessage });
|
||||
expect(uploadButton).toBeDisabled();
|
||||
userEvent.upload(dropzoneInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropzoneInput.files[0]).toStrictEqual(file);
|
||||
expect(dropzoneInput.files).toHaveLength(1);
|
||||
expect(uploadButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully upload an asset and return the URL', async () => {
|
||||
const mockUrl = `${baseUrl}/assets/course-123/test.png`;
|
||||
axiosMock.onPost(getUploadAssetsUrl(courseId).href).reply(200, {
|
||||
asset: { url: mockUrl },
|
||||
});
|
||||
|
||||
const { getByRole, getByAltText } = render(<RootWrapper {...props} />);
|
||||
const dropzoneInput = getByRole('presentation', { hidden: true }).firstChild;
|
||||
const uploadButton = getByRole('button', { name: messages.uploadModal.defaultMessage });
|
||||
|
||||
await userEvent.upload(dropzoneInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
userEvent.click(uploadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByAltText(messages.uploadImageDropzoneAlt.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle an upload error', async () => {
|
||||
axiosMock.onPost(getUploadAssetsUrl(courseId).href).networkError();
|
||||
|
||||
await expect(uploadAssets(courseId, fileData, () => {})).rejects.toThrow('Network Error');
|
||||
});
|
||||
});
|
||||
29
src/generic/modal-dropzone/data/api.js
Normal file
29
src/generic/modal-dropzone/data/api.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export const getUploadAssetsUrl = (courseId) => new URL(
|
||||
`/assets/${courseId}/`,
|
||||
getConfig().STUDIO_BASE_URL,
|
||||
);
|
||||
|
||||
/**
|
||||
* Upload assets.
|
||||
* @param {string} courseId
|
||||
* @param {FormData} fileData
|
||||
* @param {function} onUploadProgress
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function uploadAssets(courseId, fileData, onUploadProgress) {
|
||||
const config = {
|
||||
onUploadProgress,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
getUploadAssetsUrl(courseId).href,
|
||||
fileData,
|
||||
config,
|
||||
);
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
26
src/generic/modal-dropzone/messages.js
Normal file
26
src/generic/modal-dropzone/messages.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadImageDropzoneText: {
|
||||
id: 'course-authoring.certificates.modal-dropzone.text',
|
||||
defaultMessage: 'Drag and drop your image here or click to upload',
|
||||
},
|
||||
uploadImageDropzoneAlt: {
|
||||
id: 'course-authoring.certificates.modal-dropzone.dropzone-alt',
|
||||
defaultMessage: 'Uploaded image for course certificate',
|
||||
},
|
||||
uploadImageValidationText: {
|
||||
id: 'course-authoring.certificates.modal-dropzone.validation.text',
|
||||
defaultMessage: 'Only {types} files can be uploaded. Please select a file ending in {extensions} to upload.',
|
||||
},
|
||||
cancelModal: {
|
||||
id: 'course-authoring.certificates.modal-dropzone.cancel.modal',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
uploadModal: {
|
||||
id: 'course-authoring.certificates.modal-dropzone.upload.modal',
|
||||
defaultMessage: 'Upload',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
124
src/generic/modal-dropzone/useModalDropzone.jsx
Normal file
124
src/generic/modal-dropzone/useModalDropzone.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { uploadAssets } from './data/api';
|
||||
import messages from './messages';
|
||||
|
||||
const useModalDropzone = ({
|
||||
onChange, onCancel, onClose, fileTypes, onSavingStatus,
|
||||
}) => {
|
||||
const { courseId } = useParams();
|
||||
const intl = useIntl();
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [disabledUploadBtn, setDisabledUploadBtn] = useState(true);
|
||||
|
||||
const VALID_IMAGE_TYPES = ['png', 'jpeg'];
|
||||
|
||||
const imageValidator = (file) => {
|
||||
const fileType = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
const extensionsList = fileTypes.map(type => `.${type.toLowerCase()}`).join(', ');
|
||||
const typesList = fileTypes.map(type => type.toUpperCase()).join(', ');
|
||||
|
||||
if (!fileTypes.map(type => type.toLowerCase()).includes(fileType)) {
|
||||
return intl.formatMessage(messages.uploadImageValidationText, {
|
||||
types: typesList,
|
||||
extensions: extensionsList,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs an accept object for Dropzone based on provided file types.
|
||||
*
|
||||
* @param {string[]} types - Array of file extensions.
|
||||
* @returns {Object} Accept object for Dropzone.
|
||||
*
|
||||
* Example:
|
||||
* input: ['png', 'jpg', 'pdf', 'docx']
|
||||
* output:
|
||||
* {
|
||||
* "image/*": [".png", ".jpg"],
|
||||
* "* /*": [".pdf", ".docx"]
|
||||
* }
|
||||
*/
|
||||
const constructAcceptObject = (types) => types
|
||||
.reduce((acc, type) => {
|
||||
const mimeType = VALID_IMAGE_TYPES.includes(type) ? 'image/*' : '*/*';
|
||||
if (!acc[mimeType]) {
|
||||
acc[mimeType] = [];
|
||||
}
|
||||
acc[mimeType].push(`.${type}`);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const accept = useMemo(() => constructAcceptObject(fileTypes), [fileTypes]);
|
||||
|
||||
const handleSelectFile = ({ fileData }) => {
|
||||
const file = fileData.get('file');
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewUrl(reader.result);
|
||||
setDisabledUploadBtn(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setSelectedFile(fileData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPreviewUrl(null);
|
||||
setDisabledUploadBtn(true);
|
||||
onSavingStatus({ status: RequestStatus.CLEAR });
|
||||
setUploadProgress(0);
|
||||
onCancel();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) { return; }
|
||||
|
||||
onSavingStatus(RequestStatus.PENDING);
|
||||
|
||||
const onUploadProgress = (progressEvent) => {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
setUploadProgress(percentCompleted);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await uploadAssets(courseId, selectedFile, onUploadProgress);
|
||||
const url = response?.asset?.url;
|
||||
if (url) {
|
||||
onChange(url);
|
||||
onSavingStatus({ status: RequestStatus.SUCCESSFUL });
|
||||
onClose();
|
||||
setDisabledUploadBtn(true);
|
||||
setUploadProgress(0);
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
} catch (error) {
|
||||
onSavingStatus({ status: RequestStatus.FAILED });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
intl,
|
||||
accept,
|
||||
uploadProgress,
|
||||
previewUrl,
|
||||
disabledUploadBtn,
|
||||
handleSelectFile,
|
||||
imageValidator,
|
||||
handleCancel,
|
||||
handleUpload,
|
||||
};
|
||||
};
|
||||
|
||||
export default useModalDropzone;
|
||||
@@ -7,3 +7,4 @@
|
||||
@import "./WysiwygEditor";
|
||||
@import "./course-stepper/CouseStepper";
|
||||
@import "./tag-count/TagCount";
|
||||
@import "./modal-dropzone/ModalDropzone";
|
||||
|
||||
@@ -25,3 +25,4 @@
|
||||
@import "course-checklist/CourseChecklist";
|
||||
@import "content-tags-drawer/ContentTagsDropDownSelector";
|
||||
@import "content-tags-drawer/ContentTagsCollapsible";
|
||||
@import "certificates/scss/Certificates";
|
||||
|
||||
@@ -26,6 +26,7 @@ import { reducer as courseOutlineReducer } from './course-outline/data/slice';
|
||||
import { reducer as courseUnitReducer } from './course-unit/data/slice';
|
||||
import { reducer as courseChecklistReducer } from './course-checklist/data/slice';
|
||||
import { reducer as accessibilityPageReducer } from './accessibility-page/data/slice';
|
||||
import { reducer as certificatesReducer } from './certificates/data/slice';
|
||||
|
||||
export default function initializeStore(preloadedState = undefined) {
|
||||
return configureStore({
|
||||
@@ -53,6 +54,7 @@ export default function initializeStore(preloadedState = undefined) {
|
||||
courseUnit: courseUnitReducer,
|
||||
courseChecklist: courseChecklistReducer,
|
||||
accessibilityPage: accessibilityPageReducer,
|
||||
certificates: certificatesReducer,
|
||||
},
|
||||
preloadedState,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user