diff --git a/app/controllers/course/material/materials_controller.rb b/app/controllers/course/material/materials_controller.rb index 273dacbeb74..a0838ac7b38 100644 --- a/app/controllers/course/material/materials_controller.rb +++ b/app/controllers/course/material/materials_controller.rb @@ -1,6 +1,15 @@ # frozen_string_literal: true class Course::Material::MaterialsController < Course::Material::Controller - load_and_authorize_resource :material, through: :folder, class: 'Course::Material' + load_and_authorize_resource :material, through: :folder, class: 'Course::Material', except: :load_default + + def load_default + @folder = Course::Material::Folder.where(course_id: current_course.id, parent_id: nil).order(:created_at).first + if @folder + render json: @folder, status: :ok + else + render json: { error: 'No folders available' }, status: :not_found + end + end def show authorize!(:read_owner, @material.folder) diff --git a/client/app/api/course/Materials.ts b/client/app/api/course/Materials.ts index 84613b5221c..dce7b2f6ea6 100644 --- a/client/app/api/course/Materials.ts +++ b/client/app/api/course/Materials.ts @@ -3,6 +3,8 @@ import { FileListData } from 'types/course/material/files'; import { APIResponse } from 'api/types'; +import { FolderMiniEntity } from '../../types/course/material/folders'; + import BaseCourseAPI from './Base'; const getShouldDownloadFromContentDisposition = ( @@ -15,8 +17,12 @@ const getShouldDownloadFromContentDisposition = ( }; export default class MaterialsAPI extends BaseCourseAPI { + get #materialPrefix(): string { + return `/courses/${this.courseId}/materials`; + } + get #urlPrefix(): string { - return `/courses/${this.courseId}/materials/folders`; + return `${this.#materialPrefix}/folders`; } fetch(folderId: number, materialId: number): APIResponse { @@ -25,6 +31,10 @@ export default class MaterialsAPI extends BaseCourseAPI { ); } + fetchDefault(): APIResponse { + return this.client.get(`${this.#materialPrefix}/load_default`); + } + /** * Attempts to download the file at the given `url` as a `Blob` and returns * its URL and disposition. Remember to `revoke` the URL when no longer needed. diff --git a/client/app/bundles/course/material/files/components/BaseDownloadFilePage.tsx b/client/app/bundles/course/material/component/BaseRetrieveMaterialPage.tsx similarity index 80% rename from client/app/bundles/course/material/files/components/BaseDownloadFilePage.tsx rename to client/app/bundles/course/material/component/BaseRetrieveMaterialPage.tsx index 08b52cf6e88..1f9366c43ec 100644 --- a/client/app/bundles/course/material/files/components/BaseDownloadFilePage.tsx +++ b/client/app/bundles/course/material/component/BaseRetrieveMaterialPage.tsx @@ -3,15 +3,15 @@ import { Typography } from '@mui/material'; import Page from 'lib/components/core/layouts/Page'; -interface BaseDownloadFilePageProps { +interface BaseRetrieveMaterialPageProps { illustration: ReactNode; title: string; description: string; children?: ReactNode; } -const BaseDownloadFilePage = ( - props: BaseDownloadFilePageProps, +const BaseRetrieveMaterialPage = ( + props: BaseRetrieveMaterialPageProps, ): JSX.Element => ( {props.illustration} @@ -32,4 +32,4 @@ const BaseDownloadFilePage = ( ); -export default BaseDownloadFilePage; +export default BaseRetrieveMaterialPage; diff --git a/client/app/bundles/course/material/files/DownloadingFilePage.tsx b/client/app/bundles/course/material/files/DownloadingFilePage.tsx index d5b314d622e..06527b3b16d 100644 --- a/client/app/bundles/course/material/files/DownloadingFilePage.tsx +++ b/client/app/bundles/course/material/files/DownloadingFilePage.tsx @@ -10,7 +10,7 @@ import Link from 'lib/components/core/Link'; import useEffectOnce from 'lib/hooks/useEffectOnce'; import useTranslation from 'lib/hooks/useTranslation'; -import BaseDownloadFilePage from './components/BaseDownloadFilePage'; +import BaseRetrieveMaterialPage from '../component/BaseRetrieveMaterialPage'; const DEFAULT_FILE_NAME = 'file'; @@ -51,7 +51,7 @@ const SuccessDownloadingFilePage = ( const { t } = useTranslation(); return ( - @@ -61,7 +61,7 @@ const SuccessDownloadingFilePage = ( {t(translations.tryDownloadingAgain)} - + ); }; @@ -71,7 +71,7 @@ const ErrorStartingDownloadFilePage = ( const { t } = useTranslation(); return ( - @@ -93,7 +93,7 @@ const ErrorStartingDownloadFilePage = ( > {props.name} - + ); }; diff --git a/client/app/bundles/course/material/files/ErrorRetrievingFilePage.tsx b/client/app/bundles/course/material/files/ErrorRetrievingFilePage.tsx index 80e3af0b492..e6e737fff18 100644 --- a/client/app/bundles/course/material/files/ErrorRetrievingFilePage.tsx +++ b/client/app/bundles/course/material/files/ErrorRetrievingFilePage.tsx @@ -5,7 +5,7 @@ import { Cancel, InsertDriveFileOutlined } from '@mui/icons-material'; import Link from 'lib/components/core/Link'; import useTranslation from 'lib/hooks/useTranslation'; -import BaseDownloadFilePage from './components/BaseDownloadFilePage'; +import BaseRetrieveMaterialPage from '../component/BaseRetrieveMaterialPage'; const translations = defineMessages({ problemRetrievingFile: { @@ -30,7 +30,7 @@ const ErrorRetrievingFilePage = (): JSX.Element => { const workbinURL = `/courses/${params.courseId}/materials/folders/${params.folderId}`; return ( - @@ -47,7 +47,7 @@ const ErrorRetrievingFilePage = (): JSX.Element => { {t(translations.goToTheWorkbin)} - + ); }; diff --git a/client/app/bundles/course/material/folderLoader.ts b/client/app/bundles/course/material/folderLoader.ts new file mode 100644 index 00000000000..3920fc71e6f --- /dev/null +++ b/client/app/bundles/course/material/folderLoader.ts @@ -0,0 +1,15 @@ +import { LoaderFunction, redirect } from 'react-router-dom'; +import { getIdFromUnknown } from 'utilities'; + +import CourseAPI from 'api/course'; + +const folderLoader: LoaderFunction = async ({ params }) => { + const folderId = getIdFromUnknown(params?.folderId); + if (!folderId) return redirect('/'); + + const { data } = await CourseAPI.folders.fetch(folderId); + + return data; +}; + +export default folderLoader; diff --git a/client/app/bundles/course/material/folders/handles.ts b/client/app/bundles/course/material/folders/handles.ts index a3e04c8e8c5..cc5e22f7c14 100644 --- a/client/app/bundles/course/material/folders/handles.ts +++ b/client/app/bundles/course/material/folders/handles.ts @@ -3,11 +3,25 @@ import { getIdFromUnknown } from 'utilities'; import CourseAPI from 'api/course'; import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; +export const loadDefaultMaterialId = async (): Promise => { + const { + data: { id }, + } = await CourseAPI.materials.fetchDefault(); + return id; +}; + const getFolderTitle = async ( - courseUrl: string, + courseId: string, folderId: number, ): Promise => { - const { data } = await CourseAPI.folders.fetch(folderId); + const courseUrl = `/courses/${courseId}`; + let data; + try { + ({ data } = await CourseAPI.folders.fetch(folderId)); + } catch (error) { + const defaultMaterialId = await loadDefaultMaterialId(); + ({ data } = await CourseAPI.folders.fetch(defaultMaterialId)); + } const workbinUrl = `${courseUrl}/materials/folders/${data.breadcrumbs[0].id}`; @@ -32,13 +46,13 @@ const getFolderTitle = async ( * e.g., `useDynamicNest` cannot know if we move out from Folder 2 to Folder 1 from the URL. */ export const folderHandle: DataHandle = (match) => { + const courseId = match.params?.courseId; const folderId = getIdFromUnknown(match.params?.folderId); + if (!courseId) throw new Error(`Invalid course id: ${courseId}`); if (!folderId) throw new Error(`Invalid folder id: ${folderId}`); - const courseUrl = `/courses/${match.params.courseId}`; - return { shouldRevalidate: true, - getData: () => getFolderTitle(courseUrl, folderId), + getData: () => getFolderTitle(courseId, folderId), }; }; diff --git a/client/app/bundles/course/material/folders/operations.ts b/client/app/bundles/course/material/folders/operations.ts index 5405d5c36e7..c67bc860047 100644 --- a/client/app/bundles/course/material/folders/operations.ts +++ b/client/app/bundles/course/material/folders/operations.ts @@ -1,5 +1,6 @@ import { Operation } from 'store'; import { + FolderData, FolderFormData, MaterialFormData, MaterialUploadFormData, @@ -62,20 +63,20 @@ const formatMaterialAttributes = (data: MaterialFormData): FormData => { return payload; }; -export function loadFolder(folderId: number): Operation { - return async (dispatch) => - CourseAPI.folders.fetch(folderId).then((response) => { - const data = response.data; - return dispatch( - actions.saveFolder( - data.currFolderInfo, - data.subfolders, - data.materials, - data.advanceStartAt, - data.permissions, - ), - ); - }); +export function dispatchFolderData( + data: FolderData, +): Operation { + return async (dispatch) => { + return dispatch( + actions.saveFolder( + data.currFolderInfo, + data.subfolders, + data.materials, + data.advanceStartAt, + data.permissions, + ), + ); + }; } export function createFolder( diff --git a/client/app/bundles/course/material/folders/pages/ErrorRetrievingFolderPage.tsx b/client/app/bundles/course/material/folders/pages/ErrorRetrievingFolderPage.tsx new file mode 100644 index 00000000000..c7366442c13 --- /dev/null +++ b/client/app/bundles/course/material/folders/pages/ErrorRetrievingFolderPage.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { Cancel, FolderOutlined } from '@mui/icons-material'; + +import { loadDefaultMaterialId } from 'course/material/folders/handles'; +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +import BaseRetrieveMaterialPage from '../../component/BaseRetrieveMaterialPage'; + +const translations = defineMessages({ + problemRetrievingFolder: { + id: 'course.material.folders.ErrorRetrievingFolderPage.problemRetrievingFolder', + defaultMessage: 'Problem retrieving folder', + }, + problemRetrievingFolderDescription: { + id: 'course.material.folders.ErrorRetrievingFolderPage.problemRetrievingFolderDescription', + defaultMessage: + "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.", + }, + goToTheWorkbin: { + id: 'course.material.folders.ErrorRetrievingFolderPage.goToTheWorkbin', + defaultMessage: 'Go to the Workbin', + }, +}); + +const Illustration = (): JSX.Element => ( +
+ + +
+); + +const useWorkbinURL = (courseId: string | undefined): string => { + const [workbinURL, setWorkbinURL] = useState(`/courses/${courseId}`); + + useEffect(() => { + if (courseId) { + loadDefaultMaterialId().then((defaultMaterialId) => { + setWorkbinURL( + `/courses/${courseId}/materials/folders/${defaultMaterialId}`, + ); + }); + } + }, [courseId]); + + return workbinURL; +}; + +const ErrorRetrievingFolderPage = (): JSX.Element => { + const { t } = useTranslation(); + const params = useParams(); + const workbinURL = useWorkbinURL(params.courseId); + + return ( + } + title={t(translations.problemRetrievingFolder)} + > + + {t(translations.goToTheWorkbin)} + + + ); +}; + +export default ErrorRetrievingFolderPage; diff --git a/client/app/bundles/course/material/folders/pages/FolderShow/index.tsx b/client/app/bundles/course/material/folders/pages/FolderShow/index.tsx index da519430492..ed6ae605cb2 100644 --- a/client/app/bundles/course/material/folders/pages/FolderShow/index.tsx +++ b/client/app/bundles/course/material/folders/pages/FolderShow/index.tsx @@ -1,6 +1,6 @@ import { FC, ReactElement, useEffect, useState } from 'react'; import { defineMessages } from 'react-intl'; -import { useParams } from 'react-router-dom'; +import { useLoaderData, useParams } from 'react-router-dom'; import EditButton from 'lib/components/core/buttons/EditButton'; import Page from 'lib/components/core/layouts/Page'; @@ -10,12 +10,13 @@ import { getCourseId } from 'lib/helpers/url-helpers'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import { FolderData } from '../../../../../../types/course/material/folders'; import DownloadFolderButton from '../../components/buttons/DownloadFolderButton'; import NewSubfolderButton from '../../components/buttons/NewSubfolderButton'; import UploadFilesButton from '../../components/buttons/UploadFilesButton'; import MaterialUpload from '../../components/misc/MaterialUpload'; import WorkbinTable from '../../components/tables/WorkbinTable'; -import { loadFolder } from '../../operations'; +import { dispatchFolderData } from '../../operations'; import { getCurrFolderInfo, getFolderMaterials, @@ -48,13 +49,16 @@ const FolderShow: FC = () => { const materials = useAppSelector(getFolderMaterials); const currFolderInfo = useAppSelector(getCurrFolderInfo); const permissions = useAppSelector(getFolderPermissions); + const loaderData = useLoaderData() as FolderData; const [isLoading, setIsLoading] = useState(true); useEffect(() => { - if (folderId) { - dispatch(loadFolder(+folderId)).finally(() => setIsLoading(false)); + if (loaderData) { + dispatch(dispatchFolderData(loaderData)).finally(() => + setIsLoading(false), + ); } - }, [dispatch, folderId]); + }, [dispatch, loaderData]); if (isLoading) { return ; diff --git a/client/app/routers/AuthenticatedApp.tsx b/client/app/routers/AuthenticatedApp.tsx index 48c75af9bba..b7f49d2816a 100644 --- a/client/app/routers/AuthenticatedApp.tsx +++ b/client/app/routers/AuthenticatedApp.tsx @@ -67,6 +67,7 @@ import LessonPlanShow from 'bundles/course/lesson-plan/pages/LessonPlanShow'; import LevelsIndex from 'bundles/course/level/pages/LevelsIndex'; import DownloadingFilePage from 'bundles/course/material/files/DownloadingFilePage'; import ErrorRetrievingFilePage from 'bundles/course/material/files/ErrorRetrievingFilePage'; +import ErrorRetrievingFolderPage from 'bundles/course/material/folders/pages/ErrorRetrievingFolderPage'; import FolderShow from 'bundles/course/material/folders/pages/FolderShow'; import TimelineDesigner from 'bundles/course/reference-timelines/TimelineDesigner'; import ResponseEdit from 'bundles/course/survey/pages/ResponseEdit'; @@ -127,6 +128,7 @@ import { forumTopicHandle, } from 'course/forum/handles'; import { leaderboardHandle } from 'course/leaderboard/handles'; +import folderLoader from 'course/material/folderLoader'; import { folderHandle } from 'course/material/folders/handles'; import materialLoader from 'course/material/materialLoader'; import { videoWatchHistoryHandle } from 'course/statistics/handles'; @@ -221,7 +223,9 @@ const authenticatedRouter: Translated = (t) => children: [ { index: true, + loader: folderLoader, element: , + errorElement: , }, { path: 'files/:materialId', diff --git a/config/routes.rb b/config/routes.rb index db2b504c7e3..9747a2908ea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -410,6 +410,7 @@ end namespace :material, path: 'materials' do + get 'load_default', to: 'materials#load_default' resources :folders, except: [:index, :new, :create] do post 'create/subfolder', on: :member, as: 'create_subfolder', action: 'create_subfolder' put 'upload_materials', on: :member diff --git a/spec/controllers/course/material/materials_controller_spec.rb b/spec/controllers/course/material/materials_controller_spec.rb index bde54119298..dd9523c70b8 100644 --- a/spec/controllers/course/material/materials_controller_spec.rb +++ b/spec/controllers/course/material/materials_controller_spec.rb @@ -17,6 +17,25 @@ before { controller_sign_in(controller, user) } + describe '#load_default' do + context 'when a folder is found' do + it 'renders the folder as JSON with status :ok' do + get :load_default, params: { course_id: course.id } + expect(response).to have_http_status(:ok) + expect(response.body).not_to be_empty + end + end + + context 'when no folders are available' do + before { Course::Material::Folder.where(course_id: course.id).destroy_all } + + it 'renders an error message with status :not_found' do + get :load_default, params: { course_id: course.id } + expect(response).to have_http_status(:not_found) + end + end + end + describe '#show' do let(:material) { create(:material, folder: folder) } subject { get :show, params: { course_id: course, folder_id: folder, id: material } }