Skip to content

Commit

Permalink
feat: [FC-0044] Textbooks Page (#890)
Browse files Browse the repository at this point in the history
Implement Textbooks page.

---------

Co-authored-by: Glib Glugovskiy <[email protected]>
  • Loading branch information
vladislavkeblysh and GlugovGrGlib committed Apr 30, 2024
1 parent a9a73ef commit 65f45f7
Show file tree
Hide file tree
Showing 45 changed files with 2,393 additions and 16 deletions.
5 changes: 5 additions & 0 deletions src/CourseAuthoringRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from 'CourseAuthoring/textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
Expand Down Expand Up @@ -125,6 +126,10 @@ const CourseAuthoringRoutes = () => {
path="certificates"
element={<PageWrap><Certificates courseId={courseId} /></PageWrap>}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);
Expand Down
2 changes: 2 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export const DECODED_ROUTES = {
],
};

export const UPLOAD_FILE_MAX_SIZE = 20 * 1024 * 1024; // 100mb

export const COURSE_BLOCK_NAMES = ({
chapter: { id: 'chapter', name: 'Section' },
sequential: { id: 'sequential', name: 'Subsection' },
Expand Down
2 changes: 1 addition & 1 deletion src/generic/FormikControl.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const FormikControl = ({
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
isInvalid={fieldTouched && fieldError}
isInvalid={!!fieldTouched && !!fieldError}
/>
<FormikErrorFeedback name={name}>
<Form.Text>{help}</Form.Text>
Expand Down
24 changes: 20 additions & 4 deletions src/generic/delete-modal/DeleteModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';

const DeleteModal = ({
category, isOpen, close, onDeleteSubmit,
category,
isOpen,
close,
onDeleteSubmit,
title,
description,
}) => {
const intl = useIntl();

const modalTitle = title || intl.formatMessage(messages.title, { category });
const modalDescription = description || intl.formatMessage(messages.description, { category });

return (
<AlertModal
title={intl.formatMessage(messages.title, { category })}
title={modalTitle}
isOpen={isOpen}
onClose={close}
footerNode={(
Expand All @@ -35,16 +43,24 @@ const DeleteModal = ({
</ActionRow>
)}
>
<p>{intl.formatMessage(messages.description, { category })}</p>
<p>{modalDescription}</p>
</AlertModal>
);
};

DeleteModal.defaultProps = {
category: '',
title: '',
description: '',
};

DeleteModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
category: PropTypes.string.isRequired,
category: PropTypes.string,
onDeleteSubmit: PropTypes.func.isRequired,
title: PropTypes.string,
description: PropTypes.string,
};

export default DeleteModal;
22 changes: 19 additions & 3 deletions src/generic/delete-modal/DeleteModal.test.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render } from '@testing-library/react';
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 { AppProvider } from '@edx/frontend-platform/react';
import userEvent from '@testing-library/user-event';

import initializeStore from '../../store';
import DeleteModal from './DeleteModal';
Expand Down Expand Up @@ -71,15 +72,30 @@ describe('<DeleteModal />', () => {
const { getByRole } = renderComponent();

const okButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
fireEvent.click(okButton);
userEvent.click(okButton);
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
});

it('calls the close function when the "Cancel" button is clicked', () => {
const { getByRole } = renderComponent();

const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
fireEvent.click(cancelButton);
userEvent.click(cancelButton);
expect(closeMock).toHaveBeenCalledTimes(1);
});

it('render DeleteModal component with custom title and description correctly', () => {
const baseProps = {
title: 'Title',
description: 'Description',
};

const { getByText, queryByText, getByRole } = renderComponent(baseProps);
expect(queryByText(messages.title.defaultMessage)).not.toBeInTheDocument();
expect(queryByText(messages.description.defaultMessage)).not.toBeInTheDocument();
expect(getByText(baseProps.title)).toBeInTheDocument();
expect(getByText(baseProps.description)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
});
});
19 changes: 18 additions & 1 deletion src/generic/modal-dropzone/ModalDropzone.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,22 @@ import { FileUpload as FileUploadIcon } from '@openedx/paragon/icons';

import useModalDropzone from './useModalDropzone';
import messages from './messages';
import { UPLOAD_FILE_MAX_SIZE } from '../../constants';

const ModalDropzone = ({
fileTypes,
modalTitle,
imageHelpText,
previewComponent,
imageDropzoneText,
invalidFileSizeMore,
isOpen,
onClose,
onCancel,
onChange,
onSavingStatus,
onSelectFile,
maxSize = UPLOAD_FILE_MAX_SIZE,
}) => {
const {
intl,
Expand All @@ -39,9 +43,14 @@ const ModalDropzone = ({
handleCancel,
handleSelectFile,
} = useModalDropzone({
onChange, onCancel, onClose, fileTypes, onSavingStatus,
onChange, onCancel, onClose, fileTypes, onSavingStatus, onSelectFile,
});

const invalidSizeMore = invalidFileSizeMore || intl.formatMessage(
messages.uploadImageDropzoneInvalidSizeMore,
{ maxSize: maxSize / (1000 * 1000) },
);

const inputComponent = previewUrl ? (
<div>
{previewComponent || (
Expand Down Expand Up @@ -93,7 +102,9 @@ const ModalDropzone = ({
onProcessUpload={handleSelectFile}
inputComponent={inputComponent}
accept={accept}
errorMessages={{ invalidSizeMore }}
validator={imageValidator}
maxSize={maxSize}
/>
)}
</Card.Body>
Expand All @@ -118,6 +129,9 @@ ModalDropzone.defaultProps = {
imageHelpText: '',
previewComponent: null,
imageDropzoneText: '',
maxSize: UPLOAD_FILE_MAX_SIZE,
invalidFileSizeMore: '',
onSelectFile: null,
};

ModalDropzone.propTypes = {
Expand All @@ -131,6 +145,9 @@ ModalDropzone.propTypes = {
onCancel: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onSavingStatus: PropTypes.func.isRequired,
maxSize: PropTypes.number,
invalidFileSizeMore: PropTypes.string,
onSelectFile: PropTypes.func,
};

export default ModalDropzone;
27 changes: 26 additions & 1 deletion src/generic/modal-dropzone/ModalDropzone.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,13 @@ describe('<ModalDropzone />', () => {
});

it('should successfully upload an asset and return the URL', async () => {
const mockUrl = `${baseUrl}/assets/course-123/test.png`;
const mockUrl = `${baseUrl}/assets/course-123/test-file.png`;
axiosMock.onPost(getUploadAssetsUrl(courseId).href).reply(200, {
asset: { url: mockUrl },
});
const response = await uploadAssets(courseId, fileData, () => {});

expect(response.asset.url).toBe(mockUrl);

const { getByRole, getByAltText } = render(<RootWrapper {...props} />);
const dropzoneInput = getByRole('presentation', { hidden: true }).firstChild;
Expand All @@ -131,4 +134,26 @@ describe('<ModalDropzone />', () => {

await expect(uploadAssets(courseId, fileData, () => {})).rejects.toThrow('Network Error');
});

it('displays a custom error message when the file size exceeds the limit', async () => {
const maxSizeInBytes = 20 * 1000 * 1000;
const expectedErrorMessage = 'Custom error message';

const { getByText, getByRole } = render(
<RootWrapper {...props} maxSize={maxSizeInBytes} invalidFileSizeMore={expectedErrorMessage} />,
);
const dropzoneInput = getByRole('presentation', { hidden: true });

const fileToUpload = new File(
[new ArrayBuffer(maxSizeInBytes + 1)],
'test-file.png',
{ type: 'image/png' },
);

userEvent.upload(dropzoneInput.firstChild, fileToUpload);

await waitFor(() => {
expect(getByText(expectedErrorMessage)).toBeInTheDocument();
});
});
});
10 changes: 10 additions & 0 deletions src/generic/modal-dropzone/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,32 @@ const messages = defineMessages({
uploadImageDropzoneText: {
id: 'course-authoring.certificates.modal-dropzone.text',
defaultMessage: 'Drag and drop your image here or click to upload',
description: 'Description to drag and drop block',
},
uploadImageDropzoneAlt: {
id: 'course-authoring.certificates.modal-dropzone.dropzone-alt',
defaultMessage: 'Uploaded image for course certificate',
description: 'Description for the uploaded image',
},
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.',
description: 'Error message for when an invalid file type is selected',
},
cancelModal: {
id: 'course-authoring.certificates.modal-dropzone.cancel.modal',
defaultMessage: 'Cancel',
description: 'Text for the cancel button in the modal',
},
uploadModal: {
id: 'course-authoring.certificates.modal-dropzone.upload.modal',
defaultMessage: 'Upload',
description: 'Text for the upload button in the modal',
},
uploadImageDropzoneInvalidSizeMore: {
id: 'course-authoring.certificates.modal-dropzone.validation.invalid-size-more',
defaultMessage: 'Image size must be less than {maxSize}MB.',
description: 'Error message for when the uploaded image size exceeds the limit',
},
});

Expand Down
19 changes: 13 additions & 6 deletions src/generic/modal-dropzone/useModalDropzone.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { uploadAssets } from './data/api';
import messages from './messages';

const useModalDropzone = ({
onChange, onCancel, onClose, fileTypes, onSavingStatus,
onChange, onCancel, onClose, fileTypes, onSavingStatus, onSelectFile,
}) => {
const { courseId } = useParams();
const intl = useIntl();
Expand Down Expand Up @@ -49,7 +49,8 @@ const useModalDropzone = ({
*/
const constructAcceptObject = (types) => types
.reduce((acc, type) => {
const mimeType = VALID_IMAGE_TYPES.includes(type) ? 'image/*' : '*/*';
// eslint-disable-next-line no-nested-ternary
const mimeType = type === 'pdf' ? 'application/pdf' : VALID_IMAGE_TYPES.includes(type) ? 'image/*' : '*/*';
if (!acc[mimeType]) {
acc[mimeType] = [];
}
Expand All @@ -70,6 +71,10 @@ const useModalDropzone = ({
};
reader.readAsDataURL(file);
setSelectedFile(fileData);

if (onSelectFile) {
onSelectFile(file.path);
}
}
};

Expand All @@ -94,17 +99,19 @@ const useModalDropzone = ({

try {
const response = await uploadAssets(courseId, selectedFile, onUploadProgress);
const url = response?.asset?.url;
const { url } = response.asset;

if (url) {
onChange(url);
onSavingStatus({ status: RequestStatus.SUCCESSFUL });
onClose();
setDisabledUploadBtn(true);
setUploadProgress(0);
setPreviewUrl(null);
}
} catch (error) {
onSavingStatus({ status: RequestStatus.FAILED });
} finally {
setDisabledUploadBtn(true);
setUploadProgress(0);
setPreviewUrl(null);
}
};

Expand Down
24 changes: 24 additions & 0 deletions src/generic/promptIfDirty/PromptIfDirty.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import PropTypes from 'prop-types';

const PromptIfDirty = ({ dirty }) => {
useEffect(() => {
// eslint-disable-next-line consistent-return
const handleBeforeUnload = (event) => {
if (dirty) {
event.preventDefault();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);

return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [dirty]);

return null;
};
PromptIfDirty.propTypes = {
dirty: PropTypes.bool.isRequired,
};
export default PromptIfDirty;
Loading

0 comments on commit 65f45f7

Please sign in to comment.