From 65f45f72f08c1f38ced5d8d302fbdd2406da228d Mon Sep 17 00:00:00 2001 From: vladislavkeblysh <138868841+vladislavkeblysh@users.noreply.github.com> Date: Wed, 1 May 2024 00:38:43 +0300 Subject: [PATCH] feat: [FC-0044] Textbooks Page (#890) Implement Textbooks page. --------- Co-authored-by: Glib Glugovskiy --- src/CourseAuthoringRoutes.jsx | 5 + src/constants.js | 2 + src/generic/FormikControl.jsx | 2 +- src/generic/delete-modal/DeleteModal.jsx | 24 ++- src/generic/delete-modal/DeleteModal.test.jsx | 22 +- src/generic/modal-dropzone/ModalDropzone.jsx | 19 +- .../modal-dropzone/ModalDropzone.test.jsx | 27 ++- src/generic/modal-dropzone/messages.js | 10 + .../modal-dropzone/useModalDropzone.jsx | 19 +- src/generic/promptIfDirty/PromptIfDirty.jsx | 24 +++ .../promptIfDirty/PromtIfDirty.test.jsx | 72 +++++++ src/index.scss | 1 + src/store.js | 2 + src/textbooks/Textbook.test.jsx | 87 ++++++++ src/textbooks/Textbooks.jsx | 147 +++++++++++++ src/textbooks/Textbooks.scss | 7 + src/textbooks/__mocks__/index.js | 2 + src/textbooks/__mocks__/textbooksMock.js | 28 +++ src/textbooks/data/api.js | 62 ++++++ src/textbooks/data/api.test.js | 82 +++++++ src/textbooks/data/selectors.js | 4 + src/textbooks/data/slice.js | 51 +++++ src/textbooks/data/slice.test.jsx | 118 ++++++++++ src/textbooks/data/thunk.js | 85 ++++++++ src/textbooks/data/thunk.test.js | 139 ++++++++++++ .../empty-placeholder/EmptyPlaceholder.jsx | 25 +++ .../empty-placeholder/EmptyPlaceholder.scss | 10 + .../EmptyPlaceholder.test.jsx | 31 +++ src/textbooks/empty-placeholder/messages.js | 14 ++ src/textbooks/hooks.jsx | 93 ++++++++ src/textbooks/index.js | 2 + src/textbooks/messages.js | 29 +++ src/textbooks/textbook-card/TextbookCard.scss | 42 ++++ src/textbooks/textbook-card/TextbooksCard.jsx | 146 +++++++++++++ .../textbook-card/TextbooksCard.test.jsx | 192 +++++++++++++++++ src/textbooks/textbook-card/messages.js | 49 +++++ src/textbooks/textbook-form/TextbookForm.jsx | 201 ++++++++++++++++++ src/textbooks/textbook-form/TextbookForm.scss | 67 ++++++ .../textbook-form/TextbookForm.test.jsx | 177 +++++++++++++++ src/textbooks/textbook-form/messages.js | 124 +++++++++++ src/textbooks/textbook-form/validations.js | 15 ++ .../textbook-sidebar/TextbookSidebar.jsx | 45 ++++ .../textbook-sidebar/TextbookSidebar.test.jsx | 55 +++++ src/textbooks/textbook-sidebar/messages.js | 29 +++ src/textbooks/utils.js | 22 ++ 45 files changed, 2393 insertions(+), 16 deletions(-) create mode 100644 src/generic/promptIfDirty/PromptIfDirty.jsx create mode 100644 src/generic/promptIfDirty/PromtIfDirty.test.jsx create mode 100644 src/textbooks/Textbook.test.jsx create mode 100644 src/textbooks/Textbooks.jsx create mode 100644 src/textbooks/Textbooks.scss create mode 100644 src/textbooks/__mocks__/index.js create mode 100644 src/textbooks/__mocks__/textbooksMock.js create mode 100644 src/textbooks/data/api.js create mode 100644 src/textbooks/data/api.test.js create mode 100644 src/textbooks/data/selectors.js create mode 100644 src/textbooks/data/slice.js create mode 100644 src/textbooks/data/slice.test.jsx create mode 100644 src/textbooks/data/thunk.js create mode 100644 src/textbooks/data/thunk.test.js create mode 100644 src/textbooks/empty-placeholder/EmptyPlaceholder.jsx create mode 100644 src/textbooks/empty-placeholder/EmptyPlaceholder.scss create mode 100644 src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx create mode 100644 src/textbooks/empty-placeholder/messages.js create mode 100644 src/textbooks/hooks.jsx create mode 100644 src/textbooks/index.js create mode 100644 src/textbooks/messages.js create mode 100644 src/textbooks/textbook-card/TextbookCard.scss create mode 100644 src/textbooks/textbook-card/TextbooksCard.jsx create mode 100644 src/textbooks/textbook-card/TextbooksCard.test.jsx create mode 100644 src/textbooks/textbook-card/messages.js create mode 100644 src/textbooks/textbook-form/TextbookForm.jsx create mode 100644 src/textbooks/textbook-form/TextbookForm.scss create mode 100644 src/textbooks/textbook-form/TextbookForm.test.jsx create mode 100644 src/textbooks/textbook-form/messages.js create mode 100644 src/textbooks/textbook-form/validations.js create mode 100644 src/textbooks/textbook-sidebar/TextbookSidebar.jsx create mode 100644 src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx create mode 100644 src/textbooks/textbook-sidebar/messages.js create mode 100644 src/textbooks/utils.js diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 6f33831d3c..8d8d1a6c06 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -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'; @@ -125,6 +126,10 @@ const CourseAuthoringRoutes = () => { path="certificates" element={} /> + } + /> ); diff --git a/src/constants.js b/src/constants.js index 87fb9d9cb8..a641c8add8 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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' }, diff --git a/src/generic/FormikControl.jsx b/src/generic/FormikControl.jsx index 7d3b35a0fb..048ad991ab 100644 --- a/src/generic/FormikControl.jsx +++ b/src/generic/FormikControl.jsx @@ -30,7 +30,7 @@ const FormikControl = ({ onChange={handleChange} onBlur={handleBlur} onFocus={handleFocus} - isInvalid={fieldTouched && fieldError} + isInvalid={!!fieldTouched && !!fieldError} /> {help} diff --git a/src/generic/delete-modal/DeleteModal.jsx b/src/generic/delete-modal/DeleteModal.jsx index 1f9ebc286f..97768f6808 100644 --- a/src/generic/delete-modal/DeleteModal.jsx +++ b/src/generic/delete-modal/DeleteModal.jsx @@ -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 ( )} > -

{intl.formatMessage(messages.description, { category })}

+

{modalDescription}

); }; +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; diff --git a/src/generic/delete-modal/DeleteModal.test.jsx b/src/generic/delete-modal/DeleteModal.test.jsx index 5edf101f81..5f52709586 100644 --- a/src/generic/delete-modal/DeleteModal.test.jsx +++ b/src/generic/delete-modal/DeleteModal.test.jsx @@ -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('', () => { 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('', () => { 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(); + }); }); diff --git a/src/generic/modal-dropzone/ModalDropzone.jsx b/src/generic/modal-dropzone/ModalDropzone.jsx index d7e5c50c3b..8133910785 100644 --- a/src/generic/modal-dropzone/ModalDropzone.jsx +++ b/src/generic/modal-dropzone/ModalDropzone.jsx @@ -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 ? (
{previewComponent || ( @@ -93,7 +102,9 @@ const ModalDropzone = ({ onProcessUpload={handleSelectFile} inputComponent={inputComponent} accept={accept} + errorMessages={{ invalidSizeMore }} validator={imageValidator} + maxSize={maxSize} /> )} @@ -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; diff --git a/src/generic/modal-dropzone/ModalDropzone.test.jsx b/src/generic/modal-dropzone/ModalDropzone.test.jsx index ea47c2e2b7..030c171518 100644 --- a/src/generic/modal-dropzone/ModalDropzone.test.jsx +++ b/src/generic/modal-dropzone/ModalDropzone.test.jsx @@ -104,10 +104,13 @@ describe('', () => { }); 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(); const dropzoneInput = getByRole('presentation', { hidden: true }).firstChild; @@ -131,4 +134,26 @@ describe('', () => { 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( + , + ); + 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(); + }); + }); }); diff --git a/src/generic/modal-dropzone/messages.js b/src/generic/modal-dropzone/messages.js index db1292b282..8c0f2efe60 100644 --- a/src/generic/modal-dropzone/messages.js +++ b/src/generic/modal-dropzone/messages.js @@ -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', }, }); diff --git a/src/generic/modal-dropzone/useModalDropzone.jsx b/src/generic/modal-dropzone/useModalDropzone.jsx index df0fd8679f..81657d3923 100644 --- a/src/generic/modal-dropzone/useModalDropzone.jsx +++ b/src/generic/modal-dropzone/useModalDropzone.jsx @@ -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); } }; diff --git a/src/generic/promptIfDirty/PromptIfDirty.jsx b/src/generic/promptIfDirty/PromptIfDirty.jsx new file mode 100644 index 0000000000..a686ea2e87 --- /dev/null +++ b/src/generic/promptIfDirty/PromptIfDirty.jsx @@ -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; diff --git a/src/generic/promptIfDirty/PromtIfDirty.test.jsx b/src/generic/promptIfDirty/PromtIfDirty.test.jsx new file mode 100644 index 0000000000..b429a7e137 --- /dev/null +++ b/src/generic/promptIfDirty/PromtIfDirty.test.jsx @@ -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(, container); + }); + + expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); + + it('should remove event listener on unmount', () => { + act(() => { + render(, 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(, container); + }); + act(() => { + window.dispatchEvent(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.returnValue).toBe(''); + }); + + it('should not call preventDefault when dirty is false', () => { + act(() => { + render(, container); + }); + act(() => { + window.dispatchEvent(mockEvent); + }); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/src/index.scss b/src/index.scss index 41984ac61d..5409c7085a 100644 --- a/src/index.scss +++ b/src/index.scss @@ -23,6 +23,7 @@ @import "course-outline/CourseOutline"; @import "course-unit/CourseUnit"; @import "course-checklist/CourseChecklist"; +@import "textbooks/Textbooks"; @import "content-tags-drawer/ContentTagsDropDownSelector"; @import "content-tags-drawer/ContentTagsCollapsible"; @import "search-modal/SearchModal"; diff --git a/src/store.js b/src/store.js index 30621bb62f..661862f608 100644 --- a/src/store.js +++ b/src/store.js @@ -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 textbooksReducer } from './textbooks/data/slice'; import { reducer as certificatesReducer } from './certificates/data/slice'; import { reducer as groupConfigurationsReducer } from './group-configurations/data/slice'; @@ -57,6 +58,7 @@ export default function initializeStore(preloadedState = undefined) { accessibilityPage: accessibilityPageReducer, certificates: certificatesReducer, groupConfigurations: groupConfigurationsReducer, + textbooks: textbooksReducer, }, preloadedState, }); diff --git a/src/textbooks/Textbook.test.jsx b/src/textbooks/Textbook.test.jsx new file mode 100644 index 0000000000..b8437f114f --- /dev/null +++ b/src/textbooks/Textbook.test.jsx @@ -0,0 +1,87 @@ +import MockAdapter from 'axios-mock-adapter'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import userEvent from '@testing-library/user-event'; + +import initializeStore from '../store'; +import { executeThunk } from '../utils'; +import { getTextbooksApiUrl } from './data/api'; +import { fetchTextbooksQuery } from './data/thunk'; +import { textbooksMock } from './__mocks__'; +import { Textbooks } from '.'; +import messages from './messages'; + +let axiosMock; +let store; +const courseId = 'course-v1:org+101+101'; +const emptyTextbooksMock = { textbooks: [] }; + +const renderComponent = () => render( + + + + + , +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getTextbooksApiUrl(courseId)) + .reply(200, textbooksMock); + await executeThunk(fetchTextbooksQuery(courseId), store.dispatch); + }); + + it('renders Textbooks component correctly', async () => { + const { + getByText, getByRole, getAllByTestId, queryByTestId, + } = renderComponent(); + + await waitFor(() => { + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.breadcrumbContent.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.breadcrumbPagesAndResources.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.newTextbookButton.defaultMessage })).toBeInTheDocument(); + expect(getAllByTestId('textbook-card')).toHaveLength(2); + expect(queryByTestId('textbooks-empty-placeholder')).not.toBeInTheDocument(); + }); + }); + + it('renders textbooks form when "New textbooks" button is clicked', async () => { + const { getByTestId, getByRole } = renderComponent(); + + await waitFor(() => { + const newTextbookButton = getByRole('button', { name: messages.newTextbookButton.defaultMessage }); + userEvent.click(newTextbookButton); + expect(getByTestId('textbook-form')).toBeInTheDocument(); + }); + }); + + it('renders Textbooks component with empty placeholder correctly', async () => { + cleanup(); + axiosMock + .onGet(getTextbooksApiUrl(courseId)) + .reply(200, emptyTextbooksMock); + + const { getByTestId, queryAllByTestId } = renderComponent(); + + await waitFor(() => { + expect(getByTestId('textbooks-empty-placeholder')).toBeInTheDocument(); + expect(queryAllByTestId('textbook-card')).toHaveLength(0); + }); + }); +}); diff --git a/src/textbooks/Textbooks.jsx b/src/textbooks/Textbooks.jsx new file mode 100644 index 0000000000..77b7d9b867 --- /dev/null +++ b/src/textbooks/Textbooks.jsx @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Breadcrumb, + Button, + Container, + Layout, + Row, +} from '@openedx/paragon'; +import { Add as AddIcon } from '@openedx/paragon/icons'; +import { useSelector } from 'react-redux'; + +import { Helmet } from 'react-helmet'; +import React from 'react'; +import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; +import { useModel } from '../generic/model-store'; +import { LoadingSpinner } from '../generic/Loading'; +import SubHeader from '../generic/sub-header/SubHeader'; +import InternetConnectionAlert from '../generic/internet-connection-alert'; +import ProcessingNotification from '../generic/processing-notification'; +import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; +import TextbookCard from './textbook-card/TextbooksCard'; +import TextbookSidebar from './textbook-sidebar/TextbookSidebar'; +import TextbookForm from './textbook-form/TextbookForm'; +import { useTextbooks } from './hooks'; +import { getTextbookFormInitialValues } from './utils'; +import messages from './messages'; + +const Textbooks = ({ courseId }) => { + const intl = useIntl(); + + const courseDetails = useModel('courseDetails', courseId); + + const { + textbooks, + isLoading, + breadcrumbs, + isTextbookFormOpen, + openTextbookForm, + closeTextbookForm, + isInternetConnectionAlertFailed, + isQueryPending, + handleTextbookFormSubmit, + handleSavingStatusDispatch, + handleTextbookEditFormSubmit, + handleTextbookDeleteSubmit, + } = useTextbooks(courseId); + + const { + isShow: showProcessingNotification, + title: processingNotificationTitle, + } = useSelector(getProcessingNotification); + + if (isLoading) { + return ( + + + + ); + } + + return ( + <> + + + {`${courseDetails?.name} | ${intl.formatMessage(messages.headingTitle)}`} + + + +
+ + )} + headerActions={( + + )} + /> + + +
+
+
+ {textbooks.length ? textbooks.map((textbook, index) => ( + + )) : ( + !isTextbookFormOpen && + )} + {isTextbookFormOpen && ( + + )} +
+
+
+
+ + + +
+
+
+ +
+ +
+ + ); +}; + +Textbooks.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default Textbooks; diff --git a/src/textbooks/Textbooks.scss b/src/textbooks/Textbooks.scss new file mode 100644 index 0000000000..e8022dd28d --- /dev/null +++ b/src/textbooks/Textbooks.scss @@ -0,0 +1,7 @@ +@import "./empty-placeholder/EmptyPlaceholder"; +@import "./textbook-card/TextbookCard"; +@import "./textbook-form/TextbookForm"; + +.alert-toast { + z-index: $zindex-tooltip !important; +} diff --git a/src/textbooks/__mocks__/index.js b/src/textbooks/__mocks__/index.js new file mode 100644 index 0000000000..b865a19b0b --- /dev/null +++ b/src/textbooks/__mocks__/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as textbooksMock } from './textbooksMock'; diff --git a/src/textbooks/__mocks__/textbooksMock.js b/src/textbooks/__mocks__/textbooksMock.js new file mode 100644 index 0000000000..17b456c9e2 --- /dev/null +++ b/src/textbooks/__mocks__/textbooksMock.js @@ -0,0 +1,28 @@ +module.exports = { + textbooks: [ + { + tabTitle: 'Textbook Name 1', + chapters: [ + { + title: 'Chapter 1', + url: '/static/Present-Perfect.pdf', + }, + { + title: 'Chapter 2', + url: '/static/Present-Simple.pdf', + }, + ], + id: '1', + }, + { + tabTitle: 'Textbook Name 2', + chapters: [ + { + title: 'Chapter 1', + url: '/static/Present-Perfect.pdf', + }, + ], + id: '2', + }, + ], +}; diff --git a/src/textbooks/data/api.js b/src/textbooks/data/api.js new file mode 100644 index 0000000000..3200d88e0c --- /dev/null +++ b/src/textbooks/data/api.js @@ -0,0 +1,62 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { omit } from 'lodash'; + +const API_PATH_PATTERN = 'textbooks'; +const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; + +export const getTextbooksApiUrl = (courseId) => `${getStudioBaseUrl()}/api/contentstore/v1/${API_PATH_PATTERN}/${courseId}`; +export const getUpdateTextbooksApiUrl = (courseId) => `${getStudioBaseUrl()}/${API_PATH_PATTERN}/${courseId}`; +export const getEditTextbooksApiUrl = (courseId, textbookId) => `${getStudioBaseUrl()}/${API_PATH_PATTERN}/${courseId}/${textbookId}`; + +/** + * Get textbooks for course. + * @param {string} courseId + * @returns {Promise} + */ +export async function getTextbooks(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getTextbooksApiUrl(courseId)); + + return camelCaseObject(data); +} + +/** + * Create new textbook for course. + * @param {string} courseId + * @param {tab_title: string, chapters: Array<[title: string: url: string]>} textbook + * @returns {Promise} + */ +export async function createTextbook(courseId, textbook) { + const { data } = await getAuthenticatedHttpClient() + .post(getUpdateTextbooksApiUrl(courseId), textbook); + + return camelCaseObject(data); +} + +/** + * Edit textbook for course. + * @param {string} courseId + * @param {tab_title: string, id: string, chapters: Array<[title: string: url: string]>} textbook + * @param {string} textbookId + * @returns {Promise} + */ +export async function editTextbook(courseId, textbook) { + const { data } = await getAuthenticatedHttpClient() + .put(getEditTextbooksApiUrl(courseId, textbook.id), omit(textbook, ['id'])); + + return camelCaseObject(data); +} + +/** + * Edit textbook for course. + * @param {string} courseId + * @param {string} textbookId + * @returns {Promise} + */ +export async function deleteTextbook(courseId, textbookId) { + const { data } = await getAuthenticatedHttpClient() + .delete(getEditTextbooksApiUrl(courseId, textbookId)); + + return camelCaseObject(data); +} diff --git a/src/textbooks/data/api.test.js b/src/textbooks/data/api.test.js new file mode 100644 index 0000000000..1f773cb23d --- /dev/null +++ b/src/textbooks/data/api.test.js @@ -0,0 +1,82 @@ +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform'; + +import { textbooksMock } from 'CourseAuthoring/textbooks/__mocks__'; +import { + getTextbooks, + createTextbook, + editTextbook, + deleteTextbook, + getTextbooksApiUrl, + getUpdateTextbooksApiUrl, + getEditTextbooksApiUrl, +} from './api'; + +let axiosMock; +const courseId = 'course-v1:org+101+101'; + +describe('getTextbooks', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getTextbooksApiUrl(courseId)) + .reply(200, textbooksMock); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + it('should fetch textbooks for a course', async () => { + const textbooksData = [{ id: 1, title: 'Textbook 1' }, { id: 2, title: 'Textbook 2' }]; + axiosMock.onGet(getTextbooksApiUrl(courseId)).reply(200, textbooksData); + + const result = await getTextbooks(courseId); + + expect(result).toEqual(textbooksData); + }); +}); + +describe('createTextbook', () => { + it('should create a new textbook for a course', async () => { + const textbookData = { title: 'New Textbook', chapters: [] }; + axiosMock.onPost(getUpdateTextbooksApiUrl(courseId)).reply(200, textbookData); + + const result = await createTextbook(courseId, textbookData); + + expect(result).toEqual(textbookData); + }); +}); + +describe('editTextbook', () => { + it('should edit an existing textbook for a course', async () => { + const textbookId = '1'; + const editedTextbookData = { id: '1', title: 'Edited Textbook', chapters: [] }; + axiosMock.onPut(getEditTextbooksApiUrl(courseId, textbookId)).reply(200, editedTextbookData); + + const result = await editTextbook(courseId, editedTextbookData); + + expect(result).toEqual(editedTextbookData); + }); +}); + +describe('deleteTextbook', () => { + it('should delete an existing textbook for a course', async () => { + const textbookId = '1'; + axiosMock.onDelete(getEditTextbooksApiUrl(courseId, textbookId)).reply(200, {}); + + const result = await deleteTextbook(courseId, textbookId); + + expect(result).toEqual({}); + }); +}); diff --git a/src/textbooks/data/selectors.js b/src/textbooks/data/selectors.js new file mode 100644 index 0000000000..37860c4b45 --- /dev/null +++ b/src/textbooks/data/selectors.js @@ -0,0 +1,4 @@ +export const getTextbooksData = (state) => state.textbooks.textbooks; +export const getLoadingStatus = (state) => state.textbooks.loadingStatus; +export const getSavingStatus = (state) => state.textbooks.savingStatus; +export const getCurrentTextbookId = (state) => state.textbooks.currentTextbookId; diff --git a/src/textbooks/data/slice.js b/src/textbooks/data/slice.js new file mode 100644 index 0000000000..7c0ffe4400 --- /dev/null +++ b/src/textbooks/data/slice.js @@ -0,0 +1,51 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +import { RequestStatus } from '../../data/constants'; + +const slice = createSlice({ + name: 'textbooks', + initialState: { + savingStatus: '', + loadingStatus: RequestStatus.IN_PROGRESS, + textbooks: [], + currentTextbookId: '', + }, + reducers: { + fetchTextbooks: (state, { payload }) => { + state.textbooks = payload.textbooks; + }, + updateLoadingStatus: (state, { payload }) => { + state.loadingStatus = payload.status; + }, + updateSavingStatus: (state, { payload }) => { + state.savingStatus = payload.status; + }, + createTextbookSuccess: (state, { payload }) => { + state.textbooks = [...state.textbooks, payload]; + }, + editTextbookSuccess: (state, { payload }) => { + state.currentTextbookId = payload.id; + state.textbooks = state.textbooks.map((textbook) => { + if (textbook.id === payload.id) { + return payload; + } + return textbook; + }); + }, + deleteTextbookSuccess: (state, { payload }) => { + state.textbooks = state.textbooks.filter(({ id }) => id !== payload); + }, + }, +}); + +export const { + fetchTextbooks, + updateLoadingStatus, + updateSavingStatus, + createTextbookSuccess, + editTextbookSuccess, + deleteTextbookSuccess, +} = slice.actions; + +export const { reducer } = slice; diff --git a/src/textbooks/data/slice.test.jsx b/src/textbooks/data/slice.test.jsx new file mode 100644 index 0000000000..95ed0cba35 --- /dev/null +++ b/src/textbooks/data/slice.test.jsx @@ -0,0 +1,118 @@ +import { + reducer, + fetchTextbooks, + updateLoadingStatus, + updateSavingStatus, + createTextbookSuccess, + editTextbookSuccess, + deleteTextbookSuccess, +} from './slice'; + +const initialState = { + savingStatus: '', + loadingStatus: 'IN_PROGRESS', + textbooks: [], + currentTextbookId: '', +}; + +const textbooks = [ + { + tabTitle: 'Textbook Name 1', + chapters: [ + { + title: 'Chapter 1', + url: '/static/Present-Perfect.pdf', + }, + { + title: 'Chapter 2', + url: '/static/Present-Simple.pdf', + }, + ], + id: '1', + }, + { + tabTitle: 'Textbook Name 2', + chapters: [ + { + title: 'Chapter 1', + url: '/static/Present-Perfect.pdf', + }, + ], + id: '2', + }, +]; + +describe('textbooks slice', () => { + it('should handle fetchTextbooks', () => { + const nextState = reducer(initialState, fetchTextbooks({ textbooks })); + + expect(nextState.textbooks).toEqual(textbooks); + }); + + it('should handle updateLoadingStatus', () => { + const nextState = reducer(initialState, updateLoadingStatus({ status: 'SUCCESS' })); + + expect(nextState.loadingStatus).toEqual('SUCCESS'); + }); + + it('should handle updateSavingStatus', () => { + const nextState = reducer(initialState, updateSavingStatus({ status: 'ERROR' })); + + expect(nextState.savingStatus).toEqual('ERROR'); + }); + + it('should handle createTextbookSuccess', () => { + const newTextbook = { + tabTitle: 'New Textbook', + chapters: [ + { + title: 'Chapter 1', + url: '/static/New-Textbook-Chapter-1.pdf', + }, + ], + id: '3', + }; + const nextState = reducer(initialState, createTextbookSuccess(newTextbook)); + + expect(nextState.textbooks).toContainEqual(newTextbook); + }); + + it('should handle editTextbookSuccess', () => { + const newInitialState = { + savingStatus: '', + loadingStatus: 'IN_PROGRESS', + textbooks, + currentTextbookId: '', + }; + const editedTextbook = { + tabTitle: 'Edited Textbook Name 1', + chapters: [ + { + title: 'Chapter 1', + url: '/static/Edited-Chapter-1.pdf', + }, + { + title: 'Chapter 2', + url: '/static/Edited-Chapter-2.pdf', + }, + ], + id: '1', + }; + const nextState = reducer(newInitialState, editTextbookSuccess(editedTextbook)); + + expect(nextState.textbooks).toContainEqual(editedTextbook); + }); + + it('should handle deleteTextbookSuccess', () => { + const newInitialState = { + savingStatus: '', + loadingStatus: 'IN_PROGRESS', + textbooks, + currentTextbookId: '', + }; + const textbookIdToDelete = '1'; + const nextState = reducer(newInitialState, deleteTextbookSuccess(textbookIdToDelete)); + + expect(nextState.textbooks.some((textbook) => textbook.id === textbookIdToDelete)).toBe(false); + }); +}); diff --git a/src/textbooks/data/thunk.js b/src/textbooks/data/thunk.js new file mode 100644 index 0000000000..319a6a69b1 --- /dev/null +++ b/src/textbooks/data/thunk.js @@ -0,0 +1,85 @@ +import { + hideProcessingNotification, + showProcessingNotification, +} from '../../generic/processing-notification/data/slice'; +import { RequestStatus } from '../../data/constants'; +import { NOTIFICATION_MESSAGES } from '../../constants'; +import { + fetchTextbooks, + updateLoadingStatus, + updateSavingStatus, + createTextbookSuccess, + editTextbookSuccess, + deleteTextbookSuccess, +} from './slice'; +import { + getTextbooks, + createTextbook, + editTextbook, + deleteTextbook, +} from './api'; + +export function fetchTextbooksQuery(courseId) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const { textbooks } = await getTextbooks(courseId); + dispatch(fetchTextbooks({ textbooks })); + dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateLoadingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function createTextbookQuery(courseId, textbook) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + const data = await createTextbook(courseId, textbook); + dispatch(createTextbookSuccess(data)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function editTextbookQuery(courseId, textbook) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + const data = await editTextbook(courseId, textbook); + dispatch(editTextbookSuccess(data)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function deleteTextbookQuery(courseId, textbookId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + + try { + await deleteTextbook(courseId, textbookId); + dispatch(deleteTextbookSuccess(textbookId)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} diff --git a/src/textbooks/data/thunk.test.js b/src/textbooks/data/thunk.test.js new file mode 100644 index 0000000000..17a1de064d --- /dev/null +++ b/src/textbooks/data/thunk.test.js @@ -0,0 +1,139 @@ +import { + hideProcessingNotification, + showProcessingNotification, +} from '../../generic/processing-notification/data/slice'; +import { + fetchTextbooksQuery, + createTextbookQuery, + editTextbookQuery, + deleteTextbookQuery, +} from './thunk'; +import { + fetchTextbooks, + updateLoadingStatus, + updateSavingStatus, + createTextbookSuccess, + editTextbookSuccess, + deleteTextbookSuccess, +} from './slice'; +import { RequestStatus } from '../../data/constants'; +import { NOTIFICATION_MESSAGES } from '../../constants'; +import { + getTextbooks, createTextbook, editTextbook, deleteTextbook, +} from './api'; + +jest.mock('./api', () => ({ + getTextbooks: jest.fn(), + createTextbook: jest.fn(), + editTextbook: jest.fn(), + deleteTextbook: jest.fn(), +})); + +const dispatch = jest.fn(); + +describe('fetchTextbooksQuery', () => { + it('should dispatch fetchTextbooks with textbooks data on success', async () => { + const textbooks = [{ id: '1', title: 'Textbook 1' }]; + getTextbooks.mockResolvedValue({ textbooks }); + + await fetchTextbooksQuery('courseId')(dispatch); + + expect(dispatch).toHaveBeenCalledWith(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + expect(getTextbooks).toHaveBeenCalledWith('courseId'); + expect(dispatch).toHaveBeenCalledWith(fetchTextbooks({ textbooks })); + expect(dispatch).toHaveBeenCalledWith(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + }); + + it('should dispatch updateLoadingStatus with RequestStatus.FAILED on failure', async () => { + getTextbooks.mockRejectedValue(new Error('Failed to fetch textbooks')); + + await fetchTextbooksQuery('courseId')(dispatch); + + expect(dispatch).toHaveBeenCalledWith(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + expect(getTextbooks).toHaveBeenCalledWith('courseId'); + expect(dispatch).toHaveBeenCalledWith(updateLoadingStatus({ status: RequestStatus.FAILED })); + }); +}); + +describe('createTextbookQuery', () => { + it('should dispatch createTextbookSuccess on success', async () => { + const textbook = { id: '1', title: 'New Textbook' }; + createTextbook.mockResolvedValue(textbook); + + await createTextbookQuery('courseId', textbook)(dispatch); + + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + expect(createTextbook).toHaveBeenCalledWith('courseId', textbook); + expect(dispatch).toHaveBeenCalledWith(createTextbookSuccess(textbook)); + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + }); + + it('should dispatch updateSavingStatus with RequestStatus.FAILED on failure', async () => { + createTextbook.mockRejectedValue(new Error('Failed to create textbook')); + + await createTextbookQuery('courseId', {})(dispatch); + + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + expect(createTextbook).toHaveBeenCalledWith('courseId', {}); + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED })); + expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + }); +}); + +describe('editTextbookQuery', () => { + it('should dispatch editTextbookSuccess on success', async () => { + const textbook = { id: '1', title: 'Edited Textbook' }; + editTextbook.mockResolvedValue(textbook); + + await editTextbookQuery('courseId', textbook)(dispatch); + + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + expect(editTextbook).toHaveBeenCalledWith('courseId', textbook); + expect(dispatch).toHaveBeenCalledWith(editTextbookSuccess(textbook)); + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + }); + + it('should dispatch updateSavingStatus with RequestStatus.FAILED on failure', async () => { + editTextbook.mockRejectedValue(new Error('Failed to edit textbook')); + + await editTextbookQuery('courseId', {})(dispatch); + + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + expect(editTextbook).toHaveBeenCalledWith('courseId', {}); + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED })); + expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + }); +}); + +describe('deleteTextbookQuery', () => { + it('should dispatch deleteTextbookSuccess on success', async () => { + deleteTextbook.mockResolvedValue(); + + await deleteTextbookQuery('courseId', 'textbookId')(dispatch); + + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + expect(deleteTextbook).toHaveBeenCalledWith('courseId', 'textbookId'); + expect(dispatch).toHaveBeenCalledWith(deleteTextbookSuccess('textbookId')); + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + }); + + it('should dispatch updateSavingStatus with RequestStatus.FAILED on failure', async () => { + deleteTextbook.mockRejectedValue(new Error('Failed to delete textbook')); + + await deleteTextbookQuery('courseId', 'textbookId')(dispatch); + + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + expect(deleteTextbook).toHaveBeenCalledWith('courseId', 'textbookId'); + expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED })); + expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + }); +}); diff --git a/src/textbooks/empty-placeholder/EmptyPlaceholder.jsx b/src/textbooks/empty-placeholder/EmptyPlaceholder.jsx new file mode 100644 index 0000000000..785726db88 --- /dev/null +++ b/src/textbooks/empty-placeholder/EmptyPlaceholder.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Add as IconAdd } from '@openedx/paragon/icons'; +import { Button } from '@openedx/paragon'; + +import messages from './messages'; + +const EmptyPlaceholder = ({ onCreateNewTextbook }) => { + const intl = useIntl(); + + return ( +
+

{intl.formatMessage(messages.title)}

+ +
+ ); +}; + +EmptyPlaceholder.propTypes = { + onCreateNewTextbook: PropTypes.func.isRequired, +}; + +export default EmptyPlaceholder; diff --git a/src/textbooks/empty-placeholder/EmptyPlaceholder.scss b/src/textbooks/empty-placeholder/EmptyPlaceholder.scss new file mode 100644 index 0000000000..9f4290f294 --- /dev/null +++ b/src/textbooks/empty-placeholder/EmptyPlaceholder.scss @@ -0,0 +1,10 @@ +.textbooks-empty-placeholder { + @include pgn-box-shadow(1, "down"); + + display: flex; + align-items: center; + justify-content: center; + gap: 1.5rem; + border-radius: .375rem; + padding: map-get($spacers, 4); +} diff --git a/src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx b/src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx new file mode 100644 index 0000000000..e5fc97627b --- /dev/null +++ b/src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import userEvent from '@testing-library/user-event'; +import EmptyPlaceholder from './EmptyPlaceholder'; +import messages from './messages'; + +const onCreateNewTextbookMock = jest.fn(); + +const renderComponent = () => render( + + + , +); + +describe('', () => { + it('renders EmptyPlaceholder component correctly', () => { + const { getByText, getByRole } = renderComponent(); + + expect(getByText(messages.title.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument(); + }); + + it('calls the onCreateNewTextbook function when the button is clicked', () => { + const { getByRole } = renderComponent(); + + const addButton = getByRole('button', { name: messages.button.defaultMessage }); + userEvent.click(addButton); + expect(onCreateNewTextbookMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/textbooks/empty-placeholder/messages.js b/src/textbooks/empty-placeholder/messages.js new file mode 100644 index 0000000000..ad07ae1e50 --- /dev/null +++ b/src/textbooks/empty-placeholder/messages.js @@ -0,0 +1,14 @@ +const descriptions = { + title: { + id: 'course-authoring.textbooks.empty-placeholder.title', + defaultMessage: 'You haven\'t added any textbooks to this course yet.', + description: 'Message displayed when no textbooks are added to the course', + }, + button: { + id: 'course-authoring.textbooks.empty-placeholder.button.new-textbook', + defaultMessage: 'Add your first textbook', + description: 'Text for the button to add the first textbook to the course', + }, +}; + +export default descriptions; diff --git a/src/textbooks/hooks.jsx b/src/textbooks/hooks.jsx new file mode 100644 index 0000000000..ee188240ce --- /dev/null +++ b/src/textbooks/hooks.jsx @@ -0,0 +1,93 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { useContext, useEffect } from 'react'; +import { AppContext } from '@edx/frontend-platform/react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useToggle } from '@openedx/paragon'; + +import { updateSavingStatus } from './data/slice'; +import { RequestStatus } from '../data/constants'; +import { + getTextbooksData, + getLoadingStatus, + getSavingStatus, +} from './data/selectors'; +import { + createTextbookQuery, + fetchTextbooksQuery, + editTextbookQuery, + deleteTextbookQuery, +} from './data/thunk'; +import messages from './messages'; + +const useTextbooks = (courseId) => { + const intl = useIntl(); + const dispatch = useDispatch(); + const { config } = useContext(AppContext); + + const textbooks = useSelector(getTextbooksData); + const loadingStatus = useSelector(getLoadingStatus); + const savingStatus = useSelector(getSavingStatus); + + const [isTextbookFormOpen, openTextbookForm, closeTextbookForm] = useToggle(false); + + const breadcrumbs = [ + { + label: intl.formatMessage(messages.breadcrumbContent), + href: `${config.STUDIO_BASE_URL}/course/${courseId}`, + }, + { + label: intl.formatMessage(messages.breadcrumbPagesAndResources), + href: `/course/${courseId}/pages-and-resources`, + }, + { + label: '', + href: `/course/${courseId}/textbooks`, + }, + ]; + + const handleTextbookFormSubmit = (formValues) => { + dispatch(createTextbookQuery(courseId, formValues)); + }; + + const handleTextbookEditFormSubmit = (formValues) => { + dispatch(editTextbookQuery(courseId, formValues)); + }; + + const handleTextbookDeleteSubmit = (textbookId) => { + dispatch(deleteTextbookQuery(courseId, textbookId)); + }; + + const handleSavingStatusDispatch = (status) => { + if (status.status !== RequestStatus.SUCCESSFUL) { + dispatch(updateSavingStatus(status)); + } + }; + + useEffect(() => { + dispatch(fetchTextbooksQuery(courseId)); + }, [courseId]); + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL) { + closeTextbookForm(); + } + }, [savingStatus]); + + return { + isLoading: loadingStatus === RequestStatus.IN_PROGRESS, + isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED, + isQueryPending: savingStatus === RequestStatus.PENDING, + textbooks, + breadcrumbs, + isTextbookFormOpen, + openTextbookForm, + closeTextbookForm, + handleTextbookFormSubmit, + handleSavingStatusDispatch, + handleTextbookEditFormSubmit, + handleTextbookDeleteSubmit, + }; +}; + +// eslint-disable-next-line import/prefer-default-export +export { useTextbooks }; diff --git a/src/textbooks/index.js b/src/textbooks/index.js new file mode 100644 index 0000000000..59e22156b0 --- /dev/null +++ b/src/textbooks/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as Textbooks } from './Textbooks'; diff --git a/src/textbooks/messages.js b/src/textbooks/messages.js new file mode 100644 index 0000000000..d891658116 --- /dev/null +++ b/src/textbooks/messages.js @@ -0,0 +1,29 @@ +const descriptions = { + headingTitle: { + id: 'course-authoring.textbooks.header.title', + defaultMessage: 'Textbooks', + description: 'Title for the textbooks section', + }, + breadcrumbContent: { + id: 'course-authoring.textbooks.header.breadcrumb.content', + defaultMessage: 'Content', + description: 'Breadcrumb for content', + }, + breadcrumbPagesAndResources: { + id: 'course-authoring.textbooks.header.breadcrumb.pages-and-resources', + defaultMessage: 'Pages & resources', + description: 'Breadcrumb for pages and resources', + }, + breadcrumbAriaLabel: { + id: 'course-authoring.textbooks.header.breadcrumb.aria-label', + defaultMessage: 'Textbook breadcrumb', + description: 'Aria label for the textbook breadcrumb', + }, + newTextbookButton: { + id: 'course-authoring.textbooks.header.new-textbook', + defaultMessage: 'New textbook', + description: 'Text for the button to create a new textbook', + }, +}; + +export default descriptions; diff --git a/src/textbooks/textbook-card/TextbookCard.scss b/src/textbooks/textbook-card/TextbookCard.scss new file mode 100644 index 0000000000..e5df569ed9 --- /dev/null +++ b/src/textbooks/textbook-card/TextbookCard.scss @@ -0,0 +1,42 @@ +.textbook-card { + padding: $spacer $spacer $spacer map-get($spacers, 4); + + & .pgn__card-header { + padding: 0; + margin-bottom: $spacer; + } + + & .pgn__card-header-content { + margin-top: 0 !important; + } + + & .pgn__card-header-actions { + margin: 0 !important; + } + + &:not(:last-of-type) { + margin-bottom: map-get($spacers, 4); + } +} + +.textbook-card__chapters { + margin-left: -(map-get($spacers, 2)); +} + +.textbook-card__chapter-item { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: $spacer; + + & span:first-of-type { + word-break: break-word; + } + + & span:last-of-type { + word-break: break-all; + } + + &:not(:last-of-type) { + margin-bottom: map-get($spacers, 2); + } +} diff --git a/src/textbooks/textbook-card/TextbooksCard.jsx b/src/textbooks/textbook-card/TextbooksCard.jsx new file mode 100644 index 0000000000..7218a6ddef --- /dev/null +++ b/src/textbooks/textbook-card/TextbooksCard.jsx @@ -0,0 +1,146 @@ +import PropTypes from 'prop-types'; +import { useContext, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Card, + Collapsible, + Icon, + IconButtonWithTooltip, + useToggle, +} from '@openedx/paragon'; +import { + EditOutline as EditIcon, + RemoveRedEye as ViewIcon, + DeleteOutline as DeleteIcon, +} from '@openedx/paragon/icons'; +import { AppContext } from '@edx/frontend-platform/react'; + +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import { RequestStatus } from '../../data/constants'; +import { getCurrentTextbookId, getSavingStatus } from '../data/selectors'; +import TextbookForm from '../textbook-form/TextbookForm'; +import { getTextbookFormInitialValues } from '../utils'; +import messages from './messages'; + +const TextbookCard = ({ + textbook, + courseId, + handleSavingStatusDispatch, + onEditSubmit, + onDeleteSubmit, + textbookIndex, +}) => { + const intl = useIntl(); + const { config } = useContext(AppContext); + + const savingStatus = useSelector(getSavingStatus); + const currentTextbookId = useSelector(getCurrentTextbookId); + + const [isTextbookFormOpen, openTextbookForm, closeTextbookForm] = useToggle(false); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + + const { tabTitle, chapters, id } = textbook; + + const onPreviewTextbookClick = () => { + window.open(`${config.LMS_BASE_URL}/courses/${courseId}/pdfbook/${textbookIndex}/`, '_blank'); + }; + + const handleDeleteButtonSubmit = () => { + closeDeleteModal(); + onDeleteSubmit(id); + }; + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL && currentTextbookId === id) { + closeTextbookForm(); + } + }, [savingStatus, currentTextbookId]); + + return ( + <> + {isTextbookFormOpen ? ( + + ) : ( + ( + + + + + + + )} + /> +
+ + {chapters.map(({ title, url }) => ( +
+ {title} + {url} +
+ ))} +
+
+
+ ) + )} + + + ); +}; + +TextbookCard.propTypes = { + textbook: PropTypes.shape({ + tabTitle: PropTypes.string.isRequired, + chapters: PropTypes.arrayOf(PropTypes.shape({ + title: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + })).isRequired, + id: PropTypes.string.isRequired, + }).isRequired, + courseId: PropTypes.string.isRequired, + handleSavingStatusDispatch: PropTypes.func.isRequired, + onEditSubmit: PropTypes.func.isRequired, + onDeleteSubmit: PropTypes.func.isRequired, + textbookIndex: PropTypes.string.isRequired, +}; + +export default TextbookCard; diff --git a/src/textbooks/textbook-card/TextbooksCard.test.jsx b/src/textbooks/textbook-card/TextbooksCard.test.jsx new file mode 100644 index 0000000000..ce1c958ab5 --- /dev/null +++ b/src/textbooks/textbook-card/TextbooksCard.test.jsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { render, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MockAdapter from 'axios-mock-adapter'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform'; + +import { getEditTextbooksApiUrl } from '../data/api'; +import { deleteTextbookQuery, editTextbookQuery } from '../data/thunk'; +import { textbooksMock } from '../__mocks__'; +import initializeStore from '../../store'; +import { executeThunk } from '../../utils'; +import TextbookCard from './TextbooksCard'; +import messages from '../textbook-form/messages'; +import textbookCardMessages from './messages'; + +let axiosMock; +let store; + +const courseId = 'course-v1:org+101+101'; +const textbook = textbooksMock.textbooks[1]; +const onEditSubmitMock = jest.fn(); +const onDeleteSubmitMock = jest.fn(); +const handleSavingStatusDispatchMock = jest.fn(); + +const renderComponent = () => render( + + + + + , +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('render TextbookCard component correctly', () => { + const { getByText, getByTestId } = renderComponent(); + + expect(getByText(textbook.tabTitle)).toBeInTheDocument(); + expect(getByTestId('textbook-view-button')).toBeInTheDocument(); + expect(getByTestId('textbook-edit-button')).toBeInTheDocument(); + expect(getByTestId('textbook-delete-button')).toBeInTheDocument(); + expect(getByText('1 PDF chapters')).toBeInTheDocument(); + + const collapseButton = document.querySelector('.collapsible-trigger'); + userEvent.click(collapseButton); + + textbook.chapters.forEach(({ title, url }) => { + expect(getByText(title)).toBeInTheDocument(); + expect(getByText(url)).toBeInTheDocument(); + }); + }); + + it('renders edit TextbookForm after clicking on edit button', () => { + const { getByTestId, queryByTestId } = renderComponent(); + + const editButton = getByTestId('textbook-edit-button'); + userEvent.click(editButton); + + expect(getByTestId('textbook-form')).toBeInTheDocument(); + expect(queryByTestId('textbook-card')).not.toBeInTheDocument(); + }); + + it('closes edit TextbookForm after clicking on cancel button', () => { + const { getByTestId, queryByTestId } = renderComponent(); + + const editButton = getByTestId('textbook-edit-button'); + userEvent.click(editButton); + + expect(getByTestId('textbook-form')).toBeInTheDocument(); + expect(queryByTestId('textbook-card')).not.toBeInTheDocument(); + + const cancelButton = getByTestId('cancel-button'); + userEvent.click(cancelButton); + + expect(queryByTestId('textbook-form')).not.toBeInTheDocument(); + expect(getByTestId('textbook-card')).toBeInTheDocument(); + }); + + it('calls onEditSubmit when the "Save" button is clicked with a valid form', async () => { + const { getByPlaceholderText, getByRole, getByTestId } = renderComponent(); + + const editButton = getByTestId('textbook-edit-button'); + userEvent.click(editButton); + + const tabTitleInput = getByPlaceholderText(messages.tabTitlePlaceholder.defaultMessage); + const chapterInput = getByPlaceholderText( + messages.chapterTitlePlaceholder.defaultMessage.replace('{value}', textbooksMock.textbooks[1].chapters.length), + ); + const urlInput = getByPlaceholderText(messages.chapterUrlPlaceholder.defaultMessage); + + const newFormValues = { + tab_title: 'Tab title', + chapters: [ + { + title: 'Chapter', + url: 'Url', + }, + ], + id: textbooksMock.textbooks[1].id, + }; + + userEvent.clear(tabTitleInput); + userEvent.type(tabTitleInput, newFormValues.tab_title); + userEvent.clear(chapterInput); + userEvent.type(chapterInput, newFormValues.chapters[0].title); + userEvent.clear(urlInput); + userEvent.type(urlInput, newFormValues.chapters[0].url); + + userEvent.click(getByRole('button', { name: messages.saveButton.defaultMessage })); + + await waitFor(() => { + expect(onEditSubmitMock).toHaveBeenCalledTimes(1); + expect(onEditSubmitMock).toHaveBeenCalledWith( + newFormValues, + expect.objectContaining({ submitForm: expect.any(Function) }), + ); + }); + + axiosMock + .onPost(getEditTextbooksApiUrl(courseId, textbooksMock.textbooks[1].id)) + .reply(200); + + await executeThunk(editTextbookQuery(courseId, newFormValues), store.dispatch); + }); + + it('DeleteModal is open when delete button is clicked', async () => { + const { getByTestId, getByRole } = renderComponent(); + + const deleteButton = getByTestId('textbook-delete-button'); + userEvent.click(deleteButton); + + await waitFor(() => { + const deleteModal = getByRole('dialog'); + + const modalTitle = within(deleteModal) + .getByText(textbookCardMessages.deleteModalTitle.defaultMessage + .replace('{textbookTitle}', textbook.tabTitle)); + const modalDescription = within(deleteModal) + .getByText(textbookCardMessages.deleteModalDescription.defaultMessage); + + expect(modalTitle).toBeInTheDocument(); + expect(modalDescription).toBeInTheDocument(); + }); + }); + + it('calls onDeleteSubmit when the DeleteModal is open', async () => { + const { getByTestId, getByRole } = renderComponent(); + + const deleteButton = getByTestId('textbook-delete-button'); + userEvent.click(deleteButton); + + await waitFor(() => { + const deleteModal = getByRole('dialog'); + + const modalSubmitButton = within(deleteModal) + .getByRole('button', { name: 'Delete' }); + + userEvent.click(modalSubmitButton); + + const textbookId = textbooksMock.textbooks[1].id; + + expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1); + axiosMock + .onDelete(getEditTextbooksApiUrl(courseId, textbookId)) + .reply(200); + + executeThunk(deleteTextbookQuery(courseId, textbookId), store.dispatch); + }); + }); +}); diff --git a/src/textbooks/textbook-card/messages.js b/src/textbooks/textbook-card/messages.js new file mode 100644 index 0000000000..b804ec16ad --- /dev/null +++ b/src/textbooks/textbook-card/messages.js @@ -0,0 +1,49 @@ +const descriptions = { + chaptersTitle: { + id: 'course-authoring.textbooks.chapters.title', + defaultMessage: '{count} PDF chapters', + description: 'Title for the list of PDF chapters', + }, + buttonView: { + id: 'course-authoring.textbooks.button.view', + defaultMessage: 'View the PDF live', + description: 'Text for the button to view the PDF live', + }, + buttonViewAlt: { + id: 'course-authoring.textbooks.button.view.alt', + defaultMessage: 'textbook-view-button', + description: 'Alt text for the view button', + }, + buttonEdit: { + id: 'course-authoring.textbooks.button.edit', + defaultMessage: 'Edit', + description: 'Text for the edit button', + }, + buttonEditAlt: { + id: 'course-authoring.textbooks.button.edit.alt', + defaultMessage: 'textbook-edit-button', + description: 'Alt text for the edit button', + }, + buttonDelete: { + id: 'course-authoring.textbooks.button.delete', + defaultMessage: 'Delete', + description: 'Text for the delete button', + }, + buttonDeleteAlt: { + id: 'course-authoring.textbooks.button.delete.alt', + defaultMessage: 'textbook-delete-button', + description: 'Alt text for the delete button', + }, + deleteModalTitle: { + id: 'course-authoring.textbooks.form.delete-modal.title', + defaultMessage: 'Delete “{textbookTitle}”?', + description: 'Title for the delete modal', + }, + deleteModalDescription: { + id: 'course-authoring.textbooks.form.delete-modal.description', + defaultMessage: 'Deleting a textbook cannot be undone and once deleted any reference to it in your courseware\'s navigation will also be removed.', + description: 'Description for the delete modal', + }, +}; + +export default descriptions; diff --git a/src/textbooks/textbook-form/TextbookForm.jsx b/src/textbooks/textbook-form/TextbookForm.jsx new file mode 100644 index 0000000000..f065b2c110 --- /dev/null +++ b/src/textbooks/textbook-form/TextbookForm.jsx @@ -0,0 +1,201 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { FieldArray, Formik } from 'formik'; +import { + PictureAsPdf as PdfIcon, + Add as AddIcon, + DeleteOutline as DeleteIcon, + Upload as UploadIcon, +} from '@openedx/paragon/icons'; +import { + ActionRow, + Button, + Form, + Icon, + IconButtonWithTooltip, + useToggle, +} from '@openedx/paragon'; + +import FormikControl from '../../generic/FormikControl'; +import PromptIfDirty from '../../generic/promptIfDirty/PromptIfDirty'; +import ModalDropzone from '../../generic/modal-dropzone/ModalDropzone'; +import { useModel } from '../../generic/model-store'; +import { UPLOAD_FILE_MAX_SIZE } from '../../constants'; +import textbookFormValidationSchema from './validations'; +import messages from './messages'; + +const TextbookForm = ({ + closeTextbookForm, + initialFormValues, + onSubmit, + onSavingStatus, + courseId, +}) => { + const intl = useIntl(); + + const courseDetail = useModel('courseDetails', courseId); + const courseTitle = courseDetail ? courseDetail?.name : ''; + + const [currentTextbookIndex, setCurrentTextbookIndex] = useState(0); + const [isUploadModalOpen, openUploadModal, closeUploadModal] = useToggle(false); + const [selectedFile, setSelectedFile] = useState(''); + + const onCloseUploadModal = () => { + closeUploadModal(); + setSelectedFile(''); + }; + + const onUploadButtonClick = (index) => { + setCurrentTextbookIndex(index); + openUploadModal(); + }; + + return ( +
+ + {({ + values, handleSubmit, isValid, dirty, setFieldValue, + }) => ( + <> + + + {intl.formatMessage(messages.tabTitleLabel)} * + + + + {intl.formatMessage(messages.tabTitleHelperText)} + + + ( + <> + {!!values?.chapters.length && values.chapters.map(({ title, url }, index) => ( +
+ + + {intl.formatMessage(messages.chapterTitleLabel)} * + + + + {intl.formatMessage(messages.chapterTitleHelperText)} + + + +
+ + {intl.formatMessage(messages.chapterUrlLabel)} * + + onUploadButtonClick(index)} + /> + arrayHelpers.remove(index)} + /> +
+ + + {intl.formatMessage(messages.chapterUrlHelperText)} + +
+
+ ))} +
+ {!values.chapters.length && ( + + {intl.formatMessage(messages.addChapterHelperText)} + + )} + +
+ + )} + /> + + + + + setFieldValue(`chapters[${currentTextbookIndex}].url`, value)} + fileTypes={['pdf']} + modalTitle={intl.formatMessage(messages.uploadModalTitle, { courseName: courseTitle })} + imageDropzoneText={intl.formatMessage(messages.uploadModalDropzoneText)} + imageHelpText={intl.formatMessage(messages.uploadModalHelperText)} + onSavingStatus={onSavingStatus} + invalidFileSizeMore={intl.formatMessage( + messages.uploadModalFileInvalidSizeText, + { maxSize: UPLOAD_FILE_MAX_SIZE / (1000 * 1000) }, + )} + onSelectFile={setSelectedFile} + previewComponent={( +
+ + {selectedFile} +
+ )} + maxSize={UPLOAD_FILE_MAX_SIZE} + /> + + + )} +
+
+ ); +}; + +TextbookForm.propTypes = { + closeTextbookForm: PropTypes.func.isRequired, + initialFormValues: PropTypes.shape({}).isRequired, + onSubmit: PropTypes.func.isRequired, + onSavingStatus: PropTypes.func.isRequired, + courseId: PropTypes.string.isRequired, +}; + +export default TextbookForm; diff --git a/src/textbooks/textbook-form/TextbookForm.scss b/src/textbooks/textbook-form/TextbookForm.scss new file mode 100644 index 0000000000..8db5d4c736 --- /dev/null +++ b/src/textbooks/textbook-form/TextbookForm.scss @@ -0,0 +1,67 @@ +.textbook-form { + @include pgn-box-shadow(1, "centered"); + + display: flex; + flex-direction: column; + gap: 1.5rem; + background-color: $white; + padding: map-get($spacers, 4); + margin-bottom: map-get($spacers, 4); + border-radius: .5rem; + + .form-field { + margin-bottom: 0; + + .pgn__form-group { + margin-bottom: 0; + } + } + + .form-title { + font-size: 1.5rem; + margin-bottom: map-get($spacers, 4); + } + + .form-main-label { + font-size: 1.375rem; + line-height: 1.75rem; + margin-bottom: map-get($spacers, 4); + } + + .form-label { + margin-bottom: map-get($spacers, 2\.5); + } + + .form-helper-text { + font-size: $font-size-xs; + } + + .form-chapters-fields { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2rem; + } + + .field-icon-button:hover { + background-color: transparent !important; + color: $primary; + } +} + +.modal-preview { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $spacer; + + .modal-preview-icon { + height: 6.25rem; + width: 6.25rem; + } + + .modal-preview-text { + font-size: .875rem; + } +} + diff --git a/src/textbooks/textbook-form/TextbookForm.test.jsx b/src/textbooks/textbook-form/TextbookForm.test.jsx new file mode 100644 index 0000000000..687f160095 --- /dev/null +++ b/src/textbooks/textbook-form/TextbookForm.test.jsx @@ -0,0 +1,177 @@ +import { AppProvider } from '@edx/frontend-platform/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 { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import initializeStore from '../../store'; +import { executeThunk } from '../../utils'; +import { getTextbookFormInitialValues } from '../utils'; +import { getUpdateTextbooksApiUrl } from '../data/api'; +import { createTextbookQuery } from '../data/thunk'; +import TextbookForm from './TextbookForm'; +import messages from './messages'; + +// eslint-disable-next-line no-unused-vars +let axiosMock; +let store; +const courseId = 'course-v1:org+101+101'; + +const closeTextbookFormMock = jest.fn(); +const initialFormValuesMock = getTextbookFormInitialValues(); +const onSubmitMock = jest.fn(); +const onSavingStatus = jest.fn(); + +const renderComponent = () => render( + + + + + , +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('renders TextbooksForm component correctly', async () => { + const { + getByText, getByRole, getByPlaceholderText, getByTestId, + } = renderComponent(); + + await waitFor(() => { + expect(getByText(`${messages.tabTitleLabel.defaultMessage} *`)).toBeInTheDocument(); + expect(getByPlaceholderText(messages.tabTitlePlaceholder.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.tabTitleHelperText.defaultMessage)).toBeInTheDocument(); + + expect(getByText(`${messages.chapterTitleLabel.defaultMessage} *`)).toBeInTheDocument(); + expect(getByPlaceholderText( + messages.chapterTitlePlaceholder.defaultMessage.replace('{value}', initialFormValuesMock.chapters.length), + )).toBeInTheDocument(); + expect(getByText(messages.chapterTitleHelperText.defaultMessage)).toBeInTheDocument(); + + expect(getByText(`${messages.chapterUrlLabel.defaultMessage} *`)).toBeInTheDocument(); + expect(getByPlaceholderText(messages.chapterUrlPlaceholder.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.chapterUrlHelperText.defaultMessage)).toBeInTheDocument(); + + expect(getByTestId('chapter-upload-button')).toBeInTheDocument(); + expect(getByTestId('chapter-delete-button')).toBeInTheDocument(); + + expect(getByRole('button', { name: messages.addChapterButton.defaultMessage })); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })); + expect(getByRole('button', { name: messages.saveButton.defaultMessage })); + }); + }); + + it('calls onSubmit when the "Save" button is clicked with a valid form', async () => { + const { getByPlaceholderText, getByRole } = renderComponent(); + + const tabTitleInput = getByPlaceholderText(messages.tabTitlePlaceholder.defaultMessage); + const chapterInput = getByPlaceholderText( + messages.chapterTitlePlaceholder.defaultMessage.replace('{value}', initialFormValuesMock.chapters.length), + ); + const urlInput = getByPlaceholderText(messages.chapterUrlPlaceholder.defaultMessage); + + const formValues = { + tab_title: 'Tab title', + chapters: [ + { + title: 'Chapter', + url: 'Url', + }, + ], + }; + + userEvent.type(tabTitleInput, formValues.tab_title); + userEvent.type(chapterInput, formValues.chapters[0].title); + userEvent.type(urlInput, formValues.chapters[0].url); + + userEvent.click(getByRole('button', { name: messages.saveButton.defaultMessage })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalledTimes(1); + expect(onSubmitMock).toHaveBeenCalledWith( + formValues, + expect.objectContaining({ submitForm: expect.any(Function) }), + ); + }); + + axiosMock + .onPost(getUpdateTextbooksApiUrl(courseId)) + .reply(200); + + await executeThunk(createTextbookQuery(courseId, formValues), store.dispatch); + }); + + it('"Save" button is disabled when the form is empty', async () => { + const { getByRole } = renderComponent(); + + await waitFor(() => { + const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); + expect(saveButton).toBeDisabled(); + }); + }); + + it('"Save" button is disabled when the chapters length less than 1', async () => { + const { getByRole, getByTestId } = renderComponent(); + + const deleteChapterButton = getByTestId('chapter-delete-button'); + const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); + + userEvent.click(deleteChapterButton); + + await waitFor(() => { + expect(saveButton).toBeDisabled(); + }); + }); + + it('"Cancel" button is disabled when the form is empty', async () => { + const { getByRole } = renderComponent(); + + await waitFor(() => { + const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); + expect(saveButton).toBeDisabled(); + }); + }); + + it('"Add a chapter" button add new chapters field', async () => { + const { getByRole, getAllByTestId } = renderComponent(); + + const addChapterButton = getByRole('button', { name: messages.addChapterButton.defaultMessage }); + + userEvent.click(addChapterButton); + + await waitFor(() => { + expect(getAllByTestId('form-chapters-fields')).toHaveLength(2); + }); + }); + + it('open modal dropzone when "Upload" button is clicked', async () => { + const { getByTestId } = renderComponent(); + + await waitFor(() => { + const button = getByTestId('chapter-upload-button'); + userEvent.click(button); + expect(getByTestId('modal-backdrop')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/textbooks/textbook-form/messages.js b/src/textbooks/textbook-form/messages.js new file mode 100644 index 0000000000..b4cac8fba4 --- /dev/null +++ b/src/textbooks/textbook-form/messages.js @@ -0,0 +1,124 @@ +const descriptions = { + tabTitleLabel: { + id: 'course-authoring.textbooks.form.tab-title.label', + defaultMessage: 'Textbook name', + description: 'Label for the textbook name field in the form', + }, + tabTitlePlaceholder: { + id: 'course-authoring.textbooks.form.tab-title.placeholder', + defaultMessage: 'Introduction to Cookie Baking', + description: 'Placeholder text for the textbook name field in the form', + }, + tabTitleHelperText: { + id: 'course-authoring.textbooks.form.tab-title.helper-text', + defaultMessage: 'provide the title/name of the textbook as you would like your students to see it', + description: 'Helper text for the textbook name field in the form', + }, + tabTitleValidationText: { + id: 'course-authoring.textbooks.form.tab-title.validation-text', + defaultMessage: 'Textbook name is required', + description: 'Validation error message for the textbook name field in the form', + }, + chapterTitleLabel: { + id: 'course-authoring.textbooks.form.chapter.title.label', + defaultMessage: 'Chapter name', + description: 'Label for the chapter name field in the form', + }, + chapterTitlePlaceholder: { + id: 'course-authoring.textbooks.form.chapter.title.placeholder', + defaultMessage: 'Chapter {value}', + description: 'Placeholder text for the chapter name field in the form', + }, + chapterTitleHelperText: { + id: 'course-authoring.textbooks.form.chapter.title.helper-text', + defaultMessage: 'provide the title/name of the chapter that will be used in navigating', + description: 'Helper text for the chapter name field in the form', + }, + chapterTitleValidationText: { + id: 'course-authoring.textbooks.form.chapter.title.validation-text', + defaultMessage: 'Chapter name is required', + description: 'Validation error message for the chapter name field in the form', + }, + chapterUrlLabel: { + id: 'course-authoring.textbooks.form.chapter.url.label', + defaultMessage: 'Chapter asset', + description: 'Label for the chapter asset field in the form', + }, + chapterUrlPlaceholder: { + id: 'course-authoring.textbooks.form.chapter.url.placeholder', + defaultMessage: 'path/to/introductionToCookieBaking-CH1.pdf', + description: 'Placeholder text for the chapter asset field in the form', + }, + chapterUrlHelperText: { + id: 'course-authoring.textbooks.form.chapter.url.helper-text', + defaultMessage: 'upload a PDF file or provide the path to a Studio asset file', + description: 'Helper text for the chapter asset field in the form', + }, + chapterUrlValidationText: { + id: 'course-authoring.textbooks.form.chapter.url.validation-text', + defaultMessage: 'Chapter asset is required', + description: 'Validation error message for the chapter asset field in the form', + }, + addChapterHelperText: { + id: 'course-authoring.textbooks.form.add-chapter.helper-text', + defaultMessage: 'Please add at least one chapter', + description: 'Helper text for adding a new chapter in the form', + }, + addChapterButton: { + id: 'course-authoring.textbooks.form.add-chapter.button', + defaultMessage: 'Add a chapter', + description: 'Text for the button to add a new chapter in the form', + }, + uploadButtonTooltip: { + id: 'course-authoring.textbooks.form.upload-button.tooltip', + defaultMessage: 'Upload', + description: 'Tooltip text for the upload button in the form', + }, + uploadButtonAlt: { + id: 'course-authoring.textbooks.form.upload-button.alt', + defaultMessage: 'chapter-upload-button', + description: 'Alt text for the upload button in the form', + }, + deleteButtonTooltip: { + id: 'course-authoring.textbooks.form.delete-button.tooltip', + defaultMessage: 'Delete', + description: 'Tooltip text for the delete button in the form', + }, + deleteButtonAlt: { + id: 'course-authoring.textbooks.form.delete-button.alt', + defaultMessage: 'chapter-delete-button', + description: 'Alt text for the delete button in the form', + }, + cancelButton: { + id: 'course-authoring.textbooks.form.button.cancel', + defaultMessage: 'Cancel', + description: 'Text for the cancel button in the form', + }, + saveButton: { + id: 'course-authoring.textbooks.form.button.save', + defaultMessage: 'Save', + description: 'Text for the save button in the form', + }, + uploadModalTitle: { + id: 'course-authoring.textbooks.form.upload-modal.title', + defaultMessage: 'Upload a new PDF to “{courseName}”', + description: 'Title for the upload modal in the form', + }, + uploadModalDropzoneText: { + id: 'course-authoring.textbooks.form.upload-modal.dropzone-text', + defaultMessage: 'Drag and drop your PDF file here or click to upload', + description: 'Text for the dropzone in the upload modal', + }, + uploadModalHelperText: { + id: 'course-authoring.textbooks.form.upload-modal.help-text', + defaultMessage: 'File must be in PDF format', + description: 'Helper text for the upload modal', + }, + uploadModalFileInvalidSizeText: { + id: 'course-authoring.textbooks.form.upload-modal.file-size-invalid-text', + defaultMessage: 'File size must be less than {maxSize}MB.', + description: 'Error message for invalid file size in the upload modal', + }, +}; + +export default descriptions; diff --git a/src/textbooks/textbook-form/validations.js b/src/textbooks/textbook-form/validations.js new file mode 100644 index 0000000000..43ff181852 --- /dev/null +++ b/src/textbooks/textbook-form/validations.js @@ -0,0 +1,15 @@ +import * as Yup from 'yup'; + +import messages from './messages'; + +const textbookFormValidationSchema = (intl) => Yup.object().shape({ + tab_title: Yup.string().required(intl.formatMessage(messages.tabTitleValidationText)).max(255), + chapters: Yup.array().of( + Yup.object({ + title: Yup.string().required((intl.formatMessage(messages.chapterTitleValidationText))).max(255), + url: Yup.string().required(intl.formatMessage(messages.chapterUrlValidationText)).max(255), + }), + ).min(1), +}); + +export default textbookFormValidationSchema; diff --git a/src/textbooks/textbook-sidebar/TextbookSidebar.jsx b/src/textbooks/textbook-sidebar/TextbookSidebar.jsx new file mode 100644 index 0000000000..993a0f2198 --- /dev/null +++ b/src/textbooks/textbook-sidebar/TextbookSidebar.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; + +import { Hyperlink } from '@openedx/paragon'; +import { HelpSidebar } from '../../generic/help-sidebar'; +import messages from './messages'; +import { useHelpUrls } from '../../help-urls/hooks'; + +const TextbookSidebar = ({ courseId }) => { + const intl = useIntl(); + const { textbooks: textbookUrl } = useHelpUrls(['textbooks']); + + return ( + +

+ {intl.formatMessage(messages.section_1_title)} +

+

+ {intl.formatMessage(messages.section_1_descriptions)} +

+
+

+ {intl.formatMessage(messages.section_2_title)} +

+

+ {intl.formatMessage(messages.section_2_descriptions)} +

+ + {intl.formatMessage(messages.sectionLink)} + +
+ ); +}; + +TextbookSidebar.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default TextbookSidebar; diff --git a/src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx b/src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx new file mode 100644 index 0000000000..c2d6538ec1 --- /dev/null +++ b/src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx @@ -0,0 +1,55 @@ +import MockAdapter from 'axios-mock-adapter'; +import { render, waitFor } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import initializeStore from '../../store'; +import { getHelpUrlsApiUrl } from '../../help-urls/data/api'; +import { helpUrls } from '../../help-urls/__mocks__'; +import TextbookSidebar from './TextbookSidebar'; +import messages from './messages'; + +let axiosMock; +let store; +const courseId = 'course-v1:org+101+101'; + +const renderComponent = () => render( + + + + + , +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getHelpUrlsApiUrl()) + .reply(200, helpUrls); + }); + + it('renders TextbookSidebar component correctly', async () => { + const { getByText } = renderComponent(); + + await waitFor(() => { + expect(getByText(messages.section_1_title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_1_descriptions.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_2_title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_2_descriptions.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.sectionLink.defaultMessage)).toHaveAttribute('href', helpUrls.textbooks); + }); + }); +}); diff --git a/src/textbooks/textbook-sidebar/messages.js b/src/textbooks/textbook-sidebar/messages.js new file mode 100644 index 0000000000..b58ac3fc8b --- /dev/null +++ b/src/textbooks/textbook-sidebar/messages.js @@ -0,0 +1,29 @@ +const descriptions = { + section_1_title: { + id: 'course-authoring.textbooks.sidebar.section-1.title', + defaultMessage: 'Why should I break my textbook into chapters?', + description: 'Title for section 1 in the textbooks sidebar', + }, + section_1_descriptions: { + id: 'course-authoring.textbooks.sidebar.section-1.descriptions', + defaultMessage: 'Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.', + description: 'Description for section 1 in the textbooks sidebar', + }, + section_2_title: { + id: 'course-authoring.textbooks.sidebar.section-2.title', + defaultMessage: 'What if my book isn\'t divided into chapters?', + description: 'Title for section 2 in the textbooks sidebar', + }, + section_2_descriptions: { + id: 'course-authoring.textbooks.sidebar.section-2.descriptions', + defaultMessage: 'If your textbook doesn\'t have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.', + description: 'Description for section 2 in the textbooks sidebar', + }, + sectionLink: { + id: 'course-authoring.textbooks.sidebar.section-link', + defaultMessage: 'Learn more', + description: 'Text for the link to learn more in the textbooks sidebar', + }, +}; + +export default descriptions; diff --git a/src/textbooks/utils.js b/src/textbooks/utils.js new file mode 100644 index 0000000000..213c2a0372 --- /dev/null +++ b/src/textbooks/utils.js @@ -0,0 +1,22 @@ +/** + * Get textbook form initial values + * @param {boolean} isEditForm - edit or add new form value + * @param {object} textbook - value from api + * @returns {object} + */ +const getTextbookFormInitialValues = (isEditForm = false, textbook = {}) => (isEditForm + ? textbook + : { + tab_title: '', + chapters: [ + { + title: '', + url: '', + }, + ], + }); + +export { + // eslint-disable-next-line import/prefer-default-export + getTextbookFormInitialValues, +};