diff --git a/client/app/api/ErrorHandling.ts b/client/app/api/ErrorHandling.ts index 48e2ad23f9b..7dcf2cae8aa 100644 --- a/client/app/api/ErrorHandling.ts +++ b/client/app/api/ErrorHandling.ts @@ -1,4 +1,4 @@ -import { AxiosResponse } from 'axios'; +import { AxiosError, AxiosResponse } from 'axios'; import { AUTH_USER_MANAGER } from 'lib/components/wrappers/AuthProvider'; import { @@ -37,3 +37,20 @@ export const redirectIfMatchesErrorIn = (response?: AxiosResponse): void => { redirectToForbidden(); if (isComponentNotFoundResponse(response)) redirectToNotFound(); }; + +/** + * Redirects to the not-found page if `error` is a 404 response, and returns whether it + * did, so callers can skip their own error handling. + * + * The backend 404s when a resource doesn't exist under the parent resource in the URL, + * even if it exists under another parent, so this covers both a nonexistent ID and an + * ID belonging to someone else's course or assessment. + * + * This is opt-in rather than part of `redirectIfMatchesErrorIn` because some endpoints + * legitimately 404 as part of their normal flow, and expect to handle it themselves. + */ +export const redirectToNotFoundIfMissing = (error: unknown): boolean => { + const missing = (error as AxiosError)?.response?.status === 404; + if (missing) redirectToNotFound(); + return missing; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/index.tsx index 3017e034b0c..a2bc12cec2c 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/index.tsx @@ -5,6 +5,7 @@ import { isUnauthenticatedAssessmentData, } from 'types/course/assessment/assessments'; +import { redirectToNotFoundIfMissing } from 'api/ErrorHandling'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; @@ -19,8 +20,16 @@ const AssessmentShow = (): JSX.Element => { const id = parseInt(params?.assessmentId ?? '', 10) || undefined; if (!id) throw new Error(`AssessmentShow was loaded with ID: ${id}.`); - const fetchAssessmentWithId = (): Promise => - fetchAssessment(id); + const fetchAssessmentWithId = async (): Promise => { + try { + return await fetchAssessment(id); + } catch (error) { + // An ID that matches no assessment in this course 404s from the backend. Show the + // not-found page rather than `Preload`'s generic fetching error. + redirectToNotFoundIfMissing(error); + throw error; + } + }; return ( } while={fetchAssessmentWithId}> diff --git a/client/app/bundles/course/assessment/submission/actions/index.js b/client/app/bundles/course/assessment/submission/actions/index.js index 839411af1a0..01db2cc813c 100644 --- a/client/app/bundles/course/assessment/submission/actions/index.js +++ b/client/app/bundles/course/assessment/submission/actions/index.js @@ -1,5 +1,6 @@ import GlobalAPI from 'api'; import CourseAPI from 'api/course'; +import { redirectToNotFoundIfMissing } from 'api/ErrorHandling'; import { setNotification } from 'lib/actions'; import pollJob from 'lib/helpers/jobHelpers'; @@ -102,7 +103,12 @@ export function fetchSubmission(id, onGetMonitoringSessionId) { }), ); }) - .catch(() => { + .catch((error) => { + // An ID that matches no submission in this assessment 404s from the backend. The + // page has nothing to render for it, so show the not-found page instead of an + // empty attempt page. + if (redirectToNotFoundIfMissing(error)) return; + dispatch({ type: actionTypes.FETCH_SUBMISSION_FAILURE }); dispatch(resetExistingAnswerFlags()); }); diff --git a/client/app/bundles/course/assessment/submission/actions/logs.ts b/client/app/bundles/course/assessment/submission/actions/logs.ts index 318e527fc7a..4f8988503d5 100644 --- a/client/app/bundles/course/assessment/submission/actions/logs.ts +++ b/client/app/bundles/course/assessment/submission/actions/logs.ts @@ -1,10 +1,18 @@ import { LogInfo } from 'types/course/assessment/submission/logs'; import CourseAPI from 'api/course'; +import { redirectToNotFoundIfMissing } from 'api/ErrorHandling'; const fetchLogs = async (): Promise => { - const response = await CourseAPI.assessment.logs.index(); - return response.data; + try { + const response = await CourseAPI.assessment.logs.index(); + return response.data; + } catch (error) { + // As with the attempt page, an ID that matches no submission in this assessment 404s + // from the backend, and there are no logs to show for it. + redirectToNotFoundIfMissing(error); + throw error; + } }; export default fetchLogs; diff --git a/client/app/routers/course/assessments/submissions.tsx b/client/app/routers/course/assessments/submissions.tsx index 42a2a3ed30b..1f64f7fa17d 100644 --- a/client/app/routers/course/assessments/submissions.tsx +++ b/client/app/routers/course/assessments/submissions.tsx @@ -1,4 +1,4 @@ -import { RouteObject } from 'react-router-dom'; +import { Navigate, RouteObject } from 'react-router-dom'; import { WithRequired } from 'types'; import { Translated } from 'lib/hooks/useTranslation'; @@ -25,6 +25,12 @@ const submissionsRouter: Translated = (_) => ({ { path: ':submissionId', children: [ + { + // A submission on its own has no page of its own to show, so send it to + // the attempt page instead of rendering an empty outlet. + index: true, + element: , + }, { path: 'edit', lazy: async (): Promise> => {