From edb2761de769f9743c65be41b7f5d7b470f011be Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Tue, 10 Sep 2024 09:00:58 -0600 Subject: [PATCH] canvas crud on pages --- nextjs/src/app/api/canvas/[...rest]/route.ts | 104 ++++++++++++++---- .../[moduleName]/page/[pageName]/EditPage.tsx | 28 +++-- .../page/[pageName]/EditPageButtons.tsx | 75 +++++++++++++ nextjs/src/hooks/canvas/canvasPageHooks.ts | 47 +++++++- .../src/services/canvas/canvasPageService.ts | 86 +++++++-------- .../fileStorage/fileStorageService.ts | 64 ++++++++--- 6 files changed, 311 insertions(+), 93 deletions(-) create mode 100644 nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx diff --git a/nextjs/src/app/api/canvas/[...rest]/route.ts b/nextjs/src/app/api/canvas/[...rest]/route.ts index 63ce029..caecd9c 100644 --- a/nextjs/src/app/api/canvas/[...rest]/route.ts +++ b/nextjs/src/app/api/canvas/[...rest]/route.ts @@ -1,28 +1,94 @@ import { NextRequest, NextResponse } from "next/server"; import { axiosClient } from "@/services/axiosUtils"; +import { withErrorHandling } from "@/services/withErrorHandling"; + +const getUrl = (params: { rest: string[] }) => { + const { rest } = params; + const path = rest.join("/"); + const newUrl = `https://snow.instructure.com/api/v1/${path}`; + return newUrl; +}; export async function GET( req: NextRequest, { params }: { params: { rest: string[] } } ) { - const { rest } = params; - const path = rest.join("/"); + 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", + }, + }); - try { - const newUrl = `https://snow.instructure.com/api/v1/${path}`; - const response = await axiosClient.get(newUrl, { - headers: { - // Include other headers from the incoming request if needed: - // 'Content-Type': req.headers.get('content-type') || 'application/json', - "Content-Type": "application/json", - }, - }); - - return NextResponse.json(response.data); - } catch (error: any) { - return new NextResponse( - JSON.stringify({ error: error.message || "Request failed" }), - { status: error.response?.status || 500 } - ); - } + return NextResponse.json(response.data); + } catch (error: any) { + return new NextResponse( + JSON.stringify({ error: error.message || "Canvas get request failed" }), + { status: error.response?.status || 500 } + ); + } + }); +} + +export async function POST( + req: NextRequest, + { params }: { params: { rest: string[] } } +) { + return withErrorHandling(async () => { + try { + const url = getUrl(params); + const body = await req.json(); + const response = await axiosClient.post(url, body); + return NextResponse.json(response.data); + } catch (error: any) { + return new NextResponse( + JSON.stringify({ + error: error.message || "Canvas post request failed", + }), + { status: error.response?.status || 500 } + ); + } + }); +} + +export async function PUT( + req: NextRequest, + { params }: { params: { rest: string[] } } +) { + return withErrorHandling(async () => { + try { + const url = getUrl(params); + const body = await req.json(); + const response = await axiosClient.put(url, body); + return NextResponse.json(response.data); + } catch (error: any) { + return new NextResponse( + JSON.stringify({ error: error.message || "Canvas put request failed" }), + { status: error.response?.status || 500 } + ); + } + }); +} +export async function DELETE( + req: NextRequest, + { params }: { params: { rest: string[] } } +) { + return withErrorHandling(async () => { + try { + const url = getUrl(params); + const response = await axiosClient.delete(url); + return NextResponse.json(response.data); + } catch (error: any) { + return new NextResponse( + JSON.stringify({ + error: error.message || "Canvas delete request failed", + }), + { status: error.response?.status || 500 } + ); + } + }); } diff --git a/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx b/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx index 97e1eed..ff71114 100644 --- a/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx +++ b/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx @@ -9,7 +9,12 @@ import { localPageMarkdownUtils } from "@/models/local/page/localCoursePage"; import { useEffect, useState } from "react"; import PagePreview from "./PagePreview"; import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; -import { useCanvasPagesQuery } from "@/hooks/canvas/canvasPageHooks"; +import { + useCanvasPagesQuery, + useCreateCanvasPageMutation, +} from "@/hooks/canvas/canvasPageHooks"; +import { Spinner } from "@/components/Spinner"; +import EditPageButtons from "./EditPageButtons"; export default function EditPage({ moduleName, @@ -26,9 +31,6 @@ export default function EditPage({ const [error, setError] = useState(""); const { data: settings } = useLocalCourseSettingsQuery(); - const { data: canvasPages } = useCanvasPagesQuery(settings.canvasId ?? 0); - console.log("canvas pages", canvasPages); - const pageInCanvas = canvasPages?.find((p) => p.title === pageName); useEffect(() => { const delay = 500; @@ -70,17 +72,13 @@ export default function EditPage({ -
- {pageInCanvas && ( - - View Page In Canvas - - )} - -
+ {settings.canvasId && ( + + )} ); } diff --git a/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx b/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx new file mode 100644 index 0000000..d954532 --- /dev/null +++ b/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx @@ -0,0 +1,75 @@ +import { Spinner } from "@/components/Spinner"; +import { + useCanvasPagesQuery, + useCreateCanvasPageMutation, + useDeleteCanvasPageMutation, + useUpdateCanvasPageMutation, +} from "@/hooks/canvas/canvasPageHooks"; +import { usePageQuery } from "@/hooks/localCourse/pageHooks"; +import React from "react"; + +export default function EditPageButtons({ + moduleName, + pageName, + courseCanvasId, +}: { + pageName: string; + moduleName: string; + courseCanvasId: number; +}) { + const { data: page } = usePageQuery(moduleName, pageName); + const { data: canvasPages } = useCanvasPagesQuery(courseCanvasId); + const createPageInCanvas = useCreateCanvasPageMutation(courseCanvasId); + const updatePageInCanvas = useUpdateCanvasPageMutation(courseCanvasId); + const deletePageInCanvas = useDeleteCanvasPageMutation(courseCanvasId); + + const pageInCanvas = canvasPages?.find((p) => p.title === pageName); + + const requestIsPending = + createPageInCanvas.isPending || + updatePageInCanvas.isPending || + deletePageInCanvas.isPending; + + return ( +
+ {pageInCanvas && ( + + View Page In Canvas + + )} + {!pageInCanvas && ( + + )} + {pageInCanvas && ( + + )} + {pageInCanvas && ( + + )} + {requestIsPending && } +
+ ); +} diff --git a/nextjs/src/hooks/canvas/canvasPageHooks.ts b/nextjs/src/hooks/canvas/canvasPageHooks.ts index ecab28f..81ccc47 100644 --- a/nextjs/src/hooks/canvas/canvasPageHooks.ts +++ b/nextjs/src/hooks/canvas/canvasPageHooks.ts @@ -1,5 +1,6 @@ +import { LocalCoursePage } from "@/models/local/page/localCoursePage"; import { canvasPageService } from "@/services/canvas/canvasPageService"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; export const canvasPageKeys = { pagesInCourse: (canvasCourseId: number) => [ @@ -14,3 +15,47 @@ export const useCanvasPagesQuery = (canvasCourseId: number) => queryKey: canvasPageKeys.pagesInCourse(canvasCourseId), queryFn: async () => await canvasPageService.getAll(canvasCourseId), }); + +export const useCreateCanvasPageMutation = (canvasCourseId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (page: LocalCoursePage) => + canvasPageService.create(canvasCourseId, page), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: canvasPageKeys.pagesInCourse(canvasCourseId), + }); + }, + }); +}; + +export const useUpdateCanvasPageMutation = (canvasCourseId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + page, + canvasPageId, + }: { + page: LocalCoursePage; + canvasPageId: number; + }) => canvasPageService.update(canvasCourseId, canvasPageId, page), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: canvasPageKeys.pagesInCourse(canvasCourseId), + }); + }, + }); +}; + +export const useDeleteCanvasPageMutation = (canvasCourseId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (canvasPageId: number) => + canvasPageService.delete(canvasCourseId, canvasPageId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: canvasPageKeys.pagesInCourse(canvasCourseId), + }); + }, + }); +}; diff --git a/nextjs/src/services/canvas/canvasPageService.ts b/nextjs/src/services/canvas/canvasPageService.ts index 8dd0920..9837c28 100644 --- a/nextjs/src/services/canvas/canvasPageService.ts +++ b/nextjs/src/services/canvas/canvasPageService.ts @@ -1,6 +1,9 @@ import { CanvasPage } from "@/models/canvas/pages/canvasPageModel"; import { LocalCoursePage } from "@/models/local/page/localCoursePage"; import { canvasServiceUtils } from "./canvasServiceUtils"; +import { markdownToHTMLSafe } from "../htmlMarkdownUtils"; +import { axiosClient } from "../axiosUtils"; +import { rateLimitAwareDelete } from "./canvasWebRequestor"; const baseCanvasUrl = "https://snow.instructure.com/api/v1"; @@ -14,52 +17,45 @@ export const canvasPageService = { return pages.flatMap((pageList) => pageList); }, - // async create(canvasCourseId: number, localCourse: LocalCoursePage): Promise { - // console.log(`Creating course page: ${localCourse.name}`); - // const url = `courses/${canvasCourseId}/pages`; - // const body = { - // wiki_page: { - // title: localCourse.name, - // body: localCourse.getBodyHtml(), - // }, - // }; + async create( + canvasCourseId: number, + page: LocalCoursePage + ): Promise { + console.log(`Creating course page: ${page.name}`); + const url = `${baseCanvasUrl}/courses/${canvasCourseId}/pages`; + const body = { + wiki_page: { + title: page.name, + body: markdownToHTMLSafe(page.text), + }, + }; - // const { canvasPage, response } = await webRequestor.post({ - // url, - // body, - // }); + const { data: canvasPage } = await axiosClient.post(url, body); + if (!canvasPage) { + throw new Error("Created canvas course page was null"); + } + return canvasPage; + }, - // if (!canvasPage) { - // throw new Error("Created canvas course page was null"); - // } + async update( + courseId: number, + canvasPageId: number, + page: LocalCoursePage + ): Promise { + console.log(`Updating course page: ${page.name}`); + const url = `${baseCanvasUrl}/courses/${courseId}/pages/${canvasPageId}`; + const body = { + wiki_page: { + title: page.name, + body: markdownToHTMLSafe(page.text), + }, + }; + await axiosClient.put(url, body); + }, - // return canvasPage; - // }, - - // async update(courseId: number, canvasPageId: number, localCoursePage: LocalCoursePage): Promise { - // console.log(`Updating course page: ${localCoursePage.name}`); - // const url = `courses/${courseId}/pages/${canvasPageId}`; - // const body = { - // wiki_page: { - // title: localCoursePage.name, - // body: localCoursePage.getBodyHtml(), - // }, - // }; - - // await webRequestor.put({ - // url, - // body, - // }); - // }, - - // async delete(courseId: number, canvasPageId: number): Promise { - // console.log(`Deleting page from canvas ${canvasPageId}`); - // const url = `courses/${courseId}/pages/${canvasPageId}`; - // const response = await webRequestor.delete({ url }); - - // if (!response.isSuccessful) { - // console.error(url); - // throw new Error("Failed to delete canvas course page"); - // } - // }, + async delete(courseId: number, canvasPageId: number): Promise { + console.log(`Deleting page from canvas ${canvasPageId}`); + const url = `${baseCanvasUrl}/courses/${courseId}/pages/${canvasPageId}`; + await rateLimitAwareDelete(url); + }, }; diff --git a/nextjs/src/services/fileStorage/fileStorageService.ts b/nextjs/src/services/fileStorage/fileStorageService.ts index ae60fa1..1c4df66 100644 --- a/nextjs/src/services/fileStorage/fileStorageService.ts +++ b/nextjs/src/services/fileStorage/fileStorageService.ts @@ -9,9 +9,18 @@ import { directoryOrFileExists, hasFileSystemEntries, } from "./utils/fileSystemUtils"; -import { LocalAssignment, localAssignmentMarkdown } from "@/models/local/assignment/localAssignment"; -import { LocalQuiz, localQuizMarkdownUtils } from "@/models/local/quiz/localQuiz"; -import { LocalCoursePage, localPageMarkdownUtils } from "@/models/local/page/localCoursePage"; +import { + LocalAssignment, + localAssignmentMarkdown, +} from "@/models/local/assignment/localAssignment"; +import { + LocalQuiz, + localQuizMarkdownUtils, +} from "@/models/local/quiz/localQuiz"; +import { + LocalCoursePage, + localPageMarkdownUtils, +} from "@/models/local/page/localCoursePage"; import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils"; import { assignmentMarkdownSerializer } from "@/models/local/assignment/utils/assignmentMarkdownSerializer"; @@ -90,7 +99,7 @@ export const fileStorageService = { } const assignmentFiles = await fs.readdir(filePath); - return assignmentFiles.map(f => f.replace(/\.md$/, '')); + return assignmentFiles.map((f) => f.replace(/\.md$/, "")); }, async getQuizNames(courseName: string, moduleName: string) { @@ -103,7 +112,7 @@ export const fileStorageService = { } const files = await fs.readdir(filePath); - return files.map(f => f.replace(/\.md$/, '')); + return files.map((f) => f.replace(/\.md$/, "")); }, async getPageNames(courseName: string, moduleName: string) { @@ -116,7 +125,7 @@ export const fileStorageService = { } const files = await fs.readdir(filePath); - return files.map(f => f.replace(/\.md$/, '')); + return files.map((f) => f.replace(/\.md$/, "")); }, async getAssignment( @@ -137,16 +146,22 @@ export const fileStorageService = { ); return localAssignmentMarkdown.parseMarkdown(rawFile); }, - async updateAssignment(courseName: string, moduleName: string, assignmentName: string, assignment: LocalAssignment) { + async updateAssignment( + courseName: string, + moduleName: string, + assignmentName: string, + assignment: LocalAssignment + ) { const filePath = path.join( basePath, courseName, moduleName, "assignments", - assignmentName+".md" + assignmentName + ".md" ); - const assignmentMarkdown = assignmentMarkdownSerializer.toMarkdown(assignment); + const assignmentMarkdown = + assignmentMarkdownSerializer.toMarkdown(assignment); console.log(`Saving assignment ${filePath}`); await fs.writeFile(filePath, assignmentMarkdown); }, @@ -166,13 +181,18 @@ export const fileStorageService = { return localQuizMarkdownUtils.parseMarkdown(rawFile); }, - async updateQuiz(courseName: string, moduleName: string, quizName: string, quiz: LocalQuiz) { + async updateQuiz( + courseName: string, + moduleName: string, + quizName: string, + quiz: LocalQuiz + ) { const filePath = path.join( basePath, courseName, moduleName, "quizzes", - quizName+".md" + quizName + ".md" ); const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz); @@ -194,18 +214,36 @@ export const fileStorageService = { ); return localPageMarkdownUtils.parseMarkdown(rawFile); }, - async updatePage(courseName: string, moduleName: string, pageName: string, page: LocalCoursePage) { + async updatePage( + courseName: string, + moduleName: string, + pageName: string, + page: LocalCoursePage + ) { const filePath = path.join( basePath, courseName, moduleName, "pages", - pageName+".md" + page.name + ".md" ); const pageMarkdown = localPageMarkdownUtils.toMarkdown(page); console.log(`Saving page ${filePath}`); await fs.writeFile(filePath, pageMarkdown); + + const pageNameIsChanged = pageName !== page.name; + if (pageNameIsChanged) { + console.log("removing old page after name change " + pageName); + const oldFilePath = path.join( + basePath, + courseName, + moduleName, + "pages", + pageName + ".md" + ); + await fs.unlink(oldFilePath); + } }, async getEmptyDirectories(): Promise {