feat: [FC-0044] Textbooks Page (#890)
Implement Textbooks page. --------- Co-authored-by: Glib Glugovskiy <glib.glugovskiy@raccoongang.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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={(
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -71,7 +72,7 @@ describe('<DeleteModal />', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const okButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
|
||||
fireEvent.click(okButton);
|
||||
userEvent.click(okButton);
|
||||
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -79,7 +80,22 @@ describe('<DeleteModal />', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ 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,
|
||||
@@ -22,11 +23,14 @@ const ModalDropzone = ({
|
||||
imageHelpText,
|
||||
previewComponent,
|
||||
imageDropzoneText,
|
||||
invalidFileSizeMore,
|
||||
isOpen,
|
||||
onClose,
|
||||
onCancel,
|
||||
onChange,
|
||||
onSavingStatus,
|
||||
onSelectFile,
|
||||
maxSize = UPLOAD_FILE_MAX_SIZE,
|
||||
}) => {
|
||||
const {
|
||||
intl,
|
||||
@@ -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 || (
|
||||
@@ -93,7 +102,9 @@ const ModalDropzone = ({
|
||||
onProcessUpload={handleSelectFile}
|
||||
inputComponent={inputComponent}
|
||||
accept={accept}
|
||||
errorMessages={{ invalidSizeMore }}
|
||||
validator={imageValidator}
|
||||
maxSize={maxSize}
|
||||
/>
|
||||
)}
|
||||
</Card.Body>
|
||||
@@ -118,6 +129,9 @@ ModalDropzone.defaultProps = {
|
||||
imageHelpText: '',
|
||||
previewComponent: null,
|
||||
imageDropzoneText: '',
|
||||
maxSize: UPLOAD_FILE_MAX_SIZE,
|
||||
invalidFileSizeMore: '',
|
||||
onSelectFile: null,
|
||||
};
|
||||
|
||||
ModalDropzone.propTypes = {
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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] = [];
|
||||
}
|
||||
@@ -70,6 +71,10 @@ const useModalDropzone = ({
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setSelectedFile(fileData);
|
||||
|
||||
if (onSelectFile) {
|
||||
onSelectFile(file.path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
24
src/generic/promptIfDirty/PromptIfDirty.jsx
Normal file
24
src/generic/promptIfDirty/PromptIfDirty.jsx
Normal file
@@ -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;
|
||||
72
src/generic/promptIfDirty/PromtIfDirty.test.jsx
Normal file
72
src/generic/promptIfDirty/PromtIfDirty.test.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import PromptIfDirty from './PromptIfDirty';
|
||||
|
||||
describe('PromptIfDirty', () => {
|
||||
let container = null;
|
||||
let mockEvent = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
mockEvent = new Event('beforeunload');
|
||||
jest.spyOn(window, 'addEventListener');
|
||||
jest.spyOn(window, 'removeEventListener');
|
||||
jest.spyOn(mockEvent, 'preventDefault');
|
||||
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
|
||||
mockEvent.returnValue = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.addEventListener.mockRestore();
|
||||
window.removeEventListener.mockRestore();
|
||||
mockEvent.preventDefault.mockRestore();
|
||||
mockEvent = null;
|
||||
unmountComponentAtNode(container);
|
||||
container.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it('should add event listener on mount', () => {
|
||||
act(() => {
|
||||
render(<PromptIfDirty dirty />, container);
|
||||
});
|
||||
|
||||
expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should remove event listener on unmount', () => {
|
||||
act(() => {
|
||||
render(<PromptIfDirty dirty />, container);
|
||||
});
|
||||
act(() => {
|
||||
unmountComponentAtNode(container);
|
||||
});
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should call preventDefault and set returnValue when dirty is true', () => {
|
||||
act(() => {
|
||||
render(<PromptIfDirty dirty />, container);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(mockEvent);
|
||||
});
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.returnValue).toBe('');
|
||||
});
|
||||
|
||||
it('should not call preventDefault when dirty is false', () => {
|
||||
act(() => {
|
||||
render(<PromptIfDirty dirty={false} />, container);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(mockEvent);
|
||||
});
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user