diff --git a/src/generic/modal-dropzone/useModalDropzone.jsx b/src/generic/modal-dropzone/useModalDropzone.jsx index 19f2d7ae41..01d8e7649f 100644 --- a/src/generic/modal-dropzone/useModalDropzone.jsx +++ b/src/generic/modal-dropzone/useModalDropzone.jsx @@ -96,7 +96,7 @@ const useModalDropzone = ({ const handleUpload = async () => { if (!selectedFile) { return; } - onSavingStatus(RequestStatus.PENDING); + onSavingStatus({ status: RequestStatus.PENDING }); const onUploadProgress = (progressEvent) => { const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); diff --git a/src/store.ts b/src/store.ts index 53509221e2..9f11e2a666 100644 --- a/src/store.ts +++ b/src/store.ts @@ -20,7 +20,6 @@ import { reducer as genericReducer } from './generic/data/slice'; import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice'; import { reducer as courseOutlineReducer } from './course-outline/data/slice'; import { reducer as courseUnitReducer } from './course-unit/data/slice'; -import { reducer as textbooksReducer } from './textbooks/data/slice'; import { reducer as certificatesReducer } from './certificates/data/slice'; type InferState = ReducerType extends Reducer ? T : never; @@ -52,7 +51,6 @@ export interface DeprecatedReduxState { componentMode: (typeof MODE_STATES)[keyof typeof MODE_STATES]; certificatesData: any; }; - textbooks: Record; } export default function initializeStore(preloadedState: Partial | undefined = undefined) { @@ -73,7 +71,6 @@ export default function initializeStore(preloadedState: Partial - render( - - - , - ); - -describe('', () => { - beforeEach(async () => { - const mocks = initializeMocks(); - - store = mocks.reduxStore; - axiosMock = mocks.axiosMock; - 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 user = userEvent.setup(); - const { getByTestId, getByRole } = renderComponent(); - - await waitFor(async () => { - const newTextbookButton = getByRole('button', { name: messages.newTextbookButton.defaultMessage }); - await user.click(newTextbookButton); - expect(getByTestId('textbook-form')).toBeInTheDocument(); - }); - }); - - it('renders Textbooks component with empty placeholder correctly', async () => { - 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); - }); - }); - - it('displays an alert and sets status to FAILED when API responds with 403', async () => { - axiosMock - .onGet(getTextbooksApiUrl(courseId)) - .reply(403); - await executeThunk(fetchTextbooksQuery(courseId), store.dispatch); - const { getByTestId } = renderComponent(); - - await waitFor(() => { - expect(getByTestId('connectionErrorAlert')).toBeInTheDocument(); - expect(store.getState().textbooks.loadingStatus).toBe( - RequestStatus.FAILED, - ); - }); - }); -}); diff --git a/src/textbooks/Textbook.test.tsx b/src/textbooks/Textbook.test.tsx new file mode 100644 index 0000000000..1d1d683164 --- /dev/null +++ b/src/textbooks/Textbook.test.tsx @@ -0,0 +1,74 @@ +import userEvent from '@testing-library/user-event'; + +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { + initializeMocks, + render, + screen, +} from '@src/testUtils'; +import { getTextbooksApiUrl } from './data/api'; +import { textbooksMock } from './__mocks__'; +import { Textbooks } from '.'; +import messages from './messages'; + +let axiosMock; +const courseId = 'course-v1:org+101+101'; +const emptyTextbooksMock = { textbooks: [] }; + +const renderComponent = () => + render( + + + , + ); + +describe('', () => { + beforeEach(async () => { + const mocks = initializeMocks(); + + axiosMock = mocks.axiosMock; + axiosMock + .onGet(getTextbooksApiUrl(courseId)) + .reply(200, textbooksMock); + }); + + it('renders Textbooks component correctly', async () => { + renderComponent(); + + expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.breadcrumbContent.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.breadcrumbPagesAndResources.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.newTextbookButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getAllByTestId('textbook-card')).toHaveLength(2); + expect(screen.queryByTestId('textbooks-empty-placeholder')).not.toBeInTheDocument(); + }); + + it('renders textbooks form when "New textbooks" button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const newTextbookButton = await screen.findByRole('button', { name: messages.newTextbookButton.defaultMessage }); + await user.click(newTextbookButton); + expect(screen.getByTestId('textbook-form')).toBeInTheDocument(); + }); + + it('renders Textbooks component with empty placeholder correctly', async () => { + axiosMock + .onGet(getTextbooksApiUrl(courseId)) + .reply(200, emptyTextbooksMock); + + renderComponent(); + + expect(await screen.findByTestId('textbooks-empty-placeholder')).toBeInTheDocument(); + expect(screen.queryAllByTestId('textbook-card')).toHaveLength(0); + }); + + it('displays an alert when API responds with 403', async () => { + axiosMock + .onGet(getTextbooksApiUrl(courseId)) + .reply(403); + renderComponent(); + + expect(await screen.findByTestId('connectionErrorAlert')).toBeInTheDocument(); + }); +}); diff --git a/src/textbooks/Textbooks.jsx b/src/textbooks/Textbooks.tsx similarity index 80% rename from src/textbooks/Textbooks.jsx rename to src/textbooks/Textbooks.tsx index 228d1d4d22..c1102b1c86 100644 --- a/src/textbooks/Textbooks.jsx +++ b/src/textbooks/Textbooks.tsx @@ -10,13 +10,11 @@ import { Add as AddIcon } from '@openedx/paragon/icons'; import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { RequestStatus } from '@src/data/constants'; +import { SavingErrorAlert } from '@src/generic/saving-error-alert'; +import { LoadingSpinner } from '@src/generic/Loading'; +import SubHeader from '@src/generic/sub-header/SubHeader'; -import { useWaffleFlags } from '../data/apiHooks'; -import { SavingErrorAlert } from '../generic/saving-error-alert'; -import { LoadingSpinner } from '../generic/Loading'; -import SubHeader from '../generic/sub-header/SubHeader'; -import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; +import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert'; import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import TextbookCard from './textbook-card/TextbooksCard'; import TextbookSidebar from './textbook-sidebar/TextbookSidebar'; @@ -27,24 +25,22 @@ import messages from './messages'; const Textbooks = () => { const intl = useIntl(); - const { courseId, courseDetails } = useCourseAuthoringContext(); - const waffleFlags = useWaffleFlags(courseId); + const { courseDetails } = useCourseAuthoringContext(); const { textbooks, isLoading, isLoadingFailed, breadcrumbs, - errorMessage, - savingStatus, + mutationErrorMessage, + anyMutationFailed, isTextbookFormOpen, openTextbookForm, closeTextbookForm, handleTextbookFormSubmit, - handleSavingStatusDispatch, handleTextbookEditFormSubmit, handleTextbookDeleteSubmit, - } = useTextbooks(courseId, waffleFlags); + } = useTextbooks(); if (isLoadingFailed) { return ( @@ -106,8 +102,6 @@ const Textbooks = () => { { closeTextbookForm={closeTextbookForm} initialFormValues={getTextbookFormInitialValues()} onSubmit={handleTextbookFormSubmit} - onSavingStatus={handleSavingStatusDispatch} /> )} @@ -129,15 +122,15 @@ const Textbooks = () => { - +
diff --git a/src/textbooks/data/api.test.js b/src/textbooks/data/api.test.ts similarity index 70% rename from src/textbooks/data/api.test.js rename to src/textbooks/data/api.test.ts index 1f773cb23d..622e1e9b88 100644 --- a/src/textbooks/data/api.test.js +++ b/src/textbooks/data/api.test.ts @@ -1,6 +1,4 @@ -import MockAdapter from 'axios-mock-adapter'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { initializeMockApp } from '@edx/frontend-platform'; +import { initializeMocks } from '@src/testUtils'; import { textbooksMock } from 'CourseAuthoring/textbooks/__mocks__'; import { @@ -18,25 +16,14 @@ const courseId = 'course-v1:org+101+101'; describe('getTextbooks', () => { beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); + const mocks = initializeMocks(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock = mocks.axiosMock; 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); @@ -49,7 +36,7 @@ describe('getTextbooks', () => { describe('createTextbook', () => { it('should create a new textbook for a course', async () => { - const textbookData = { title: 'New Textbook', chapters: [] }; + const textbookData = { tabTitle: 'New Textbook', chapters: [] }; axiosMock.onPost(getUpdateTextbooksApiUrl(courseId)).reply(200, textbookData); const result = await createTextbook(courseId, textbookData); @@ -61,7 +48,7 @@ describe('createTextbook', () => { describe('editTextbook', () => { it('should edit an existing textbook for a course', async () => { const textbookId = '1'; - const editedTextbookData = { id: '1', title: 'Edited Textbook', chapters: [] }; + const editedTextbookData = { id: '1', tabTitle: 'Edited Textbook', chapters: [] }; axiosMock.onPut(getEditTextbooksApiUrl(courseId, textbookId)).reply(200, editedTextbookData); const result = await editTextbook(courseId, editedTextbookData); @@ -75,8 +62,6 @@ describe('deleteTextbook', () => { const textbookId = '1'; axiosMock.onDelete(getEditTextbooksApiUrl(courseId, textbookId)).reply(200, {}); - const result = await deleteTextbook(courseId, textbookId); - - expect(result).toEqual({}); + await expect(deleteTextbook(courseId, textbookId)).resolves.toBeUndefined(); }); }); diff --git a/src/textbooks/data/api.js b/src/textbooks/data/api.ts similarity index 50% rename from src/textbooks/data/api.js rename to src/textbooks/data/api.ts index 2c46797af7..456e9664e1 100644 --- a/src/textbooks/data/api.js +++ b/src/textbooks/data/api.ts @@ -5,18 +5,32 @@ import { omit } from 'lodash'; const API_PATH_PATTERN = 'textbooks'; const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getTextbooksApiUrl = (courseId) => +export const getTextbooksApiUrl = (courseId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/${API_PATH_PATTERN}/${courseId}`; -export const getUpdateTextbooksApiUrl = (courseId) => `${getStudioBaseUrl()}/${API_PATH_PATTERN}/${courseId}`; -export const getEditTextbooksApiUrl = (courseId, textbookId) => +export const getUpdateTextbooksApiUrl = (courseId: string) => `${getStudioBaseUrl()}/${API_PATH_PATTERN}/${courseId}`; +export const getEditTextbooksApiUrl = (courseId: string, textbookId: string) => `${getStudioBaseUrl()}/${API_PATH_PATTERN}/${courseId}/${textbookId}`; +export interface TextbookResponse { + textbooks: Textbook[]; +} + +export interface BaseTextbook { + chapters: { + title: string; + url: string; + }[]; + tabTitle: string; +} + +export interface Textbook extends BaseTextbook { + id: string; +} + /** * Get textbooks for course. - * @param {string} courseId - * @returns {Promise} */ -export async function getTextbooks(courseId) { +export async function getTextbooks(courseId: string): Promise { const { data } = await getAuthenticatedHttpClient() .get(getTextbooksApiUrl(courseId)); @@ -25,11 +39,8 @@ export async function getTextbooks(courseId) { /** * Create new textbook for course. - * @param {string} courseId - * @param {tab_title: string, chapters: Array<[title: string: url: string]>} textbook - * @returns {Promise} */ -export async function createTextbook(courseId, textbook) { +export async function createTextbook(courseId: string, textbook: BaseTextbook): Promise { const { data } = await getAuthenticatedHttpClient() .post(getUpdateTextbooksApiUrl(courseId), textbook); @@ -38,12 +49,8 @@ export async function createTextbook(courseId, textbook) { /** * Edit textbook for course. - * @param {string} courseId - * @param {tab_title: string, id: string, chapters: Array<[title: string: url: string]>} textbook - * @param {string} textbookId - * @returns {Promise} */ -export async function editTextbook(courseId, textbook) { +export async function editTextbook(courseId: string, textbook: Textbook): Promise { const { data } = await getAuthenticatedHttpClient() .put(getEditTextbooksApiUrl(courseId, textbook.id), omit(textbook, ['id'])); @@ -51,14 +58,9 @@ export async function editTextbook(courseId, textbook) { } /** - * Edit textbook for course. - * @param {string} courseId - * @param {string} textbookId - * @returns {Promise} + * Delete textbook for course. */ -export async function deleteTextbook(courseId, textbookId) { - const { data } = await getAuthenticatedHttpClient() +export async function deleteTextbook(courseId: string, textbookId: string): Promise { + await getAuthenticatedHttpClient() .delete(getEditTextbooksApiUrl(courseId, textbookId)); - - return camelCaseObject(data); } diff --git a/src/textbooks/data/apiHooks.ts b/src/textbooks/data/apiHooks.ts new file mode 100644 index 0000000000..ea852d9d23 --- /dev/null +++ b/src/textbooks/data/apiHooks.ts @@ -0,0 +1,60 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { useMutationWithProcessingNotification } from '@src/generic/processing-notification/data/apiHooks'; +import * as api from './api'; + +export const textbooksQueryKeys = { + all: ['textbooks'], + /** Base key for textbook data specific to a courseId */ + textbooks: (courseId: string) => [...textbooksQueryKeys.all, courseId], +}; + +/** + * Hook to fetch textbooks for the given courseId + */ +export const useGetTextbooks = (courseId: string) => ( + useQuery({ + queryKey: textbooksQueryKeys.textbooks(courseId), + queryFn: () => api.getTextbooks(courseId), + }) +); + +/** + * Hook to create a new textbook for a course + */ +export const useCreateTextbook = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutationWithProcessingNotification({ + mutationFn: (textbook: api.BaseTextbook) => api.createTextbook(courseId, textbook), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: textbooksQueryKeys.textbooks(courseId) }); + }, + }); +}; + +/** + * Hook to edit an existing textbook for a course + */ +export const useEditTextbook = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutationWithProcessingNotification({ + mutationFn: (textbook: api.Textbook) => api.editTextbook(courseId, textbook), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: textbooksQueryKeys.textbooks(courseId) }); + }, + }); +}; + +/** + * Hook to delete a textbook from a course + */ +export const useDeleteTextbook = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutationWithProcessingNotification({ + mutationFn: (textbookId: string) => api.deleteTextbook(courseId, textbookId), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: textbooksQueryKeys.textbooks(courseId) }); + }, + }); +}; diff --git a/src/textbooks/data/selectors.js b/src/textbooks/data/selectors.js deleted file mode 100644 index db2acc36da..0000000000 --- a/src/textbooks/data/selectors.js +++ /dev/null @@ -1,5 +0,0 @@ -export const getTextbooksData = (state) => state.textbooks.textbooks; -export const getLoadingStatus = (state) => state.textbooks.loadingStatus; -export const getSavingStatus = (state) => state.textbooks.savingStatus; -export const getErrorMessage = (state) => state.textbooks.errorMessage; -export const getCurrentTextbookId = (state) => state.textbooks.currentTextbookId; diff --git a/src/textbooks/data/slice.js b/src/textbooks/data/slice.js deleted file mode 100644 index d7622e42ee..0000000000 --- a/src/textbooks/data/slice.js +++ /dev/null @@ -1,54 +0,0 @@ -/* 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: [], - errorMessage: '', - currentTextbookId: '', - }, - reducers: { - fetchTextbooks: (state, { payload }) => { - state.textbooks = payload.textbooks; - }, - updateLoadingStatus: (state, { payload }) => { - state.loadingStatus = payload.status; - }, - updateSavingStatus: (state, { payload }) => { - const { status, errorMessage } = payload; - state.savingStatus = status; - state.errorMessage = errorMessage; - }, - createTextbookSuccess: (state, { payload }) => { - state.textbooks = [...state.textbooks, payload]; - }, - editTextbookSuccess: (state, { payload }) => { - state.currentTextbookId = payload.id; - state.textbooks = state.textbooks.map((textbook) => { - if (textbook.id === payload.id) { - return payload; - } - return textbook; - }); - }, - deleteTextbookSuccess: (state, { payload }) => { - state.textbooks = state.textbooks.filter(({ id }) => id !== payload); - }, - }, -}); - -export const { - fetchTextbooks, - updateLoadingStatus, - updateSavingStatus, - createTextbookSuccess, - editTextbookSuccess, - deleteTextbookSuccess, -} = slice.actions; - -export const { reducer } = slice; diff --git a/src/textbooks/data/slice.test.jsx b/src/textbooks/data/slice.test.jsx deleted file mode 100644 index 95ed0cba35..0000000000 --- a/src/textbooks/data/slice.test.jsx +++ /dev/null @@ -1,118 +0,0 @@ -import { - reducer, - fetchTextbooks, - updateLoadingStatus, - updateSavingStatus, - createTextbookSuccess, - editTextbookSuccess, - deleteTextbookSuccess, -} from './slice'; - -const initialState = { - savingStatus: '', - loadingStatus: 'IN_PROGRESS', - textbooks: [], - currentTextbookId: '', -}; - -const textbooks = [ - { - tabTitle: 'Textbook Name 1', - chapters: [ - { - title: 'Chapter 1', - url: '/static/Present-Perfect.pdf', - }, - { - title: 'Chapter 2', - url: '/static/Present-Simple.pdf', - }, - ], - id: '1', - }, - { - tabTitle: 'Textbook Name 2', - chapters: [ - { - title: 'Chapter 1', - url: '/static/Present-Perfect.pdf', - }, - ], - id: '2', - }, -]; - -describe('textbooks slice', () => { - it('should handle fetchTextbooks', () => { - const nextState = reducer(initialState, fetchTextbooks({ textbooks })); - - expect(nextState.textbooks).toEqual(textbooks); - }); - - it('should handle updateLoadingStatus', () => { - const nextState = reducer(initialState, updateLoadingStatus({ status: 'SUCCESS' })); - - expect(nextState.loadingStatus).toEqual('SUCCESS'); - }); - - it('should handle updateSavingStatus', () => { - const nextState = reducer(initialState, updateSavingStatus({ status: 'ERROR' })); - - expect(nextState.savingStatus).toEqual('ERROR'); - }); - - it('should handle createTextbookSuccess', () => { - const newTextbook = { - tabTitle: 'New Textbook', - chapters: [ - { - title: 'Chapter 1', - url: '/static/New-Textbook-Chapter-1.pdf', - }, - ], - id: '3', - }; - const nextState = reducer(initialState, createTextbookSuccess(newTextbook)); - - expect(nextState.textbooks).toContainEqual(newTextbook); - }); - - it('should handle editTextbookSuccess', () => { - const newInitialState = { - savingStatus: '', - loadingStatus: 'IN_PROGRESS', - textbooks, - currentTextbookId: '', - }; - const editedTextbook = { - tabTitle: 'Edited Textbook Name 1', - chapters: [ - { - title: 'Chapter 1', - url: '/static/Edited-Chapter-1.pdf', - }, - { - title: 'Chapter 2', - url: '/static/Edited-Chapter-2.pdf', - }, - ], - id: '1', - }; - const nextState = reducer(newInitialState, editTextbookSuccess(editedTextbook)); - - expect(nextState.textbooks).toContainEqual(editedTextbook); - }); - - it('should handle deleteTextbookSuccess', () => { - const newInitialState = { - savingStatus: '', - loadingStatus: 'IN_PROGRESS', - textbooks, - currentTextbookId: '', - }; - const textbookIdToDelete = '1'; - const nextState = reducer(newInitialState, deleteTextbookSuccess(textbookIdToDelete)); - - expect(nextState.textbooks.some((textbook) => textbook.id === textbookIdToDelete)).toBe(false); - }); -}); diff --git a/src/textbooks/data/thunk.js b/src/textbooks/data/thunk.js deleted file mode 100644 index 7b6bf3d6ad..0000000000 --- a/src/textbooks/data/thunk.js +++ /dev/null @@ -1,83 +0,0 @@ -import { showToastOutsideReact, closeToastOutsideReact } from '../../generic/toast-context'; -import { handleResponseErrors } from '../../generic/saving-error-alert'; -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 { - dispatch(updateLoadingStatus({ status: RequestStatus.FAILED })); - } - }; -} - -export function createTextbookQuery(courseId, textbook) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - showToastOutsideReact(NOTIFICATION_MESSAGES.saving); - - try { - const data = await createTextbook(courseId, textbook); - dispatch(createTextbookSuccess(data)); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - } catch (error) { - handleResponseErrors(error, dispatch, updateSavingStatus); - } finally { - closeToastOutsideReact(); - } - }; -} - -export function editTextbookQuery(courseId, textbook) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - showToastOutsideReact(NOTIFICATION_MESSAGES.saving); - - try { - const data = await editTextbook(courseId, textbook); - dispatch(editTextbookSuccess(data)); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - } catch (error) { - handleResponseErrors(error, dispatch, updateSavingStatus); - } finally { - closeToastOutsideReact(); - } - }; -} - -export function deleteTextbookQuery(courseId, textbookId) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - showToastOutsideReact(NOTIFICATION_MESSAGES.deleting); - - try { - await deleteTextbook(courseId, textbookId); - dispatch(deleteTextbookSuccess(textbookId)); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - } catch (error) { - handleResponseErrors(error, dispatch, updateSavingStatus); - } finally { - closeToastOutsideReact(); - } - }; -} diff --git a/src/textbooks/data/thunk.test.js b/src/textbooks/data/thunk.test.js deleted file mode 100644 index 6c559a0514..0000000000 --- a/src/textbooks/data/thunk.test.js +++ /dev/null @@ -1,144 +0,0 @@ -import { showToastOutsideReact, closeToastOutsideReact } from '../../generic/toast-context'; -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('../../generic/toast-context', () => ({ - showToastOutsideReact: jest.fn(), - closeToastOutsideReact: jest.fn(), -})); - -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(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.saving); - expect(createTextbook).toHaveBeenCalledWith('courseId', textbook); - expect(dispatch).toHaveBeenCalledWith(createTextbookSuccess(textbook)); - expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - expect(closeToastOutsideReact).toHaveBeenCalled(); - }); - - 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(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.saving); - expect(createTextbook).toHaveBeenCalledWith('courseId', {}); - expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED, errorMessage: '' })); - expect(closeToastOutsideReact).toHaveBeenCalled(); - }); -}); - -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(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.saving); - expect(editTextbook).toHaveBeenCalledWith('courseId', textbook); - expect(dispatch).toHaveBeenCalledWith(editTextbookSuccess(textbook)); - expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - expect(closeToastOutsideReact).toHaveBeenCalled(); - }); - - 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(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.saving); - expect(editTextbook).toHaveBeenCalledWith('courseId', {}); - expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED, errorMessage: '' })); - expect(closeToastOutsideReact).toHaveBeenCalled(); - }); -}); - -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(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.deleting); - expect(deleteTextbook).toHaveBeenCalledWith('courseId', 'textbookId'); - expect(dispatch).toHaveBeenCalledWith(deleteTextbookSuccess('textbookId')); - expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - expect(closeToastOutsideReact).toHaveBeenCalled(); - }); - - 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(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.deleting); - expect(deleteTextbook).toHaveBeenCalledWith('courseId', 'textbookId'); - expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED, errorMessage: '' })); - expect(closeToastOutsideReact).toHaveBeenCalled(); - }); -}); diff --git a/src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx b/src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx deleted file mode 100644 index b1691733a5..0000000000 --- a/src/textbooks/empty-placeholder/EmptyPlaceholder.test.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import userEvent from '@testing-library/user-event'; -import EmptyPlaceholder from './EmptyPlaceholder'; -import messages from './messages'; - -const onCreateNewTextbookMock = jest.fn(); - -const renderComponent = () => - render( - - - , - ); - -describe('', () => { - it('renders EmptyPlaceholder component correctly', () => { - const { getByText, getByRole } = renderComponent(); - - expect(getByText(messages.title.defaultMessage)).toBeInTheDocument(); - expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument(); - }); - - it('calls the onCreateNewTextbook function when the button is clicked', async () => { - const user = userEvent.setup(); - const { getByRole } = renderComponent(); - - const addButton = getByRole('button', { name: messages.button.defaultMessage }); - await user.click(addButton); - expect(onCreateNewTextbookMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/textbooks/empty-placeholder/EmptyPlaceholder.test.tsx b/src/textbooks/empty-placeholder/EmptyPlaceholder.test.tsx new file mode 100644 index 0000000000..e5e85b4be6 --- /dev/null +++ b/src/textbooks/empty-placeholder/EmptyPlaceholder.test.tsx @@ -0,0 +1,33 @@ +import { render, initializeMocks, screen } from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; +import EmptyPlaceholder from './EmptyPlaceholder'; +import messages from './messages'; + +const onCreateNewTextbookMock = jest.fn(); + +const renderComponent = () => + render( + , + ); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('renders EmptyPlaceholder component correctly', () => { + renderComponent(); + + expect(screen.getByText(messages.title.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument(); + }); + + it('calls the onCreateNewTextbook function when the button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const addButton = screen.getByRole('button', { name: messages.button.defaultMessage }); + await user.click(addButton); + expect(onCreateNewTextbookMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/textbooks/empty-placeholder/EmptyPlaceholder.jsx b/src/textbooks/empty-placeholder/EmptyPlaceholder.tsx similarity index 77% rename from src/textbooks/empty-placeholder/EmptyPlaceholder.jsx rename to src/textbooks/empty-placeholder/EmptyPlaceholder.tsx index 785726db88..c7bc14af4a 100644 --- a/src/textbooks/empty-placeholder/EmptyPlaceholder.jsx +++ b/src/textbooks/empty-placeholder/EmptyPlaceholder.tsx @@ -1,11 +1,14 @@ -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 }) => { +export interface EmptyPlaceholderProps { + onCreateNewTextbook: () => void; +} + +const EmptyPlaceholder = ({ onCreateNewTextbook }: EmptyPlaceholderProps) => { const intl = useIntl(); return ( @@ -18,8 +21,4 @@ const EmptyPlaceholder = ({ onCreateNewTextbook }) => { ); }; -EmptyPlaceholder.propTypes = { - onCreateNewTextbook: PropTypes.func.isRequired, -}; - export default EmptyPlaceholder; diff --git a/src/textbooks/hooks.jsx b/src/textbooks/hooks.jsx deleted file mode 100644 index 0d9de3ae3e..0000000000 --- a/src/textbooks/hooks.jsx +++ /dev/null @@ -1,95 +0,0 @@ -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, - getErrorMessage, -} from './data/selectors'; -import { - createTextbookQuery, - fetchTextbooksQuery, - editTextbookQuery, - deleteTextbookQuery, -} from './data/thunk'; -import messages from './messages'; - -const useTextbooks = (courseId, waffleFlags) => { - const intl = useIntl(); - const dispatch = useDispatch(); - const { config } = useContext(AppContext); - - const textbooks = useSelector(getTextbooksData); - const loadingStatus = useSelector(getLoadingStatus); - const savingStatus = useSelector(getSavingStatus); - const errorMessage = useSelector(getErrorMessage); - - const [isTextbookFormOpen, openTextbookForm, closeTextbookForm] = useToggle(false); - - const breadcrumbs = [ - { - label: intl.formatMessage(messages.breadcrumbContent), - to: waffleFlags.useNewCourseOutlinePage ? `/course/${courseId}` : `${config.STUDIO_BASE_URL}/course/${courseId}`, - }, - { - label: intl.formatMessage(messages.breadcrumbPagesAndResources), - to: `/course/${courseId}/pages-and-resources`, - }, - { - label: '', - to: `/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, - isLoadingFailed: loadingStatus === RequestStatus.FAILED, - savingStatus, - errorMessage, - textbooks, - breadcrumbs, - isTextbookFormOpen, - openTextbookForm, - closeTextbookForm, - handleTextbookFormSubmit, - handleSavingStatusDispatch, - handleTextbookEditFormSubmit, - handleTextbookDeleteSubmit, - }; -}; - -export { useTextbooks }; diff --git a/src/textbooks/hooks.tsx b/src/textbooks/hooks.tsx new file mode 100644 index 0000000000..45e7db3f39 --- /dev/null +++ b/src/textbooks/hooks.tsx @@ -0,0 +1,99 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { AxiosError } from 'axios'; +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useToggle } from '@openedx/paragon'; +import { getConfig } from '@edx/frontend-platform'; + +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useWaffleFlags } from '@src/data/apiHooks'; +import { getMessageFromAxiosError } from '@src/generic/saving-error-alert/utils'; +import messages from './messages'; +import { + useCreateTextbook, + useDeleteTextbook, + useEditTextbook, + useGetTextbooks, +} from './data/apiHooks'; +import { BaseTextbook, Textbook } from './data/api'; + +export type OnErrorCallbackFunc = (error: AxiosError) => void; + +export const useTextbooks = () => { + const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); + const waffleFlags = useWaffleFlags(courseId); + + const { + data: textbooksData, + isPending: isPendingGetTextbooks, + isError: isErrorGetTextbooks, + } = useGetTextbooks(courseId); + + const [mutationErrorMessage, setMutationErrorMessage] = useState(); + + const handleMutationError: OnErrorCallbackFunc = (error) => ( + setMutationErrorMessage(getMessageFromAxiosError(error)) + ); + + const createMutation = useCreateTextbook(courseId); + const updateMutation = useEditTextbook(courseId); + const deleteMutation = useDeleteTextbook(courseId); + + const textbooks = textbooksData?.textbooks ?? []; + const anyMutationFailed = createMutation.isError || updateMutation.isError || deleteMutation.isError; + + const [isTextbookFormOpen, openTextbookForm, closeTextbookForm] = useToggle(false); + + const breadcrumbs = [ + { + label: intl.formatMessage(messages.breadcrumbContent), + to: waffleFlags.useNewCourseOutlinePage + ? `/course/${courseId}` + : `${getConfig().STUDIO_BASE_URL}/course/${courseId}`, + }, + { + label: intl.formatMessage(messages.breadcrumbPagesAndResources), + to: `/course/${courseId}/pages-and-resources`, + }, + { + label: '', + to: `/course/${courseId}/textbooks`, + }, + ]; + + const handleTextbookFormSubmit = (formValues: BaseTextbook) => { + createMutation.mutate(formValues, { + onSuccess: closeTextbookForm, + onError: handleMutationError, + }); + }; + + const handleTextbookEditFormSubmit = (formValues: Textbook, onSuccess: () => void) => { + updateMutation.mutate(formValues, { + onSuccess, + onError: handleMutationError, + }); + }; + + const handleTextbookDeleteSubmit = (textbookId: string) => { + deleteMutation.mutate(textbookId, { + onError: handleMutationError, + }); + }; + + return { + isLoading: isPendingGetTextbooks, + isLoadingFailed: isErrorGetTextbooks, + anyMutationFailed, + textbooks, + breadcrumbs, + isTextbookFormOpen, + mutationErrorMessage, + openTextbookForm, + closeTextbookForm, + handleTextbookFormSubmit, + handleTextbookEditFormSubmit, + handleTextbookDeleteSubmit, + }; +}; diff --git a/src/textbooks/textbook-card/TextbooksCard.test.jsx b/src/textbooks/textbook-card/TextbooksCard.test.jsx deleted file mode 100644 index 45e3a22e11..0000000000 --- a/src/textbooks/textbook-card/TextbooksCard.test.jsx +++ /dev/null @@ -1,185 +0,0 @@ -import { - render, - waitFor, - within, - initializeMocks, -} from '@src/testUtils'; -import userEvent from '@testing-library/user-event'; - -import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; -import { getEditTextbooksApiUrl } from '../data/api'; -import { deleteTextbookQuery, editTextbookQuery } from '../data/thunk'; -import { textbooksMock } from '../__mocks__'; -import { executeThunk } from '../../utils'; -import TextbookCard from './TextbooksCard'; -import messages from '../textbook-form/messages'; -import textbookCardMessages from './messages'; - -let axiosMock; -let store; - -const courseId = 'course-v1:org+101+101'; -const textbook = textbooksMock.textbooks[1]; -const onEditSubmitMock = jest.fn(); -const onDeleteSubmitMock = jest.fn(); -const handleSavingStatusDispatchMock = jest.fn(); - -const renderComponent = () => - render( - - , - , - ); - -describe('', () => { - let user; - beforeEach(async () => { - user = userEvent.setup(); - const mocks = initializeMocks(); - - store = mocks.reduxStore; - axiosMock = mocks.axiosMock; - }); - - it('render TextbookCard component correctly', async () => { - 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'); - await user.click(collapseButton); - - textbook.chapters.forEach(({ title, url }) => { - expect(getByText(title)).toBeInTheDocument(); - expect(getByText(url)).toBeInTheDocument(); - }); - }); - - it('renders edit TextbookForm after clicking on edit button', async () => { - const { getByTestId, queryByTestId } = renderComponent(); - - const editButton = getByTestId('textbook-edit-button'); - await user.click(editButton); - - expect(getByTestId('textbook-form')).toBeInTheDocument(); - expect(queryByTestId('textbook-card')).not.toBeInTheDocument(); - }); - - it('closes edit TextbookForm after clicking on cancel button', async () => { - const { getByTestId, queryByTestId } = renderComponent(); - - const editButton = getByTestId('textbook-edit-button'); - await user.click(editButton); - - expect(getByTestId('textbook-form')).toBeInTheDocument(); - expect(queryByTestId('textbook-card')).not.toBeInTheDocument(); - - const cancelButton = getByTestId('cancel-button'); - await user.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'); - await user.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, - }; - - await user.clear(tabTitleInput); - await user.type(tabTitleInput, newFormValues.tab_title); - await user.clear(chapterInput); - await user.type(chapterInput, newFormValues.chapters[0].title); - await user.clear(urlInput); - await user.type(urlInput, newFormValues.chapters[0].url); - - await user.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'); - await user.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'); - await user.click(deleteButton); - - await waitFor(async () => { - const deleteModal = getByRole('dialog'); - - const modalSubmitButton = within(deleteModal) - .getByRole('button', { name: 'Delete' }); - - await user.click(modalSubmitButton); - - const textbookId = textbooksMock.textbooks[1].id; - - expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1); - axiosMock - .onDelete(getEditTextbooksApiUrl(courseId, textbookId)) - .reply(200); - - await executeThunk(deleteTextbookQuery(courseId, textbookId), store.dispatch); - }); - }); -}); diff --git a/src/textbooks/textbook-card/TextbooksCard.test.tsx b/src/textbooks/textbook-card/TextbooksCard.test.tsx new file mode 100644 index 0000000000..5d4f342eab --- /dev/null +++ b/src/textbooks/textbook-card/TextbooksCard.test.tsx @@ -0,0 +1,161 @@ +import { + render, + waitFor, + within, + initializeMocks, + screen, +} from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; + +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { textbooksMock } from '../__mocks__'; +import TextbookCard from './TextbooksCard'; +import messages from '../textbook-form/messages'; +import textbookCardMessages from './messages'; + +const courseId = 'course-v1:org+101+101'; +const textbook = textbooksMock.textbooks[1]; +const onEditSubmitMock = jest.fn(); +const onDeleteSubmitMock = jest.fn(); + +const renderComponent = () => + render( + + , + , + ); + +describe('', () => { + let user; + beforeEach(async () => { + user = userEvent.setup(); + initializeMocks(); + }); + + it('render TextbookCard component correctly', async () => { + renderComponent(); + + expect(screen.getByText(textbook.tabTitle)).toBeInTheDocument(); + expect(screen.getByTestId('textbook-view-button')).toBeInTheDocument(); + expect(screen.getByTestId('textbook-edit-button')).toBeInTheDocument(); + expect(screen.getByTestId('textbook-delete-button')).toBeInTheDocument(); + expect(screen.getByText('1 PDF chapters')).toBeInTheDocument(); + + const collapseButton = document.querySelector('.collapsible-trigger'); + await user.click(collapseButton); + + textbook.chapters.forEach(({ title, url }) => { + expect(screen.getByText(title)).toBeInTheDocument(); + expect(screen.getByText(url)).toBeInTheDocument(); + }); + }); + + it('renders edit TextbookForm after clicking on edit button', async () => { + renderComponent(); + + const editButton = screen.getByTestId('textbook-edit-button'); + await user.click(editButton); + + expect(screen.getByTestId('textbook-form')).toBeInTheDocument(); + expect(screen.queryByTestId('textbook-card')).not.toBeInTheDocument(); + }); + + it('closes edit TextbookForm after clicking on cancel button', async () => { + renderComponent(); + + const editButton = screen.getByTestId('textbook-edit-button'); + await user.click(editButton); + + expect(screen.getByTestId('textbook-form')).toBeInTheDocument(); + expect(screen.queryByTestId('textbook-card')).not.toBeInTheDocument(); + + const cancelButton = screen.getByTestId('cancel-button'); + await user.click(cancelButton); + + expect(screen.queryByTestId('textbook-form')).not.toBeInTheDocument(); + expect(screen.getByTestId('textbook-card')).toBeInTheDocument(); + }); + + it('calls onEditSubmit when the "Save" button is clicked with a valid form', async () => { + renderComponent(); + + const editButton = screen.getByTestId('textbook-edit-button'); + await user.click(editButton); + + const tabTitleInput = screen.getByPlaceholderText(messages.tabTitlePlaceholder.defaultMessage); + const chapterInput = screen.getByPlaceholderText( + messages.chapterTitlePlaceholder.defaultMessage.replace( + '{value}', + textbooksMock.textbooks[1].chapters.length.toString(), + ), + ); + const urlInput = screen.getByPlaceholderText(messages.chapterUrlPlaceholder.defaultMessage); + + const newFormValues = { + tab_title: 'Tab title', + chapters: [ + { + title: 'Chapter', + url: 'Url', + }, + ], + id: textbooksMock.textbooks[1].id, + }; + + await user.clear(tabTitleInput); + await user.type(tabTitleInput, newFormValues.tab_title); + await user.clear(chapterInput); + await user.type(chapterInput, newFormValues.chapters[0].title); + await user.clear(urlInput); + await user.type(urlInput, newFormValues.chapters[0].url); + + await user.click(screen.getByRole('button', { name: messages.saveButton.defaultMessage })); + + await waitFor(() => { + expect(onEditSubmitMock).toHaveBeenCalledTimes(1); + }); + expect(onEditSubmitMock).toHaveBeenCalledWith( + newFormValues, + expect.any(Function), + ); + }); + + it('DeleteModal is open when delete button is clicked', async () => { + renderComponent(); + + const deleteButton = screen.getByTestId('textbook-delete-button'); + await user.click(deleteButton); + + const deleteModal = screen.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 () => { + renderComponent(); + + const deleteButton = screen.getByTestId('textbook-delete-button'); + await user.click(deleteButton); + + const deleteModal = screen.getByRole('dialog'); + + const modalSubmitButton = within(deleteModal) + .getByRole('button', { name: 'Delete' }); + + await user.click(modalSubmitButton); + + expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/textbooks/textbook-card/TextbooksCard.jsx b/src/textbooks/textbook-card/TextbooksCard.tsx similarity index 68% rename from src/textbooks/textbook-card/TextbooksCard.jsx rename to src/textbooks/textbook-card/TextbooksCard.tsx index ecc2026e56..ab5005478e 100644 --- a/src/textbooks/textbook-card/TextbooksCard.jsx +++ b/src/textbooks/textbook-card/TextbooksCard.tsx @@ -1,6 +1,4 @@ -import PropTypes from 'prop-types'; -import { useContext, useEffect } from 'react'; -import { useSelector } from 'react-redux'; +import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, @@ -15,28 +13,29 @@ import { RemoveRedEye as ViewIcon, DeleteOutline as DeleteIcon, } from '@openedx/paragon/icons'; -import { AppContext } from '@edx/frontend-platform/react'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import DeleteModal from '@src/generic/delete-modal/DeleteModal'; -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 TextbookForm, { TextbookFormOnSubmit } from '../textbook-form/TextbookForm'; import { getTextbookFormInitialValues } from '../utils'; import messages from './messages'; +import { Textbook } from '../data/api'; + +export interface TextbookCardProps { + textbook: Textbook; + onEditSubmit: (fromValues: Textbook, onSuccess: () => void) => void; + onDeleteSubmit: (id: string) => void; + textbookIndex: number; +} const TextbookCard = ({ textbook, - courseId, - handleSavingStatusDispatch, onEditSubmit, onDeleteSubmit, textbookIndex, -}) => { +}: TextbookCardProps) => { const intl = useIntl(); - const { config } = useContext(AppContext); - - const savingStatus = useSelector(getSavingStatus); - const currentTextbookId = useSelector(getCurrentTextbookId); + const { courseId } = useCourseAuthoringContext(); const [isTextbookFormOpen, openTextbookForm, closeTextbookForm] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); @@ -44,7 +43,7 @@ const TextbookCard = ({ const { tabTitle, chapters, id } = textbook; const onPreviewTextbookClick = () => { - window.open(`${config.LMS_BASE_URL}/courses/${courseId}/pdfbook/${textbookIndex}/`, '_blank'); + window.open(`${getConfig().LMS_BASE_URL}/courses/${courseId}/pdfbook/${textbookIndex}/`, '_blank'); }; const handleDeleteButtonSubmit = () => { @@ -52,11 +51,9 @@ const TextbookCard = ({ onDeleteSubmit(id); }; - useEffect(() => { - if (savingStatus === RequestStatus.SUCCESSFUL && currentTextbookId === id) { - closeTextbookForm(); - } - }, [savingStatus, currentTextbookId]); + const handleEditSubmit: TextbookFormOnSubmit = (values) => { + onEditSubmit(values, closeTextbookForm); + }; return ( <> @@ -65,8 +62,7 @@ const TextbookCard = ({ ) : ( @@ -81,6 +77,7 @@ const TextbookCard = ({ src={ViewIcon} iconAs={Icon} data-testid="textbook-view-button" + alt={intl.formatMessage(messages.buttonView)} onClick={onPreviewTextbookClick} /> @@ -128,20 +127,4 @@ const TextbookCard = ({ ); }; -TextbookCard.propTypes = { - textbook: PropTypes.shape({ - tabTitle: PropTypes.string.isRequired, - chapters: PropTypes.arrayOf(PropTypes.shape({ - title: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - })).isRequired, - id: PropTypes.string.isRequired, - }).isRequired, - courseId: PropTypes.string.isRequired, - handleSavingStatusDispatch: PropTypes.func.isRequired, - onEditSubmit: PropTypes.func.isRequired, - onDeleteSubmit: PropTypes.func.isRequired, - textbookIndex: PropTypes.string.isRequired, -}; - export default TextbookCard; diff --git a/src/textbooks/textbook-form/TextbookForm.test.jsx b/src/textbooks/textbook-form/TextbookForm.test.jsx deleted file mode 100644 index 8cf6153430..0000000000 --- a/src/textbooks/textbook-form/TextbookForm.test.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import { - initializeMocks, - render, - waitFor, - within, -} from '@src/testUtils'; -import userEvent from '@testing-library/user-event'; - -import { executeThunk } from '@src/utils'; -import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; -import { getTextbookFormInitialValues } from '../utils'; -import { getUpdateTextbooksApiUrl } from '../data/api'; -import { createTextbookQuery } from '../data/thunk'; -import TextbookForm from './TextbookForm'; -import messages from './messages'; - -let axiosMock; -let store; -const courseId = 'course-v1:org+101+101'; - -const closeTextbookFormMock = jest.fn(); -const initialFormValuesMock = getTextbookFormInitialValues(); -const onSubmitMock = jest.fn(); -const onSavingStatus = jest.fn(); - -const renderComponent = () => - render( - - - , - ); - -describe('', () => { - beforeEach(async () => { - const mocks = initializeMocks(); - - store = mocks.reduxStore; - axiosMock = mocks.axiosMock; - }); - - 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 user = userEvent.setup(); - 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', - }, - ], - }; - - await user.type(tabTitleInput, formValues.tab_title); - await user.type(chapterInput, formValues.chapters[0].title); - await user.type(urlInput, formValues.chapters[0].url); - - await user.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 user = userEvent.setup(); - const { getByRole, getByTestId } = renderComponent(); - - const deleteChapterButton = getByTestId('chapter-delete-button'); - const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); - - await user.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 user = userEvent.setup(); - const { getByRole, getAllByTestId } = renderComponent(); - - const addChapterButton = getByRole('button', { name: messages.addChapterButton.defaultMessage }); - - await user.click(addChapterButton); - - await waitFor(() => { - expect(getAllByTestId('form-chapters-fields')).toHaveLength(2); - }); - }); - - it('open modal dropzone when "Upload" button is clicked', async () => { - const user = userEvent.setup(); - const { findByTestId, findByRole } = renderComponent(); - - const button = await findByTestId('chapter-upload-button'); - await user.click(button); - const modalBackdrop = await findByTestId('modal-backdrop'); - - const cancelButton = await within(await findByRole('dialog')).findByText('Cancel'); - await user.click(cancelButton); - await waitFor(() => { - expect(modalBackdrop).not.toBeInTheDocument(); - }); - }); -}); diff --git a/src/textbooks/textbook-form/TextbookForm.test.tsx b/src/textbooks/textbook-form/TextbookForm.test.tsx new file mode 100644 index 0000000000..143176deb3 --- /dev/null +++ b/src/textbooks/textbook-form/TextbookForm.test.tsx @@ -0,0 +1,151 @@ +import { + initializeMocks, + render, + screen, + waitFor, + within, +} from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; + +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { getTextbookFormInitialValues } from '../utils'; +import TextbookForm from './TextbookForm'; +import messages from './messages'; + +const courseId = 'course-v1:org+101+101'; + +const closeTextbookFormMock = jest.fn(); +const initialFormValuesMock = getTextbookFormInitialValues(); +const onSubmitMock = jest.fn(); + +const renderComponent = () => + render( + + + , + ); + +describe('', () => { + beforeEach(async () => { + initializeMocks(); + }); + + it('renders TextbooksForm component correctly', async () => { + renderComponent(); + + expect(await screen.findByText(`${messages.tabTitleLabel.defaultMessage} *`)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.tabTitlePlaceholder.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.tabTitleHelperText.defaultMessage)).toBeInTheDocument(); + + expect(screen.getByText(`${messages.chapterTitleLabel.defaultMessage} *`)).toBeInTheDocument(); + expect(screen.getByPlaceholderText( + messages.chapterTitlePlaceholder.defaultMessage.replace( + '{value}', + initialFormValuesMock.chapters.length.toString(), + ), + )).toBeInTheDocument(); + expect(screen.getByText(messages.chapterTitleHelperText.defaultMessage)).toBeInTheDocument(); + + expect(screen.getByText(`${messages.chapterUrlLabel.defaultMessage} *`)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.chapterUrlPlaceholder.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.chapterUrlHelperText.defaultMessage)).toBeInTheDocument(); + + expect(screen.getByTestId('chapter-upload-button')).toBeInTheDocument(); + expect(screen.getByTestId('chapter-delete-button')).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: messages.addChapterButton.defaultMessage })); + expect(screen.getByRole('button', { name: messages.cancelButton.defaultMessage })); + expect(screen.getByRole('button', { name: messages.saveButton.defaultMessage })); + }); + + it('calls onSubmit when the "Save" button is clicked with a valid form', async () => { + const user = userEvent.setup(); + renderComponent(); + + const tabTitleInput = screen.getByPlaceholderText(messages.tabTitlePlaceholder.defaultMessage); + const chapterInput = screen.getByPlaceholderText( + messages.chapterTitlePlaceholder.defaultMessage.replace( + '{value}', + initialFormValuesMock.chapters.length.toString(), + ), + ); + const urlInput = screen.getByPlaceholderText(messages.chapterUrlPlaceholder.defaultMessage); + + const formValues = { + tab_title: 'Tab title', + chapters: [ + { + title: 'Chapter', + url: 'Url', + }, + ], + }; + + await user.type(tabTitleInput, formValues.tab_title); + await user.type(chapterInput, formValues.chapters[0].title); + await user.type(urlInput, formValues.chapters[0].url); + + await user.click(screen.getByRole('button', { name: messages.saveButton.defaultMessage })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalledTimes(1); + }); + + expect(onSubmitMock).toHaveBeenCalledWith( + formValues, + expect.objectContaining({ submitForm: expect.any(Function) }), + ); + }); + + it('"Save" button is disabled when the form is empty', async () => { + renderComponent(); + + const saveButton = await screen.findByRole('button', { name: messages.saveButton.defaultMessage }); + expect(saveButton).toBeDisabled(); + }); + + it('"Save" button is disabled when the chapters length less than 1', async () => { + const user = userEvent.setup(); + renderComponent(); + + const deleteChapterButton = screen.getByTestId('chapter-delete-button'); + const saveButton = screen.getByRole('button', { name: messages.saveButton.defaultMessage }); + + await user.click(deleteChapterButton); + expect(saveButton).toBeDisabled(); + }); + + it('"Cancel" button is disabled when the form is empty', async () => { + renderComponent(); + + const saveButton = await screen.findByRole('button', { name: messages.saveButton.defaultMessage }); + expect(saveButton).toBeDisabled(); + }); + + it('"Add a chapter" button add new chapters field', async () => { + const user = userEvent.setup(); + renderComponent(); + + const addChapterButton = screen.getByRole('button', { name: messages.addChapterButton.defaultMessage }); + + await user.click(addChapterButton); + expect(screen.getAllByTestId('form-chapters-fields')).toHaveLength(2); + }); + + it('open modal dropzone when "Upload" button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const button = await screen.findByTestId('chapter-upload-button'); + await user.click(button); + const modalBackdrop = await screen.findByTestId('modal-backdrop'); + + const cancelButton = await within(await screen.findByRole('dialog')).findByText('Cancel'); + await user.click(cancelButton); + expect(modalBackdrop).not.toBeInTheDocument(); + }); +}); diff --git a/src/textbooks/textbook-form/TextbookForm.jsx b/src/textbooks/textbook-form/TextbookForm.tsx similarity index 89% rename from src/textbooks/textbook-form/TextbookForm.jsx rename to src/textbooks/textbook-form/TextbookForm.tsx index 482c0fe724..4f81a42198 100644 --- a/src/textbooks/textbook-form/TextbookForm.jsx +++ b/src/textbooks/textbook-form/TextbookForm.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { FieldArray, Formik } from 'formik'; +import { FieldArray, Formik, FormikConfig } from 'formik'; import { PictureAsPdf as PdfIcon, Add as AddIcon, @@ -24,17 +23,26 @@ import ModalDropzone from '@src/generic/modal-dropzone/ModalDropzone'; import { UPLOAD_FILE_MAX_SIZE } from '@src/constants'; import textbookFormValidationSchema from './validations'; import messages from './messages'; +import { Textbook } from '../data/api'; +import { TextbookFromValues } from '../utils'; + +export type TextbookFormOnSubmit = FormikConfig['onSubmit']; + +export interface TextbookFormProps { + closeTextbookForm: () => void; + initialFormValues: TextbookFromValues; + onSubmit: TextbookFormOnSubmit; +} const TextbookForm = ({ closeTextbookForm, initialFormValues, onSubmit, - onSavingStatus, -}) => { +}: TextbookFormProps) => { const intl = useIntl(); - const { courseDetail } = useCourseAuthoringContext(); - const courseTitle = courseDetail ? courseDetail?.name : ''; + const { courseDetails } = useCourseAuthoringContext(); + const courseTitle = courseDetails ? courseDetails?.name : ''; const [currentTextbookIndex, setCurrentTextbookIndex] = useState(0); const [isUploadModalOpen, openUploadModal, closeUploadModal] = useToggle(false); @@ -68,7 +76,7 @@ const TextbookForm = ({ }) => ( <> - + {intl.formatMessage(messages.tabTitleLabel)} * - + {intl.formatMessage(messages.chapterTitleLabel)} *
- + {intl.formatMessage(messages.chapterUrlLabel)} * {intl.formatMessage(messages.cancelButton)} - @@ -172,7 +180,7 @@ const TextbookForm = ({ modalTitle={intl.formatMessage(messages.uploadModalTitle, { courseName: courseTitle })} imageDropzoneText={intl.formatMessage(messages.uploadModalDropzoneText)} imageHelpText={intl.formatMessage(messages.uploadModalHelperText)} - onSavingStatus={onSavingStatus} + onSavingStatus={() => {}} invalidFileSizeMore={intl.formatMessage( messages.uploadModalFileInvalidSizeText, { maxSize: UPLOAD_FILE_MAX_SIZE / (1000 * 1000) }, @@ -194,11 +202,4 @@ const TextbookForm = ({ ); }; -TextbookForm.propTypes = { - closeTextbookForm: PropTypes.func.isRequired, - initialFormValues: PropTypes.shape({}).isRequired, - onSubmit: PropTypes.func.isRequired, - onSavingStatus: PropTypes.func.isRequired, -}; - export default TextbookForm; diff --git a/src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx b/src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx deleted file mode 100644 index 6cee5f9f85..0000000000 --- a/src/textbooks/textbook-sidebar/TextbookSidebar.test.jsx +++ /dev/null @@ -1,31 +0,0 @@ -// @ts-check -import { initializeMocks, render, waitFor } from '../../testUtils'; -import { getHelpUrlsApiUrl } from '../../help-urls/data/api'; -import { helpUrls } from '../../help-urls/__mocks__'; -import TextbookSidebar from './TextbookSidebar'; -import messages from './messages'; - -const courseId = 'course-v1:org+101+101'; - -const renderComponent = () => render(); - -describe('', () => { - beforeEach(async () => { - const { axiosMock } = initializeMocks(); - axiosMock - .onGet(getHelpUrlsApiUrl()) - .reply(200, helpUrls); - }); - - it('renders TextbookSidebar component correctly', async () => { - const { getByText } = renderComponent(); - - await waitFor(() => { - expect(getByText(messages.section_1_title.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.section_1_descriptions.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.section_2_title.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.section_2_descriptions.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.sectionLink.defaultMessage)).toHaveAttribute('href', helpUrls.textbooks); - }); - }); -}); diff --git a/src/textbooks/textbook-sidebar/TextbookSidebar.test.tsx b/src/textbooks/textbook-sidebar/TextbookSidebar.test.tsx new file mode 100644 index 0000000000..9ac5088599 --- /dev/null +++ b/src/textbooks/textbook-sidebar/TextbookSidebar.test.tsx @@ -0,0 +1,35 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import { getHelpUrlsApiUrl } from '@src/help-urls/data/api'; +import { helpUrls } from '@src/help-urls/__mocks__'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; + +import TextbookSidebar from './TextbookSidebar'; +import messages from './messages'; + +const courseId = 'course-v1:org+101+101'; + +const renderComponent = () => + render( + + + , + ); + +describe('', () => { + beforeEach(async () => { + const { axiosMock } = initializeMocks(); + axiosMock + .onGet(getHelpUrlsApiUrl()) + .reply(200, helpUrls); + }); + + it('renders TextbookSidebar component correctly', async () => { + renderComponent(); + + expect(await screen.findByText(messages.section_1_title.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(messages.section_1_descriptions.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(messages.section_2_title.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(messages.section_2_descriptions.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(messages.sectionLink.defaultMessage)).toHaveAttribute('href', helpUrls.textbooks); + }); +}); diff --git a/src/textbooks/textbook-sidebar/TextbookSidebar.jsx b/src/textbooks/textbook-sidebar/TextbookSidebar.tsx similarity index 78% rename from src/textbooks/textbook-sidebar/TextbookSidebar.jsx rename to src/textbooks/textbook-sidebar/TextbookSidebar.tsx index 993a0f2198..07c94fd30a 100644 --- a/src/textbooks/textbook-sidebar/TextbookSidebar.jsx +++ b/src/textbooks/textbook-sidebar/TextbookSidebar.tsx @@ -1,14 +1,14 @@ -import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import PropTypes from 'prop-types'; +import { HelpSidebar } from '@src/generic/help-sidebar'; +import { useHelpUrls } from '@src/help-urls/hooks'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; 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 TextbookSidebar = () => { const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); const { textbooks: textbookUrl } = useHelpUrls(['textbooks']); return ( @@ -38,8 +38,4 @@ const TextbookSidebar = ({ courseId }) => { ); }; -TextbookSidebar.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default TextbookSidebar; diff --git a/src/textbooks/utils.js b/src/textbooks/utils.js deleted file mode 100644 index c6d86035cd..0000000000 --- a/src/textbooks/utils.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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 { getTextbookFormInitialValues }; diff --git a/src/textbooks/utils.ts b/src/textbooks/utils.ts new file mode 100644 index 0000000000..0c8d0338bd --- /dev/null +++ b/src/textbooks/utils.ts @@ -0,0 +1,28 @@ +export interface TextbookFromValues { + id?: string; + chapters: { + title: string; + url: string; + }[]; + tab_title?: string; +} + +/** + * Get textbook form initial values + */ +export const getTextbookFormInitialValues = ( + isEditForm: boolean = false, + textbook: TextbookFromValues = { + chapters: [], + }, +): TextbookFromValues => (isEditForm + ? textbook + : { + tab_title: '', + chapters: [ + { + title: '', + url: '', + }, + ], + });