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:
Kyr
2024-04-04 20:28:04 +03:00
committed by GitHub
parent b61cb5c7cd
commit e306b62dd1
68 changed files with 4255 additions and 0 deletions

View File

@@ -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>
);

View 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;

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

View 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',
},
],
},
];

View 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',
};

View File

@@ -0,0 +1,3 @@
export { default as certificatesDataMock } from './certificatesData';
export { default as signatoriesMock } from './signatories';
export { default as certificatesMock } from './certificates';

View 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',
},
];

View File

@@ -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;

View File

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

View File

@@ -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;

View 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;

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

View File

@@ -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;

View File

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

View File

@@ -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;

View 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;

View 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;

View File

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

View File

@@ -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;

View 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;

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

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

View File

@@ -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;

View File

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

View File

@@ -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;

View File

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

View 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;

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

View File

@@ -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;

View 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: '',
}],
};

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

View 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',
};

View 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,
);

View 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;

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

View File

@@ -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;

View File

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

View 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;

View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as Certificates } from './Certificates';

View 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;

View File

@@ -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;

View File

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

View 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;

View File

@@ -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;

View File

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

View 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> },
),
],
},
];

View 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;

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

View File

@@ -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;

View 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;

View 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;

View 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;
}

View File

@@ -0,0 +1 @@
$popover: 9999;

15
src/certificates/utils.js Normal file
View 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);

View 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;

View 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;
}
}

View 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');
});
});

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

View 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;

View 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;

View File

@@ -7,3 +7,4 @@
@import "./WysiwygEditor";
@import "./course-stepper/CouseStepper";
@import "./tag-count/TagCount";
@import "./modal-dropzone/ModalDropzone";

View File

@@ -25,3 +25,4 @@
@import "course-checklist/CourseChecklist";
@import "content-tags-drawer/ContentTagsDropDownSelector";
@import "content-tags-drawer/ContentTagsCollapsible";
@import "certificates/scss/Certificates";

View File

@@ -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,
});