feat: better api error handling (#972)

Improve API error handling.
This commit is contained in:
Navin Karkera
2024-05-09 11:18:00 +05:30
committed by GitHub
parent 8aea28c6e0
commit 5c1df3e16e
13 changed files with 415 additions and 170 deletions

View File

@@ -13,7 +13,6 @@ import { Helmet } from 'react-helmet';
import {
Add as IconAdd,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
} from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';
import {
@@ -66,7 +65,6 @@ const CourseOutline = ({ courseId }) => {
isCustomRelativeDatesActive,
isLoading,
isReIndexShow,
showErrorAlert,
showSuccessAlert,
isSectionsExpanded,
isEnableHighlightsModalOpen,
@@ -116,6 +114,7 @@ const CourseOutline = ({ courseId }) => {
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
errors,
} = useCourseOutline({ courseId });
const [sections, setSections] = useState(sectionsList);
@@ -231,6 +230,7 @@ const CourseOutline = ({ courseId }) => {
mfeProctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
advanceSettingsUrl={advanceSettingsUrl}
savingStatus={savingStatus}
errors={errors}
/>
<TransitionReplace>
{showSuccessAlert ? (
@@ -258,6 +258,7 @@ const CourseOutline = ({ courseId }) => {
isDisabledReindexButton={isDisabledReindexButton}
hasSections={Boolean(sectionsList.length)}
courseActions={courseActions}
errors={errors}
/>
)}
/>
@@ -279,132 +280,134 @@ const CourseOutline = ({ courseId }) => {
openEnableHighlightsModal={openEnableHighlightsModal}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
/>
<div className="pt-4">
{sections.length ? (
<>
<DraggableList
items={sections}
setSections={setSections}
restoreSectionList={restoreSectionList}
handleSectionDragAndDrop={handleSectionDragAndDrop}
handleSubsectionDragAndDrop={handleSubsectionDragAndDrop}
handleUnitDragAndDrop={handleUnitDragAndDrop}
>
<SortableContext
id="root"
{!errors?.outlineIndexApi && (
<div className="pt-4">
{sections.length ? (
<>
<DraggableList
items={sections}
strategy={verticalListSortingStrategy}
setSections={setSections}
restoreSectionList={restoreSectionList}
handleSectionDragAndDrop={handleSectionDragAndDrop}
handleSubsectionDragAndDrop={handleSubsectionDragAndDrop}
handleUnitDragAndDrop={handleUnitDragAndDrop}
>
{sections.map((section, sectionIndex) => (
<SectionCard
key={section.id}
section={section}
index={sectionIndex}
canMoveItem={canMoveSection(sections)}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
onOpenConfigureModal={openConfigureModal}
onOpenDeleteModal={openDeleteModal}
onEditSectionSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
onNewSubsectionSubmit={handleNewSubsectionSubmit}
onOrderChange={updateSectionOrderByIndex}
>
<SortableContext
id={section.id}
items={section.childInfo.children}
strategy={verticalListSortingStrategy}
<SortableContext
id="root"
items={sections}
strategy={verticalListSortingStrategy}
>
{sections.map((section, sectionIndex) => (
<SectionCard
key={section.id}
section={section}
index={sectionIndex}
canMoveItem={canMoveSection(sections)}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
onOpenConfigureModal={openConfigureModal}
onOpenDeleteModal={openDeleteModal}
onEditSectionSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
onNewSubsectionSubmit={handleNewSubsectionSubmit}
onOrderChange={updateSectionOrderByIndex}
>
{section.childInfo.children.map((subsection, subsectionIndex) => (
<SubsectionCard
key={subsection.id}
section={section}
subsection={subsection}
index={subsectionIndex}
getPossibleMoves={possibleSubsectionMoves(
[...sections],
sectionIndex,
section,
section.childInfo.children,
)}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onOpenConfigureModal={openConfigureModal}
onNewUnitSubmit={handleNewUnitSubmit}
onOrderChange={updateSubsectionOrderByIndex}
onPasteClick={handlePasteClipboardClick}
>
<SortableContext
id={subsection.id}
items={subsection.childInfo.children}
strategy={verticalListSortingStrategy}
<SortableContext
id={section.id}
items={section.childInfo.children}
strategy={verticalListSortingStrategy}
>
{section.childInfo.children.map((subsection, subsectionIndex) => (
<SubsectionCard
key={subsection.id}
section={section}
subsection={subsection}
index={subsectionIndex}
getPossibleMoves={possibleSubsectionMoves(
[...sections],
sectionIndex,
section,
section.childInfo.children,
)}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onOpenConfigureModal={openConfigureModal}
onNewUnitSubmit={handleNewUnitSubmit}
onOrderChange={updateSubsectionOrderByIndex}
onPasteClick={handlePasteClipboardClick}
>
{subsection.childInfo.children.map((unit, unitIndex) => (
<UnitCard
key={unit.id}
unit={unit}
subsection={subsection}
section={section}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
index={unitIndex}
getPossibleMoves={possibleUnitMoves(
[...sections],
sectionIndex,
subsectionIndex,
section,
subsection,
subsection.childInfo.children,
)}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenConfigureModal={openConfigureModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
onOrderChange={updateUnitOrderByIndex}
onCopyToClipboardClick={handleCopyToClipboardClick}
discussionsSettings={discussionsSettings}
/>
))}
</SortableContext>
</SubsectionCard>
))}
</SortableContext>
</SectionCard>
))}
</SortableContext>
</DraggableList>
{courseActions.childAddable && (
<Button
data-testid="new-section-button"
className="mt-4"
variant="outline-primary"
onClick={handleNewSectionSubmit}
iconBefore={IconAdd}
block
>
{intl.formatMessage(messages.newSectionButton)}
</Button>
)}
</>
) : (
<EmptyPlaceholder
onCreateNewSection={handleNewSectionSubmit}
childAddable={courseActions.childAddable}
/>
)}
</div>
<SortableContext
id={subsection.id}
items={subsection.childInfo.children}
strategy={verticalListSortingStrategy}
>
{subsection.childInfo.children.map((unit, unitIndex) => (
<UnitCard
key={unit.id}
unit={unit}
subsection={subsection}
section={section}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
index={unitIndex}
getPossibleMoves={possibleUnitMoves(
[...sections],
sectionIndex,
subsectionIndex,
section,
subsection,
subsection.childInfo.children,
)}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenConfigureModal={openConfigureModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
onOrderChange={updateUnitOrderByIndex}
onCopyToClipboardClick={handleCopyToClipboardClick}
discussionsSettings={discussionsSettings}
/>
))}
</SortableContext>
</SubsectionCard>
))}
</SortableContext>
</SectionCard>
))}
</SortableContext>
</DraggableList>
{courseActions.childAddable && (
<Button
data-testid="new-section-button"
className="mt-4"
variant="outline-primary"
onClick={handleNewSectionSubmit}
iconBefore={IconAdd}
block
>
{intl.formatMessage(messages.newSectionButton)}
</Button>
)}
</>
) : (
<EmptyPlaceholder
onCreateNewSection={handleNewSectionSubmit}
childAddable={courseActions.childAddable}
/>
)}
</div>
)}
</section>
</div>
</article>
@@ -453,17 +456,6 @@ const CourseOutline = ({ courseId }) => {
isQueryPending={savingStatus === RequestStatus.PENDING}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
{showErrorAlert && (
<AlertMessage
key={intl.formatMessage(messages.alertErrorTitle)}
show={showErrorAlert}
variant="danger"
icon={WarningIcon}
title={intl.formatMessage(messages.alertErrorTitle)}
description=""
aria-hidden="true"
/>
)}
</div>
</>
);

View File

@@ -140,6 +140,17 @@ describe('<CourseOutline />', () => {
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, courseOutlineIndexMock);
axiosMock
.onGet(getCourseBestPracticesApiUrl({
courseId, excludeGraded: true, all: true,
}))
.reply(200, courseBestPracticesMock);
axiosMock
.onGet(getCourseLaunchApiUrl({
courseId, gradedOnly: true, validateOras: true, all: true,
}))
.reply(200, courseLaunchMock);
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
});
@@ -152,6 +163,29 @@ describe('<CourseOutline />', () => {
});
});
it('handles course outline fetch api errors', async () => {
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(500, 'some internal error');
const { findByText, queryByRole } = render(<RootWrapper />);
expect(await findByText('"some internal error"')).toBeInTheDocument();
// check errors in store
expect(store.getState().courseOutline.errors).toEqual({
courseLaunchApi: null,
outlineIndexApi: {
data: '"some internal error"',
dismissible: false,
status: 500,
type: 'serverError',
},
reindexApi: null,
sectionLoadingApi: null,
});
expect(queryByRole('button', { name: 'Dismiss' })).not.toBeInTheDocument();
});
it('check reindex and render success alert is correctly', async () => {
const { findByText, findByTestId } = render(<RootWrapper />);
@@ -224,7 +258,7 @@ describe('<CourseOutline />', () => {
const reindexButton = await findByTestId('course-reindex');
await act(async () => fireEvent.click(reindexButton));
expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument();
expect(await findByText('Request failed with status code 500')).toBeInTheDocument();
});
it('check that new section list is saved when dragged', async () => {
@@ -359,18 +393,6 @@ describe('<CourseOutline />', () => {
it('render checklist value correctly', async () => {
const { getByText } = render(<RootWrapper />);
axiosMock
.onGet(getCourseBestPracticesApiUrl({
courseId, excludeGraded: true, all: true,
}))
.reply(200, courseBestPracticesMock);
axiosMock
.onGet(getCourseLaunchApiUrl({
courseId, gradedOnly: true, validateOras: true, all: true,
}))
.reply(200, courseLaunchMock);
await executeThunk(fetchCourseLaunchQuery({
courseId, gradedOnly: true, validateOras: true, all: true,
}), store.dispatch);
@@ -381,6 +403,41 @@ describe('<CourseOutline />', () => {
expect(getByText('4/9 completed')).toBeInTheDocument();
});
it('render alerts if checklist api fails', async () => {
axiosMock
.onGet(getCourseLaunchApiUrl({
courseId, gradedOnly: true, validateOras: true, all: true,
}))
.reply(500);
const { findByText, findByRole } = render(<RootWrapper />);
await executeThunk(fetchCourseLaunchQuery({
courseId, gradedOnly: true, validateOras: true, all: true,
}), store.dispatch);
expect(await findByText('Request failed with status code 500')).toBeInTheDocument();
// check errors in store
expect(store.getState().courseOutline.errors).toEqual({
courseLaunchApi: {
data: 'Request failed with status code 500',
type: 'unknown',
dismissible: true,
},
outlineIndexApi: null,
reindexApi: null,
sectionLoadingApi: null,
});
const dismissBtn = await findByRole('button', { name: 'Dismiss' });
fireEvent.click(dismissBtn);
expect(store.getState().courseOutline.errors).toEqual({
courseLaunchApi: null,
outlineIndexApi: null,
reindexApi: null,
sectionLoadingApi: null,
});
});
it('check highlights are enabled after enable highlights query is successful', async () => {
const { findByTestId, findByText } = render(<RootWrapper />);
@@ -2093,6 +2150,9 @@ describe('<CourseOutline />', () => {
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, courseSectionMock);
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));

View File

@@ -82,3 +82,9 @@ export const VIDEO_SHARING_OPTIONS = /** @type {const} */ ({
allOn: 'all-on',
allOff: 'all-off',
});
export const API_ERROR_TYPES = /** @type {const} */ ({
networkError: 'networkError',
serverError: 'serverError',
unknown: 'unknown',
});

View File

@@ -10,3 +10,4 @@ export const getCourseActions = (state) => state.courseOutline.actions;
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices;
export const getErrors = (state) => state.courseOutline.errors;

View File

@@ -11,6 +11,13 @@ const slice = createSlice({
outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS,
reIndexLoadingStatus: RequestStatus.IN_PROGRESS,
fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS,
courseLaunchQueryStatus: RequestStatus.IN_PROGRESS,
},
errors: {
outlineIndexApi: null,
reindexApi: null,
sectionLoadingApi: null,
courseLaunchApi: null,
},
outlineIndexData: {},
savingStatus: '',
@@ -53,18 +60,31 @@ const slice = createSlice({
...state.loadingStatus,
outlineIndexLoadingStatus: payload.status,
};
state.errors.outlineIndexApi = payload.errors || null;
},
updateReindexLoadingStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
reIndexLoadingStatus: payload.status,
};
state.errors.reindexApi = payload.errors || null;
},
updateFetchSectionLoadingStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
fetchSectionLoadingStatus: payload.status,
};
state.errors.sectionLoadingApi = payload.errors || null;
},
updateCourseLaunchQueryStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
courseLaunchQueryStatus: payload.status,
};
state.errors.courseLaunchApi = payload.errors || null;
},
dismissError: (state, { payload }) => {
state.errors[payload] = null;
},
updateStatusBar: (state, { payload }) => {
state.statusBarData = {
@@ -188,6 +208,7 @@ export const {
fetchStatusBarChecklistSuccess,
fetchStatusBarSelPacedSuccess,
updateFetchSectionLoadingStatus,
updateCourseLaunchQueryStatus,
updateSavingStatus,
updateSectionList,
setCurrentItem,
@@ -202,6 +223,7 @@ export const {
reorderUnitList,
setPasteFileNotices,
removePasteFileNotices,
dismissError,
} = slice.actions;
export const {

View File

@@ -1,7 +1,7 @@
import { RequestStatus } from '../../data/constants';
import { updateClipboardData } from '../../generic/data/slice';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { COURSE_BLOCK_NAMES } from '../constants';
import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants';
import {
hideProcessingNotification,
showProcessingNotification,
@@ -51,8 +51,24 @@ import {
duplicateSection,
reorderSectionList,
setPasteFileNotices,
updateCourseLaunchQueryStatus,
} from './slice';
const getErrorDetails = (error, dismissible = true) => {
const errorInfo = { dismissible };
if (error.response?.data) {
errorInfo.data = JSON.stringify(error.response.data);
errorInfo.status = error.response.status;
errorInfo.type = API_ERROR_TYPES.serverError;
} else if (error.request) {
errorInfo.type = API_ERROR_TYPES.networkError;
} else {
errorInfo.type = API_ERROR_TYPES.unknown;
errorInfo.data = error.message;
}
return errorInfo;
};
export function fetchCourseOutlineIndexQuery(courseId) {
return async (dispatch) => {
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
@@ -80,7 +96,10 @@ export function fetchCourseOutlineIndexQuery(courseId) {
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.FAILED }));
dispatch(updateOutlineIndexLoadingStatus({
status: RequestStatus.FAILED,
errors: getErrorDetails(error, false),
}));
}
};
}
@@ -92,6 +111,7 @@ export function fetchCourseLaunchQuery({
all = true,
}) {
return async (dispatch) => {
dispatch(updateCourseLaunchQueryStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const data = await getCourseLaunch({
courseId, gradedOnly, validateOras, all,
@@ -99,9 +119,12 @@ export function fetchCourseLaunchQuery({
dispatch(fetchStatusBarSelPacedSuccess({ isSelfPaced: data.isSelfPaced }));
dispatch(fetchStatusBarChecklistSuccess(getCourseLaunchChecklist(data)));
return true;
dispatch(updateCourseLaunchQueryStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
return false;
dispatch(updateCourseLaunchQueryStatus({
status: RequestStatus.FAILED,
errors: getErrorDetails(error),
}));
}
};
}
@@ -166,7 +189,10 @@ export function fetchCourseReindexQuery(courseId, reindexLink) {
await restartIndexingOnCourse(reindexLink);
dispatch(updateReindexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateReindexLoadingStatus({ status: RequestStatus.FAILED }));
dispatch(updateReindexLoadingStatus({
status: RequestStatus.FAILED,
errors: getErrorDetails(error),
}));
}
};
}
@@ -185,7 +211,10 @@ export function fetchCourseSectionQuery(sectionIds, shouldScroll = false) {
dispatch(updateSectionList(sections));
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.FAILED }));
dispatch(updateFetchSectionLoadingStatus({
status: RequestStatus.FAILED,
errors: getErrorDetails(error),
}));
}
};
}

View File

@@ -17,6 +17,7 @@ const HeaderNavigations = ({
isDisabledReindexButton,
hasSections,
courseActions,
errors,
}) => {
const intl = useIntl();
const {
@@ -37,6 +38,7 @@ const HeaderNavigations = ({
<Button
iconBefore={IconAdd}
onClick={handleNewSection}
disabled={errors?.outlineIndexApi}
>
{intl.formatMessage(messages.newSectionButton)}
</Button>
@@ -92,6 +94,10 @@ const HeaderNavigations = ({
);
};
HeaderNavigations.defaultProps = {
errors: {},
};
HeaderNavigations.propTypes = {
isReIndexShow: PropTypes.bool.isRequired,
isSectionsExpanded: PropTypes.bool.isRequired,
@@ -109,6 +115,24 @@ HeaderNavigations.propTypes = {
childAddable: PropTypes.bool.isRequired,
duplicable: PropTypes.bool.isRequired,
}).isRequired,
errors: PropTypes.shape({
outlineIndexApi: PropTypes.shape({
data: PropTypes.string,
type: PropTypes.string.isRequired,
}),
reindexApi: PropTypes.shape({
data: PropTypes.string,
type: PropTypes.string.isRequired,
}),
sectionLoadingApi: PropTypes.shape({
data: PropTypes.string,
type: PropTypes.string.isRequired,
}),
courseLaunchApi: PropTypes.shape({
data: PropTypes.string,
type: PropTypes.string.isRequired,
}),
}),
};
export default HeaderNavigations;

View File

@@ -124,4 +124,13 @@ describe('<HeaderNavigations />', () => {
expect(queryByText(messages.reindexButtonTooltip.defaultMessage)).not.toBeInTheDocument();
});
});
it('disables new section button if course outline fetch fails', () => {
const { getByRole } = renderComponent({
errors: { outlineIndexApi: { data: 'some error', type: 'serverError' } },
});
expect(getByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeDisabled();
});
});

View File

@@ -24,6 +24,7 @@ import {
getCurrentSection,
getCurrentSubsection,
getCustomRelativeDatesActiveFlag,
getErrors,
} from './data/selectors';
import {
addNewSectionQuery,
@@ -81,12 +82,12 @@ const useCourseOutline = ({ courseId }) => {
const currentSubsection = useSelector(getCurrentSubsection);
const isCustomRelativeDatesActive = useSelector(getCustomRelativeDatesActiveFlag);
const genericSavingStatus = useSelector(getGenericSavingStatus);
const errors = useSelector(getErrors);
const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false);
const [isSectionsExpanded, setSectionsExpanded] = useState(true);
const [isDisabledReindexButton, setDisableReindexButton] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showErrorAlert, setShowErrorAlert] = useState(false);
const [isHighlightsModalOpen, openHighlightsModal, closeHighlightsModal] = useToggle(false);
const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
@@ -135,7 +136,6 @@ const useCourseOutline = ({ courseId }) => {
handleReIndex: () => {
setDisableReindexButton(true);
setShowSuccessAlert(false);
setShowErrorAlert(false);
dispatch(fetchCourseReindexQuery(courseId, reindexLink)).then(() => {
setDisableReindexButton(false);
@@ -291,13 +291,7 @@ const useCourseOutline = ({ courseId }) => {
}, [courseId]);
useEffect(() => {
if (reIndexLoadingStatus === RequestStatus.FAILED) {
setShowErrorAlert(true);
}
if (reIndexLoadingStatus === RequestStatus.SUCCESSFUL) {
setShowSuccessAlert(true);
}
setShowSuccessAlert(reIndexLoadingStatus === RequestStatus.SUCCESSFUL);
}, [reIndexLoadingStatus]);
return {
@@ -308,7 +302,6 @@ const useCourseOutline = ({ courseId }) => {
isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS,
isReIndexShow: Boolean(reindexLink),
showSuccessAlert,
showErrorAlert,
isDisabledReindexButton,
isSectionsExpanded,
isPublishModalOpen,
@@ -361,6 +354,7 @@ const useCourseOutline = ({ courseId }) => {
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
errors,
};
};

View File

@@ -25,10 +25,6 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.reindex.alert.success.aria.describedby',
defaultMessage: 'alert-confirmation-description',
},
alertErrorTitle: {
id: 'course-authoring.course-outline.reindex.alert.error.title',
defaultMessage: 'There were errors reindexing course.',
},
newSectionButton: {
id: 'course-authoring.course-outline.section-list.button.new-section',
defaultMessage: 'New section',

View File

@@ -8,8 +8,11 @@ import {
Campaign as CampaignIcon,
InfoOutline as InfoOutlineIcon,
Warning as WarningIcon,
Error as ErrorIcon,
} from '@openedx/paragon/icons';
import { Alert, Button, Hyperlink } from '@openedx/paragon';
import {
Alert, Button, Hyperlink, Truncate,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { RequestStatus } from '../../data/constants';
@@ -18,7 +21,8 @@ import AlertProctoringError from '../../generic/AlertProctoringError';
import messages from './messages';
import advancedSettingsMessages from '../../advanced-settings/messages';
import { getPasteFileNotices } from '../data/selectors';
import { removePasteFileNotices } from '../data/slice';
import { dismissError, removePasteFileNotices } from '../data/slice';
import { API_ERROR_TYPES } from '../constants';
const PageAlerts = ({
courseId,
@@ -32,6 +36,7 @@ const PageAlerts = ({
mfeProctoredExamSettingsUrl,
advanceSettingsUrl,
savingStatus,
errors,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -330,6 +335,67 @@ const PageAlerts = ({
return null;
};
const renderApiErrors = () => {
const errorList = Object.entries(errors).filter(obj => obj[1] !== null).map(([k, v]) => {
switch (v.type) {
case API_ERROR_TYPES.serverError:
return {
key: k,
desc: v.data,
title: intl.formatMessage(messages.serverErrorAlert, {
status: v.status,
}),
dismissible: v.dismissible,
};
case API_ERROR_TYPES.networkError:
return {
key: k,
title: intl.formatMessage(messages.networkErrorAlert),
dismissible: v.dismissible,
};
default:
return {
key: k,
desc: v.data,
dismissible: v.dismissible,
};
}
});
if (!errorList?.length) {
return null;
}
return (
errorList.map((msgObj) => (
msgObj.dismissible ? (
<ErrorAlert
isError
hideHeading
key={msgObj.key}
dismissError={() => dispatch(dismissError(msgObj.key))}
>
{msgObj.title
&& (
<Alert.Heading>{msgObj.title}</Alert.Heading>
)}
{msgObj.desc && <Truncate lines={2}>{msgObj.desc}</Truncate>}
</ErrorAlert>
) : (
<Alert
variant="danger"
icon={ErrorIcon}
key={msgObj.key}
>
{msgObj.title
&& (
<Alert.Heading>{msgObj.title}</Alert.Heading>
)}
{msgObj.desc && <Truncate lines={2}>{msgObj.desc}</Truncate>}
</Alert>
)
))
);
};
return (
<>
{configurationErrors()}
@@ -339,6 +405,7 @@ const PageAlerts = ({
<ErrorAlert hideHeading isError={savingStatus === RequestStatus.FAILED}>
{intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })}
</ErrorAlert>
{renderApiErrors()}
{errorFilesPasteAlert()}
{conflictingFilesPasteAlert()}
{newFilesPasteAlert()}
@@ -357,6 +424,7 @@ PageAlerts.defaultProps = {
mfeProctoredExamSettingsUrl: '',
advanceSettingsUrl: '',
savingStatus: '',
errors: {},
};
PageAlerts.propTypes = {
@@ -387,6 +455,24 @@ PageAlerts.propTypes = {
mfeProctoredExamSettingsUrl: PropTypes.string,
advanceSettingsUrl: PropTypes.string,
savingStatus: PropTypes.string,
errors: PropTypes.shape({
outlineIndexApi: PropTypes.shape({
data: PropTypes.string,
type: PropTypes.string.isRequired,
}),
reindexApi: PropTypes.shape({
data: PropTypes.string,
type: PropTypes.string.isRequired,
}),
sectionLoadingApi: PropTypes.shape({
data: PropTypes.string,
type: PropTypes.string.isRequired,
}),
courseLaunchApi: PropTypes.shape({
data: PropTypes.string,
type: PropTypes.string.isRequired,
}),
}),
};
export default PageAlerts;

View File

@@ -8,6 +8,7 @@ import { initializeMockApp, getConfig } from '@edx/frontend-platform';
import PageAlerts from './PageAlerts';
import messages from './messages';
import initializeStore from '../../store';
import { API_ERROR_TYPES } from '../constants';
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
@@ -189,4 +190,19 @@ describe('<PageAlerts />', () => {
`${getConfig().STUDIO_BASE_URL}/assets/course-id`,
);
});
it('renders api error alerts', async () => {
const { queryByText } = renderComponent({
...pageAlertsData,
errors: {
outlineIndexApi: { data: 'some error', status: 400, type: API_ERROR_TYPES.serverError },
courseLaunchApi: { type: API_ERROR_TYPES.networkError },
reindexApi: { type: API_ERROR_TYPES.unknown, data: 'some unknown error' },
},
});
expect(queryByText(messages.networkErrorAlert.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.serverErrorAlert.defaultMessage)).toBeInTheDocument();
expect(queryByText('some error')).toBeInTheDocument();
expect(queryByText('some unknown error')).toBeInTheDocument();
});
});

View File

@@ -106,6 +106,16 @@ const messages = defineMessages({
defaultMessage: 'The following {conflictingFilesLen, plural, one {file} other {files}} already exist in this course but don\'t match the version used by the component you pasted: {conflictingFilesStr}',
description: 'This alert description is displayed when files being imported conflict with existing files in the course and advises the user to update the conflicting files manually.',
},
serverErrorAlert: {
id: 'course-authoring.course-outline.page-alert.server-error.title',
defaultMessage: 'Request failed with status: {status}',
description: 'Generic server error alert title.',
},
networkErrorAlert: {
id: 'course-authoring.course-outline.page-alert.network-error.title',
defaultMessage: 'Network error',
description: 'Generic network error alert.',
},
});
export default messages;