feat: [FC-0044] Textbooks Page (#890)

Implement Textbooks page.

---------

Co-authored-by: Glib Glugovskiy <glib.glugovskiy@raccoongang.com>
This commit is contained in:
vladislavkeblysh
2024-05-01 00:38:43 +03:00
committed by GitHub
parent a9a73efbb6
commit 65f45f72f0
45 changed files with 2393 additions and 16 deletions

View File

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

View File

@@ -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' },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -26,6 +26,7 @@ import { reducer as courseOutlineReducer } from './course-outline/data/slice';
import { reducer as courseUnitReducer } from './course-unit/data/slice';
import { reducer as courseChecklistReducer } from './course-checklist/data/slice';
import { reducer as accessibilityPageReducer } from './accessibility-page/data/slice';
import { reducer as 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,
});

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

View File

@@ -0,0 +1,7 @@
@import "./empty-placeholder/EmptyPlaceholder";
@import "./textbook-card/TextbookCard";
@import "./textbook-form/TextbookForm";
.alert-toast {
z-index: $zindex-tooltip !important;
}

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

29
src/textbooks/messages.js Normal file
View 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;

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

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

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

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

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

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

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

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

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

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

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

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