feat: improve error messaging and empty updates (#1025)
* feat: improve error messaging and empty updates * chore: improve code coverage * fix: update error messages * fix: message title for saving handouts
This commit is contained in:
@@ -67,7 +67,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
<div>
|
||||
{/* While V2 Editors are temporarily served from their own pages
|
||||
using url pattern containing /editor/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
Container,
|
||||
Layout,
|
||||
} from '@openedx/paragon';
|
||||
import { Add as AddIcon } from '@openedx/paragon/icons';
|
||||
import { Add as AddIcon, ErrorOutline as ErrorIcon } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../generic/model-store';
|
||||
@@ -22,15 +24,18 @@ import UpdateForm from './update-form/UpdateForm';
|
||||
import { REQUEST_TYPES } from './constants';
|
||||
import messages from './messages';
|
||||
import { useCourseUpdates } from './hooks';
|
||||
import { getLoadingStatuses, getSavingStatuses } from './data/selectors';
|
||||
import {
|
||||
getErrors,
|
||||
getLoadingStatuses,
|
||||
getSavingStatuses,
|
||||
} from './data/selectors';
|
||||
import { matchesAnyStatus } from './utils';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
|
||||
const CourseUpdates = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
const {
|
||||
requestType,
|
||||
@@ -56,6 +61,7 @@ const CourseUpdates = ({ courseId }) => {
|
||||
|
||||
const loadingStatuses = useSelector(getLoadingStatuses);
|
||||
const savingStatuses = useSelector(getSavingStatuses);
|
||||
const errors = useSelector(getErrors);
|
||||
|
||||
const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED);
|
||||
const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS);
|
||||
@@ -63,8 +69,61 @@ const CourseUpdates = ({ courseId }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="px-4">
|
||||
<section className="setting-items mb-4 mt-5">
|
||||
<Helmet>
|
||||
<title>
|
||||
{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size="xl" className="px-4 pt-4">
|
||||
<section className="setting-items mb-4">
|
||||
{errors.loadingUpdates && (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.loadingUpdatesErrorTitle)}
|
||||
description={intl.formatMessage(messages.loadingUpdatesErrorDescription, { courseId })}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
/>
|
||||
)}
|
||||
{errors.loadingHandouts && (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.loadingHandoutsErrorTitle)}
|
||||
description={intl.formatMessage(messages.loadingHandoutsErrorDescription, { courseId })}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
/>
|
||||
)}
|
||||
{errors.creatingUpdate && (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.savingUpdatesErrorTitle)}
|
||||
description={intl.formatMessage(messages.savingNewUpdateErrorAlertDescription)}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
/>
|
||||
)}
|
||||
{errors.savingUpdates && (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.savingUpdatesErrorTitle)}
|
||||
description={intl.formatMessage(messages.savingUpdatesErrorDescription)}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
/>
|
||||
)}
|
||||
{errors.deletingUpdates && (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.deletingUpdatesErrorTitle)}
|
||||
description={intl.formatMessage(messages.deletingUpdatesErrorDescription)}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
/>
|
||||
)}
|
||||
{errors.savingHandouts && (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.savingHandoutsErrorTitle)}
|
||||
description={intl.formatMessage(messages.savingHandoutsErrorDescription)}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
/>
|
||||
)}
|
||||
<Layout
|
||||
lg={[{ span: 12 }]}
|
||||
md={[{ span: 12 }]}
|
||||
@@ -72,7 +131,7 @@ const CourseUpdates = ({ courseId }) => {
|
||||
xs={[{ span: 12 }]}
|
||||
xl={[{ span: 12 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<Layout.Element className="mt-3">
|
||||
<article>
|
||||
<div>
|
||||
<SubHeader
|
||||
@@ -85,7 +144,7 @@ const CourseUpdates = ({ courseId }) => {
|
||||
iconBefore={AddIcon}
|
||||
size="sm"
|
||||
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}
|
||||
disabled={isUpdateFormOpen}
|
||||
disabled={isUpdateFormOpen || errors.loadingUpdates}
|
||||
>
|
||||
{intl.formatMessage(messages.newUpdateButton)}
|
||||
</Button>
|
||||
@@ -102,33 +161,54 @@ const CourseUpdates = ({ courseId }) => {
|
||||
/>
|
||||
)}
|
||||
<div className="updates-container">
|
||||
<div className="p-4.5">
|
||||
{courseUpdates.length ? courseUpdates.map((courseUpdate, index) => (
|
||||
isInnerFormOpen(courseUpdate.id) ? (
|
||||
<UpdateForm
|
||||
isOpen={isUpdateFormOpen}
|
||||
close={closeUpdateForm}
|
||||
requestType={requestType}
|
||||
isInnerForm
|
||||
isFirstUpdate={index === 0}
|
||||
onSubmit={handleUpdatesSubmit}
|
||||
courseUpdatesInitialValues={courseUpdatesInitialValues}
|
||||
/>
|
||||
) : (
|
||||
<CourseUpdate
|
||||
dateForUpdate={courseUpdate.date}
|
||||
contentForUpdate={courseUpdate.content}
|
||||
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_update, courseUpdate)}
|
||||
onDelete={() => handleOpenDeleteForm(courseUpdate)}
|
||||
isDisabledButtons={isUpdateFormOpen}
|
||||
/>
|
||||
))) : null}
|
||||
</div>
|
||||
{courseUpdates.length > 0 && (
|
||||
<div className="p-4.5">
|
||||
{courseUpdates.map((courseUpdate, index) => (
|
||||
isInnerFormOpen(courseUpdate.id) ? (
|
||||
<UpdateForm
|
||||
isOpen={isUpdateFormOpen}
|
||||
close={closeUpdateForm}
|
||||
requestType={requestType}
|
||||
isInnerForm
|
||||
isFirstUpdate={index === 0}
|
||||
onSubmit={handleUpdatesSubmit}
|
||||
courseUpdatesInitialValues={courseUpdatesInitialValues}
|
||||
/>
|
||||
) : (
|
||||
<CourseUpdate
|
||||
dateForUpdate={courseUpdate.date}
|
||||
contentForUpdate={courseUpdate.content}
|
||||
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_update, courseUpdate)}
|
||||
onDelete={() => handleOpenDeleteForm(courseUpdate)}
|
||||
isDisabledButtons={isUpdateFormOpen}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!courseUpdates.length && (
|
||||
<ActionRow>
|
||||
<ActionRow.Spacer />
|
||||
<span className="small mr-2">
|
||||
{intl.formatMessage(messages.noCourseUpdates)}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconBefore={AddIcon}
|
||||
size="sm"
|
||||
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}
|
||||
disabled={isUpdateFormOpen || errors.loadingUpdates}
|
||||
>
|
||||
{intl.formatMessage(messages.firstUpdateButton)}
|
||||
</Button>
|
||||
<ActionRow.Spacer />
|
||||
</ActionRow>
|
||||
)}
|
||||
<div className="updates-handouts-container">
|
||||
<CourseHandouts
|
||||
contentForHandouts={courseHandouts?.data || ''}
|
||||
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_handouts)}
|
||||
isDisabledButtons={isUpdateFormOpen}
|
||||
isDisabledButtons={isUpdateFormOpen || errors.loadingHandouts}
|
||||
/>
|
||||
</div>
|
||||
<DeleteModal
|
||||
|
||||
@@ -54,171 +54,319 @@ jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<IntlProvider locale="es">
|
||||
<CourseUpdates courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<CourseUpdates />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
describe('Successful API responses', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(200, courseUpdatesMock);
|
||||
axiosMock
|
||||
.onGet(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, courseHandoutsMock);
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(200, courseUpdatesMock);
|
||||
axiosMock
|
||||
.onGet(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, courseHandoutsMock);
|
||||
});
|
||||
it('render CourseUpdates component correctly', async () => {
|
||||
const {
|
||||
getByText, getAllByTestId, getByTestId, getByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
it('render CourseUpdates component correctly', async () => {
|
||||
const {
|
||||
getByText, getAllByTestId, getByTestId, getByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sectionInfo.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getAllByTestId('course-update')).toHaveLength(3);
|
||||
expect(getByTestId('course-handouts')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sectionInfo.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getAllByTestId('course-update')).toHaveLength(3);
|
||||
expect(getByTestId('course-handouts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should create course update', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
it('should create course update', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
content: '<p>Some text</p>',
|
||||
date: 'August 29, 2023',
|
||||
};
|
||||
const data = {
|
||||
content: '<p>Some text</p>',
|
||||
date: 'August 29, 2023',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(200, data);
|
||||
axiosMock
|
||||
.onPost(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(200, data);
|
||||
|
||||
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some text')).toBeInTheDocument();
|
||||
expect(getByText(data.date)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should edit course update', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
id: courseUpdatesMock[0].id,
|
||||
content: '<p>Some text</p>',
|
||||
date: 'August 29, 2023',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
|
||||
.reply(200, data);
|
||||
|
||||
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some text')).toBeInTheDocument();
|
||||
expect(getByText(data.date)).toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should delete course update', async () => {
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
|
||||
.reply(200);
|
||||
|
||||
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
|
||||
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should edit course handouts', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
...courseHandoutsMock,
|
||||
data: '<p>Some handouts 1</p>',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, data);
|
||||
|
||||
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some handouts 1')).toBeInTheDocument();
|
||||
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Add new update form is visible after clicking "New update" button', async () => {
|
||||
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editUpdateButtons = getAllByTestId('course-update-edit-button');
|
||||
const deleteButtons = getAllByTestId('course-update-delete-button');
|
||||
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
|
||||
const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
|
||||
|
||||
fireEvent.click(newUpdateButton);
|
||||
|
||||
expect(newUpdateButton).toBeDisabled();
|
||||
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(getByText('Add new update')).toBeInTheDocument();
|
||||
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some text')).toBeInTheDocument();
|
||||
expect(getByText(data.date)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Edit handouts form is visible after clicking "Edit" button', async () => {
|
||||
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
|
||||
it('should edit course update', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editUpdateButtons = getAllByTestId('course-update-edit-button');
|
||||
const deleteButtons = getAllByTestId('course-update-delete-button');
|
||||
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
|
||||
const editHandoutsButton = editHandoutsButtons[0];
|
||||
const data = {
|
||||
id: courseUpdatesMock[0].id,
|
||||
content: '<p>Some text</p>',
|
||||
date: 'August 29, 2023',
|
||||
};
|
||||
|
||||
fireEvent.click(editHandoutsButton);
|
||||
axiosMock
|
||||
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
|
||||
.reply(200, data);
|
||||
|
||||
expect(editHandoutsButton).toBeDisabled();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
|
||||
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(getByText('Edit handouts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Edit update form is visible after clicking "Edit" button', async () => {
|
||||
const {
|
||||
getByText, getByRole, getAllByTestId, queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editUpdateButtons = getAllByTestId('course-update-edit-button');
|
||||
const deleteButtons = getAllByTestId('course-update-delete-button');
|
||||
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
|
||||
const editUpdateFirstButton = editUpdateButtons[0];
|
||||
|
||||
fireEvent.click(editUpdateFirstButton);
|
||||
expect(getByText('Edit update')).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
|
||||
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some text')).toBeInTheDocument();
|
||||
expect(getByText(data.date)).toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should delete course update', async () => {
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
|
||||
.reply(200);
|
||||
|
||||
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
|
||||
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should edit course handouts', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
...courseHandoutsMock,
|
||||
data: '<p>Some handouts 1</p>',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, data);
|
||||
|
||||
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some handouts 1')).toBeInTheDocument();
|
||||
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Add new update form is visible after clicking "New update" button', async () => {
|
||||
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editUpdateButtons = getAllByTestId('course-update-edit-button');
|
||||
const deleteButtons = getAllByTestId('course-update-delete-button');
|
||||
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
|
||||
const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
|
||||
|
||||
fireEvent.click(newUpdateButton);
|
||||
|
||||
expect(newUpdateButton).toBeDisabled();
|
||||
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(getByText('Add new update')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Edit handouts form is visible after clicking "Edit" button', async () => {
|
||||
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editUpdateButtons = getAllByTestId('course-update-edit-button');
|
||||
const deleteButtons = getAllByTestId('course-update-delete-button');
|
||||
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
|
||||
const editHandoutsButton = editHandoutsButtons[0];
|
||||
|
||||
fireEvent.click(editHandoutsButton);
|
||||
|
||||
expect(editHandoutsButton).toBeDisabled();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
|
||||
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(getByText('Edit handouts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Edit update form is visible after clicking "Edit" button', async () => {
|
||||
const {
|
||||
getByText, getByRole, getAllByTestId, queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editUpdateButtons = getAllByTestId('course-update-edit-button');
|
||||
const deleteButtons = getAllByTestId('course-update-delete-button');
|
||||
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
|
||||
const editUpdateFirstButton = editUpdateButtons[0];
|
||||
|
||||
fireEvent.click(editUpdateFirstButton);
|
||||
expect(getByText('Edit update')).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
|
||||
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('page load failure API responses', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('Course updates fetch should show updates loading error', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(404);
|
||||
axiosMock
|
||||
.onGet(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, courseHandoutsMock);
|
||||
|
||||
const {
|
||||
getByText, queryByTestId, getByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const newButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
|
||||
expect(getByText(messages.loadingUpdatesErrorTitle.defaultMessage));
|
||||
expect(newButton).toBeDisabled();
|
||||
expect(getByText(messages.noCourseUpdates.defaultMessage)).toBeVisible();
|
||||
expect(queryByTestId('course-update')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('Course handouts fetch should show handouts loading error', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(200, courseUpdatesMock);
|
||||
axiosMock
|
||||
.onGet(getCourseHandoutApiUrl(courseId))
|
||||
.reply(404);
|
||||
|
||||
const {
|
||||
getByText, getByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.loadingHandoutsErrorTitle.defaultMessage));
|
||||
expect(getByTestId('course-handouts-edit-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving failure API responses', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(200, courseUpdatesMock);
|
||||
axiosMock
|
||||
.onGet(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, courseHandoutsMock);
|
||||
});
|
||||
it('creating new update should show saving error alert', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
content: '<p>Some text</p>',
|
||||
date: 'August 29, 2023',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseUpdatesApiUrl(courseId), data)
|
||||
.reply(404);
|
||||
|
||||
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
|
||||
expect(getByText(messages.savingNewUpdateErrorAlertDescription.defaultMessage)).toBeVisible();
|
||||
expect(queryByText('Some text')).toBeNull();
|
||||
expect(queryByText(data.date)).toBeNull();
|
||||
});
|
||||
|
||||
it('editing course update should show saving error alert', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
id: courseUpdatesMock[0].id,
|
||||
content: '<p>Some text</p>',
|
||||
date: 'August 29, 2023',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
|
||||
.reply(404);
|
||||
|
||||
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
|
||||
expect(queryByText('Some text')).toBeNull();
|
||||
expect(queryByText(data.date)).toBeNull();
|
||||
expect(getByText(courseUpdatesMock[0].date)).toBeVisible();
|
||||
expect(getByText(courseUpdatesMock[0].content)).toBeVisible();
|
||||
expect(getByText(messages.savingUpdatesErrorDescription.defaultMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
it('deleting course update should show delete saving error alert', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
|
||||
.reply(404);
|
||||
|
||||
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
|
||||
expect(getByText(courseUpdatesMock[0].date)).toBeVisible();
|
||||
expect(getByText(courseUpdatesMock[0].content)).toBeVisible();
|
||||
expect(getByText(messages.deletingUpdatesErrorDescription.defaultMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
it('editing course handouts should show saving error alert', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
...courseHandoutsMock,
|
||||
data: '<p>Some handouts 1</p>',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseHandoutApiUrl(courseId))
|
||||
.reply(404);
|
||||
|
||||
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
|
||||
expect(queryByText('Some handouts 1')).toBeNull();
|
||||
expect(getByText(courseHandoutsMock.data)).toBeVisible();
|
||||
expect(getByText(messages.savingHandoutsErrorDescription.defaultMessage));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,3 +2,4 @@ export const getCourseUpdates = (state) => state.courseUpdates.courseUpdates;
|
||||
export const getCourseHandouts = (state) => state.courseUpdates.courseHandouts;
|
||||
export const getSavingStatuses = (state) => state.courseUpdates.savingStatuses;
|
||||
export const getLoadingStatuses = (state) => state.courseUpdates.loadingStatuses;
|
||||
export const getErrors = (state) => state.courseUpdates.errors;
|
||||
|
||||
@@ -15,6 +15,14 @@ const initialState = {
|
||||
fetchCourseUpdatesQuery: '',
|
||||
fetchCourseHandoutsQuery: '',
|
||||
},
|
||||
errors: {
|
||||
creatingUpdate: false,
|
||||
deletingUpdates: false,
|
||||
loadingUpdates: false,
|
||||
loadingHandouts: false,
|
||||
savingUpdates: false,
|
||||
savingHandouts: false,
|
||||
},
|
||||
};
|
||||
|
||||
const slice = createSlice({
|
||||
@@ -48,10 +56,14 @@ const slice = createSlice({
|
||||
};
|
||||
},
|
||||
updateSavingStatuses: (state, { payload }) => {
|
||||
state.savingStatuses = { ...state.savingStatuses, ...payload };
|
||||
const { status, error } = payload;
|
||||
state.errors = { ...initialState.errors, ...error };
|
||||
state.savingStatuses = { ...state.savingStatuses, ...status };
|
||||
},
|
||||
updateLoadingStatuses: (state, { payload }) => {
|
||||
state.loadingStatuses = { ...state.loadingStatuses, ...payload };
|
||||
const { status, error } = payload;
|
||||
state.errors = { ...initialState.errors, ...error };
|
||||
state.loadingStatuses = { ...state.loadingStatuses, ...status };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,12 +23,18 @@ import {
|
||||
export function fetchCourseUpdatesQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.IN_PROGRESS }));
|
||||
dispatch(updateLoadingStatuses({ fetchCourseUpdatesQuery: RequestStatus.IN_PROGRESS }));
|
||||
const courseUpdates = await getCourseUpdates(courseId);
|
||||
dispatch(fetchCourseUpdatesSuccess(courseUpdates));
|
||||
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateLoadingStatuses({
|
||||
status: { fetchCourseUpdatesQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { loadingUpdates: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.FAILED }));
|
||||
dispatch(updateLoadingStatuses({
|
||||
status: { fetchCourseUpdatesQuery: RequestStatus.FAILED },
|
||||
error: { loadingUpdates: true },
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -41,10 +47,16 @@ export function createCourseUpdateQuery(courseId, data) {
|
||||
const courseUpdate = await createUpdate(courseId, data);
|
||||
dispatch(createCourseUpdate(courseUpdate));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { creatingUpdate: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED }));
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
error: { creatingUpdate: true },
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -57,10 +69,16 @@ export function editCourseUpdateQuery(courseId, data) {
|
||||
const courseUpdate = await editUpdate(courseId, data);
|
||||
dispatch(editCourseUpdate(courseUpdate));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { savingUpdates: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED }));
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
error: { savingUpdates: true },
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -73,10 +91,16 @@ export function deleteCourseUpdateQuery(courseId, updateId) {
|
||||
const courseUpdates = await deleteUpdate(courseId, updateId);
|
||||
dispatch(deleteCourseUpdate(courseUpdates));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { deletingUpdates: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED }));
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
error: { deletingUpdates: true },
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -87,9 +111,15 @@ export function fetchCourseHandoutsQuery(courseId) {
|
||||
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.IN_PROGRESS }));
|
||||
const courseHandouts = await getCourseHandouts(courseId);
|
||||
dispatch(fetchCourseHandoutsSuccess(courseHandouts));
|
||||
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateLoadingStatuses({
|
||||
status: { fetchCourseHandoutsQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { loadingHandouts: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.FAILED }));
|
||||
dispatch(updateLoadingStatuses({
|
||||
status: { fetchCourseHandoutsQuery: RequestStatus.FAILED },
|
||||
error: { loadingHandouts: true },
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -102,10 +132,16 @@ export function editCourseHandoutsQuery(courseId, data) {
|
||||
const courseHandouts = await editHandouts(courseId, data);
|
||||
dispatch(editCourseHandouts(courseHandouts));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { savingHandouts: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED }));
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
error: { savingHandouts: true },
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ const useCourseUpdates = ({ courseId }) => {
|
||||
courseHandouts,
|
||||
courseUpdatesInitialValues,
|
||||
isMainFormOpen: isUpdateFormOpen && requestType !== REQUEST_TYPES.edit_update,
|
||||
isInnerFormOpen: (id) => isUpdateFormOpen && currentUpdate.id === id && requestType === REQUEST_TYPES.edit_update,
|
||||
isInnerFormOpen: (id) => (isUpdateFormOpen && currentUpdate.id === id && requestType === REQUEST_TYPES.edit_update),
|
||||
isUpdateFormOpen,
|
||||
isDeleteModalOpen,
|
||||
closeUpdateForm,
|
||||
|
||||
@@ -4,18 +4,87 @@ const messages = defineMessages({
|
||||
headingTitle: {
|
||||
id: 'course-authoring.course-updates.header.title',
|
||||
defaultMessage: 'Course updates',
|
||||
description: 'Title for page',
|
||||
},
|
||||
headingSubtitle: {
|
||||
id: 'course-authoring.course-updates.header.subtitle',
|
||||
defaultMessage: 'Content',
|
||||
description: 'Subtitle for page',
|
||||
},
|
||||
sectionInfo: {
|
||||
id: 'course-authoring.course-updates.section-info',
|
||||
defaultMessage: 'Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions.',
|
||||
description: 'Message describing the use of course updates in a course',
|
||||
},
|
||||
newUpdateButton: {
|
||||
id: 'course-authoring.course-updates.actions.new-update',
|
||||
defaultMessage: 'New update',
|
||||
description: 'Button label for header button to add a new course update',
|
||||
},
|
||||
firstUpdateButton: {
|
||||
id: 'course-authoring.course-updates.actions.first-update',
|
||||
defaultMessage: 'Add first update',
|
||||
description: 'Button label for button to add first course update',
|
||||
},
|
||||
noCourseUpdates: {
|
||||
id: 'course-authoring.course-updates.actions.first-update-message',
|
||||
defaultMessage: 'You have not added any updates to this course yet.',
|
||||
description: 'Message to notify user that they do not have any existing course updates',
|
||||
},
|
||||
loadingUpdatesErrorTitle: {
|
||||
id: 'course-authoring.course-updates.error.loading-updates.title',
|
||||
defaultMessage: 'Failed to load course updates',
|
||||
description: 'Alert title for loading updates error alert',
|
||||
},
|
||||
loadingUpdatesErrorDescription: {
|
||||
id: 'course-authoring.course-updates.error.loading-updates.description',
|
||||
defaultMessage: 'Failed to load course updates for {courseId}. Please try again later.',
|
||||
description: 'Alert body message for loading course update errors',
|
||||
},
|
||||
loadingHandoutsErrorTitle: {
|
||||
id: 'course-authoring.course-updates.error.loading-handouts.title',
|
||||
defaultMessage: 'Failed to load course handouts',
|
||||
description: 'Alert title for loading handouts error alert',
|
||||
},
|
||||
loadingHandoutsErrorDescription: {
|
||||
id: 'course-authoring.course-updates.error.loading-handouts.description',
|
||||
defaultMessage: 'Failed to load course updates for {courseId}. Please try again later.',
|
||||
description: 'Alert body message for loading course handout errors',
|
||||
},
|
||||
savingUpdatesErrorTitle: {
|
||||
id: 'course-authoring.course-updates.error.saving-updates.title',
|
||||
defaultMessage: 'Failed to save course update',
|
||||
description: 'Alert title for saving updates error alert',
|
||||
},
|
||||
savingUpdatesErrorDescription: {
|
||||
id: 'course-authoring.course-updates.error.saving-updates.description',
|
||||
defaultMessage: 'Failed to save recent changes to course update. Please try again later.',
|
||||
description: 'Alert body message for saving edits to course update errors',
|
||||
},
|
||||
savingNewUpdateErrorAlertDescription: {
|
||||
id: 'course-authoring.course-updates.error.saving-new-updates.description',
|
||||
defaultMessage: 'Failed to save new course update. Please try again later.',
|
||||
description: 'Alert body message for saving new course update errors',
|
||||
},
|
||||
savingHandoutsErrorTitle: {
|
||||
id: 'course-authoring.course-updates.error.saving-handouts.title',
|
||||
defaultMessage: 'Failed to save course handouts',
|
||||
description: 'Alert title for saving handouts error alert',
|
||||
},
|
||||
savingHandoutsErrorDescription: {
|
||||
id: 'course-authoring.course-updates.error.saving-handouts.description',
|
||||
defaultMessage: 'Failed to save recent changes to course handouts. Please try again later.',
|
||||
description: 'Alert body message for saving course handout errors',
|
||||
},
|
||||
deletingUpdatesErrorTitle: {
|
||||
id: 'course-authoring.course-updates.error.deleting-updates.title',
|
||||
defaultMessage: 'Failed to delete course update',
|
||||
description: 'Alert title for deleting update error alert',
|
||||
},
|
||||
deletingUpdatesErrorDescription: {
|
||||
id: 'course-authoring.course-updates.error.deleting-updates.description',
|
||||
defaultMessage: 'Failed to delete selected course update. Please try again later.',
|
||||
description: 'Alert body message for deleting course update errors',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -51,3 +51,11 @@ div.xblock-highlight {
|
||||
box-shadow: unset;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: $light-200;
|
||||
|
||||
.editor-page {
|
||||
background-color: $light-100;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user