diff --git a/Management.Test/Markdown/Quiz/MatchingTests.cs b/Management.Test/Markdown/Quiz/MatchingTests.cs index 601aba7..e58b16d 100644 --- a/Management.Test/Markdown/Quiz/MatchingTests.cs +++ b/Management.Test/Markdown/Quiz/MatchingTests.cs @@ -97,7 +97,7 @@ Description: Match the following terms & definitions ^statement - a single command to be executed -^ - this is the distractor +^ - this is the distractor "; var quiz = LocalQuiz.ParseMarkdown(rawMarkdownQuiz); @@ -127,4 +127,33 @@ Match the following terms & definitions quizMarkdown.Should().Contain("^ statement - a single command to be executed\n^ - this is the distractor"); } + [Fact] + public void DistractorsDoNotAddDelimiterOntheEnd() + { + var rawMarkdownQuiz = @" +Name: Test Quiz +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 2023-08-21T23:59:00 +LockAt: 2023-08-21T23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: +--- + +Points: 2 + +Match up the term with the best possible answer. +^ - a variable name +^ - A reserved word with special meaning to the compiler +"; + + var quiz = LocalQuiz.ParseMarkdown(rawMarkdownQuiz); + var quizMarkdown = quiz.ToMarkdown(); + + quizMarkdown.Should().Contain(@"Match up the term with the best possible answer. + +^ - a variable name +^ - A reserved word with special meaning to the compiler"); + } } diff --git a/Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs b/Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs index 1b2e71c..7a8044e 100644 --- a/Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs +++ b/Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs @@ -19,13 +19,21 @@ public record LocalQuizQuestionAnswer if (questionType == QuestionType.MATCHING) { - string matchingPattern = @"^\^ ?"; - var textWithoutMatchDelimiter = Regex.Replace(input, matchingPattern, string.Empty).Trim(); + string matchingPattern = @"^\^"; + var textWithoutMatchDelimiter = Regex.Replace(input, matchingPattern, string.Empty); + + var leftRightDelimiter = " - "; return new LocalQuizQuestionAnswer() { Correct = true, - Text = textWithoutMatchDelimiter.Split('-')[0].Trim(), - MatchedText = string.Join("-", textWithoutMatchDelimiter.Split('-')[1..]).Trim(), + Text = textWithoutMatchDelimiter.Split(leftRightDelimiter)[0].Trim(), + MatchedText = string.Join( + leftRightDelimiter, + textWithoutMatchDelimiter + .Split(leftRightDelimiter)[1..] + .Select(a => a.Trim()) + .Where(a => a != "") + ).Trim(), }; } diff --git a/nextjs/package-lock.json b/nextjs/package-lock.json index 51cdf3a..677f6e8 100644 --- a/nextjs/package-lock.json +++ b/nextjs/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-query": "^5.52.0", + "axios": "^1.7.5", "next": "14.2.5", "react": "^18", "react-dom": "^18", + "react-error-boundary": "^4.0.13", "react-hot-toast": "^2.4.1", "yaml": "^2.5.0" }, @@ -406,7 +408,6 @@ "version": "7.25.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -2137,8 +2138,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.7", @@ -2164,6 +2164,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -2425,7 +2435,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -2681,7 +2690,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -3576,6 +3584,25 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -3605,7 +3632,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4816,7 +4842,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -4825,7 +4850,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -5594,6 +5618,11 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -5658,6 +5687,17 @@ "react": "^18.3.1" } }, + "node_modules/react-error-boundary": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.13.tgz", + "integrity": "sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-hot-toast": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", @@ -5733,8 +5773,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", diff --git a/nextjs/package.json b/nextjs/package.json index df3e583..93555bd 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -11,9 +11,11 @@ }, "dependencies": { "@tanstack/react-query": "^5.52.0", + "axios": "^1.7.5", "next": "14.2.5", "react": "^18", "react-dom": "^18", + "react-error-boundary": "^4.0.13", "react-hot-toast": "^2.4.1", "yaml": "^2.5.0" }, diff --git a/nextjs/src/app/api/courses/route.ts b/nextjs/src/app/api/courses/route.ts new file mode 100644 index 0000000..ffdcd5d --- /dev/null +++ b/nextjs/src/app/api/courses/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json([]); +} diff --git a/nextjs/src/app/layout.tsx b/nextjs/src/app/layout.tsx index c9bcbe1..77d82fe 100644 --- a/nextjs/src/app/layout.tsx +++ b/nextjs/src/app/layout.tsx @@ -1,9 +1,11 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; -import { createQueryClient } from "@/services/utils/queryClient"; import { dehydrate } from "@tanstack/react-query"; import { MyQueryClientProvider } from "@/services/utils/MyQueryClientProvider"; +import { hydrateCourses } from "@/hooks/hookHydration"; +import { LoadingAndErrorHandling } from "@/components/LoadingAndErrorHandling"; +import { createQueryClientForServer } from "@/services/utils/queryClientServer"; const inter = Inter({ subsets: ["latin"] }); @@ -12,9 +14,9 @@ export const metadata: Metadata = { }; export async function getDehydratedClient() { - const queryClient = createQueryClient(); + const queryClient = createQueryClientForServer(); - // await hydrateOpenSections(queryClient); + await hydrateCourses(queryClient); const dehydratedState = dehydrate(queryClient); return dehydratedState; } @@ -25,10 +27,13 @@ export default async function RootLayout({ children: React.ReactNode; }>) { const dehydratedState = await getDehydratedClient(); + return ( - {children} + + {children} + ); diff --git a/nextjs/src/app/page.tsx b/nextjs/src/app/page.tsx index b43edf1..e8fdbfc 100644 --- a/nextjs/src/app/page.tsx +++ b/nextjs/src/app/page.tsx @@ -1,12 +1,12 @@ -import { canvasAssignmentService } from "@/services/canvas/canvasAssignmentService"; - -export default async function Home() { - const assignments = await canvasAssignmentService.getAll(960410); +"use client" +import { useLocalCoursesQuery } from "@/hooks/localCoursesHooks"; +export default function Home() { + const { data: courses } = useLocalCoursesQuery(); return ( - {assignments.map((assignment) => ( - {assignment.name} + {courses.map((c) => ( + {c.settings.name} ))} ); diff --git a/nextjs/src/components/LoadingAndErrorHandling.tsx b/nextjs/src/components/LoadingAndErrorHandling.tsx new file mode 100644 index 0000000..9984576 --- /dev/null +++ b/nextjs/src/components/LoadingAndErrorHandling.tsx @@ -0,0 +1,30 @@ +import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import { FC, ReactNode, Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +export const LoadingAndErrorHandling: FC<{ children: ReactNode }> = ({ + children, +}) => { + return ( + + {({ reset }) => ( + ( + + {JSON.stringify(props.error)} + props.resetErrorBoundary()} + > + Try again + + + )} + > + loading...}>{children} + + )} + + ); +}; diff --git a/nextjs/src/hooks/hookHydration.ts b/nextjs/src/hooks/hookHydration.ts new file mode 100644 index 0000000..2e088ba --- /dev/null +++ b/nextjs/src/hooks/hookHydration.ts @@ -0,0 +1,10 @@ +import { QueryClient } from "@tanstack/react-query"; +import { localCourseKeys } from "./localCoursesHooks"; +import { fileStorageService } from "@/services/fileStorage/fileStorageService"; + +export const hydrateCourses = async (queryClient: QueryClient) => { + await queryClient.prefetchQuery({ + queryKey: localCourseKeys.allCourses, + queryFn: async () => await fileStorageService.loadSavedCourses(), + }); +}; diff --git a/nextjs/src/hooks/localCoursesHooks.ts b/nextjs/src/hooks/localCoursesHooks.ts new file mode 100644 index 0000000..a9fef95 --- /dev/null +++ b/nextjs/src/hooks/localCoursesHooks.ts @@ -0,0 +1,17 @@ +import { LocalCourse } from "@/models/local/localCourse"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import axios from "axios"; + +export const localCourseKeys = { + allCourses: ["all courses"] as const, +}; + +export const useLocalCoursesQuery = () => + useSuspenseQuery({ + queryKey: localCourseKeys.allCourses, + queryFn: async (): Promise => { + const url = `/api/courses`; + const response = await axios.get(url); + return response.data; + }, + }); diff --git a/nextjs/src/models/local/tests/timeUtils.test.ts b/nextjs/src/models/local/tests/timeUtils.test.ts new file mode 100644 index 0000000..cf43df8 --- /dev/null +++ b/nextjs/src/models/local/tests/timeUtils.test.ts @@ -0,0 +1,19 @@ + + + +import { describe, it, expect } from "vitest"; +import { getDateFromString } from "../timeUtils"; + +describe("Can properly handle expected date formats", () => { + it("can use AM/PM dates", () =>{ + const dateString = "8/27/2024 1:00:00 AM" + const dateObject = getDateFromString(dateString) + expect(dateObject).not.toBeUndefined() + }) + it("can use 24 hour dates", () =>{ + const dateString = "8/27/2024 23:95:00" + const dateObject = getDateFromString(dateString) + expect(dateObject).not.toBeUndefined() + + }) +}) \ No newline at end of file diff --git a/nextjs/src/models/local/timeUtils.ts b/nextjs/src/models/local/timeUtils.ts index 1e2a004..c3774b7 100644 --- a/nextjs/src/models/local/timeUtils.ts +++ b/nextjs/src/models/local/timeUtils.ts @@ -1,24 +1,39 @@ export const getDateFromString = (value: string) => { - // may need to check for other formats - const validDateRegex = - /\d{2}\/\d{2}\/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]/; + // Updated regex to match both formats: "MM/DD/YYYY HH:mm:ss" and "M/D/YYYY h:mm:ss AM/PM" + const validDateRegex = /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2}:\d{2}(?:\s?[APap][Mm])?$/; if (!validDateRegex.test(value)) { + console.log("invalid date format", value); return undefined; } - const [datePart, timePart] = value.split(" "); + const [datePart, timePartWithMeridian] = value.split(" "); const [day, month, year] = datePart.split("/").map(Number); + let [timePart, meridian] = timePartWithMeridian.split(" "); const [hours, minutes, seconds] = timePart.split(":").map(Number); - const date = new Date(year, month - 1, day, hours, minutes, seconds); + + let adjustedHours = hours; + if (meridian) { + meridian = meridian.toUpperCase(); + if (meridian === "PM" && hours < 12) { + adjustedHours += 12; + } else if (meridian === "AM" && hours === 12) { + adjustedHours = 0; + } + } + + const date = new Date(year, month - 1, day, adjustedHours, minutes, seconds); if (isNaN(date.getTime())) { + console.log("could not parse time out of value", value); + return undefined; } return date; }; + export const verifyDateStringOrUndefined = ( value: string ): string | undefined => { diff --git a/nextjs/src/services/fileStorage/fileStorageService.ts b/nextjs/src/services/fileStorage/fileStorageService.ts index 1ddec86..2783703 100644 --- a/nextjs/src/services/fileStorage/fileStorageService.ts +++ b/nextjs/src/services/fileStorage/fileStorageService.ts @@ -5,6 +5,7 @@ import { courseMarkdownLoader } from "./utils/couresMarkdownLoader"; import { courseMarkdownSaver } from "./utils/courseMarkdownSaver"; const basePath = process.env.STORAGE_DIRECTORY ?? "./storage"; +console.log("base path", basePath); export const fileStorageService = { async saveCourseAsync( diff --git a/nextjs/src/services/utils/MyQueryClientProvider.tsx b/nextjs/src/services/utils/MyQueryClientProvider.tsx index 0962d7f..21b9358 100644 --- a/nextjs/src/services/utils/MyQueryClientProvider.tsx +++ b/nextjs/src/services/utils/MyQueryClientProvider.tsx @@ -3,6 +3,7 @@ import { DehydratedState, hydrate, + HydrationBoundary, QueryClientProvider, } from "@tanstack/react-query"; import React from "react"; @@ -15,9 +16,9 @@ export const MyQueryClientProvider: FC<{ }> = ({ children, dehydratedState }) => { const [queryClient] = useState(createQueryClient()); - hydrate(queryClient, dehydratedState); - return ( - {children} + + {children} + ); }; diff --git a/nextjs/src/services/utils/queryClientServer.tsx b/nextjs/src/services/utils/queryClientServer.tsx new file mode 100644 index 0000000..516b1ef --- /dev/null +++ b/nextjs/src/services/utils/queryClientServer.tsx @@ -0,0 +1,20 @@ +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; + +export const createQueryClientForServer = () => new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 0, + }, + mutations: { + onError: (e) => console.log(e), + retry: 0, + }, + }, + queryCache: new QueryCache({ + onError: (e) => console.log(e), + }), + mutationCache: new MutationCache({ + onError: (e) => console.log(e), + }), +});