feat: [FC-0044] Textbooks Page (#890)
Implement Textbooks page. --------- Co-authored-by: Glib Glugovskiy <glib.glugovskiy@raccoongang.com>
This commit is contained in:
@@ -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={<PageWrap><Certificates courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="textbooks"
|
||||
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
</Routes>
|
||||
</CourseAuthoringPage>
|
||||
);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
87
src/textbooks/Textbook.test.jsx
Normal file
87
src/textbooks/Textbook.test.jsx
Normal file
@@ -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(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<Textbooks courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<Textbooks />', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
147
src/textbooks/Textbooks.jsx
Normal file
147
src/textbooks/Textbooks.jsx
Normal file
@@ -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 (
|
||||
<Row className="m-0 mt-4 justify-content-center">
|
||||
<LoadingSpinner />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{`${courseDetails?.name} | ${intl.formatMessage(messages.headingTitle)}`}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size="xl" className="px-4">
|
||||
<section className="mb-4 mt-5">
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
breadcrumbs={(
|
||||
<Breadcrumb ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)} links={breadcrumbs} />
|
||||
)}
|
||||
headerActions={(
|
||||
<Button
|
||||
iconBefore={AddIcon}
|
||||
onClick={openTextbookForm}
|
||||
disabled={isTextbookFormOpen}
|
||||
>
|
||||
{intl.formatMessage(messages.newTextbookButton)}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
sm={[{ span: 12 }, { span: 12 }]}
|
||||
xs={[{ span: 12 }, { span: 12 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<article>
|
||||
<section className="textbook-section">
|
||||
<div className="pt-4">
|
||||
{textbooks.length ? textbooks.map((textbook, index) => (
|
||||
<TextbookCard
|
||||
key={textbook.id}
|
||||
textbook={textbook}
|
||||
courseId={courseId}
|
||||
handleSavingStatusDispatch={handleSavingStatusDispatch}
|
||||
onEditSubmit={handleTextbookEditFormSubmit}
|
||||
onDeleteSubmit={handleTextbookDeleteSubmit}
|
||||
textbookIndex={index}
|
||||
/>
|
||||
)) : (
|
||||
!isTextbookFormOpen && <EmptyPlaceholder onCreateNewTextbook={openTextbookForm} />
|
||||
)}
|
||||
{isTextbookFormOpen && (
|
||||
<TextbookForm
|
||||
closeTextbookForm={closeTextbookForm}
|
||||
initialFormValues={getTextbookFormInitialValues()}
|
||||
onSubmit={handleTextbookFormSubmit}
|
||||
onSavingStatus={handleSavingStatusDispatch}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<TextbookSidebar courseId={courseId} />
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
</Container>
|
||||
<ProcessingNotification
|
||||
isShow={showProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
/>
|
||||
<div className="alert-toast">
|
||||
<InternetConnectionAlert
|
||||
isFailed={isInternetConnectionAlertFailed}
|
||||
isQueryPending={isQueryPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Textbooks.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default Textbooks;
|
||||
7
src/textbooks/Textbooks.scss
Normal file
7
src/textbooks/Textbooks.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@import "./empty-placeholder/EmptyPlaceholder";
|
||||
@import "./textbook-card/TextbookCard";
|
||||
@import "./textbook-form/TextbookForm";
|
||||
|
||||
.alert-toast {
|
||||
z-index: $zindex-tooltip !important;
|
||||
}
|
||||
2
src/textbooks/__mocks__/index.js
Normal file
2
src/textbooks/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as textbooksMock } from './textbooksMock';
|
||||
28
src/textbooks/__mocks__/textbooksMock.js
Normal file
28
src/textbooks/__mocks__/textbooksMock.js
Normal file
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
62
src/textbooks/data/api.js
Normal file
62
src/textbooks/data/api.js
Normal file
@@ -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<Object>}
|
||||
*/
|
||||
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<Object>}
|
||||
*/
|
||||
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<Object>}
|
||||
*/
|
||||
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<Object>}
|
||||
*/
|
||||
export async function deleteTextbook(courseId, textbookId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getEditTextbooksApiUrl(courseId, textbookId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
82
src/textbooks/data/api.test.js
Normal file
82
src/textbooks/data/api.test.js
Normal file
@@ -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({});
|
||||
});
|
||||
});
|
||||
4
src/textbooks/data/selectors.js
Normal file
4
src/textbooks/data/selectors.js
Normal file
@@ -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;
|
||||
51
src/textbooks/data/slice.js
Normal file
51
src/textbooks/data/slice.js
Normal file
@@ -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;
|
||||
118
src/textbooks/data/slice.test.jsx
Normal file
118
src/textbooks/data/slice.test.jsx
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
85
src/textbooks/data/thunk.js
Normal file
85
src/textbooks/data/thunk.js
Normal file
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
139
src/textbooks/data/thunk.test.js
Normal file
139
src/textbooks/data/thunk.test.js
Normal file
@@ -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());
|
||||
});
|
||||
});
|
||||
25
src/textbooks/empty-placeholder/EmptyPlaceholder.jsx
Normal file
25
src/textbooks/empty-placeholder/EmptyPlaceholder.jsx
Normal file
@@ -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 (
|
||||
<div className="textbooks-empty-placeholder bg-white" data-testid="textbooks-empty-placeholder">
|
||||
<p className="mb-0 small text-gray-700">{intl.formatMessage(messages.title)}</p>
|
||||
<Button iconBefore={IconAdd} onClick={onCreateNewTextbook}>
|
||||
{intl.formatMessage(messages.button)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EmptyPlaceholder.propTypes = {
|
||||
onCreateNewTextbook: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EmptyPlaceholder;
|
||||
10
src/textbooks/empty-placeholder/EmptyPlaceholder.scss
Normal file
10
src/textbooks/empty-placeholder/EmptyPlaceholder.scss
Normal file
@@ -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);
|
||||
}
|
||||
31
src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx
Normal file
31
src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx
Normal file
@@ -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(
|
||||
<IntlProvider locale="en">
|
||||
<EmptyPlaceholder onCreateNewTextbook={onCreateNewTextbookMock} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<EmptyPlaceholder />', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
14
src/textbooks/empty-placeholder/messages.js
Normal file
14
src/textbooks/empty-placeholder/messages.js
Normal file
@@ -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;
|
||||
93
src/textbooks/hooks.jsx
Normal file
93
src/textbooks/hooks.jsx
Normal file
@@ -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 };
|
||||
2
src/textbooks/index.js
Normal file
2
src/textbooks/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as Textbooks } from './Textbooks';
|
||||
29
src/textbooks/messages.js
Normal file
29
src/textbooks/messages.js
Normal file
@@ -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;
|
||||
42
src/textbooks/textbook-card/TextbookCard.scss
Normal file
42
src/textbooks/textbook-card/TextbookCard.scss
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
146
src/textbooks/textbook-card/TextbooksCard.jsx
Normal file
146
src/textbooks/textbook-card/TextbooksCard.jsx
Normal file
@@ -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 ? (
|
||||
<TextbookForm
|
||||
closeTextbookForm={closeTextbookForm}
|
||||
initialFormValues={getTextbookFormInitialValues(true, { tab_title: tabTitle, chapters, id })}
|
||||
onSubmit={onEditSubmit}
|
||||
onSavingStatus={handleSavingStatusDispatch}
|
||||
courseId={courseId}
|
||||
/>
|
||||
) : (
|
||||
(
|
||||
<Card className="textbook-card" data-testid="textbook-card">
|
||||
<Card.Header
|
||||
title={tabTitle}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<IconButtonWithTooltip
|
||||
tooltipContent={intl.formatMessage(messages.buttonView)}
|
||||
src={ViewIcon}
|
||||
iconAs={Icon}
|
||||
data-testid="textbook-view-button"
|
||||
onClick={onPreviewTextbookClick}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
tooltipContent={intl.formatMessage(messages.buttonEdit)}
|
||||
src={EditIcon}
|
||||
iconAs={Icon}
|
||||
data-testid="textbook-edit-button"
|
||||
onClick={openTextbookForm}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
tooltipContent={intl.formatMessage(messages.buttonDelete)}
|
||||
src={DeleteIcon}
|
||||
iconAs={Icon}
|
||||
data-testid="textbook-delete-button"
|
||||
onClick={openDeleteModal}
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<div className="textbook-card__chapters">
|
||||
<Collapsible
|
||||
styling="basic"
|
||||
data-testid="chapters-button"
|
||||
title={intl.formatMessage(messages.chaptersTitle, { count: chapters.length })}
|
||||
>
|
||||
{chapters.map(({ title, url }) => (
|
||||
<div className="textbook-card__chapter-item" key={title}>
|
||||
<span className="small">{title}</span>
|
||||
<span className="small text-gray-700">{url}</span>
|
||||
</div>
|
||||
))}
|
||||
</Collapsible>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
close={closeDeleteModal}
|
||||
title={intl.formatMessage(messages.deleteModalTitle, { textbookTitle: textbook.tabTitle })}
|
||||
description={intl.formatMessage(messages.deleteModalDescription)}
|
||||
onDeleteSubmit={handleDeleteButtonSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
192
src/textbooks/textbook-card/TextbooksCard.test.jsx
Normal file
192
src/textbooks/textbook-card/TextbooksCard.test.jsx
Normal file
@@ -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(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<TextbookCard
|
||||
textbook={textbook}
|
||||
courseId={courseId}
|
||||
onEditSubmit={onEditSubmitMock}
|
||||
onDeleteSubmit={onDeleteSubmitMock}
|
||||
handleSavingStatusDispatch={handleSavingStatusDispatchMock}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<TextbookCard />', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/textbooks/textbook-card/messages.js
Normal file
49
src/textbooks/textbook-card/messages.js
Normal file
@@ -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;
|
||||
201
src/textbooks/textbook-form/TextbookForm.jsx
Normal file
201
src/textbooks/textbook-form/TextbookForm.jsx
Normal file
@@ -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 (
|
||||
<div className="textbook-form" data-testid="textbook-form">
|
||||
<Formik
|
||||
initialValues={initialFormValues}
|
||||
validationSchema={textbookFormValidationSchema(intl)}
|
||||
onSubmit={onSubmit}
|
||||
validateOnBlur
|
||||
validateOnMount
|
||||
>
|
||||
{({
|
||||
values, handleSubmit, isValid, dirty, setFieldValue,
|
||||
}) => (
|
||||
<>
|
||||
<Form.Group size="sm" className="form-field">
|
||||
<Form.Label size="sm" className="font-weight-bold form-main-label text-black">
|
||||
{intl.formatMessage(messages.tabTitleLabel)} *
|
||||
</Form.Label>
|
||||
<FormikControl
|
||||
name="tab_title"
|
||||
value={values.tab_title}
|
||||
placeholder={intl.formatMessage(messages.tabTitlePlaceholder)}
|
||||
/>
|
||||
<Form.Control.Feedback className="form-helper-text">
|
||||
{intl.formatMessage(messages.tabTitleHelperText)}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<FieldArray
|
||||
name="chapters"
|
||||
render={(arrayHelpers) => (
|
||||
<>
|
||||
{!!values?.chapters.length && values.chapters.map(({ title, url }, index) => (
|
||||
<div className="form-chapters-fields" data-testid="form-chapters-fields">
|
||||
<Form.Group size="sm" className="form-field">
|
||||
<Form.Label size="sm" className="form-label font-weight-bold required text-black">
|
||||
{intl.formatMessage(messages.chapterTitleLabel)} *
|
||||
</Form.Label>
|
||||
<FormikControl
|
||||
name={`chapters[${index}].title`}
|
||||
value={title}
|
||||
placeholder={intl.formatMessage(messages.chapterTitlePlaceholder, { value: index + 1 })}
|
||||
/>
|
||||
<Form.Control.Feedback className="form-helper-text">
|
||||
{intl.formatMessage(messages.chapterTitleHelperText)}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Form.Group size="sm" className="form-field">
|
||||
<div className="d-flex align-items-center mb-1">
|
||||
<Form.Label size="sm" className="font-weight-bold mb-0 text-black">
|
||||
{intl.formatMessage(messages.chapterUrlLabel)} *
|
||||
</Form.Label>
|
||||
<IconButtonWithTooltip
|
||||
size="sm"
|
||||
className="ml-auto field-icon-button"
|
||||
tooltipContent={intl.formatMessage(messages.uploadButtonTooltip)}
|
||||
src={UploadIcon}
|
||||
iconAs={Icon}
|
||||
data-testid="chapter-upload-button"
|
||||
alt={intl.formatMessage(messages.uploadButtonAlt)}
|
||||
onClick={() => onUploadButtonClick(index)}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
size="sm"
|
||||
className="field-icon-button"
|
||||
tooltipContent={intl.formatMessage(messages.deleteButtonTooltip)}
|
||||
src={DeleteIcon}
|
||||
iconAs={Icon}
|
||||
data-testid="chapter-delete-button"
|
||||
alt={intl.formatMessage(messages.deleteButtonAlt)}
|
||||
onClick={() => arrayHelpers.remove(index)}
|
||||
/>
|
||||
</div>
|
||||
<FormikControl
|
||||
name={`chapters[${index}].url`}
|
||||
value={url}
|
||||
placeholder={intl.formatMessage(messages.chapterUrlPlaceholder)}
|
||||
/>
|
||||
<Form.Control.Feedback className="form-helper-text">
|
||||
{intl.formatMessage(messages.chapterUrlHelperText)}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
{!values.chapters.length && (
|
||||
<Form.Control.Feedback className="pgn__form-text-invalid mb-2">
|
||||
{intl.formatMessage(messages.addChapterHelperText)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
className="w-100"
|
||||
iconBefore={AddIcon}
|
||||
onClick={() => arrayHelpers.push({ title: '', url: '' })}
|
||||
>
|
||||
{intl.formatMessage(messages.addChapterButton)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={closeTextbookForm} data-testid="cancel-button">
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid} type="submit">
|
||||
{intl.formatMessage(messages.saveButton)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
<ModalDropzone
|
||||
isOpen={isUploadModalOpen}
|
||||
onClose={onCloseUploadModal}
|
||||
onCancel={onCloseUploadModal}
|
||||
onChange={(value) => 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={(
|
||||
<div className="modal-preview">
|
||||
<Icon src={PdfIcon} className="modal-preview-icon" />
|
||||
<span className="modal-preview-text">{selectedFile}</span>
|
||||
</div>
|
||||
)}
|
||||
maxSize={UPLOAD_FILE_MAX_SIZE}
|
||||
/>
|
||||
<PromptIfDirty dirty={dirty} />
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
67
src/textbooks/textbook-form/TextbookForm.scss
Normal file
67
src/textbooks/textbook-form/TextbookForm.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
177
src/textbooks/textbook-form/TextbookForm.test.jsx
Normal file
177
src/textbooks/textbook-form/TextbookForm.test.jsx
Normal file
@@ -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(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<TextbookForm
|
||||
closeTextbookForm={closeTextbookFormMock}
|
||||
initialFormValues={initialFormValuesMock}
|
||||
onSubmit={onSubmitMock}
|
||||
onSavingStatus={onSavingStatus}
|
||||
courseId={courseId}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<TextbookForm />', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
124
src/textbooks/textbook-form/messages.js
Normal file
124
src/textbooks/textbook-form/messages.js
Normal file
@@ -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;
|
||||
15
src/textbooks/textbook-form/validations.js
Normal file
15
src/textbooks/textbook-form/validations.js
Normal file
@@ -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;
|
||||
45
src/textbooks/textbook-sidebar/TextbookSidebar.jsx
Normal file
45
src/textbooks/textbook-sidebar/TextbookSidebar.jsx
Normal file
@@ -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 (
|
||||
<HelpSidebar courseId={courseId} className="pt-4">
|
||||
<h4 className="help-sidebar-about-title">
|
||||
{intl.formatMessage(messages.section_1_title)}
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.section_1_descriptions)}
|
||||
</p>
|
||||
<hr className="my-3.5" />
|
||||
<h4 className="help-sidebar-about-title">
|
||||
{intl.formatMessage(messages.section_2_title)}
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.section_2_descriptions)}
|
||||
</p>
|
||||
<Hyperlink
|
||||
className="small"
|
||||
destination={textbookUrl}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.sectionLink)}
|
||||
</Hyperlink>
|
||||
</HelpSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
TextbookSidebar.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default TextbookSidebar;
|
||||
55
src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx
Normal file
55
src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx
Normal file
@@ -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(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<TextbookSidebar courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<TextbookSidebar />', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
29
src/textbooks/textbook-sidebar/messages.js
Normal file
29
src/textbooks/textbook-sidebar/messages.js
Normal file
@@ -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;
|
||||
22
src/textbooks/utils.js
Normal file
22
src/textbooks/utils.js
Normal file
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user