@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user