diff --git a/nextjs/src/app/course/[courseName]/settings/AssignmentGroupManagement.tsx b/nextjs/src/app/course/[courseName]/settings/AssignmentGroupManagement.tsx new file mode 100644 index 0000000..2bad45d --- /dev/null +++ b/nextjs/src/app/course/[courseName]/settings/AssignmentGroupManagement.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { + useLocalCourseSettingsQuery, + useUpdateLocalCourseSettingsMutation, +} from "@/hooks/localCourse/localCoursesHooks"; +import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup"; +import { useEffect, useState } from "react"; +import TextInput from "./TextInput"; +import { useSetAssignmentGroupsMutation } from "@/hooks/canvas/canvasCourseHooks"; + +export default function AssignmentGroupManagement() { + const { data: settings } = useLocalCourseSettingsQuery(); + const updateSettings = useUpdateLocalCourseSettingsMutation(); + const applyInCanvas = useSetAssignmentGroupsMutation(settings.canvasId); // untested + + const [assignmentGroups, setAssignmentGroups] = useState< + LocalAssignmentGroup[] + >(settings.assignmentGroups); + + useEffect(() => { + const delay = 1000; + const handler = setTimeout(() => { + if ( + !areAssignmentGroupsEqual(assignmentGroups, settings.assignmentGroups) + ) { + updateSettings.mutate({ + ...settings, + assignmentGroups, + }); + } + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [assignmentGroups, settings, updateSettings]); + + return ( +
+ {assignmentGroups.map((group) => ( +
+ + setAssignmentGroups((oldGroups) => + oldGroups.map((g) => + g.id === group.id ? { ...g, name: newValue } : g + ) + ) + } + label={"Group Name"} + /> + + setAssignmentGroups((oldGroups) => + oldGroups.map((g) => + g.id === group.id + ? { ...g, weight: parseInt(newValue || "0") } + : g + ) + ) + } + label={"Weight"} + /> +
+ ))} + +
+ ); +} + +function areAssignmentGroupsEqual( + list1: LocalAssignmentGroup[], + list2: LocalAssignmentGroup[] +): boolean { + // Check if lists have the same length + if (list1.length !== list2.length) return false; + + // Sort both lists by the unique 'id' or 'canvasId' as a fallback + const sortedList1 = [...list1].sort((a, b) => { + if (a.id !== b.id) return a.id > b.id ? 1 : -1; + if (a.canvasId !== b.canvasId) return (a.canvasId || 0) - (b.canvasId || 0); + return 0; + }); + + const sortedList2 = [...list2].sort((a, b) => { + if (a.id !== b.id) return a.id > b.id ? 1 : -1; + if (a.canvasId !== b.canvasId) return (a.canvasId || 0) - (b.canvasId || 0); + return 0; + }); + + // Deep compare each object in the sorted lists + for (let i = 0; i < sortedList1.length; i++) { + const group1 = sortedList1[i]; + const group2 = sortedList2[i]; + + if ( + group1.id !== group2.id || + group1.name !== group2.name || + group1.weight !== group2.weight || + group1.canvasId !== group2.canvasId + ) { + return false; + } + } + + return true; +} diff --git a/nextjs/src/app/course/[courseName]/settings/TextInput.tsx b/nextjs/src/app/course/[courseName]/settings/TextInput.tsx new file mode 100644 index 0000000..465a543 --- /dev/null +++ b/nextjs/src/app/course/[courseName]/settings/TextInput.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +export default function TextInput({ + value, + setValue, + label, +}: { + value: string; + setValue: (newValue: string) => void; + label: string; +}) { + return ( + + ); +} diff --git a/nextjs/src/app/course/[courseName]/settings/page.tsx b/nextjs/src/app/course/[courseName]/settings/page.tsx index 3ca0371..73488e8 100644 --- a/nextjs/src/app/course/[courseName]/settings/page.tsx +++ b/nextjs/src/app/course/[courseName]/settings/page.tsx @@ -4,6 +4,7 @@ import StartAndEndDate from "./StartAndEndDate"; import SettingsHeader from "./SettingsHeader"; import DefaultDueTime from "./DefaultDueTime"; import DaysOfWeekSelector from "./DaysOfWeekSelector"; +import AssignmentGroupManagement from "./AssignmentGroupManagement"; export default function page() { return ( @@ -12,6 +13,7 @@ export default function page() { + ); } diff --git a/nextjs/src/hooks/canvas/canvasCourseHooks.ts b/nextjs/src/hooks/canvas/canvasCourseHooks.ts index bc2e781..6620067 100644 --- a/nextjs/src/hooks/canvas/canvasCourseHooks.ts +++ b/nextjs/src/hooks/canvas/canvasCourseHooks.ts @@ -1,9 +1,13 @@ +import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup"; +import { canvasAssignmentGroupService } from "@/services/canvas/canvasAssignmentGroupService"; import { canvasService } from "@/services/canvas/canvasService"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; export const canvasCourseKeys = { courseDetails: (canavasId: number) => ["canvas", canavasId, "course details"] as const, + assignmentGroups: (canavasId: number) => + ["canvas", canavasId, "assignment groups"] as const, }; export const useCanvasCourseQuery = (canvasId: number) => @@ -11,3 +15,39 @@ export const useCanvasCourseQuery = (canvasId: number) => queryKey: canvasCourseKeys.courseDetails(canvasId), queryFn: async () => await canvasService.getCourse(canvasId), }); + +export const useSetAssignmentGroupsMutation = (canvasId: number) => { + const { data: canvasAssignmentGroups } = useAssignmentGroupsQuery(canvasId); + return useMutation({ + mutationFn: async (localAssignmentGroups: LocalAssignmentGroup[]) => { + const localNames = localAssignmentGroups.map((g) => g.name); + const groupsToDelete = canvasAssignmentGroups.filter( + (c) => !localNames.includes(c.name) + ); + await Promise.all([ + ...groupsToDelete.map( + async (g) => + await canvasAssignmentGroupService.delete(canvasId, g.id, g.name) + ), + ...localAssignmentGroups.map(async (group) => { + const canvasGroup = canvasAssignmentGroups.find( + (c) => c.name === group.name + ); + if (!canvasGroup) { + await canvasAssignmentGroupService.create(canvasId, group); + } else { + if (canvasGroup.group_weight !== group.weight) + await canvasAssignmentGroupService.update(canvasId, group); + } + }), + ]); + }, + }); +}; + +export const useAssignmentGroupsQuery = (canvasId: number) => { + return useSuspenseQuery({ + queryKey: canvasCourseKeys.assignmentGroups(canvasId), + queryFn: async () => await canvasAssignmentGroupService.getAll(canvasId), + }); +}; diff --git a/nextjs/src/models/canvas/assignments/canvasAssignmentGroup.ts b/nextjs/src/models/canvas/assignments/canvasAssignmentGroup.ts new file mode 100644 index 0000000..d30ab7e --- /dev/null +++ b/nextjs/src/models/canvas/assignments/canvasAssignmentGroup.ts @@ -0,0 +1,10 @@ +export interface CanvasAssignmentGroup { + id: number; // TypeScript doesn't have `ulong`, so using `number` for large integers. + name: string; + position: number; + group_weight: number; + // sis_source_id?: string; // Uncomment if needed. + // integration_data?: Record; // Uncomment if needed. + // assignments?: CanvasAssignment[]; // Uncomment if needed, assuming CanvasAssignment is defined. + // rules?: any; // Assuming 'rules' is of unknown type, so using 'any' here. +} \ No newline at end of file diff --git a/nextjs/src/models/local/localCourse.ts b/nextjs/src/models/local/localCourse.ts index 6e47b71..3a29fe0 100644 --- a/nextjs/src/models/local/localCourse.ts +++ b/nextjs/src/models/local/localCourse.ts @@ -16,7 +16,7 @@ export interface LocalCourseSettings { name: string; assignmentGroups: LocalAssignmentGroup[]; daysOfWeek: DayOfWeek[]; - canvasId?: number; + canvasId: number; startDate: string; endDate: string; defaultDueTime: SimpleTimeOnly; diff --git a/nextjs/src/services/canvas/canvasAssignmentGroupService.ts b/nextjs/src/services/canvas/canvasAssignmentGroupService.ts new file mode 100644 index 0000000..3ceb243 --- /dev/null +++ b/nextjs/src/services/canvas/canvasAssignmentGroupService.ts @@ -0,0 +1,67 @@ +import { canvasServiceUtils } from "./canvasServiceUtils"; +import { axiosClient } from "../axiosUtils"; +import { CanvasAssignmentGroup } from "@/models/canvas/assignments/canvasAssignmentGroup"; +import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup"; +import { rateLimitAwareDelete } from "./canvasWebRequestor"; + +const baseCanvasUrl = "https://snow.instructure.com/api/v1"; + +export const canvasAssignmentGroupService = { + async getAll(courseId: number): Promise { + console.log("Requesting assignment groups"); + const url = `${baseCanvasUrl}/courses/${courseId}/assignment_groups`; + const assignmentGroups = await canvasServiceUtils.paginatedRequest< + CanvasAssignmentGroup[] + >({ + url, + }); + return assignmentGroups.flatMap((groupList) => groupList); + }, + + async create( + canvasCourseId: number, + localAssignmentGroup: LocalAssignmentGroup + ): Promise { + console.log(`Creating assignment group: ${localAssignmentGroup.name}`); + const url = `${baseCanvasUrl}/courses/${canvasCourseId}/assignment_groups`; + const body = { + name: localAssignmentGroup.name, + group_weight: localAssignmentGroup.weight, + }; + + const { data: canvasAssignmentGroup } = + await axiosClient.post(url, body); + + return { + ...localAssignmentGroup, + canvasId: canvasAssignmentGroup.id, + }; + }, + + async update( + canvasCourseId: number, + localAssignmentGroup: LocalAssignmentGroup + ): Promise { + console.log(`Updating assignment group: ${localAssignmentGroup.name}`); + if (!localAssignmentGroup.canvasId) { + throw new Error("Cannot update assignment group if canvas ID is null"); + } + const url = `${baseCanvasUrl}/courses/${canvasCourseId}/assignment_groups/${localAssignmentGroup.canvasId}`; + const body = { + name: localAssignmentGroup.name, + group_weight: localAssignmentGroup.weight, + }; + + await axiosClient.put(url, body); + }, + + async delete( + canvasCourseId: number, + canvasAssignmentGroupId: number + , assignmentGroupName: string + ): Promise { + console.log(`Deleting assignment group: ${assignmentGroupName}`); + const url = `${baseCanvasUrl}/courses/${canvasCourseId}/assignment_groups/${canvasAssignmentGroupId}`; + await rateLimitAwareDelete(url); + }, +};