diff --git a/nextjs/src/app/CourseList.tsx b/nextjs/src/app/CourseList.tsx index d1d48ce..8992e69 100644 --- a/nextjs/src/app/CourseList.tsx +++ b/nextjs/src/app/CourseList.tsx @@ -1,14 +1,19 @@ "use client"; -import { useLocalCourseNamesQuery } from "@/hooks/localCourse/localCoursesHooks"; +import { useLocalCoursesSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; +import { getCourseUrl } from "@/services/urlUtils"; import Link from "next/link"; export default function CourseList() { - const { data: courses } = useLocalCourseNamesQuery(); + const { data: allSettings } = useLocalCoursesSettingsQuery(); return (
- {courses.map((c) => ( - - {c}{" "} + {allSettings.map((settings) => ( + + {settings.name} ))}
diff --git a/nextjs/src/app/NewCourseForm.tsx b/nextjs/src/app/NewCourseForm.tsx deleted file mode 100644 index 7b66a4f..0000000 --- a/nextjs/src/app/NewCourseForm.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { DayOfWeekInput } from "@/components/form/DayOfWeekInput"; -import SelectInput from "@/components/form/SelectInput"; -import { useCourseListInTermQuery } from "@/hooks/canvas/canvasCourseHooks"; -import { useCanvasTermsQuery } from "@/hooks/canvas/canvasHooks"; -import { useEmptyDirectoriesQuery } from "@/hooks/localCourse/storageDirectoryHooks"; -import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel"; -import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel"; -import { DayOfWeek } from "@/models/local/localCourse"; -import React, { useMemo, useState } from "react"; - -const sampleCompose = ` -services: - canvas_manager: - image: alexmickelson/canvas_management:2 - user: 1000:1000 # userid:groupid that matches file ownership on host system - ports: - - 8080:8080 # hostPort:containerPort - you can change the first one if you like - env_file: - - .env # needs to have your CANVAS_TOKEN set - environment: - - storageDirectory=/app/storage - - TZ=America/Denver - volumes: - - ~/projects/faculty/1430/2024-fall-alex/modules:/app/storage/UX - - ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend - - ~/projects/faculty/1810/2024-fall-alex/modules:/app/storage/intro_to_web - - ~/projects/faculty/1420/2024-fall/Modules:/app/storage/1420 - - ~/projects/faculty/1425/2024-fall/Modules:/app/storage/1425 -` - -export default function NewCourseForm() { - const today = useMemo(() => new Date(), []); - const { data: canvasTerms } = useCanvasTermsQuery(today); - const { data: emptyDirectories } = useEmptyDirectoriesQuery(); - const [selectedTerm, setSelectedTerm] = useState< - CanvasEnrollmentTermModel | undefined - >(); - const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm?.id); - const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState([]); - const [selectedCanvasCourse, setSelectedCanvasCourse] = useState< - CanvasCourseModel | undefined - >(); - const [selectedDirectory, setSelectedDirectory] = useState< - string | undefined - >(); - - return ( -
- t.name} - /> - {selectedTerm && ( - <> - c.name} - /> - d} - /> -
- { - setSelectedDaysOfWeek((oldDays) => { - const hasDay = oldDays.includes(day); - - return hasDay - ? oldDays.filter((d) => d !== day) - : [day, ...oldDays]; - }); - }} - /> - - )} - -
- ); -} diff --git a/nextjs/src/app/api/courses/route.ts b/nextjs/src/app/api/courses/route.ts index 7c35f44..adb13e2 100644 --- a/nextjs/src/app/api/courses/route.ts +++ b/nextjs/src/app/api/courses/route.ts @@ -1,8 +1,19 @@ +import { LocalCourse } from "@/models/local/localCourse"; import { fileStorageService } from "@/services/fileStorage/fileStorageService"; import { withErrorHandling } from "@/services/withErrorHandling"; export const GET = async () => - await withErrorHandling(async () => { + await withErrorHandling(async () => { const courses = await fileStorageService.getCourseNames(); return Response.json(courses); }); + +export const POST = async (request: Request) => + await withErrorHandling(async () => { + const newCourse: LocalCourse = await request.json(); + await fileStorageService.updateCourseSettings( + newCourse.settings.name, + newCourse.settings + ); + return Response.json({}); + }); diff --git a/nextjs/src/app/api/courses/settings/route.ts b/nextjs/src/app/api/courses/settings/route.ts new file mode 100644 index 0000000..9894e39 --- /dev/null +++ b/nextjs/src/app/api/courses/settings/route.ts @@ -0,0 +1,9 @@ +import { fileStorageService } from "@/services/fileStorage/fileStorageService"; +import { withErrorHandling } from "@/services/withErrorHandling"; + +export const GET = async () => + await withErrorHandling(async () => { + const settings = await fileStorageService.getAllCoursesSettings(); + + return Response.json(settings); + }); diff --git a/nextjs/src/app/course/[courseName]/CourseSettingsLink.tsx b/nextjs/src/app/course/[courseName]/CourseSettingsLink.tsx index 78c34c2..d486668 100644 --- a/nextjs/src/app/course/[courseName]/CourseSettingsLink.tsx +++ b/nextjs/src/app/course/[courseName]/CourseSettingsLink.tsx @@ -3,22 +3,16 @@ import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import Link from "next/link"; import { useCourseContext } from "./context/courseContext"; +import { getCourseSettingsUrl } from "@/services/urlUtils"; export default function CourseSettingsLink() { - const {courseName} = useCourseContext(); + const { courseName } = useCourseContext(); const { data: settings } = useLocalCourseSettingsQuery(); return (
{settings.name} - + Course Settings
diff --git a/nextjs/src/app/course/[courseName]/calendar/Day.tsx b/nextjs/src/app/course/[courseName]/calendar/Day.tsx index 8edb76a..250c3d5 100644 --- a/nextjs/src/app/course/[courseName]/calendar/Day.tsx +++ b/nextjs/src/app/course/[courseName]/calendar/Day.tsx @@ -9,7 +9,8 @@ import { useCourseContext } from "../context/courseContext"; import Link from "next/link"; import { IModuleItem } from "@/models/local/IModuleItem"; import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; -import { getDayOfWeek } from "@/models/local/localCourse"; +import { getDayOfWeek } from "@/models/local/localCourse"; +import { getModuleItemUrl } from "@/services/urlUtils"; export default function Day({ day, month }: { day: string; month: number }) { const dayAsDate = getDateFromStringOrThrow( @@ -32,13 +33,11 @@ export default function Day({ day, month }: { day: string; month: number }) { const classIsToday = settings.daysOfWeek.includes(getDayOfWeek(dayAsDate)); const todayClass = classIsToday ? " bg-slate-900 " : " "; - const monthClass = isInSameMonth ? " border border-slate-600 " : " " + const monthClass = isInSameMonth ? " border border-slate-600 " : " "; return (
itemDrop(e, day)} onDragOver={(e) => e.preventDefault()} > @@ -135,14 +134,7 @@ function DraggableListItem({ }} > {item.name} diff --git a/nextjs/src/app/course/[courseName]/calendar/DayItemsInModule.tsx b/nextjs/src/app/course/[courseName]/calendar/DayItemsInModule.tsx deleted file mode 100644 index 430f584..0000000 --- a/nextjs/src/app/course/[courseName]/calendar/DayItemsInModule.tsx +++ /dev/null @@ -1,218 +0,0 @@ -"use client"; -import React, { useMemo } from "react"; -import { useCourseContext } from "../context/courseContext"; -import { getDateFromStringOrThrow } from "@/models/local/timeUtils"; -import Link from "next/link"; -import { - usePageNamesQuery, - usePagesQueries, -} from "@/hooks/localCourse/pageHooks"; -import { - useQuizNamesQuery, - useQuizzesQueries, -} from "@/hooks/localCourse/quizHooks"; -import { - useAssignmentNamesQuery, - useAssignmentsQueries, -} from "@/hooks/localCourse/assignmentHooks"; - -export default function DayItemsInModule({ - day, - moduleName, -}: { - day: string; - moduleName: string; -}) { - return ( - - ); -} - -function Pages({ moduleName, day }: { moduleName: string; day: string }) { - const { courseName } = useCourseContext(); - const { data: pageNames } = usePageNamesQuery(moduleName); - const { data: pages } = usePagesQueries(moduleName, pageNames); - const todaysPages = useMemo( - () => - pages.filter((p) => { - const dueDate = getDateFromStringOrThrow( - p.dueAt, - "due at for page in day" - ); - const dayAsDate = getDateFromStringOrThrow( - day, - "in pages in DayItemsInModule" - ); - return ( - dueDate.getFullYear() === dayAsDate.getFullYear() && - dueDate.getMonth() === dayAsDate.getMonth() && - dueDate.getDate() === dayAsDate.getDate() - ); - }), - [day, pages] - ); - return ( - <> - {todaysPages.map((p) => ( -
  • { - e.dataTransfer.setData( - "draggableItem", - JSON.stringify({ - type: "page", - item: p, - sourceModuleName: moduleName, - }) - ); - }} - > - - {p.name} - -
  • - ))} - - ); -} - -function Quizzes({ moduleName, day }: { moduleName: string; day: string }) { - const { data: quizNames } = useQuizNamesQuery(moduleName); - const { data: quizzes } = useQuizzesQueries(moduleName, quizNames); - const { courseName } = useCourseContext(); - - const todaysQuizzes = useMemo( - () => - quizzes.filter((q) => { - const dueDate = getDateFromStringOrThrow( - q.dueAt, - "due at for quiz in day" - ); - const dayAsDate = getDateFromStringOrThrow( - day, - "in quizzes in DayItemsInModule" - ); - return ( - dueDate.getFullYear() === dayAsDate.getFullYear() && - dueDate.getMonth() === dayAsDate.getMonth() && - dueDate.getDate() === dayAsDate.getDate() - ); - }), - [day, quizzes] - ); - return ( - <> - {todaysQuizzes.map((q) => ( -
  • { - e.dataTransfer.setData( - "draggableItem", - JSON.stringify({ - type: "quiz", - item: q, - sourceModuleName: moduleName, - }) - ); - }} - onDragEnd={(e) => e.preventDefault()} - > - - {q.name} - -
  • - ))} - - ); -} - -function Assignments({ moduleName, day }: { moduleName: string; day: string }) { - const { data: assignmentNames } = useAssignmentNamesQuery(moduleName); - const { courseName } = useCourseContext(); - const { data: assignments } = useAssignmentsQueries( - moduleName, - assignmentNames - ); - const todaysAssignments = useMemo( - () => - assignments.filter((a) => { - const dueDate = getDateFromStringOrThrow( - a.dueAt, - "due at for assignment in day" - ); - const dayAsDate = getDateFromStringOrThrow( - day, - "in assignment in DayItemsInModule" - ); - return ( - dueDate.getFullYear() === dayAsDate.getFullYear() && - dueDate.getMonth() === dayAsDate.getMonth() && - dueDate.getDate() === dayAsDate.getDate() - ); - }), - [assignments, day] - ); - return ( - <> - {todaysAssignments.map((a) => ( -
  • { - e.dataTransfer.setData( - "draggableItem", - JSON.stringify({ - type: "assignment", - item: a, - sourceModuleName: moduleName, - }) - ); - }} - > - - {a.name} - -
  • - ))} - - ); -} diff --git a/nextjs/src/app/globals.css b/nextjs/src/app/globals.css index f7aab16..244cbea 100644 --- a/nextjs/src/app/globals.css +++ b/nextjs/src/app/globals.css @@ -67,7 +67,7 @@ blockquote { } code { - @apply font-mono text-sm bg-gray-800 px-1; + @apply font-mono text-sm bg-gray-800 px-1 leading-tight inline-block; } p { @apply mb-3; diff --git a/nextjs/src/app/AddNewCourse.tsx b/nextjs/src/app/newCourse/AddNewCourse.tsx similarity index 69% rename from nextjs/src/app/AddNewCourse.tsx rename to nextjs/src/app/newCourse/AddNewCourse.tsx index a1f69a1..a0b6275 100644 --- a/nextjs/src/app/AddNewCourse.tsx +++ b/nextjs/src/app/newCourse/AddNewCourse.tsx @@ -1,9 +1,7 @@ "use client"; -import SelectInput from "@/components/form/SelectInput"; -import { useCanvasTermsQuery } from "@/hooks/canvas/canvasHooks"; import React, { useState } from "react"; -import NewCourseForm from "./NewCourseForm"; import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; +import NewCourseForm from "./NewCourseForm"; export default function AddNewCourse() { const [showForm, setShowForm] = useState(false); @@ -14,10 +12,9 @@ export default function AddNewCourse() {
    - - - {showForm && } - + + {showForm && } +
    diff --git a/nextjs/src/app/newCourse/NewCourseForm.tsx b/nextjs/src/app/newCourse/NewCourseForm.tsx new file mode 100644 index 0000000..6371705 --- /dev/null +++ b/nextjs/src/app/newCourse/NewCourseForm.tsx @@ -0,0 +1,182 @@ +"use client"; +import { DayOfWeekInput } from "@/components/form/DayOfWeekInput"; +import SelectInput from "@/components/form/SelectInput"; +import { Spinner } from "@/components/Spinner"; +import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; +import { useCourseListInTermQuery } from "@/hooks/canvas/canvasCourseHooks"; +import { useCanvasTermsQuery } from "@/hooks/canvas/canvasHooks"; +import { + useCreateLocalCourseMutation, + useLocalCoursesSettingsQuery, +} from "@/hooks/localCourse/localCoursesHooks"; +import { useEmptyDirectoriesQuery } from "@/hooks/localCourse/storageDirectoryHooks"; +import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel"; +import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel"; +import { DayOfWeek } from "@/models/local/localCourse"; +import { getCourseUrl } from "@/services/urlUtils"; +import { useRouter } from "next/navigation"; +import React, { useMemo, useState } from "react"; + +const sampleCompose = `services: + canvas_manager: + image: alexmickelson/canvas_management:2 # pull this image regularly + user: 1000:1000 # userid:groupid that matches file ownership on host system + ports: + - 8080:8080 # hostPort:containerPort - you can change the first one if you like + env_file: + - .env # needs to have your CANVAS_TOKEN set + environment: + - TZ=America/Denver # prevents timezone issues for due dates + volumes: + - ~/projects/faculty/1430/2024-fall-alex/modules:/app/storage/UX + - ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend +`; + +export default function NewCourseForm() { + const router = useRouter(); + const today = useMemo(() => new Date(), []); + const { data: canvasTerms } = useCanvasTermsQuery(today); + const [selectedTerm, setSelectedTerm] = useState< + CanvasEnrollmentTermModel | undefined + >(); + const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState([]); + const [selectedCanvasCourse, setSelectedCanvasCourse] = useState< + CanvasCourseModel | undefined + >(); + const [selectedDirectory, setSelectedDirectory] = useState< + string | undefined + >(); + const createCourse = useCreateLocalCourseMutation(); + + const formIsComplete = + selectedTerm && selectedCanvasCourse && selectedDirectory; + + return ( +
    + t.name} + /> + + {selectedTerm && ( + + )} + +
    + +
    + {createCourse.isPending && } + +
    +        
    Example docker compose
    + {sampleCompose} +
    +
    + ); +} + +function OtherSettings({ + selectedTerm, + selectedCanvasCourse, + setSelectedCanvasCourse, + selectedDirectory, + setSelectedDirectory, + selectedDaysOfWeek, + setSelectedDaysOfWeek, +}: { + selectedTerm: CanvasEnrollmentTermModel; + selectedCanvasCourse: CanvasCourseModel | undefined; + setSelectedCanvasCourse: React.Dispatch< + React.SetStateAction + >; + selectedDirectory: string | undefined; + setSelectedDirectory: React.Dispatch< + React.SetStateAction + >; + selectedDaysOfWeek: DayOfWeek[]; + setSelectedDaysOfWeek: React.Dispatch>; +}) { + const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm.id); + const { data: allSettings } = useLocalCoursesSettingsQuery(); + const { data: emptyDirectories } = useEmptyDirectoriesQuery(); + + const populatedCanvasCourseIds = allSettings.map((s) => s.canvasId); + const availableCourses = canvasCourses.filter( + (canvas) => !populatedCanvasCourseIds.includes(canvas.id) + ); + + return ( + <> + c.name} + /> + d} + /> +
    + New folders will not be created automatically, you are expected to mount + a docker volume for each coures. +
    +
    +
    + { + setSelectedDaysOfWeek((oldDays) => { + const hasDay = oldDays.includes(day); + + return hasDay + ? oldDays.filter((d) => d !== day) + : [day, ...oldDays]; + }); + }} + /> +
    + + ); +} diff --git a/nextjs/src/app/page.tsx b/nextjs/src/app/page.tsx index 41b4311..70d6b06 100644 --- a/nextjs/src/app/page.tsx +++ b/nextjs/src/app/page.tsx @@ -1,5 +1,5 @@ -import AddNewCourse from "./AddNewCourse"; import CourseList from "./CourseList"; +import AddNewCourse from "./newCourse/AddNewCourse"; export default async function Home() { return ( diff --git a/nextjs/src/hooks/hookHydration.ts b/nextjs/src/hooks/hookHydration.ts index 3f2057d..c20e502 100644 --- a/nextjs/src/hooks/hookHydration.ts +++ b/nextjs/src/hooks/hookHydration.ts @@ -1,26 +1,28 @@ import { QueryClient } from "@tanstack/react-query"; import { localCourseKeys } from "./localCourse/localCourseKeys"; import { fileStorageService } from "@/services/fileStorage/fileStorageService"; +import { LocalCourseSettings } from "@/models/local/localCourse"; // https://tanstack.com/query/latest/docs/framework/react/guides/ssr export const hydrateCourses = async (queryClient: QueryClient) => { - const courseNames = await fileStorageService.getCourseNames(); + const allSettings = await fileStorageService.getAllCoursesSettings(); + const courseNames = allSettings.map((s) => s.name); await queryClient.prefetchQuery({ - queryKey: localCourseKeys.allCourses, - queryFn: () => courseNames, + queryKey: localCourseKeys.allCoursesSettings, + queryFn: () => allSettings, }); await Promise.all( - courseNames.map(async (courseName) => { - await hydrateCourse(queryClient, courseName); + allSettings.map(async (settings) => { + await hydrateCourse(queryClient, settings); }) ); }; export const hydrateCourse = async ( queryClient: QueryClient, - courseName: string + courseSettings: LocalCourseSettings ) => { - const settings = await fileStorageService.getCourseSettings(courseName); + const courseName = courseSettings.name const moduleNames = await fileStorageService.getModuleNames(courseName); const modulesData = await Promise.all( moduleNames.map(async (moduleName) => { @@ -69,7 +71,7 @@ export const hydrateCourse = async ( await queryClient.prefetchQuery({ queryKey: localCourseKeys.settings(courseName), - queryFn: () => settings, + queryFn: () => courseSettings, }); await queryClient.prefetchQuery({ queryKey: localCourseKeys.moduleNames(courseName), diff --git a/nextjs/src/hooks/localCourse/localCourseKeys.ts b/nextjs/src/hooks/localCourse/localCourseKeys.ts index 7e8baf7..ebacc44 100644 --- a/nextjs/src/hooks/localCourse/localCourseKeys.ts +++ b/nextjs/src/hooks/localCourse/localCourseKeys.ts @@ -1,5 +1,6 @@ export const localCourseKeys = { - allCourses: ["all courses"] as const, + allCoursesSettings: ["all courses settings"] as const, + allCoursesNames: ["all courses names"] as const, settings: (courseName: string) => ["course details", courseName, "settings"] as const, moduleNames: (courseName: string) => diff --git a/nextjs/src/hooks/localCourse/localCoursesHooks.ts b/nextjs/src/hooks/localCourse/localCoursesHooks.ts index dc883e2..2546bdd 100644 --- a/nextjs/src/hooks/localCourse/localCoursesHooks.ts +++ b/nextjs/src/hooks/localCourse/localCoursesHooks.ts @@ -1,5 +1,5 @@ "use client"; -import { LocalCourseSettings } from "@/models/local/localCourse"; +import { LocalCourse, LocalCourseSettings } from "@/models/local/localCourse"; import { useMutation, useQueries, @@ -30,24 +30,43 @@ import { } from "./quizHooks"; import { useCourseContext } from "@/app/course/[courseName]/context/courseContext"; -export const useLocalCourseNamesQuery = () => +export const useLocalCoursesSettingsQuery = () => useSuspenseQuery({ - queryKey: localCourseKeys.allCourses, - queryFn: async (): Promise => { - const url = `/api/courses`; - const response = await axiosClient.get(url); + queryKey: localCourseKeys.allCoursesSettings, + queryFn: async () => { + const url = `/api/courses/settings`; + const response = await axiosClient.get(url); return response.data; }, }); export const useLocalCourseSettingsQuery = () => { const { courseName } = useCourseContext(); + const { data: settingsList } = useLocalCoursesSettingsQuery(); return useSuspenseQuery({ queryKey: localCourseKeys.settings(courseName), - queryFn: async (): Promise => { - const url = `/api/courses/${courseName}/settings`; - const response = await axiosClient.get(url); - return response.data; + queryFn: () => { + const s = settingsList.find((s) => s.name === courseName); + if (!s) { + console.log(courseName, settingsList); + throw Error("Could not find settings for course " + courseName); + } + return s; + }, + }); +}; + +export const useCreateLocalCourseMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (newCourse: LocalCourse) => { + const url = `/api/courses`; + await axiosClient.post(url, newCourse); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: localCourseKeys.allCoursesSettings, + }); }, }); }; @@ -68,6 +87,9 @@ export const useUpdateLocalCourseSettingsMutation = () => { queryClient.invalidateQueries({ queryKey: localCourseKeys.settings(courseName), }); + queryClient.invalidateQueries({ + queryKey: localCourseKeys.allCoursesSettings, + }); }, }); }; diff --git a/nextjs/src/models/local/tests/timeUtils.test.ts b/nextjs/src/models/local/tests/timeUtils.test.ts index 7308cfd..d9422bc 100644 --- a/nextjs/src/models/local/tests/timeUtils.test.ts +++ b/nextjs/src/models/local/tests/timeUtils.test.ts @@ -17,6 +17,11 @@ describe("Can properly handle expected date formats", () => { const dateObject = getDateFromString(dateString); expect(dateObject).not.toBeUndefined(); }); + it("can use other ISO format", () => { + const dateString = "2024-08-26T06:00:00Z"; + const dateObject = getDateFromString(dateString); + expect(dateObject).not.toBeUndefined(); + }); it("can get correct time from format", () => { const dateString = "08/28/2024 23:59:00"; const dateObject = getDateFromString(dateString); diff --git a/nextjs/src/models/local/timeUtils.ts b/nextjs/src/models/local/timeUtils.ts index 70e5c2d..cab4849 100644 --- a/nextjs/src/models/local/timeUtils.ts +++ b/nextjs/src/models/local/timeUtils.ts @@ -37,10 +37,11 @@ const _getDateFromISO = (value: string): Date | undefined => { }; export const getDateFromString = (value: string): Date | undefined => { + const ampmDateRegex = /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2}:\d{2}\s{1}[APap][Mm]$/; //"M/D/YYYY h:mm:ss AM/PM" const militaryDateRegex = /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2}:\d{2}$/; //"MM/DD/YYYY HH:mm:ss" - const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)$/; //"2024-08-26T00:00:00.0000000" + const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}((.\d+)|(Z))$/; //"2024-08-26T00:00:00.0000000" if (isoDateRegex.test(value)) { return _getDateFromISO(value); diff --git a/nextjs/src/services/fileStorage/fileStorageService.ts b/nextjs/src/services/fileStorage/fileStorageService.ts index 8ee3377..f6d2c8f 100644 --- a/nextjs/src/services/fileStorage/fileStorageService.ts +++ b/nextjs/src/services/fileStorage/fileStorageService.ts @@ -4,10 +4,7 @@ import { LocalCourseSettings, localCourseYamlUtils, } from "@/models/local/localCourse"; -import { - directoryOrFileExists, - hasFileSystemEntries, -} from "./utils/fileSystemUtils"; +import { directoryOrFileExists } from "./utils/fileSystemUtils"; import { LocalAssignment, localAssignmentMarkdown, @@ -53,6 +50,15 @@ export const fileStorageService = { return courseNamesFromDirectories; }, + async getAllCoursesSettings() { + const courses = await fileStorageService.getCourseNames(); + + const settings = await Promise.all( + courses.map(async (c) => await fileStorageService.getCourseSettings(c)) + ); + return settings; + }, + async getCourseSettings(courseName: string): Promise { const courseDirectory = path.join(basePath, courseName); const settingsPath = path.join(courseDirectory, "settings.yml"); @@ -97,6 +103,11 @@ export const fileStorageService = { const modules = await Promise.all(modulePromises); return modules.sort((a, b) => a.localeCompare(b)); }, + async createModule(courseName: string, moduleName: string) { + const courseDirectory = path.join(basePath, courseName); + + await fs.mkdir(courseDirectory + "/" + moduleName, { recursive: true }); + }, async getAssignmentNames(courseName: string, moduleName: string) { const filePath = path.join(basePath, courseName, moduleName, "assignments"); @@ -263,7 +274,6 @@ export const fileStorageService = { } const directories = await fs.readdir(basePath, { withFileTypes: true }); - console.log(directories); const emptyDirectories = ( await Promise.all( directories @@ -282,4 +292,10 @@ export const fileStorageService = { return emptyDirectories; }, + + async createCourseFolderForTesting(courseName: string) { + const courseDirectory = path.join(basePath, courseName); + + await fs.mkdir(courseDirectory, { recursive: true }); + }, }; diff --git a/nextjs/src/services/tests/fileStorage.test.ts b/nextjs/src/services/tests/fileStorage.test.ts index 1143b16..dfa2316 100644 --- a/nextjs/src/services/tests/fileStorage.test.ts +++ b/nextjs/src/services/tests/fileStorage.test.ts @@ -1,293 +1,252 @@ -// import path from "path"; -// import { describe, it, expect, beforeEach } from "vitest"; -// import fs from "fs"; -// import { DayOfWeek, LocalCourse } from "@/models/local/localCourse"; -// import { AssignmentSubmissionType } from "@/models/local/assignmnet/assignmentSubmissionType"; -// import { QuestionType } from "@/models/local/quiz/localQuizQuestion"; -// import { fileStorageService } from "../fileStorage/fileStorageService"; +import path from "path"; +import { describe, it, expect, beforeEach } from "vitest"; +import fs from "fs"; +import { + DayOfWeek, + LocalCourse, + LocalCourseSettings, +} from "@/models/local/localCourse"; +import { QuestionType } from "@/models/local/quiz/localQuizQuestion"; +import { fileStorageService } from "../fileStorage/fileStorageService"; -// describe("FileStorageTests", () => { -// beforeEach(() => { -// const storageDirectory = process.env.STORAGE_DIRECTORY ?? "/tmp/canvasManagerTests"; -// if (fs.existsSync(storageDirectory)) { -// fs.rmdirSync(storageDirectory, { recursive: true }); -// } -// fs.mkdirSync(storageDirectory, { recursive: true }); -// }); +describe("FileStorageTests", () => { + beforeEach(() => { + const storageDirectory = + process.env.STORAGE_DIRECTORY ?? "/tmp/canvasManagerTests"; + if (fs.existsSync(storageDirectory)) { + fs.rmdirSync(storageDirectory, { recursive: true }); + } + fs.mkdirSync(storageDirectory, { recursive: true }); + }); -// it("empty course can be saved and loaded", async () => { -// const testCourse: LocalCourse = { -// settings: { -// name: "test empty course", -// assignmentGroups: [], -// daysOfWeek: [], -// startDate: "07/09/2024 23:59:00", -// endDate: "07/09/2024 23:59:00", -// defaultDueTime: { hour: 1, minute: 59 }, -// }, -// modules: [], -// }; + it("course settings can be saved and loaded", async () => { + const name = "test empty course"; + await fileStorageService.createCourseFolderForTesting(name); + const settings: LocalCourseSettings = { + name, + assignmentGroups: [], + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], + startDate: "07/09/2024 23:59:00", + endDate: "07/09/2024 23:59:00", + defaultDueTime: { hour: 1, minute: 59 }, + canvasId: 0, + }; -// await fileStorageService.saveCourseAsync(testCourse); + await fileStorageService.updateCourseSettings(name, settings); -// const loadedCourses = await fileStorageService.loadSavedCourses(); -// const loadedCourse = loadedCourses.find( -// (c) => c.settings.name === testCourse.settings.name -// ); + const loadedSettings = await fileStorageService.getCourseSettings(name); -// expect(loadedCourse).toEqual(testCourse); -// }); + expect(loadedSettings).toEqual(settings); + }); -// it("course settings can be saved and loaded", async () => { -// const testCourse: LocalCourse = { -// settings: { -// assignmentGroups: [], -// name: "Test Course with settings", -// daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], -// startDate: "07/09/2024 23:59:00", -// endDate: "07/09/2024 23:59:00", -// defaultDueTime: { hour: 1, minute: 59 }, -// }, -// modules: [], -// }; + it("empty course modules can be created", async () => { + const courseName = "test empty course"; + const moduleName = "test module 1"; -// await fileStorageService.saveCourseAsync(testCourse); + await fileStorageService.createModule(courseName, moduleName); -// const loadedCourses = await fileStorageService.loadSavedCourses(); -// const loadedCourse = loadedCourses.find( -// (c) => c.settings.name === testCourse.settings.name -// ); + const moduleNames = await fileStorageService.getModuleNames(courseName); -// expect(loadedCourse?.settings).toEqual(testCourse.settings); -// }); + expect(moduleNames).toContain(moduleName); + }); -// it("empty course modules can be saved and loaded", async () => { -// const testCourse: LocalCourse = { -// settings: { -// name: "Test Course with modules", -// assignmentGroups: [], -// daysOfWeek: [], -// startDate: "07/09/2024 23:59:00", -// endDate: "07/09/2024 23:59:00", -// defaultDueTime: { hour: 1, minute: 59 }, -// }, -// modules: [ -// { -// name: "test module 1", -// assignments: [], -// quizzes: [], -// pages: [], -// }, -// ], -// }; + // it("course modules with assignments can be saved and loaded", async () => { + // const testCourse: LocalCourse = { + // settings: { + // name: "Test Course with modules and assignments", + // assignmentGroups: [], + // daysOfWeek: [], + // startDate: "07/09/2024 23:59:00", + // endDate: "07/09/2024 23:59:00", + // defaultDueTime: { hour: 1, minute: 59 }, + // }, + // modules: [ + // { + // name: "test module 1 with assignments", + // assignments: [ + // { + // name: "test assignment", + // description: "here is the description", + // dueAt: "07/09/2024 23:59:00", + // lockAt: "07/09/2024 23:59:00", + // submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD], + // localAssignmentGroupName: "Final Project", + // rubric: [ + // { points: 4, label: "do task 1" }, + // { points: 2, label: "do task 2" }, + // ], + // allowedFileUploadExtensions: [], + // }, + // ], + // quizzes: [], + // pages: [], + // }, + // ], + // }; -// await fileStorageService.saveCourseAsync(testCourse); + // await fileStorageService.saveCourseAsync(testCourse); -// const loadedCourses = await fileStorageService.loadSavedCourses(); -// const loadedCourse = loadedCourses.find( -// (c) => c.settings.name === testCourse.settings.name -// ); + // const loadedCourses = await fileStorageService.loadSavedCourses(); + // const loadedCourse = loadedCourses.find( + // (c) => c.settings.name === testCourse.settings.name + // ); -// expect(loadedCourse?.modules).toEqual(testCourse.modules); -// }); + // expect(loadedCourse?.modules[0].assignments).toEqual( + // testCourse.modules[0].assignments + // ); + // }); -// it("course modules with assignments can be saved and loaded", async () => { -// const testCourse: LocalCourse = { -// settings: { -// name: "Test Course with modules and assignments", -// assignmentGroups: [], -// daysOfWeek: [], -// startDate: "07/09/2024 23:59:00", -// endDate: "07/09/2024 23:59:00", -// defaultDueTime: { hour: 1, minute: 59 }, -// }, -// modules: [ -// { -// name: "test module 1 with assignments", -// assignments: [ -// { -// name: "test assignment", -// description: "here is the description", -// dueAt: "07/09/2024 23:59:00", -// lockAt: "07/09/2024 23:59:00", -// submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD], -// localAssignmentGroupName: "Final Project", -// rubric: [ -// { points: 4, label: "do task 1" }, -// { points: 2, label: "do task 2" }, -// ], -// allowedFileUploadExtensions: [], -// }, -// ], -// quizzes: [], -// pages: [], -// }, -// ], -// }; + // it("course modules with quizzes can be saved and loaded", async () => { + // const testCourse: LocalCourse = { + // settings: { + // name: "Test Course with modules and quiz", + // assignmentGroups: [], + // daysOfWeek: [], + // startDate: "07/09/2024 23:59:00", + // endDate: "07/09/2024 23:59:00", + // defaultDueTime: { hour: 1, minute: 59 }, + // }, + // modules: [ + // { + // name: "test module 1 with quiz", + // assignments: [], + // quizzes: [ + // { + // name: "Test Quiz", + // description: "quiz description", + // lockAt: "07/09/2024 12:05:00", + // dueAt: "07/09/2024 12:05:00", + // shuffleAnswers: true, + // oneQuestionAtATime: true, + // localAssignmentGroupName: "Assignments", + // questions: [ + // { + // text: "test essay", + // questionType: QuestionType.ESSAY, + // points: 1, + // answers: [], + // matchDistractors: [], + // }, + // ], + // showCorrectAnswers: false, + // allowedAttempts: 0, + // }, + // ], + // pages: [], + // }, + // ], + // }; -// await fileStorageService.saveCourseAsync(testCourse); + // await fileStorageService.saveCourseAsync(testCourse); -// const loadedCourses = await fileStorageService.loadSavedCourses(); -// const loadedCourse = loadedCourses.find( -// (c) => c.settings.name === testCourse.settings.name -// ); + // const loadedCourses = await fileStorageService.loadSavedCourses(); + // const loadedCourse = loadedCourses.find( + // (c) => c.settings.name === testCourse.settings.name + // ); -// expect(loadedCourse?.modules[0].assignments).toEqual( -// testCourse.modules[0].assignments -// ); -// }); + // expect(loadedCourse?.modules[0].quizzes).toEqual( + // testCourse.modules[0].quizzes + // ); + // }); -// it("course modules with quizzes can be saved and loaded", async () => { -// const testCourse: LocalCourse = { -// settings: { -// name: "Test Course with modules and quiz", -// assignmentGroups: [], -// daysOfWeek: [], -// startDate: "07/09/2024 23:59:00", -// endDate: "07/09/2024 23:59:00", -// defaultDueTime: { hour: 1, minute: 59 }, -// }, -// modules: [ -// { -// name: "test module 1 with quiz", -// assignments: [], -// quizzes: [ -// { -// name: "Test Quiz", -// description: "quiz description", -// lockAt: "07/09/2024 12:05:00", -// dueAt: "07/09/2024 12:05:00", -// shuffleAnswers: true, -// oneQuestionAtATime: true, -// localAssignmentGroupName: "Assignments", -// questions: [ -// { -// text: "test essay", -// questionType: QuestionType.ESSAY, -// points: 1, -// answers: [], -// matchDistractors: [], -// }, -// ], -// showCorrectAnswers: false, -// allowedAttempts: 0, -// }, -// ], -// pages: [], -// }, -// ], -// }; + // it("markdown storage fully populated does not lose data", async () => { + // const testCourse: LocalCourse = { + // settings: { + // name: "Test Course with lots of data", + // assignmentGroups: [], + // daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], + // startDate: "07/09/2024 23:59:00", + // endDate: "07/09/2024 23:59:00", + // defaultDueTime: { hour: 1, minute: 59 }, + // }, + // modules: [ + // { + // name: "new test module", + // assignments: [ + // { + // name: "test assignment", + // description: "here is the description", + // dueAt: "07/09/2024 23:59:00", + // lockAt: "07/09/2024 23:59:00", + // submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD], + // localAssignmentGroupName: "Final Project", + // rubric: [ + // { points: 4, label: "do task 1" }, + // { points: 2, label: "do task 2" }, + // ], + // allowedFileUploadExtensions: [], + // }, + // ], + // quizzes: [ + // { + // name: "Test Quiz", + // description: "quiz description", + // lockAt: "07/09/2024 23:59:00", + // dueAt: "07/09/2024 23:59:00", + // shuffleAnswers: true, + // oneQuestionAtATime: false, + // localAssignmentGroupName: "someId", + // allowedAttempts: -1, + // questions: [ + // { + // text: "test short answer", + // questionType: QuestionType.SHORT_ANSWER, + // points: 1, + // answers: [], + // matchDistractors: [], + // }, + // ], + // showCorrectAnswers: false, + // }, + // ], + // pages: [], + // }, + // ], + // }; -// await fileStorageService.saveCourseAsync(testCourse); + // await fileStorageService.saveCourseAsync(testCourse); -// const loadedCourses = await fileStorageService.loadSavedCourses(); -// const loadedCourse = loadedCourses.find( -// (c) => c.settings.name === testCourse.settings.name -// ); + // const loadedCourses = await fileStorageService.loadSavedCourses(); + // const loadedCourse = loadedCourses.find( + // (c) => c.settings.name === testCourse.settings.name + // ); -// expect(loadedCourse?.modules[0].quizzes).toEqual( -// testCourse.modules[0].quizzes -// ); -// }); + // expect(loadedCourse).toEqual(testCourse); + // }); -// it("markdown storage fully populated does not lose data", async () => { -// const testCourse: LocalCourse = { -// settings: { -// name: "Test Course with lots of data", -// assignmentGroups: [], -// daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], -// startDate: "07/09/2024 23:59:00", -// endDate: "07/09/2024 23:59:00", -// defaultDueTime: { hour: 1, minute: 59 }, -// }, -// modules: [ -// { -// name: "new test module", -// assignments: [ -// { -// name: "test assignment", -// description: "here is the description", -// dueAt: "07/09/2024 23:59:00", -// lockAt: "07/09/2024 23:59:00", -// submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD], -// localAssignmentGroupName: "Final Project", -// rubric: [ -// { points: 4, label: "do task 1" }, -// { points: 2, label: "do task 2" }, -// ], -// allowedFileUploadExtensions: [], -// }, -// ], -// quizzes: [ -// { -// name: "Test Quiz", -// description: "quiz description", -// lockAt: "07/09/2024 23:59:00", -// dueAt: "07/09/2024 23:59:00", -// shuffleAnswers: true, -// oneQuestionAtATime: false, -// localAssignmentGroupName: "someId", -// allowedAttempts: -1, -// questions: [ -// { -// text: "test short answer", -// questionType: QuestionType.SHORT_ANSWER, -// points: 1, -// answers: [], -// matchDistractors: [], -// }, -// ], -// showCorrectAnswers: false, -// }, -// ], -// pages: [], -// }, -// ], -// }; + // it("markdown storage can persist pages", async () => { + // const testCourse: LocalCourse = { + // settings: { + // name: "Test Course with page", + // assignmentGroups: [], + // daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], + // startDate: "07/09/2024 23:59:00", + // endDate: "07/09/2024 23:59:00", + // defaultDueTime: { hour: 1, minute: 59 }, + // }, + // modules: [ + // { + // name: "page test module", + // assignments: [], + // quizzes: [], + // pages: [ + // { + // name: "test page persistence", + // dueAt: "07/09/2024 23:59:00", + // text: "this is some\n## markdown\n", + // }, + // ], + // }, + // ], + // }; -// await fileStorageService.saveCourseAsync(testCourse); + // await fileStorageService.saveCourseAsync(testCourse); -// const loadedCourses = await fileStorageService.loadSavedCourses(); -// const loadedCourse = loadedCourses.find( -// (c) => c.settings.name === testCourse.settings.name -// ); + // const loadedCourses = await fileStorageService.loadSavedCourses(); + // const loadedCourse = loadedCourses.find( + // (c) => c.settings.name === testCourse.settings.name + // ); -// expect(loadedCourse).toEqual(testCourse); -// }); - -// it("markdown storage can persist pages", async () => { -// const testCourse: LocalCourse = { -// settings: { -// name: "Test Course with page", -// assignmentGroups: [], -// daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], -// startDate: "07/09/2024 23:59:00", -// endDate: "07/09/2024 23:59:00", -// defaultDueTime: { hour: 1, minute: 59 }, -// }, -// modules: [ -// { -// name: "page test module", -// assignments: [], -// quizzes: [], -// pages: [ -// { -// name: "test page persistence", -// dueAt: "07/09/2024 23:59:00", -// text: "this is some\n## markdown\n", -// }, -// ], -// }, -// ], -// }; - -// await fileStorageService.saveCourseAsync(testCourse); - -// const loadedCourses = await fileStorageService.loadSavedCourses(); -// const loadedCourse = loadedCourses.find( -// (c) => c.settings.name === testCourse.settings.name -// ); - -// expect(loadedCourse).toEqual(testCourse); -// }); -// }); + // expect(loadedCourse).toEqual(testCourse); + // }); +}); diff --git a/nextjs/src/services/urlUtils.ts b/nextjs/src/services/urlUtils.ts new file mode 100644 index 0000000..437c6d5 --- /dev/null +++ b/nextjs/src/services/urlUtils.ts @@ -0,0 +1,24 @@ + +export function getModuleItemUrl( + courseName: string, + moduleName: string, + type: "assignment" | "page" | "quiz", + itemName: string +) { + return ( + "/course/" + + encodeURIComponent(courseName) + + "/modules/" + + encodeURIComponent(moduleName) + + `/${type}/` + + encodeURIComponent(itemName) + ); +} + +export function getCourseUrl(courseName: string) { + return "/course/" + encodeURIComponent(courseName); +} + +export function getCourseSettingsUrl(courseName: string) { + return "/course/" + encodeURIComponent(courseName) + "/settings"; +}