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:
Kristin Aoki
2024-05-22 14:28:53 -04:00
committed by GitHub
parent 3647bcbbf9
commit 3f987f9958
9 changed files with 547 additions and 193 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,3 +51,11 @@ div.xblock-highlight {
box-shadow: unset;
}
}
body {
background-color: $light-200;
.editor-page {
background-color: $light-100;
}
}