fix: error handling for save api call (#805)

This commit is contained in:
Raymond Zhou
2024-01-23 12:55:24 -08:00
committed by GitHub
parent bdb4ffe69d
commit c2ad1b8c99
4 changed files with 150 additions and 10 deletions

View File

@@ -9,8 +9,10 @@ import {
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { courseDetailsMock, courseSettingsMock } from './__mocks__';
import { getCourseDetailsApiUrl, getCourseSettingsApiUrl } from './data/api';
import { updateCourseDetailsQuery } from './data/thunks';
import { DATE_FORMAT } from '../constants';
import creditMessages from './credit-section/messages';
import pacingMessages from './pacing-section/messages';
@@ -75,6 +77,12 @@ describe('<ScheduleAndDetails />', () => {
axiosMock
.onGet(getCourseSettingsApiUrl(courseId))
.reply(200, courseSettingsMock);
axiosMock
.onPut(getCourseDetailsApiUrl(courseId))
.reply(200);
});
afterEach(() => {
axiosMock.reset();
});
it('should render without errors', async () => {
@@ -136,4 +144,39 @@ describe('<ScheduleAndDetails />', () => {
).toBeInTheDocument();
});
});
it('should display a success message when course details saves', async () => {
const { getByText } = render(<RootWrapper />);
await executeThunk(updateCourseDetailsQuery(courseId, 'DaTa'), store.dispatch);
expect(getByText(messages.alertSuccess.defaultMessage)).toBeInTheDocument();
});
it('should display an error when GET CourseDetails fails', async () => {
axiosMock
.onGet(getCourseDetailsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.alertLoadFail.defaultMessage)).toBeInTheDocument();
});
});
it('should display an error when GET CourseSettings fails', async () => {
axiosMock
.onGet(getCourseSettingsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.alertLoadFail.defaultMessage)).toBeInTheDocument();
});
});
it('should display an error when PUT CourseDetails fails', async () => {
axiosMock
.onPut(getCourseDetailsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = render(<RootWrapper />);
await executeThunk(updateCourseDetailsQuery(courseId, 'DaTa'), store.dispatch);
expect(getByText(messages.alertFail.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -3,9 +3,36 @@ import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { RequestStatus } from '../data/constants';
import { getSavingStatus } from './data/selectors';
import { getLoadingDetailsStatus, getLoadingSettingsStatus, getSavingStatus } from './data/selectors';
import { validateScheduleAndDetails, updateWithDefaultValues } from './utils';
const useLoadValuesPrompt = (
courseId,
fetchCourseDetailsQuery,
fetchCourseSettingsQuery,
) => {
const dispatch = useDispatch();
const loadingDetailsStatus = useSelector(getLoadingDetailsStatus);
const loadingSettingsStatus = useSelector(getLoadingSettingsStatus);
const [showLoadFailedAlert, setShowLoadFailedAlert] = useState(false);
useEffect(() => {
dispatch(fetchCourseDetailsQuery(courseId));
dispatch(fetchCourseSettingsQuery(courseId));
}, [courseId]);
useEffect(() => {
if (loadingDetailsStatus === RequestStatus.FAILED || loadingSettingsStatus === RequestStatus.FAILED) {
setShowLoadFailedAlert(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [loadingDetailsStatus, loadingSettingsStatus]);
return {
showLoadFailedAlert,
};
};
const useSaveValuesPrompt = (
courseId,
updateDataQuery,
@@ -17,6 +44,7 @@ const useSaveValuesPrompt = (
const savingStatus = useSelector(getSavingStatus);
const [editedValues, setEditedValues] = useState(initialEditedData);
const [showSuccessfulAlert, setShowSuccessfulAlert] = useState(false);
const [showFailedAlert, setShowFailedAlert] = useState(false);
const [showModifiedAlert, setShowModifiedAlert] = useState(false);
const [isQueryPending, setIsQueryPending] = useState(false);
const [isEditableState, setIsEditableState] = useState(false);
@@ -36,6 +64,7 @@ const useSaveValuesPrompt = (
const handleValuesChange = (value, fieldName) => {
setIsEditableState(true);
setShowSuccessfulAlert(false);
setShowFailedAlert(false);
if (editedValues[fieldName] !== value) {
setEditedValues((prevEditedValues) => ({
@@ -54,6 +83,7 @@ const useSaveValuesPrompt = (
setEditedValues(initialEditedData || {});
setShowModifiedAlert(false);
setShowSuccessfulAlert(false);
setShowFailedAlert(false);
};
const handleUpdateValues = () => {
@@ -64,11 +94,13 @@ const useSaveValuesPrompt = (
const handleInternetConnectionFailed = () => {
setShowModifiedAlert(false);
setShowSuccessfulAlert(false);
setShowFailedAlert(false);
setIsQueryPending(false);
};
const handleQueryProcessing = () => {
setShowSuccessfulAlert(false);
setShowFailedAlert(false);
dispatch(updateDataQuery(courseId, updateWithDefaultValues(editedValues)));
};
@@ -76,9 +108,19 @@ const useSaveValuesPrompt = (
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessfulAlert(true);
setShowFailedAlert(false);
setTimeout(() => setShowSuccessfulAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
if (!isEditableState) {
setShowModifiedAlert(false);
}
} else if (savingStatus === RequestStatus.FAILED) {
setIsQueryPending(false);
setShowSuccessfulAlert(false);
setShowFailedAlert(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
if (!isEditableState) {
setShowModifiedAlert(false);
}
@@ -93,6 +135,7 @@ const useSaveValuesPrompt = (
isEditableState,
showModifiedAlert,
showSuccessfulAlert,
showFailedAlert,
dispatch,
setErrorFields,
handleResetValues,
@@ -104,4 +147,4 @@ const useSaveValuesPrompt = (
};
/* eslint-disable-next-line import/prefer-default-export */
export { useSaveValuesPrompt };
export { useLoadValuesPrompt, useSaveValuesPrompt };

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import {
@@ -6,6 +6,7 @@ import {
} from '@edx/paragon';
import {
CheckCircle as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
Warning as WarningIcon,
} from '@edx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -41,7 +42,7 @@ import RequirementsSection from './requirements-section';
import LicenseSection from './license-section';
import ScheduleSidebar from './schedule-sidebar';
import messages from './messages';
import { useSaveValuesPrompt } from './hooks';
import { useLoadValuesPrompt, useSaveValuesPrompt } from './hooks';
const ScheduleAndDetails = ({ intl, courseId }) => {
const courseSettings = useSelector(getCourseSettings);
@@ -76,6 +77,14 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
canShowCertificateAvailableDateField,
} = courseSettings;
const {
showLoadFailedAlert,
} = useLoadValuesPrompt(
courseId,
fetchCourseDetailsQuery,
fetchCourseSettingsQuery,
);
const {
errorFields,
savingStatus,
@@ -84,7 +93,7 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
isEditableState,
showModifiedAlert,
showSuccessfulAlert,
dispatch,
showFailedAlert,
handleResetValues,
handleValuesChange,
handleUpdateValues,
@@ -129,11 +138,6 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
videoThumbnailImageAssetPath,
} = editedValues;
useEffect(() => {
dispatch(fetchCourseSettingsQuery(courseId));
dispatch(fetchCourseDetailsQuery(courseId));
}, [courseId]);
useScrollToHashElement({ isLoading });
if (isLoading) {
@@ -184,6 +188,32 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
messages.alertSuccessAriaDescribedby,
)}
/>
<AlertMessage
show={showLoadFailedAlert}
variant="danger"
icon={ErrorOutlineIcon}
title={intl.formatMessage(messages.alertLoadFail)}
aria-hidden="true"
aria-labelledby={intl.formatMessage(
messages.alertFailAriaLabelledby,
)}
aria-describedby={intl.formatMessage(
messages.alertFailAriaDescribedby,
)}
/>
<AlertMessage
show={showFailedAlert}
variant="danger"
icon={ErrorOutlineIcon}
title={intl.formatMessage(messages.alertFail)}
aria-hidden="true"
aria-labelledby={intl.formatMessage(
messages.alertFailAriaLabelledby,
)}
aria-describedby={intl.formatMessage(
messages.alertFailAriaDescribedby,
)}
/>
<header>
<span className="small text-gray-700">
{intl.formatMessage(messages.headingSubtitle)}

View File

@@ -57,6 +57,30 @@ const messages = defineMessages({
id: 'course-authoring.schedule.alert.success',
defaultMessage: 'Your changes have been saved.',
},
alertLoadFailAriaLabelledby: {
id: 'course-authoring.schedule.alert.load.fail.aria.labelledby',
defaultMessage: 'alert-confirmation-title',
},
alertLoadFailAriaDescribedby: {
id: 'course-authoring.schedule.alert.load.fail.aria.describedby',
defaultMessage: 'alert-confirmation-description',
},
alertLoadFail: {
id: 'course-authoring.schedule.alert.load.fail',
defaultMessage: 'We encountered an error when loading your settings.',
},
alertFailAriaLabelledby: {
id: 'course-authoring.schedule.alert.fail.aria.labelledby',
defaultMessage: 'alert-confirmation-title',
},
alertFailAriaDescribedby: {
id: 'course-authoring.schedule.alert.fail.aria.describedby',
defaultMessage: 'alert-confirmation-description',
},
alertFail: {
id: 'course-authoring.schedule.alert.fail',
defaultMessage: 'We encountered an error when saving your changes.',
},
errorMessage1: {
id: 'course-authoring.schedule.schedule-section.error-message-1',
defaultMessage: 'The certificates display behavior must be \'A date after the course end date\' if certificate available date is set.',