diff --git a/Management/Models/CanvasModels/EnrollmentTerms/RedundantEnrollmentTermsResponse.cs b/Management/Models/CanvasModels/EnrollmentTerms/RedundantEnrollmentTermsResponse.cs index 7850973..3b96ced 100644 --- a/Management/Models/CanvasModels/EnrollmentTerms/RedundantEnrollmentTermsResponse.cs +++ b/Management/Models/CanvasModels/EnrollmentTerms/RedundantEnrollmentTermsResponse.cs @@ -1,6 +1,6 @@ namespace CanvasModel.EnrollmentTerms; -public record RedundantEnrollmentTermsResponse +public record coRedundantEnrollmentTermsResponse ( [property: JsonPropertyName("enrollment_terms")] IEnumerable EnrollmentTerms diff --git a/nextjs/package-lock.json b/nextjs/package-lock.json index 839d90a..4e48e53 100644 --- a/nextjs/package-lock.json +++ b/nextjs/package-lock.json @@ -8,9 +8,11 @@ "name": "canvas-mangement", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.52.0", "next": "14.2.5", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hot-toast": "^2.4.1" }, "devDependencies": { "@testing-library/dom": "^10.4.0", @@ -1439,6 +1441,30 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.52.0.tgz", + "integrity": "sha512-U1DOEgltjUwalN6uWYTewSnA14b+tE7lSylOiASKCAO61ENJeCq9VVD/TXHA6O5u9+6v5+UgGYBSccTKDoyMqw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.52.0.tgz", + "integrity": "sha512-T8tLZdPEopSD3A1EBZ/sq7WkI76pKLKKiT82F486K8wf26EPgYCdeiSnJfuayssdQjWwLQMQVl/ROUBNmlWgCQ==", + "dependencies": { + "@tanstack/query-core": "5.52.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -2474,8 +2500,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -3832,6 +3857,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -5624,6 +5657,21 @@ "react": "^18.3.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", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/nextjs/package.json b/nextjs/package.json index d0e2e2c..75b95a2 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -9,9 +9,11 @@ "lint": "next lint" }, "dependencies": { + "@tanstack/react-query": "^5.52.0", "next": "14.2.5", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hot-toast": "^2.4.1" }, "devDependencies": { "@testing-library/dom": "^10.4.0", diff --git a/nextjs/src/app/layout.tsx b/nextjs/src/app/layout.tsx index 3314e47..c9bcbe1 100644 --- a/nextjs/src/app/layout.tsx +++ b/nextjs/src/app/layout.tsx @@ -1,22 +1,35 @@ 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"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Canvas Manager 2.0", }; -export default function RootLayout({ +export async function getDehydratedClient() { + const queryClient = createQueryClient(); + + // await hydrateOpenSections(queryClient); + const dehydratedState = dehydrate(queryClient); + return dehydratedState; +} + +export default async function RootLayout({ children, }: Readonly<{ 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 65fb74f..b43edf1 100644 --- a/nextjs/src/app/page.tsx +++ b/nextjs/src/app/page.tsx @@ -1,5 +1,4 @@ import { canvasAssignmentService } from "@/services/canvas/canvasAssignmentService"; -import Image from "next/image"; export default async function Home() { const assignments = await canvasAssignmentService.getAll(960410); diff --git a/nextjs/src/hooks/cavnasCouresHooks.ts b/nextjs/src/hooks/cavnasCouresHooks.ts new file mode 100644 index 0000000..b4096b7 --- /dev/null +++ b/nextjs/src/hooks/cavnasCouresHooks.ts @@ -0,0 +1,11 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +export const canvasCourseKeys = { + courseDetails: (canavasId: number) => ["canvas course", canavasId] as const, +}; + + +export const useCanvasCourseQuery =(canvasId: number) => useSuspenseQuery({ + queryKey: canvasCourseKeys.courseDetails(canvasId), + queryFn: async () => canvasserv +}) diff --git a/nextjs/src/models/canvas/enrollmentTerms/canvasEnrollmentTermModel.ts b/nextjs/src/models/canvas/enrollmentTerms/canvasEnrollmentTermModel.ts new file mode 100644 index 0000000..90aff4c --- /dev/null +++ b/nextjs/src/models/canvas/enrollmentTerms/canvasEnrollmentTermModel.ts @@ -0,0 +1,16 @@ +export interface CanvasEnrollmentTermModel { + id: number; + name: string; + sis_term_id?: string; + sis_import_id?: number; + start_at?: string; // ISO 8601 date string + end_at?: string; // ISO 8601 date string + grading_period_group_id?: number; + workflow_state?: string; + overrides?: { + [key: string]: { + start_at?: string; // ISO 8601 date string + end_at?: string; // ISO 8601 date string + }; + }; +} diff --git a/nextjs/src/models/canvas/enrollments/canvasEnrollmentModel.ts b/nextjs/src/models/canvas/enrollments/canvasEnrollmentModel.ts index d965907..9c4c5a5 100644 --- a/nextjs/src/models/canvas/enrollments/canvasEnrollmentModel.ts +++ b/nextjs/src/models/canvas/enrollments/canvasEnrollmentModel.ts @@ -1,5 +1,5 @@ import { CanvasUserDisplayModel } from "../users/userDisplayModel"; -import { CanvasGradeModel } from "./canvasG'radeModel"; +import { CanvasGradeModel } from "./canvasGradeModel"; export interface CanvasEnrollmentModel { id: number; diff --git a/nextjs/src/models/canvas/enrollments/canvasG'radeModel.ts b/nextjs/src/models/canvas/enrollments/canvasGradeModel.ts similarity index 100% rename from nextjs/src/models/canvas/enrollments/canvasG'radeModel.ts rename to nextjs/src/models/canvas/enrollments/canvasGradeModel.ts diff --git a/nextjs/src/services/canvas/canvasService.ts b/nextjs/src/services/canvas/canvasService.ts new file mode 100644 index 0000000..ca89c60 --- /dev/null +++ b/nextjs/src/services/canvas/canvasService.ts @@ -0,0 +1,124 @@ +import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel"; +import { canvasServiceUtils } from "./canvasServiceUtils"; +import { webRequestor } from "./webRequestor"; +import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel"; +import { CanvasModuleItem } from "@/models/canvas/modules/canvasModuleItems"; +import { CanvasPage } from "@/models/canvas/pages/canvasPageModel"; +import { CanvasEnrollmentModel } from "@/models/canvas/enrollments/canvasEnrollmentModel"; + +const getTerms = async () => { + const url = `accounts/10/terms`; + const data = await canvasServiceUtils.paginatedRequest<{ + enrollment_terms: CanvasEnrollmentTermModel[]; + }>({ url }); + const terms = data.flatMap((r) => r.enrollment_terms); + return terms; +}; + +export const canvasService = { + async getCourses(termId: number) { + const url = `courses`; + const coursesResponse = + await canvasServiceUtils.paginatedRequest({ url }); + return coursesResponse + .flat() + .filter((c) => c.enrollment_term_id === termId); + }, + + async getCourse(courseId: number): Promise { + const url = `courses/${courseId}`; + const { data, response } = await webRequestor.get(url); + + if (!data) { + console.error((await response?.text()) ?? ""); + console.error(response?.url ?? ""); + throw new Error("Error getting course from Canvas"); + } + + return data; + }, + + async getCurrentTermsFor(queryDate: Date = new Date()) { + const terms = await getTerms(); + const currentTerms = terms + .filter( + (t) => + t.end_at && + new Date(t.end_at) > queryDate && + new Date(t.end_at) < + new Date(queryDate.setFullYear(queryDate.getFullYear() + 1)) + ) + .sort( + (a, b) => + new Date(a.start_at ?? "").getTime() - + new Date(b.start_at ?? "").getTime() + ) + .slice(0, 3); + + return currentTerms; + }, + + async updateModuleItem( + canvasCourseId: number, + canvasModuleId: number, + item: CanvasModuleItem + ) { + console.log(`Updating module item ${item.title}`); + const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items/${item.id}`; + const body = { + module_item: { title: item.title, position: item.position }, + }; + const { data, response } = + await webRequestor.putWithDeserialize(url, body); + + if (!data) throw new Error("Something went wrong updating module item"); + }, + + async createModuleItem( + canvasCourseId: number, + canvasModuleId: number, + title: string, + type: string, + contentId: number | string + ): Promise { + console.log(`Creating new module item ${title}`); + const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items`; + const body = { module_item: { title, type, content_id: contentId } }; + const response = await webRequestor.post(url, body); + + if (!response.ok) + throw new Error("Something went wrong creating module item"); + }, + + async createPageModuleItem( + canvasCourseId: number, + canvasModuleId: number, + title: string, + canvasPage: CanvasPage + ): Promise { + console.log(`Creating new module item ${title}`); + const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items`; + const body = { + module_item: { title, type: "Page", page_url: canvasPage.url }, + }; + const { data, response } = + await webRequestor.postWithDeserialize(url, body); + + if (!response.ok) + throw new Error("Something went wrong creating page module item"); + }, + + async getEnrolledStudents(canvasCourseId: number) { + console.log(`Getting students for course ${canvasCourseId}`); + const url = `courses/${canvasCourseId}/enrollments?enrollment_type=student`; + const { data, response } = + await webRequestor.getMany(url); + + if (!data) + throw new Error( + `Something went wrong getting enrollments for ${canvasCourseId}` + ); + + return data; + }, +}; diff --git a/nextjs/src/services/canvas/webRequestor.ts b/nextjs/src/services/canvas/webRequestor.ts index b9cf3d9..0df88aa 100644 --- a/nextjs/src/services/canvas/webRequestor.ts +++ b/nextjs/src/services/canvas/webRequestor.ts @@ -34,16 +34,15 @@ const deserialize = async (response: Response): Promise => { } }; -const rateLimitAwarePostAsync = async ( +const rateLimitAwarePost = async ( url: string, - options: FetchOptions, + body: any, retryCount = 0 ): Promise => { const response = await fetch(url, { - ...options, method: "POST", + body: JSON.stringify(body), headers: { - ...options.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, @@ -55,7 +54,7 @@ const rateLimitAwarePostAsync = async ( `Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying` ); await sleep(rateLimitSleepInterval); - return await rateLimitAwarePostAsync(url, options, retryCount + 1); + return await rateLimitAwarePost(url, body, retryCount + 1); } } @@ -71,7 +70,7 @@ const rateLimitAwarePostAsync = async ( return response; }; -const recursiveDeleteAsync = async ( +const recursiveDelete = async ( url: string, options: FetchOptions, retryCount = 0 @@ -89,7 +88,7 @@ const recursiveDeleteAsync = async ( if (await isRateLimited(response)) { console.info("After delete response in rate limited"); await sleep(rateLimitSleepInterval); - return await recursiveDeleteAsync(url, options, retryCount + 1); + return await recursiveDelete(url, options, retryCount + 1); } return response; @@ -101,7 +100,7 @@ const recursiveDeleteAsync = async ( `Hit rate limit in delete, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying` ); await sleep(rateLimitSleepInterval); - return await recursiveDeleteAsync(url, options, retryCount + 1); + return await recursiveDelete(url, options, retryCount + 1); } else { console.info( `Hit rate limit in delete, ${rateLimitRetryCount} retries did not fix it` @@ -112,10 +111,7 @@ const recursiveDeleteAsync = async ( } }; export const webRequestor = { - getMany: async ( - url: string, - options: FetchOptions = {} - ): Promise<[T[] | undefined, Response]> => { + getMany: async (url: string, options: FetchOptions = {}) => { const response = await fetch(url, { ...options, method: "GET", @@ -124,13 +120,10 @@ export const webRequestor = { Authorization: `Bearer ${token}`, }, }); - return [await deserialize(response), response]; + return { data: await deserialize(response), response }; }, - get: async ( - url: string, - options: FetchOptions = {} - ): Promise<[T | undefined, Response]> => { + get: async (url: string, options: FetchOptions = {}) => { const response = await fetch(url, { ...options, method: "GET", @@ -139,27 +132,23 @@ export const webRequestor = { Authorization: `Bearer ${token}`, }, }); - return [await deserialize(response), response]; + return { data: await deserialize(response), response }; }, - post: async (url: string, options: FetchOptions = {}): Promise => { - return await rateLimitAwarePostAsync(url, options); + post: async (url: string, body: any) => { + return await rateLimitAwarePost(url, body); }, - postWithDeserialize: async ( - url: string, - options: FetchOptions = {} - ): Promise<[T | undefined, Response]> => { - const response = await rateLimitAwarePostAsync(url, options); - return [await deserialize(response), response]; + postWithDeserialize: async (url: string, body: any) => { + const response = await rateLimitAwarePost(url, body); + return { data: await deserialize(response), response }; }, - put: async (url: string, options: FetchOptions = {}): Promise => { + put: async (url: string, body: any = {}) => { const response = await fetch(url, { - ...options, method: "PUT", + body: JSON.stringify(body), headers: { - ...options.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, @@ -167,26 +156,22 @@ export const webRequestor = { return response; }, - putWithDeserialize: async ( - url: string, - options: FetchOptions = {} - ): Promise<[T | undefined, Response]> => { + putWithDeserialize: async (url: string, body: any = {}) => { const response = await fetch(url, { - ...options, + body: JSON.stringify(body), method: "PUT", headers: { - ...options.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); - return [await deserialize(response), response]; + return { data: await deserialize(response), response }; }, delete: async ( url: string, options: FetchOptions = {} ): Promise => { - return await recursiveDeleteAsync(url, options); + return await recursiveDelete(url, options); }, }; diff --git a/nextjs/src/services/utils/MyQueryClientProvider.tsx b/nextjs/src/services/utils/MyQueryClientProvider.tsx new file mode 100644 index 0000000..0962d7f --- /dev/null +++ b/nextjs/src/services/utils/MyQueryClientProvider.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { + DehydratedState, + hydrate, + QueryClientProvider, +} from "@tanstack/react-query"; +import React from "react"; +import { FC, ReactNode, useState } from "react"; +import { createQueryClient } from "./queryClient"; + +export const MyQueryClientProvider: FC<{ + children: ReactNode; + dehydratedState: DehydratedState; +}> = ({ children, dehydratedState }) => { + const [queryClient] = useState(createQueryClient()); + + hydrate(queryClient, dehydratedState); + + return ( + {children} + ); +}; diff --git a/nextjs/src/services/utils/queryClient.tsx b/nextjs/src/services/utils/queryClient.tsx new file mode 100644 index 0000000..5fa2fa1 --- /dev/null +++ b/nextjs/src/services/utils/queryClient.tsx @@ -0,0 +1,143 @@ +import toast, { ErrorIcon, CheckmarkIcon } from "react-hot-toast"; +import { ReactNode } from "react"; +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; + +const addErrorAsToast = async (error: any) => { + console.error("error from toast", error); + const message = getErrorMessage(error); + + toast( + (t: any) => ( +
+
+ +
+
+
{message}
+ +
+
+ +
+
+ ), + { + duration: Infinity, + } + ); +}; + +export function getErrorMessage(error: any) { + if (error?.response?.status === 422) { + console.log(error.response.data.detail); + const serializationMessages = error.response.data.detail.map( + (d: any) => `${d.type} - ${d.loc[1]}` + ); + return `Deserialization error on request:\n${serializationMessages.join( + "\n" + )}`; + } + if (typeof error === "string") { + return error; + } + if (error.response?.data.detail) { + if (typeof error.response?.data.detail === "string") { + return error.response?.data.detail; + } else return JSON.stringify(error.response?.data.detail); + } + console.log(error); + return "Error With Request"; +} + +export function createInfoToast( + children: ReactNode, + onClose: () => void = () => {} +) { + const closeHandler = (t: any) => { + toast.dismiss(t.id); + onClose(); + }; + toast( + (t: any) => ( +
+
+ +
+
{children}
+
+ +
+
+ ), + { + duration: Infinity, + style: { + maxWidth: "75em", + }, + } + ); +} + +export function createSuccessToast(message: string) { + toast( + (t: any) => ( +
+
+ +
+
{message}
+
+ +
+
+ ), + { + duration: Infinity, + style: { + maxWidth: "75em", + }, + } + ); +} + +export const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 0, + }, + mutations: { + onError: addErrorAsToast, + retry: 0, + }, + }, + queryCache: new QueryCache({ + onError: addErrorAsToast, + }), + mutationCache: new MutationCache({ + onError: addErrorAsToast, + }), +});