diff --git a/src/course-checklist/CourseChecklist.jsx b/src/course-checklist/CourseChecklist.jsx index 8c15a11dc0..a220cff8fa 100644 --- a/src/course-checklist/CourseChecklist.jsx +++ b/src/course-checklist/CourseChecklist.jsx @@ -13,6 +13,7 @@ import AriaLiveRegion from './AriaLiveRegion'; import { RequestStatus } from '../data/constants'; import ChecklistSection from './ChecklistSection'; import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; const CourseChecklist = ({ courseId, @@ -34,10 +35,19 @@ const CourseChecklist = ({ bestPracticeData, } = useSelector(state => state.courseChecklist); - const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus } = loadingStatus; + const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus; const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS; const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS; + const isLoadingDenied = launchChecklistStatus === RequestStatus.DENIED; + + if (isLoadingDenied) { + return ( + + + + ); + } return ( <> diff --git a/src/course-checklist/CourseChecklist.test.jsx b/src/course-checklist/CourseChecklist.test.jsx index 53d52af77e..69057abf70 100644 --- a/src/course-checklist/CourseChecklist.test.jsx +++ b/src/course-checklist/CourseChecklist.test.jsx @@ -149,5 +149,20 @@ describe('CourseChecklistPage', () => { }); }); }); + + it('displays an alert and sets status to DENIED when API responds with 403', async () => { + const courseLaunchApiUrl = getCourseLaunchApiUrl({ + courseId, gradedOnly: true, validateOras: true, all: true, + }); + axiosMock.onGet(courseLaunchApiUrl).reply(403); + + renderComponent(); + + await waitFor(() => { + const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus; + expect(launchChecklistStatus).toEqual(RequestStatus.DENIED); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); }); }); diff --git a/src/course-checklist/data/thunks.js b/src/course-checklist/data/thunks.js index 20be7648a1..74547dab7b 100644 --- a/src/course-checklist/data/thunks.js +++ b/src/course-checklist/data/thunks.js @@ -24,7 +24,11 @@ export function fetchCourseLaunchQuery({ dispatch(fetchLaunchChecklistSuccess({ data })); dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED })); + if (error.response && error.response.status === 403) { + dispatch(updateLaunchChecklistStatus({ status: RequestStatus.DENIED })); + } else { + dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED })); + } } }; } diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index fe428c1c74..21d35831df 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -68,6 +68,7 @@ const CourseOutline = ({ courseId }) => { sectionsList, isCustomRelativeDatesActive, isLoading, + isLoadingDenied, isReIndexShow, showSuccessAlert, isSectionsExpanded, @@ -233,6 +234,27 @@ const CourseOutline = ({ courseId }) => { ); } + if (isLoadingDenied) { + return ( + + + + ); + } + return ( <> diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index b7f8332eeb..bfe6c07705 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -2291,4 +2291,18 @@ describe('', () => { expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument(); expect(await screen.findByText('An error has occurred creating the file')).toBeInTheDocument(); }); + + it('displays an alert and sets status to DENIED when API responds with 403', async () => { + axiosMock + .onGet(getCourseOutlineIndexApiUrl(courseId)) + .reply(403); + + const { getByRole } = render(); + + await waitFor(() => { + expect(getByRole('alert')).toBeInTheDocument(); + const { outlineIndexLoadingStatus } = store.getState().courseOutline.loadingStatus; + expect(outlineIndexLoadingStatus).toEqual(RequestStatus.DENIED); + }); + }); }); diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 315c5846c0..c555fcb978 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -99,10 +99,16 @@ export function fetchCourseOutlineIndexQuery(courseId) { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(updateOutlineIndexLoadingStatus({ - status: RequestStatus.FAILED, - errors: getErrorDetails(error, false), - })); + if (error.response && error.response.status === 403) { + dispatch(updateOutlineIndexLoadingStatus({ + status: RequestStatus.DENIED, + })); + } else { + dispatch(updateOutlineIndexLoadingStatus({ + status: RequestStatus.FAILED, + errors: getErrorDetails(error, false), + })); + } } }; } diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 739d599432..c3b43a9fd3 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -302,6 +302,7 @@ const useCourseOutline = ({ courseId }) => { sectionsList, isCustomRelativeDatesActive, isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS, + isLoadingDenied: outlineIndexLoadingStatus === RequestStatus.DENIED, isReIndexShow: Boolean(reindexLink), showSuccessAlert, isDisabledReindexButton, diff --git a/src/course-team/CourseTeam.jsx b/src/course-team/CourseTeam.jsx index 10360460a3..29ad5b80f9 100644 --- a/src/course-team/CourseTeam.jsx +++ b/src/course-team/CourseTeam.jsx @@ -20,6 +20,7 @@ import CourseTeamMember from './course-team-member/CourseTeamMember'; import InfoModal from './info-modal/InfoModal'; import { useCourseTeam } from './hooks'; import getPageHeadTitle from '../generic/utils'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; const CourseTeam = ({ courseId }) => { const intl = useIntl(); @@ -35,6 +36,7 @@ const CourseTeam = ({ courseId }) => { courseTeamUsers, currentUserEmail, isLoading, + isLoadingDenied, isSingleAdmin, isFormVisible, isQueryPending, @@ -55,6 +57,14 @@ const CourseTeam = ({ courseId }) => { handleInternetConnectionFailed, } = useCourseTeam({ intl, courseId }); + if (isLoadingDenied) { + return ( + + + + ); + } + if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; diff --git a/src/course-team/CourseTeam.test.jsx b/src/course-team/CourseTeam.test.jsx index 4f33788744..046e8ef95f 100644 --- a/src/course-team/CourseTeam.test.jsx +++ b/src/course-team/CourseTeam.test.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { render, fireEvent, @@ -18,6 +17,7 @@ import CourseTeam from './CourseTeam'; import messages from './messages'; import { USER_ROLES } from '../constants'; import { executeThunk } from '../utils'; +import { RequestStatus } from '../data/constants'; import { changeRoleTeamUserQuery, deleteCourseTeamQuery } from './data/thunk'; let axiosMock; @@ -219,4 +219,31 @@ describe('', () => { await executeThunk(changeRoleTeamUserQuery(courseId, 'staff@example.com', { role: USER_ROLES.admin }), store.dispatch); expect(getAllByText('Admin')).toHaveLength(1); }); + + it('displays an alert and sets status to DENIED when API responds with 403', async () => { + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(403); + + const { getByRole } = render(); + + await waitFor(() => { + expect(getByRole('alert')).toBeInTheDocument(); + const { loadingCourseTeamStatus } = store.getState().courseTeam; + expect(loadingCourseTeamStatus).toEqual(RequestStatus.DENIED); + }); + }); + + it('sets loading status to FAILED upon receiving a 404 response from the API', async () => { + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(404); + + render(); + + await waitFor(() => { + const { loadingCourseTeamStatus } = store.getState().courseTeam; + expect(loadingCourseTeamStatus).toEqual(RequestStatus.FAILED); + }); + }); }); diff --git a/src/course-team/data/thunk.js b/src/course-team/data/thunk.js index 78012870f6..bfc3db193e 100644 --- a/src/course-team/data/thunk.js +++ b/src/course-team/data/thunk.js @@ -24,7 +24,11 @@ export function fetchCourseTeamQuery(courseId) { dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.SUCCESSFUL })); return true; } catch (error) { - dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.FAILED })); + if (error.response && error.response.status === 403) { + dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.DENIED })); + } else { + dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.FAILED })); + } return false; } }; diff --git a/src/course-team/hooks.jsx b/src/course-team/hooks.jsx index 1b3778fecf..7c6bc09a48 100644 --- a/src/course-team/hooks.jsx +++ b/src/course-team/hooks.jsx @@ -113,6 +113,7 @@ const useCourseTeam = ({ courseId }) => { courseTeamUsers, currentUserEmail, isLoading: loadingCourseTeamStatus === RequestStatus.IN_PROGRESS, + isLoadingDenied: loadingCourseTeamStatus === RequestStatus.DENIED, isSingleAdmin, isFormVisible, isAllowActions, diff --git a/src/course-updates/CourseUpdates.jsx b/src/course-updates/CourseUpdates.jsx index a6b7af677b..0d82f3bf8d 100644 --- a/src/course-updates/CourseUpdates.jsx +++ b/src/course-updates/CourseUpdates.jsx @@ -16,6 +16,7 @@ import { getProcessingNotification } from '../generic/processing-notification/da import ProcessingNotification from '../generic/processing-notification'; import SubHeader from '../generic/sub-header/SubHeader'; import InternetConnectionAlert from '../generic/internet-connection-alert'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import { RequestStatus } from '../data/constants'; import CourseHandouts from './course-handouts/CourseHandouts'; import CourseUpdate from './course-update/CourseUpdate'; @@ -64,9 +65,18 @@ const CourseUpdates = ({ courseId }) => { const errors = useSelector(getErrors); const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED); + const anyStatusDenied = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.DENIED); const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS); const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING); + if (anyStatusDenied) { + return ( + + + + ); + } + return ( <> diff --git a/src/course-updates/CourseUpdates.test.jsx b/src/course-updates/CourseUpdates.test.jsx index 387d3b3c26..ab8e1a8022 100644 --- a/src/course-updates/CourseUpdates.test.jsx +++ b/src/course-updates/CourseUpdates.test.jsx @@ -1,5 +1,6 @@ -import React from 'react'; -import { render, waitFor, fireEvent } from '@testing-library/react'; +import { + render, waitFor, fireEvent, +} from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; import { initializeMockApp } from '@edx/frontend-platform'; @@ -19,6 +20,7 @@ import { } from './data/thunk'; import initializeStore from '../store'; import { executeThunk } from '../utils'; +import { RequestStatus } from '../data/constants'; import { courseUpdatesMock, courseHandoutsMock } from './__mocks__'; import CourseUpdates from './CourseUpdates'; import messages from './messages'; @@ -278,6 +280,24 @@ describe('', () => { expect(getByTestId('course-handouts-edit-button')).toBeDisabled(); }); }); + + it('displays an alert and sets status to DENIED when API responds with 403', async () => { + axiosMock + .onGet(getCourseUpdatesApiUrl(courseId)) + .reply(403, courseUpdatesMock); + axiosMock + .onGet(getCourseHandoutApiUrl(courseId)) + .reply(403); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('connectionErrorAlert')).toBeInTheDocument(); + const { loadingStatuses } = store.getState().courseUpdates; + Object.values(loadingStatuses) + .some(status => expect(status).toEqual(RequestStatus.DENIED)); + }); + }); }); describe('saving failure API responses', () => { diff --git a/src/course-updates/data/thunk.js b/src/course-updates/data/thunk.js index 88b3a0578a..225ca9e461 100644 --- a/src/course-updates/data/thunk.js +++ b/src/course-updates/data/thunk.js @@ -31,10 +31,17 @@ export function fetchCourseUpdatesQuery(courseId) { error: { loadingUpdates: false }, })); } catch (error) { - dispatch(updateLoadingStatuses({ - status: { fetchCourseUpdatesQuery: RequestStatus.FAILED }, - error: { loadingUpdates: true }, - })); + if (error.response && error.response.status === 403) { + dispatch(updateLoadingStatuses({ + status: { fetchCourseUpdatesQuery: RequestStatus.DENIED }, + error: { loadingUpdates: true }, + })); + } else { + dispatch(updateLoadingStatuses({ + status: { fetchCourseUpdatesQuery: RequestStatus.FAILED }, + error: { loadingUpdates: true }, + })); + } } }; } @@ -116,10 +123,17 @@ export function fetchCourseHandoutsQuery(courseId) { error: { loadingHandouts: false }, })); } catch (error) { - dispatch(updateLoadingStatuses({ - status: { fetchCourseHandoutsQuery: RequestStatus.FAILED }, - error: { loadingHandouts: true }, - })); + if (error.response && error.response.status === 403) { + dispatch(updateLoadingStatuses({ + status: { fetchCourseHandoutsQuery: RequestStatus.DENIED }, + error: { loadingHandouts: true }, + })); + } else { + dispatch(updateLoadingStatuses({ + status: { fetchCourseHandoutsQuery: RequestStatus.FAILED }, + error: { loadingHandouts: true }, + })); + } } }; } diff --git a/src/export-page/CourseExportPage.jsx b/src/export-page/CourseExportPage.jsx index b332499732..36f96f2513 100644 --- a/src/export-page/CourseExportPage.jsx +++ b/src/export-page/CourseExportPage.jsx @@ -11,6 +11,7 @@ import { getConfig } from '@edx/frontend-platform'; import { Helmet } from 'react-helmet'; import InternetConnectionAlert from '../generic/internet-connection-alert'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import SubHeader from '../generic/sub-header/SubHeader'; import { RequestStatus } from '../data/constants'; import { useModel } from '../generic/model-store'; @@ -37,6 +38,7 @@ const CourseExportPage = ({ intl, courseId }) => { const cookies = new Cookies(); const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS; const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; + const isLoadingDenied = loadingStatus === RequestStatus.DENIED; const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; useEffect(() => { @@ -48,6 +50,14 @@ const CourseExportPage = ({ intl, courseId }) => { } }, []); + if (isLoadingDenied) { + return ( + + + + ); + } + return ( <> diff --git a/src/export-page/CourseExportPage.test.jsx b/src/export-page/CourseExportPage.test.jsx index 05db07d5bf..4c0a01d5e6 100644 --- a/src/export-page/CourseExportPage.test.jsx +++ b/src/export-page/CourseExportPage.test.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { getConfig, initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; @@ -6,9 +5,10 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { fireEvent, render, waitFor } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { Helmet } from 'react-helmet'; - import Cookies from 'universal-cookie'; + import initializeStore from '../store'; +import { RequestStatus } from '../data/constants'; import stepperMessages from './export-stepper/messages'; import modalErrorMessages from './export-modal-error/messages'; import { getExportStatusApiUrl, postExportCourseApiUrl } from './data/api'; @@ -137,4 +137,30 @@ describe('', () => { expect(downloadButton).toBeInTheDocument(); expect(downloadButton.getAttribute('href')).toEqual('http://test-download-path.test'); }); + it('displays an alert and sets status to DENIED when API responds with 403', async () => { + axiosMock + .onGet(getExportStatusApiUrl(courseId)) + .reply(403); + const { getByRole, container } = render(); + const startExportButton = container.querySelector('.btn-primary'); + fireEvent.click(startExportButton); + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 3500)); + expect(getByRole('alert')).toBeInTheDocument(); + const { loadingStatus } = store.getState().courseExport; + expect(loadingStatus).toEqual(RequestStatus.DENIED); + }); + + it('sets loading status to FAILED upon receiving a 404 response from the API', async () => { + axiosMock + .onGet(getExportStatusApiUrl(courseId)) + .reply(404); + const { container } = render(); + const startExportButton = container.querySelector('.btn-primary'); + fireEvent.click(startExportButton); + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 3500)); + const { loadingStatus } = store.getState().courseExport; + expect(loadingStatus).toEqual(RequestStatus.FAILED); + }); }); diff --git a/src/export-page/data/thunks.js b/src/export-page/data/thunks.js index f5fdcae07c..dd86ba84ef 100644 --- a/src/export-page/data/thunks.js +++ b/src/export-page/data/thunks.js @@ -89,7 +89,11 @@ export function fetchExportStatus(courseId) { dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); return true; } catch (error) { - dispatch(updateLoadingStatus({ status: RequestStatus.FAILED })); + if (error.response && error.response.status === 403) { + dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); + } else { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); + } return false; } }; diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index cda1a6278c..bcb3a8f089 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -16,6 +16,7 @@ import AlertMessage from '../generic/alert-message'; import InternetConnectionAlert from '../generic/internet-connection-alert'; import { useModel } from '../generic/model-store'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import SectionSubHeader from '../generic/section-sub-header'; import SubHeader from '../generic/sub-header/SubHeader'; import getPageHeadTitle from '../generic/utils'; @@ -32,10 +33,12 @@ const GradingSettings = ({ courseId }) => { const { data: gradingSettings, isLoading: isGradingSettingsLoading, + isError: isGradingSettingsError, } = useGradingSettings(courseId); const { data: courseSettingsData, isLoading: isCourseSettingsLoading, + isError: isCourseSettingsError, } = useCourseSettings(courseId); const { mutate: updateGradingSettings, @@ -46,7 +49,7 @@ const GradingSettings = ({ courseId }) => { const courseAssignmentLists = gradingSettings?.courseAssignmentLists; const courseGradingDetails = gradingSettings?.courseDetails; - + const isLoadingDenied = isGradingSettingsError || isCourseSettingsError; const [showSuccessAlert, setShowSuccessAlert] = useState(false); const isLoading = isCourseSettingsLoading || isGradingSettingsLoading; const [isQueryPending, setIsQueryPending] = useState(false); @@ -86,6 +89,14 @@ const GradingSettings = ({ courseId }) => { } }, [savePending]); + if (isLoadingDenied) { + return ( + + + + ); + } + if (isLoading) { return null; } diff --git a/src/grading-settings/GradingSettings.test.jsx b/src/grading-settings/GradingSettings.test.jsx index 84b574367f..686cf9acd1 100644 --- a/src/grading-settings/GradingSettings.test.jsx +++ b/src/grading-settings/GradingSettings.test.jsx @@ -7,11 +7,11 @@ import { act, fireEvent, render, screen, } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; -import React from 'react'; import initializeStore from '../store'; import gradingSettings from './__mocks__/gradingSettings'; import { getCourseSettingsApiUrl, getGradingSettingsApiUrl } from './data/api'; +import * as apiHooks from './data/apiHooks'; import GradingSettings from './GradingSettings'; import messages from './messages'; @@ -112,4 +112,10 @@ describe('', () => { setOnlineStatus(true); testSaving(); }); + + it('should display connection error alert when loading is denied', async () => { + jest.spyOn(apiHooks, 'useGradingSettings').mockReturnValue({ isError: true }); + render(); + expect(screen.getByTestId('connectionErrorAlert')).toBeInTheDocument(); + }); }); diff --git a/src/group-configurations/GroupConfigurations.test.jsx b/src/group-configurations/GroupConfigurations.test.jsx index 34486c368b..303b557f2c 100644 --- a/src/group-configurations/GroupConfigurations.test.jsx +++ b/src/group-configurations/GroupConfigurations.test.jsx @@ -103,4 +103,21 @@ describe('', () => { RequestStatus.FAILED, ); }); + + it('displays an alert and sets status to DENIED when API responds with 403', async () => { + axiosMock + .onGet(getContentStoreApiUrl(courseId)) + .reply(403); + + await executeThunk(fetchGroupConfigurationsQuery(courseId), store.dispatch); + + const { getByTestId } = renderComponent(); + + await waitFor(() => { + expect(getByTestId('connectionErrorAlert')).toBeInTheDocument(); + expect(store.getState().groupConfigurations.loadingStatus).toBe( + RequestStatus.DENIED, + ); + }); + }); }); diff --git a/src/group-configurations/data/thunk.js b/src/group-configurations/data/thunk.js index 16b961d96d..30ae354730 100644 --- a/src/group-configurations/data/thunk.js +++ b/src/group-configurations/data/thunk.js @@ -33,7 +33,11 @@ export function fetchGroupConfigurationsQuery(courseId) { dispatch(fetchGroupConfigurations({ groupConfigurations })); dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(updateLoadingStatus({ status: RequestStatus.FAILED })); + if (error.response && error.response.status === 403) { + dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); + } else { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); + } } }; } diff --git a/src/group-configurations/hooks.jsx b/src/group-configurations/hooks.jsx index 01187f1d2c..cbc3cb7b0e 100644 --- a/src/group-configurations/hooks.jsx +++ b/src/group-configurations/hooks.jsx @@ -85,6 +85,7 @@ const useGroupConfigurations = (courseId) => { return { isLoading: loadingStatus === RequestStatus.IN_PROGRESS, + isLoadingDenied: loadingStatus === RequestStatus.DENIED, savingStatus, contentGroupActions, experimentConfigurationActions, diff --git a/src/group-configurations/index.jsx b/src/group-configurations/index.jsx index ad7900946d..1f150c8288 100644 --- a/src/group-configurations/index.jsx +++ b/src/group-configurations/index.jsx @@ -16,6 +16,7 @@ import ExperimentConfigurationsSection from './experiment-configurations-section import EnrollmentTrackGroupsSection from './enrollment-track-groups-section'; import GroupConfigurationSidebar from './group-configuration-sidebar'; import { useGroupConfigurations } from './hooks'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; const GroupConfigurations = ({ courseId }) => { const { formatMessage } = useIntl(); @@ -34,6 +35,7 @@ const GroupConfigurations = ({ courseId }) => { shouldShowExperimentGroups, experimentGroupConfigurations, }, + isLoadingDenied, } = useGroupConfigurations(courseId); document.title = getPageHeadTitle( @@ -41,6 +43,14 @@ const GroupConfigurations = ({ courseId }) => { formatMessage(messages.headingTitle), ); + if (isLoadingDenied) { + return ( + + + + ); + } + if (isLoading) { return ( diff --git a/src/import-page/CourseImportPage.jsx b/src/import-page/CourseImportPage.jsx index 368d44f741..2daea3fdb2 100644 --- a/src/import-page/CourseImportPage.jsx +++ b/src/import-page/CourseImportPage.jsx @@ -13,6 +13,7 @@ import SubHeader from '../generic/sub-header/SubHeader'; import InternetConnectionAlert from '../generic/internet-connection-alert'; import { RequestStatus } from '../data/constants'; import { useModel } from '../generic/model-store'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import { updateFileName, updateImportTriggered, updateSavingStatus, updateSuccessDate, } from './data/slice'; @@ -31,6 +32,7 @@ const CourseImportPage = ({ intl, courseId }) => { const savingStatus = useSelector(getSavingStatus); const loadingStatus = useSelector(getLoadingStatus); const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; + const isLoadingDenied = loadingStatus === RequestStatus.DENIED; const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; useEffect(() => { @@ -43,6 +45,14 @@ const CourseImportPage = ({ intl, courseId }) => { } }, []); + if (isLoadingDenied) { + return ( + + + + ); + } + return ( <> diff --git a/src/import-page/CourseImportPage.test.jsx b/src/import-page/CourseImportPage.test.jsx index 8359312692..646eadbe6e 100644 --- a/src/import-page/CourseImportPage.test.jsx +++ b/src/import-page/CourseImportPage.test.jsx @@ -1,14 +1,14 @@ -import React from 'react'; import { initializeMockApp } from '@edx/frontend-platform'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; import { render, waitFor } from '@testing-library/react'; import { Helmet } from 'react-helmet'; - import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + import Cookies from 'universal-cookie'; import initializeStore from '../store'; +import { RequestStatus } from '../data/constants'; import messages from './messages'; import CourseImportPage from './CourseImportPage'; import { getImportStatusApiUrl } from './data/api'; @@ -108,4 +108,29 @@ describe('', () => { await new Promise((r) => setTimeout(r, 3500)); expect(getByText(stepperMessages.viewOutlineButton.defaultMessage)).toBeInTheDocument(); }); + + it('displays an alert and sets status to DENIED when API responds with 403', async () => { + axiosMock + .onGet(getImportStatusApiUrl(courseId, 'testFileName.tar.gz')) + .reply(403); + cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.tar.gz' }); + const { getByRole } = render(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 3500)); + expect(getByRole('alert')).toBeInTheDocument(); + const { loadingStatus } = store.getState().courseImport; + expect(loadingStatus).toEqual(RequestStatus.DENIED); + }); + + it('sets loading status to FAILED upon receiving a 404 response from the API', async () => { + axiosMock + .onGet(getImportStatusApiUrl(courseId, 'testFileName.tar.gz')) + .reply(404); + cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.tar.gz' }); + render(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((r) => setTimeout(r, 3500)); + const { loadingStatus } = store.getState().courseImport; + expect(loadingStatus).toEqual(RequestStatus.FAILED); + }); }); diff --git a/src/import-page/data/thunks.js b/src/import-page/data/thunks.js index 95acb291ec..028fea2da2 100644 --- a/src/import-page/data/thunks.js +++ b/src/import-page/data/thunks.js @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ import Cookies from 'universal-cookie'; import moment from 'moment'; @@ -32,7 +31,11 @@ export function fetchImportStatus(courseId, fileName) { dispatch(updateLoadingStatus(RequestStatus.SUCCESSFUL)); return true; } catch (error) { - dispatch(updateLoadingStatus(RequestStatus.FAILED)); + if (error.response && error.response.status === 403) { + dispatch(updateLoadingStatus(RequestStatus.DENIED)); + } else { + dispatch(updateLoadingStatus(RequestStatus.FAILED)); + } return false; } }; diff --git a/src/textbooks/Textbook.test.jsx b/src/textbooks/Textbook.test.jsx index b8437f114f..60af1e3026 100644 --- a/src/textbooks/Textbook.test.jsx +++ b/src/textbooks/Textbook.test.jsx @@ -6,6 +6,7 @@ import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import userEvent from '@testing-library/user-event'; +import { RequestStatus } from '../data/constants'; import initializeStore from '../store'; import { executeThunk } from '../utils'; import { getTextbooksApiUrl } from './data/api'; @@ -84,4 +85,19 @@ describe('', () => { expect(queryAllByTestId('textbook-card')).toHaveLength(0); }); }); + + it('displays an alert and sets status to FAILED when API responds with 403', async () => { + axiosMock + .onGet(getTextbooksApiUrl(courseId)) + .reply(403); + await executeThunk(fetchTextbooksQuery(courseId), store.dispatch); + const { getByTestId } = renderComponent(); + + await waitFor(() => { + expect(getByTestId('connectionErrorAlert')).toBeInTheDocument(); + expect(store.getState().textbooks.loadingStatus).toBe( + RequestStatus.FAILED, + ); + }); + }); }); diff --git a/src/textbooks/Textbooks.jsx b/src/textbooks/Textbooks.jsx index e796ac2ed5..b2c0607976 100644 --- a/src/textbooks/Textbooks.jsx +++ b/src/textbooks/Textbooks.jsx @@ -17,6 +17,7 @@ import { getProcessingNotification } from '../generic/processing-notification/da import { useModel } from '../generic/model-store'; import { LoadingSpinner } from '../generic/Loading'; import SubHeader from '../generic/sub-header/SubHeader'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import ProcessingNotification from '../generic/processing-notification'; import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import TextbookCard from './textbook-card/TextbooksCard'; @@ -36,6 +37,7 @@ const Textbooks = ({ courseId }) => { const { textbooks, isLoading, + isLoadingFailed, breadcrumbs, errorMessage, savingStatus, @@ -53,6 +55,14 @@ const Textbooks = ({ courseId }) => { title: processingNotificationTitle, } = useSelector(getProcessingNotification); + if (isLoadingFailed) { + return ( + + + + ); + } + if (isLoading) { return ( diff --git a/src/textbooks/hooks.jsx b/src/textbooks/hooks.jsx index 74d33ceb0b..00a3dcf6de 100644 --- a/src/textbooks/hooks.jsx +++ b/src/textbooks/hooks.jsx @@ -77,6 +77,7 @@ const useTextbooks = (courseId, waffleFlags) => { return { isLoading: loadingStatus === RequestStatus.IN_PROGRESS, + isLoadingFailed: loadingStatus === RequestStatus.FAILED, savingStatus, errorMessage, textbooks,