Skip to content

Commit 23414e8

Browse files
fix(materials): display custom error page when file or folder is missing in course materials
1 parent fe47f4d commit 23414e8

File tree

14 files changed

+179
-39
lines changed

14 files changed

+179
-39
lines changed

app/controllers/course/material/materials_controller.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# frozen_string_literal: true
22
class Course::Material::MaterialsController < Course::Material::Controller
3-
load_and_authorize_resource :material, through: :folder, class: 'Course::Material'
3+
load_and_authorize_resource :material, through: :folder, class: 'Course::Material', except: :load_default
4+
5+
def load_default
6+
@folder = Course::Material::Folder.where(course_id: current_course.id, parent_id: nil).order(:created_at).first
7+
if @folder
8+
render json: @folder, status: :ok
9+
else
10+
render json: { error: 'No folders available' }, status: :not_found
11+
end
12+
end
413

514
def show
615
authorize!(:read_owner, @material.folder)

client/app/api/course/Materials.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { FileListData } from 'types/course/material/files';
33

44
import { APIResponse } from 'api/types';
55

6+
import { FolderMiniEntity } from '../../types/course/material/folders';
7+
68
import BaseCourseAPI from './Base';
79

810
const getShouldDownloadFromContentDisposition = (
@@ -15,8 +17,12 @@ const getShouldDownloadFromContentDisposition = (
1517
};
1618

1719
export default class MaterialsAPI extends BaseCourseAPI {
20+
get #materialPrefix(): string {
21+
return `/courses/${this.courseId}/materials`;
22+
}
23+
1824
get #urlPrefix(): string {
19-
return `/courses/${this.courseId}/materials/folders`;
25+
return `${this.#materialPrefix}/folders`;
2026
}
2127

2228
fetch(folderId: number, materialId: number): APIResponse<FileListData> {
@@ -25,6 +31,10 @@ export default class MaterialsAPI extends BaseCourseAPI {
2531
);
2632
}
2733

34+
fetchDefault(): APIResponse<FolderMiniEntity> {
35+
return this.client.get(`${this.#materialPrefix}`);
36+
}
37+
2838
/**
2939
* Attempts to download the file at the given `url` as a `Blob` and returns
3040
* its URL and disposition. Remember to `revoke` the URL when no longer needed.

client/app/bundles/course/material/files/components/BaseDownloadFilePage.tsx renamed to client/app/bundles/course/material/component/BaseRetrieveMaterialPage.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { Typography } from '@mui/material';
33

44
import Page from 'lib/components/core/layouts/Page';
55

6-
interface BaseDownloadFilePageProps {
6+
interface BaseRetrieveMaterialPageProps {
77
illustration: ReactNode;
88
title: string;
99
description: string;
1010
children?: ReactNode;
1111
}
1212

13-
const BaseDownloadFilePage = (
14-
props: BaseDownloadFilePageProps,
13+
const BaseRetrieveMaterialPage = (
14+
props: BaseRetrieveMaterialPageProps,
1515
): JSX.Element => (
1616
<Page className="h-full m-auto flex flex-col items-center justify-center text-center">
1717
{props.illustration}
@@ -32,4 +32,4 @@ const BaseDownloadFilePage = (
3232
</Page>
3333
);
3434

35-
export default BaseDownloadFilePage;
35+
export default BaseRetrieveMaterialPage;

client/app/bundles/course/material/files/DownloadingFilePage.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Link from 'lib/components/core/Link';
1010
import useEffectOnce from 'lib/hooks/useEffectOnce';
1111
import useTranslation from 'lib/hooks/useTranslation';
1212

13-
import BaseDownloadFilePage from './components/BaseDownloadFilePage';
13+
import BaseRetrieveMaterialPage from '../component/BaseRetrieveMaterialPage';
1414

1515
const DEFAULT_FILE_NAME = 'file';
1616

@@ -51,7 +51,7 @@ const SuccessDownloadingFilePage = (
5151
const { t } = useTranslation();
5252

5353
return (
54-
<BaseDownloadFilePage
54+
<BaseRetrieveMaterialPage
5555
description={t(translations.downloadingDescription)}
5656
illustration={
5757
<DownloadingOutlined className="text-[6rem]" color="success" />
@@ -61,7 +61,7 @@ const SuccessDownloadingFilePage = (
6161
<Link className="mt-10" href={props.url}>
6262
{t(translations.tryDownloadingAgain)}
6363
</Link>
64-
</BaseDownloadFilePage>
64+
</BaseRetrieveMaterialPage>
6565
);
6666
};
6767

@@ -71,7 +71,7 @@ const ErrorStartingDownloadFilePage = (
7171
const { t } = useTranslation();
7272

7373
return (
74-
<BaseDownloadFilePage
74+
<BaseRetrieveMaterialPage
7575
description={t(translations.clickToDownloadFileDescription)}
7676
illustration={
7777
<div className="relative">
@@ -93,7 +93,7 @@ const ErrorStartingDownloadFilePage = (
9393
>
9494
{props.name}
9595
</Button>
96-
</BaseDownloadFilePage>
96+
</BaseRetrieveMaterialPage>
9797
);
9898
};
9999

client/app/bundles/course/material/files/ErrorRetrievingFilePage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Cancel, InsertDriveFileOutlined } from '@mui/icons-material';
55
import Link from 'lib/components/core/Link';
66
import useTranslation from 'lib/hooks/useTranslation';
77

8-
import BaseDownloadFilePage from './components/BaseDownloadFilePage';
8+
import BaseRetrieveMaterialPage from '../component/BaseRetrieveMaterialPage';
99

1010
const translations = defineMessages({
1111
problemRetrievingFile: {
@@ -30,7 +30,7 @@ const ErrorRetrievingFilePage = (): JSX.Element => {
3030
const workbinURL = `/courses/${params.courseId}/materials/folders/${params.folderId}`;
3131

3232
return (
33-
<BaseDownloadFilePage
33+
<BaseRetrieveMaterialPage
3434
description={t(translations.problemRetrievingFileDescription)}
3535
illustration={
3636
<div className="relative">
@@ -47,7 +47,7 @@ const ErrorRetrievingFilePage = (): JSX.Element => {
4747
<Link className="mt-10" to={workbinURL}>
4848
{t(translations.goToTheWorkbin)}
4949
</Link>
50-
</BaseDownloadFilePage>
50+
</BaseRetrieveMaterialPage>
5151
);
5252
};
5353

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { LoaderFunction, redirect } from 'react-router-dom';
2+
import { getIdFromUnknown } from 'utilities';
3+
4+
import CourseAPI from 'api/course';
5+
6+
const folderLoader: LoaderFunction = async ({ params }) => {
7+
const folderId = getIdFromUnknown(params?.folderId);
8+
if (!folderId) return redirect('/');
9+
10+
const { data } = await CourseAPI.folders.fetch(folderId);
11+
12+
return data;
13+
};
14+
15+
export default folderLoader;

client/app/bundles/course/material/folders/handles.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,25 @@ import { getIdFromUnknown } from 'utilities';
33
import CourseAPI from 'api/course';
44
import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest';
55

6+
export const loadDefaultMaterialId = async (): Promise<number> => {
7+
const {
8+
data: { id },
9+
} = await CourseAPI.materials.fetchDefault();
10+
return id;
11+
};
12+
613
const getFolderTitle = async (
7-
courseUrl: string,
14+
courseId: string,
815
folderId: number,
916
): Promise<CrumbPath> => {
10-
const { data } = await CourseAPI.folders.fetch(folderId);
17+
const courseUrl = `/courses/${courseId}`;
18+
let data;
19+
try {
20+
({ data } = await CourseAPI.folders.fetch(folderId));
21+
} catch (error) {
22+
const defaultMaterialId = await loadDefaultMaterialId();
23+
({ data } = await CourseAPI.folders.fetch(defaultMaterialId));
24+
}
1125

1226
const workbinUrl = `${courseUrl}/materials/folders/${data.breadcrumbs[0].id}`;
1327

@@ -32,13 +46,13 @@ const getFolderTitle = async (
3246
* e.g., `useDynamicNest` cannot know if we move out from Folder 2 to Folder 1 from the URL.
3347
*/
3448
export const folderHandle: DataHandle = (match) => {
49+
const courseId = match.params?.courseId;
3550
const folderId = getIdFromUnknown(match.params?.folderId);
51+
if (!courseId) throw new Error(`Invalid course id: ${courseId}`);
3652
if (!folderId) throw new Error(`Invalid folder id: ${folderId}`);
3753

38-
const courseUrl = `/courses/${match.params.courseId}`;
39-
4054
return {
4155
shouldRevalidate: true,
42-
getData: () => getFolderTitle(courseUrl, folderId),
56+
getData: () => getFolderTitle(courseId, folderId),
4357
};
4458
};

client/app/bundles/course/material/folders/operations.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Operation } from 'store';
22
import {
3+
FolderData,
34
FolderFormData,
45
MaterialFormData,
56
MaterialUploadFormData,
@@ -62,20 +63,20 @@ const formatMaterialAttributes = (data: MaterialFormData): FormData => {
6263
return payload;
6364
};
6465

65-
export function loadFolder(folderId: number): Operation<SaveFolderAction> {
66-
return async (dispatch) =>
67-
CourseAPI.folders.fetch(folderId).then((response) => {
68-
const data = response.data;
69-
return dispatch(
70-
actions.saveFolder(
71-
data.currFolderInfo,
72-
data.subfolders,
73-
data.materials,
74-
data.advanceStartAt,
75-
data.permissions,
76-
),
77-
);
78-
});
66+
export function dispatchFolderData(
67+
data: FolderData,
68+
): Operation<SaveFolderAction> {
69+
return async (dispatch) => {
70+
return dispatch(
71+
actions.saveFolder(
72+
data.currFolderInfo,
73+
data.subfolders,
74+
data.materials,
75+
data.advanceStartAt,
76+
data.permissions,
77+
),
78+
);
79+
};
7980
}
8081

8182
export function createFolder(
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { defineMessages } from 'react-intl';
3+
import { useParams } from 'react-router-dom';
4+
import { Cancel, FolderOutlined } from '@mui/icons-material';
5+
6+
import { loadDefaultMaterialId } from 'course/material/folders/handles';
7+
import Link from 'lib/components/core/Link';
8+
import useTranslation from 'lib/hooks/useTranslation';
9+
10+
import BaseRetrieveMaterialPage from '../../component/BaseRetrieveMaterialPage';
11+
12+
const translations = defineMessages({
13+
problemRetrievingFolder: {
14+
id: 'course.material.folders.ErrorRetrievingFolderPage.problemRetrievingFolder',
15+
defaultMessage: 'Problem retrieving folder',
16+
},
17+
problemRetrievingFolderDescription: {
18+
id: 'course.material.folders.ErrorRetrievingFolderPage.problemRetrievingFolderDescription',
19+
defaultMessage:
20+
"Either it no longer exists, you don't have the permission to access it, or something unexpected happened when we were trying to retrieve it.",
21+
},
22+
goToTheWorkbin: {
23+
id: 'course.material.folders.ErrorRetrievingFolderPage.goToTheWorkbin',
24+
defaultMessage: 'Go to the Workbin',
25+
},
26+
});
27+
28+
const Illustration = (): JSX.Element => (
29+
<div className="relative">
30+
<FolderOutlined className="text-[6rem]" color="disabled" />
31+
<Cancel
32+
className="absolute bottom-0 -right-2 text-[4rem] bg-white rounded-full"
33+
color="error"
34+
/>
35+
</div>
36+
);
37+
38+
const useWorkbinURL = (courseId: string | undefined): string => {
39+
const [workbinURL, setWorkbinURL] = useState(`/courses/${courseId}`);
40+
41+
useEffect(() => {
42+
if (courseId) {
43+
loadDefaultMaterialId().then((defaultMaterialId) => {
44+
setWorkbinURL(
45+
`/courses/${courseId}/materials/folders/${defaultMaterialId}`,
46+
);
47+
});
48+
}
49+
}, [courseId]);
50+
51+
return workbinURL;
52+
};
53+
54+
const ErrorRetrievingFolderPage = (): JSX.Element => {
55+
const { t } = useTranslation();
56+
const params = useParams();
57+
const workbinURL = useWorkbinURL(params.courseId);
58+
59+
return (
60+
<BaseRetrieveMaterialPage
61+
description={t(translations.problemRetrievingFolderDescription)}
62+
illustration={<Illustration />}
63+
title={t(translations.problemRetrievingFolder)}
64+
>
65+
<Link className="mt-10" to={workbinURL}>
66+
{t(translations.goToTheWorkbin)}
67+
</Link>
68+
</BaseRetrieveMaterialPage>
69+
);
70+
};
71+
72+
export default ErrorRetrievingFolderPage;

client/app/bundles/course/material/folders/pages/FolderShow/index.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FC, ReactElement, useEffect, useState } from 'react';
22
import { defineMessages } from 'react-intl';
3-
import { useParams } from 'react-router-dom';
3+
import { useLoaderData, useParams } from 'react-router-dom';
44

55
import EditButton from 'lib/components/core/buttons/EditButton';
66
import Page from 'lib/components/core/layouts/Page';
@@ -10,12 +10,13 @@ import { getCourseId } from 'lib/helpers/url-helpers';
1010
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
1111
import useTranslation from 'lib/hooks/useTranslation';
1212

13+
import { FolderData } from '../../../../../../types/course/material/folders';
1314
import DownloadFolderButton from '../../components/buttons/DownloadFolderButton';
1415
import NewSubfolderButton from '../../components/buttons/NewSubfolderButton';
1516
import UploadFilesButton from '../../components/buttons/UploadFilesButton';
1617
import MaterialUpload from '../../components/misc/MaterialUpload';
1718
import WorkbinTable from '../../components/tables/WorkbinTable';
18-
import { loadFolder } from '../../operations';
19+
import { dispatchFolderData } from '../../operations';
1920
import {
2021
getCurrFolderInfo,
2122
getFolderMaterials,
@@ -48,13 +49,16 @@ const FolderShow: FC = () => {
4849
const materials = useAppSelector(getFolderMaterials);
4950
const currFolderInfo = useAppSelector(getCurrFolderInfo);
5051
const permissions = useAppSelector(getFolderPermissions);
52+
const loaderData = useLoaderData() as FolderData;
5153

5254
const [isLoading, setIsLoading] = useState(true);
5355
useEffect(() => {
54-
if (folderId) {
55-
dispatch(loadFolder(+folderId)).finally(() => setIsLoading(false));
56+
if (loaderData) {
57+
dispatch(dispatchFolderData(loaderData)).finally(() =>
58+
setIsLoading(false),
59+
);
5660
}
57-
}, [dispatch, folderId]);
61+
}, [dispatch, loaderData]);
5862

5963
if (isLoading) {
6064
return <LoadingIndicator />;

0 commit comments

Comments
 (0)