From 72dcb2f54bbaf42e5e1b7c4b1a12b1467e8dc3d1 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Wed, 11 Sep 2024 09:21:05 -0600 Subject: [PATCH] term dropdown populating --- nextjs/src/app/AddNewCourse.tsx | 25 +++++ nextjs/src/app/NewCourseForm.tsx | 25 +++++ nextjs/src/app/api/canvas/[...rest]/route.ts | 97 ++++++++++++++++--- .../app/course/[courseName]/calendar/Day.tsx | 2 +- nextjs/src/app/course/[courseName]/page.tsx | 7 ++ .../settings/AssignmentGroupManagement.tsx | 2 +- nextjs/src/app/page.tsx | 11 ++- .../components/SuspenseAndErrorHandling.tsx | 32 ++++++ nextjs/src/components/form/SelectInput.tsx | 33 +++++++ .../form}/TextInput.tsx | 0 nextjs/src/hooks/canvas/canvasHooks.ts | 37 +++++++ nextjs/src/services/canvas/canvasService.ts | 26 ++--- .../src/services/canvas/canvasServiceUtils.ts | 1 + 13 files changed, 266 insertions(+), 32 deletions(-) create mode 100644 nextjs/src/app/AddNewCourse.tsx create mode 100644 nextjs/src/app/NewCourseForm.tsx create mode 100644 nextjs/src/components/SuspenseAndErrorHandling.tsx create mode 100644 nextjs/src/components/form/SelectInput.tsx rename nextjs/src/{app/course/[courseName]/settings => components/form}/TextInput.tsx (100%) create mode 100644 nextjs/src/hooks/canvas/canvasHooks.ts diff --git a/nextjs/src/app/AddNewCourse.tsx b/nextjs/src/app/AddNewCourse.tsx new file mode 100644 index 0000000..a1f69a1 --- /dev/null +++ b/nextjs/src/app/AddNewCourse.tsx @@ -0,0 +1,25 @@ +"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"; + +export default function AddNewCourse() { + const [showForm, setShowForm] = useState(false); + + return ( +
+ + +
+
+ + + {showForm && } + +
+
+
+ ); +} diff --git a/nextjs/src/app/NewCourseForm.tsx b/nextjs/src/app/NewCourseForm.tsx new file mode 100644 index 0000000..05acf75 --- /dev/null +++ b/nextjs/src/app/NewCourseForm.tsx @@ -0,0 +1,25 @@ +import SelectInput from "@/components/form/SelectInput"; +import { useCanvasTermsQuery } from "@/hooks/canvas/canvasHooks"; +import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel"; +import React, { useState } from "react"; + +export default function NewCourseForm() { + const { data: canvasTerms } = useCanvasTermsQuery(new Date()); + + const [selectedTerm, setSelectedTerm] = useState< + CanvasEnrollmentTermModel | undefined + >(); + + return ( +
+ form is here + t.name} + /> + + ); +} diff --git a/nextjs/src/app/api/canvas/[...rest]/route.ts b/nextjs/src/app/api/canvas/[...rest]/route.ts index caecd9c..5362ce1 100644 --- a/nextjs/src/app/api/canvas/[...rest]/route.ts +++ b/nextjs/src/app/api/canvas/[...rest]/route.ts @@ -1,12 +1,46 @@ import { NextRequest, NextResponse } from "next/server"; import { axiosClient } from "@/services/axiosUtils"; import { withErrorHandling } from "@/services/withErrorHandling"; +import { AxiosResponseHeaders, RawAxiosResponseHeaders } from "axios"; const getUrl = (params: { rest: string[] }) => { const { rest } = params; const path = rest.join("/"); const newUrl = `https://snow.instructure.com/api/v1/${path}`; - return newUrl; + return new URL(newUrl); +}; + +const getNextUrl = ( + headers: AxiosResponseHeaders | RawAxiosResponseHeaders +): string | undefined => { + const linkHeader: string | undefined = + typeof headers.get === "function" + ? (headers.get("link") as string) + : ((headers as RawAxiosResponseHeaders)["link"] as string); + + if (!linkHeader) { + console.log("could not find link header in the response"); + return undefined; + } + + const links = linkHeader.split(",").map((link) => link.trim()); + const nextLink = links.find((link) => link.includes('rel="next"')); + + if (!nextLink) { + console.log("could not find next url in link header, reached end of pagination"); + return undefined; + } + + const nextUrl = nextLink.split(";")[0].trim().slice(1, -1); + return nextUrl; +}; + +const proxyResponseHeaders = (response: any) => { + const headers = new Headers(); + Object.entries(response.headers).forEach(([key, value]) => { + headers.set(key, value as string); + }); + return headers; }; export async function GET( @@ -16,18 +50,42 @@ export async function GET( return withErrorHandling(async () => { try { const url = getUrl(params); - const response = await axiosClient.get(url, { - headers: { - // Include other headers from the incoming request if needed: - // 'Content-Type': req.headers.get('content-type') || 'application/json', - "Content-Type": "application/json", - }, - }); + // const response = await axiosClient.get(url, { + // headers: { + // // Include other headers from the incoming request if needed: + // "Content-Type": "application/json", + // }, + // }); - return NextResponse.json(response.data); + var requestCount = 1; + url.searchParams.set("per_page", "100"); + + const { data: firstData, headers: firstHeaders } = await axiosClient.get( + url.toString() + ); + + var returnData = firstData ? [firstData] : []; + var nextUrl = getNextUrl(firstHeaders); + + while (nextUrl) { + requestCount += 1; + const { data, headers } = await axiosClient.get(nextUrl); + if (data) { + returnData = [...returnData, data]; + } + nextUrl = getNextUrl(headers); + } + + if (requestCount > 1) { + console.log( + `Requesting ${typeof returnData} took ${requestCount} requests` + ); + } + + return NextResponse.json(returnData); } catch (error: any) { return new NextResponse( - JSON.stringify({ error: error.message || "Canvas get request failed" }), + JSON.stringify({ error: error.message || "Canvas GET request failed" }), { status: error.response?.status || 500 } ); } @@ -43,11 +101,13 @@ export async function POST( const url = getUrl(params); const body = await req.json(); const response = await axiosClient.post(url, body); - return NextResponse.json(response.data); + + const headers = proxyResponseHeaders(response); + return new NextResponse(JSON.stringify(response.data), { headers }); } catch (error: any) { return new NextResponse( JSON.stringify({ - error: error.message || "Canvas post request failed", + error: error.message || "Canvas POST request failed", }), { status: error.response?.status || 500 } ); @@ -64,15 +124,18 @@ export async function PUT( const url = getUrl(params); const body = await req.json(); const response = await axiosClient.put(url, body); - return NextResponse.json(response.data); + + const headers = proxyResponseHeaders(response); + return new NextResponse(JSON.stringify(response.data), { headers }); } catch (error: any) { return new NextResponse( - JSON.stringify({ error: error.message || "Canvas put request failed" }), + JSON.stringify({ error: error.message || "Canvas PUT request failed" }), { status: error.response?.status || 500 } ); } }); } + export async function DELETE( req: NextRequest, { params }: { params: { rest: string[] } } @@ -81,11 +144,13 @@ export async function DELETE( try { const url = getUrl(params); const response = await axiosClient.delete(url); - return NextResponse.json(response.data); + + const headers = proxyResponseHeaders(response); + return new NextResponse(JSON.stringify(response.data), { headers }); } catch (error: any) { return new NextResponse( JSON.stringify({ - error: error.message || "Canvas delete request failed", + error: error.message || "Canvas DELETE request failed", }), { status: error.response?.status || 500 } ); diff --git a/nextjs/src/app/course/[courseName]/calendar/Day.tsx b/nextjs/src/app/course/[courseName]/calendar/Day.tsx index 7d7e8e4..8edb76a 100644 --- a/nextjs/src/app/course/[courseName]/calendar/Day.tsx +++ b/nextjs/src/app/course/[courseName]/calendar/Day.tsx @@ -9,7 +9,7 @@ import { useCourseContext } from "../context/courseContext"; import Link from "next/link"; import { IModuleItem } from "@/models/local/IModuleItem"; import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; -import { DayOfWeek, getDayOfWeek } from "@/models/local/localCourse"; +import { getDayOfWeek } from "@/models/local/localCourse"; export default function Day({ day, month }: { day: string; month: number }) { const dayAsDate = getDateFromStringOrThrow( diff --git a/nextjs/src/app/course/[courseName]/page.tsx b/nextjs/src/app/course/[courseName]/page.tsx index 228f5dc..32ef123 100644 --- a/nextjs/src/app/course/[courseName]/page.tsx +++ b/nextjs/src/app/course/[courseName]/page.tsx @@ -2,6 +2,7 @@ import CourseCalendar from "./calendar/CourseCalendar"; import CourseSettingsLink from "./CourseSettingsLink"; import ModuleList from "./modules/ModuleList"; import DraggingContextProvider from "./context/DraggingContextProvider"; +import Link from "next/link"; export default async function CoursePage({}: {}) { return ( @@ -9,6 +10,12 @@ export default async function CoursePage({}: {}) {
+
+ + Back to Course List + +
+
diff --git a/nextjs/src/app/course/[courseName]/settings/AssignmentGroupManagement.tsx b/nextjs/src/app/course/[courseName]/settings/AssignmentGroupManagement.tsx index 902c1fc..5ffc339 100644 --- a/nextjs/src/app/course/[courseName]/settings/AssignmentGroupManagement.tsx +++ b/nextjs/src/app/course/[courseName]/settings/AssignmentGroupManagement.tsx @@ -6,7 +6,7 @@ import { } from "@/hooks/localCourse/localCoursesHooks"; import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup"; import { useEffect, useState } from "react"; -import TextInput from "./TextInput"; +import TextInput from "../../../../components/form/TextInput"; import { useSetAssignmentGroupsMutation } from "@/hooks/canvas/canvasCourseHooks"; export default function AssignmentGroupManagement() { diff --git a/nextjs/src/app/page.tsx b/nextjs/src/app/page.tsx index d1c929f..41b4311 100644 --- a/nextjs/src/app/page.tsx +++ b/nextjs/src/app/page.tsx @@ -1,9 +1,16 @@ +import AddNewCourse from "./AddNewCourse"; import CourseList from "./CourseList"; export default async function Home() { return ( -
- +
+
+ +
+
+ + +
); } diff --git a/nextjs/src/components/SuspenseAndErrorHandling.tsx b/nextjs/src/components/SuspenseAndErrorHandling.tsx new file mode 100644 index 0000000..38fd739 --- /dev/null +++ b/nextjs/src/components/SuspenseAndErrorHandling.tsx @@ -0,0 +1,32 @@ +import { getErrorMessage } from "@/services/utils/queryClient"; +import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import { FC, ReactNode, Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { Spinner } from "./Spinner"; + +export const SuspenseAndErrorHandling: FC<{ children: ReactNode }> = ({ + children, +}) => { + return ( + + {({ reset }) => ( + ( +
+
{getErrorMessage(props.error)}
+ +
+ )} + > + }>{children} +
+ )} +
+ ); +}; diff --git a/nextjs/src/components/form/SelectInput.tsx b/nextjs/src/components/form/SelectInput.tsx new file mode 100644 index 0000000..96fb88c --- /dev/null +++ b/nextjs/src/components/form/SelectInput.tsx @@ -0,0 +1,33 @@ +export default function SelectInput({ + value, + setValue, + label, + options, + getOptionName, +}: { + value: T | undefined; + setValue: (newValue: T | undefined) => void; + label: string; + options: T[]; + getOptionName: (item: T) => string; +}) { + return ( + + ); +} diff --git a/nextjs/src/app/course/[courseName]/settings/TextInput.tsx b/nextjs/src/components/form/TextInput.tsx similarity index 100% rename from nextjs/src/app/course/[courseName]/settings/TextInput.tsx rename to nextjs/src/components/form/TextInput.tsx diff --git a/nextjs/src/hooks/canvas/canvasHooks.ts b/nextjs/src/hooks/canvas/canvasHooks.ts new file mode 100644 index 0000000..68b2ab0 --- /dev/null +++ b/nextjs/src/hooks/canvas/canvasHooks.ts @@ -0,0 +1,37 @@ +import { canvasService } from "@/services/canvas/canvasService"; +import { useSuspenseQuery } from "@tanstack/react-query"; + +export const canvasKeys = { + allTerms: ["all canvas terms"] as const, + allAroundDate: (date: Date) => ["all canvas terms", date] as const, +}; + +export const useAllCanvasTermsQuery = () => + useSuspenseQuery({ + queryKey: canvasKeys.allTerms, + queryFn: canvasService.getAllTerms, + }); +export const useCanvasTermsQuery = (queryDate: Date) => { + const { data: terms } = useAllCanvasTermsQuery(); + + return useSuspenseQuery({ + queryKey: canvasKeys.allAroundDate(queryDate), + queryFn: () => { + const currentTerms = terms + .filter((t) => { + if (!t.end_at) return false; + + const endDate = new Date(t.end_at); + return endDate > queryDate; + }) + .sort( + (a, b) => + new Date(a.start_at ?? "").getTime() - + new Date(b.start_at ?? "").getTime() + ) + .slice(0, 3); + + return currentTerms; + }, + }); +}; diff --git a/nextjs/src/services/canvas/canvasService.ts b/nextjs/src/services/canvas/canvasService.ts index 81cc922..168b870 100644 --- a/nextjs/src/services/canvas/canvasService.ts +++ b/nextjs/src/services/canvas/canvasService.ts @@ -6,19 +6,21 @@ import { CanvasPage } from "@/models/canvas/pages/canvasPageModel"; import { CanvasEnrollmentModel } from "@/models/canvas/enrollments/canvasEnrollmentModel"; import { axiosClient } from "../axiosUtils"; -const getTerms = async () => { - const url = `accounts/10/terms`; - const data = await canvasServiceUtils.paginatedRequest<{ +const baseCanvasUrl = "https://snow.instructure.com/api/v1"; + +const getAllTerms = async () => { + const url = `${baseCanvasUrl}/accounts/10/terms`; + const {data} = await axiosClient.get<{ enrollment_terms: CanvasEnrollmentTermModel[]; - }>({ url }); + }[]>(url); const terms = data.flatMap((r) => r.enrollment_terms); return terms; }; export const canvasService = { - getTerms, + getAllTerms, async getCourses(termId: number) { - const url = `courses`; + const url = `${baseCanvasUrl}/courses`; const coursesResponse = await canvasServiceUtils.paginatedRequest({ url }); return coursesResponse @@ -27,13 +29,13 @@ export const canvasService = { }, async getCourse(courseId: number): Promise { - const url = `courses/${courseId}`; + const url = `${baseCanvasUrl}/courses/${courseId}`; const { data } = await axiosClient.get(url); return data; }, async getCurrentTermsFor(queryDate: Date = new Date()) { - const terms = await getTerms(); + const terms = await getAllTerms(); const currentTerms = terms .filter( (t) => @@ -58,7 +60,7 @@ export const canvasService = { item: CanvasModuleItem ) { console.log(`Updating module item ${item.title}`); - const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items/${item.id}`; + const url = `${baseCanvasUrl}/courses/${canvasCourseId}/modules/${canvasModuleId}/items/${item.id}`; const body = { module_item: { title: item.title, position: item.position }, }; @@ -75,7 +77,7 @@ export const canvasService = { contentId: number | string ): Promise { console.log(`Creating new module item ${title}`); - const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items`; + const url = `${baseCanvasUrl}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`; const body = { module_item: { title, type, content_id: contentId } }; const response = await axiosClient.post(url, body); }, @@ -87,7 +89,7 @@ export const canvasService = { canvasPage: CanvasPage ): Promise { console.log(`Creating new module item ${title}`); - const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items`; + const url = `${baseCanvasUrl}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`; const body = { module_item: { title, type: "Page", page_url: canvasPage.url }, }; @@ -96,7 +98,7 @@ export const canvasService = { async getEnrolledStudents(canvasCourseId: number) { console.log(`Getting students for course ${canvasCourseId}`); - const url = `courses/${canvasCourseId}/enrollments?enrollment_type=student`; + const url = `${baseCanvasUrl}/courses/${canvasCourseId}/enrollments?enrollment_type=student`; const { data } = await axiosClient.get(url); if (!data) diff --git a/nextjs/src/services/canvas/canvasServiceUtils.ts b/nextjs/src/services/canvas/canvasServiceUtils.ts index d418009..acf8a27 100644 --- a/nextjs/src/services/canvas/canvasServiceUtils.ts +++ b/nextjs/src/services/canvas/canvasServiceUtils.ts @@ -34,6 +34,7 @@ export const canvasServiceUtils = { var returnData: T[] = firstData ? [firstData] : []; var nextUrl = getNextUrl(firstHeaders); + console.log("got first request", nextUrl, firstHeaders); while (nextUrl) { requestCount += 1;