diff --git a/docs/getting-started.html b/docs/getting-started.html
index 7eb9a05a..d791310e 100644
--- a/docs/getting-started.html
+++ b/docs/getting-started.html
@@ -291,7 +291,7 @@
Publishing to Canvas
Each editor’s footer says where the item stands and shows the one button you need next:
- Not in Canvas yet: Add to Canvas creates the item in Canvas, unpublished, with its rendered description, dates, rubric and module entry. A module that does not exist yet is created.
-- 3 differences from Canvas (in amber): Update in Canvas pushes the current file over the existing Canvas item. Submissions and grades are untouched. Click the amber text to open Compare with Canvas.
+- 3 differences from Canvas (in amber): Update in Canvas pushes the current file over the existing Canvas item, and moves it into the module its file is in if you moved the file, taking it out of any other module. Submissions and grades are untouched. Click the amber text to open Compare with Canvas.
- In sync · students can’t see it yet: Publish in Canvas makes it visible to students. If the item’s module is still unpublished in Canvas, the app says so and links to the modules page; it does not publish the module for you.
- In sync · Published: nothing to do; View in Canvas opens it.
- For a quiz, Update in Canvas also replaces the questions with the file’s when any of them differ. If students have already started, it asks first: their attempts keep the questions they got and are not regraded. It refuses a change to the quiz’s point total, since their scores are out of the total they were given.
@@ -308,8 +308,8 @@ Publishing to Canvas
Compare with Canvas
When an item’s border is red, Compare with Canvas (the amber differences from Canvas text in the editor footer, the menu on the footer’s main button, the right-click menu, and the red pill’s hover card) opens a page that lists every difference between your file and Canvas:
-- A settings table with the file’s value and Canvas’s side by side: dates, assignment group, group set, points and each rubric row, and for a quiz its password, attempts and other options.
-- For a quiz, a row for every question, compared on type, points, text, answers (correct ones marked ✓) and feedback. Questions are lined up by what they say, not by number, so deleting or adding one doesn’t make every later question look changed: a question you deleted from the file shows as one Canvas question N row, one you added as a row with nothing on the Canvas side, and one you moved as a position difference. Every question shows in full on both sides, answers included, and ≠ marks the lines of each part that differs. The quiz editor’s footer counts these differences too. The calendar checks questions for this week’s quizzes and the next three weeks’, fetching them in the background one quiz at a time (this week first) so Canvas doesn’t throttle you; a quiz outside those four weeks gets its red border from settings and description only.
+- A settings table with the file’s value and Canvas’s side by side: the modules (Canvas should have the item only in the module its file is in), dates, assignment group, group set, points and each rubric row, and for a quiz its password, attempts and other options.
+- For a quiz, a row for every question, compared on type, points, text, answers (correct ones marked ✓) and feedback. Questions are lined up by what they say, not by number, so deleting or adding one doesn’t make every later question look changed: a question you deleted from the file shows as one Canvas question N row, one you added as a row with nothing on the Canvas side, and one you moved as a position difference. Every question shows in full on both sides, answers included, and ≠ marks the lines of each part that differs. The quiz editor’s footer counts these differences too. The calendar checks questions for this week’s quizzes and the next three weeks’, fetching them in the background one quiz at a time (this week first) so Canvas doesn’t throttle you; a quiz outside those four weeks gets its red border from its module, settings and description only.
- For a quiz students have started, a What students got section for each quiz version they took, comparing the file with the questions from one of its attempts (ignoring each attempt’s own answer shuffle). Canvas’s current questions can differ from these until Save it now, and this is the only way to see what students actually got.
- The description (a page’s content) side by side, as changes only (a word-level diff), or as the normalized HTML when you need to see why two descriptions that look the same count as different.
- An Update Canvas button at the bottom that pushes the file, with a note saying exactly what it will change. The top of the page says when it last read Canvas, and refresh reads it again.
@@ -364,6 +364,7 @@ Tips
Recent changes
| When | What changed |
+| Oct 2026 | Update in Canvas moves an item to the module its file is now in, or adds it to that module if it was in none, and takes it out of any other module. Compare with Canvas, the editor footer and the calendar count a moved file, or an item in more than one Canvas module, as a difference. |
| Oct 2026 | Opening a course whose term has ended no longer shows a roster.groupSets … 403 error: Canvas won’t list group sets for a finished course, so it shows none. |
| Oct 2026 | Compare with Canvas checks a quiz’s password, allowed attempts, shuffle answers, show correct answers and one question at a time, so changing only one of them no longer shows the quiz as in sync. |
| Oct 2026 | Compare with Canvas compares short-answer answers as plain text, so an answer like + or a*b*c no longer shows as different when it matches. |
diff --git a/src/app/course/[courseName]/calendar/day/getAssignmentSyncStatus.ts b/src/app/course/[courseName]/calendar/day/getAssignmentSyncStatus.ts
index a0c06a87..41a3fc87 100644
--- a/src/app/course/[courseName]/calendar/day/getAssignmentSyncStatus.ts
+++ b/src/app/course/[courseName]/calendar/day/getAssignmentSyncStatus.ts
@@ -35,6 +35,7 @@ export interface RosterForStatus {
}
export type SyncSection =
+ | "modules"
| "dates"
| "grading"
| "groups"
@@ -677,6 +678,26 @@ function buildReport(
};
}
+/**
+ * The file's module folder against the Canvas modules holding the item: a
+ * file is in one folder, so Canvas should have the item in that module only.
+ */
+function checkModules(file: string, canvasModules: string[]): SyncField {
+ const canvas = [...new Set(canvasModules)].sort();
+ return field({
+ key: "modules",
+ label: "Modules",
+ section: "modules",
+ local: file,
+ canvas: canvas.join(", ") || UNSET,
+ same: canvas.length === 1 && canvas[0] === file,
+ message:
+ canvas.length > 0
+ ? `modules different: ${file} vs ${canvas.join(", ")}`
+ : "not in a module in canvas",
+ });
+}
+
/**
* Compares a local item with its Canvas copy field by field. `status` and
* `message` drive the calendar border and tooltip; `fields` and `description`
@@ -690,6 +711,7 @@ export function getSyncReport({
canvasLinkTargets,
roster,
canvasQuestions,
+ module,
}: {
item: LocalQuiz | LocalAssignment | LocalCoursePage;
canvasItem: CanvasQuiz | CanvasAssignment | CanvasPage | undefined;
@@ -699,6 +721,8 @@ export function getSyncReport({
roster?: RosterForStatus;
/** A quiz's questions in Canvas; without them only its settings are compared. */
canvasQuestions?: CanvasQuizQuestion[];
+ /** The file's module and the Canvas modules holding the item; without them the module is not compared. */
+ module?: { file: string; canvas: string[] };
}): SyncReport {
if (!canvasItem)
return {
@@ -708,7 +732,9 @@ export function getSyncReport({
fields: [],
};
- const fields: SyncField[] = [];
+ const fields: SyncField[] = module
+ ? [checkModules(module.file, module.canvas)]
+ : [];
if (type === "page") {
const page = item as LocalCoursePage;
diff --git a/src/app/course/[courseName]/calendar/day/getStatus.tsx b/src/app/course/[courseName]/calendar/day/getStatus.tsx
deleted file mode 100644
index 50abe5df..00000000
--- a/src/app/course/[courseName]/calendar/day/getStatus.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client";
-import { CanvasAssignment } from "@/features/canvas/models/assignments/canvasAssignment";
-import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
-import { CanvasQuiz } from "@/features/canvas/models/quizzes/canvasQuizModel";
-import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
-import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
-import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
-import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
-import {
- getSyncStatus,
- ItemSyncStatus,
- RosterForStatus,
-} from "./getAssignmentSyncStatus";
-import { CanvasLinkTargets } from "@/services/urlUtils";
-import { CanvasQuizQuestion } from "@/features/canvas/models/quizzes/canvasQuizQuestionModel";
-
-export const getStatus = ({
- item,
- canvasItem,
- type,
- settings,
- canvasLinkTargets,
- roster,
- canvasQuestions,
-}: {
- item: LocalQuiz | LocalAssignment | LocalCoursePage;
- canvasItem?: CanvasQuiz | CanvasAssignment | CanvasPage;
- type: "assignment" | "page" | "quiz";
- settings: LocalCourseSettings;
- canvasLinkTargets?: CanvasLinkTargets;
- roster?: RosterForStatus;
- canvasQuestions?: CanvasQuizQuestion[];
-}): ItemSyncStatus => {
- return getSyncStatus({
- item,
- canvasItem,
- type,
- settings,
- canvasLinkTargets,
- roster,
- canvasQuestions,
- });
-};
diff --git a/src/app/course/[courseName]/calendar/day/getSyncStatus.test.ts b/src/app/course/[courseName]/calendar/day/getSyncStatus.test.ts
index 51cc69fa..4f637c7e 100644
--- a/src/app/course/[courseName]/calendar/day/getSyncStatus.test.ts
+++ b/src/app/course/[courseName]/calendar/day/getSyncStatus.test.ts
@@ -534,3 +534,47 @@ describe("getSyncReport - quiz options", () => {
).toEqual([]);
});
});
+
+describe("getSyncReport - module", () => {
+ const report = (canvasModules?: string[]) =>
+ getSyncReport({
+ item: baseLocalAssignment,
+ canvasItem: baseCanvasAssignment,
+ type: "assignment",
+ settings: baseSettings,
+ module: canvasModules && { file: "Week 2", canvas: canvasModules },
+ });
+
+ it("matches when the Canvas item is only in the file's module", () => {
+ expect(report(["Week 2"]).differences).toEqual([]);
+ });
+
+ it("differs when the Canvas item is also in another module", () => {
+ expect(report(["Week 2", "Week 1"]).differences[0]).toMatchObject({
+ label: "Modules",
+ canvas: "Week 1, Week 2",
+ message: "modules different: Week 2 vs Week 1, Week 2",
+ });
+ });
+
+ it("differs when the file moved to another module", () => {
+ const [difference] = report(["Week 1"]).differences;
+ expect(difference).toMatchObject({
+ key: "modules",
+ local: "Week 2",
+ canvas: "Week 1",
+ message: "modules different: Week 2 vs Week 1",
+ });
+ });
+
+ it("differs when the Canvas item is in no module", () => {
+ expect(report([]).differences[0]).toMatchObject({
+ canvas: "—",
+ message: "not in a module in canvas",
+ });
+ });
+
+ it("is not compared until the Canvas modules load", () => {
+ expect(report(undefined).fields.map((f) => f.key)).not.toContain("modules");
+ });
+});
diff --git a/src/app/course/[courseName]/calendar/day/itemInDay/AssignmentDayItemContextMenu.tsx b/src/app/course/[courseName]/calendar/day/itemInDay/AssignmentDayItemContextMenu.tsx
index 65af31b7..59e2ff76 100644
--- a/src/app/course/[courseName]/calendar/day/itemInDay/AssignmentDayItemContextMenu.tsx
+++ b/src/app/course/[courseName]/calendar/day/itemInDay/AssignmentDayItemContextMenu.tsx
@@ -140,6 +140,7 @@ export const AssignmentDayItemContextMenu: FC<{
updateInCanvasMutation.mutate({
canvasAssignmentId: assignmentInCanvas.id,
assignment: item as LocalAssignment,
+ moduleName,
});
handleClose();
}
diff --git a/src/app/course/[courseName]/calendar/day/itemInDay/QuizDayItemContextMenu.tsx b/src/app/course/[courseName]/calendar/day/itemInDay/QuizDayItemContextMenu.tsx
index 23784ca9..8f81de95 100644
--- a/src/app/course/[courseName]/calendar/day/itemInDay/QuizDayItemContextMenu.tsx
+++ b/src/app/course/[courseName]/calendar/day/itemInDay/QuizDayItemContextMenu.tsx
@@ -107,6 +107,7 @@ export const QuizDayItemContextMenu: FC<{
updateInCanvasMutation.mutate({
quiz: item as LocalQuiz,
canvasQuizId: quizInCanvas.id,
+ moduleName,
});
handleClose();
};
diff --git a/src/app/course/[courseName]/calendar/day/useTodaysItems.tsx b/src/app/course/[courseName]/calendar/day/useTodaysItems.tsx
index 66726a03..46bbea15 100644
--- a/src/app/course/[courseName]/calendar/day/useTodaysItems.tsx
+++ b/src/app/course/[courseName]/calendar/day/useTodaysItems.tsx
@@ -6,7 +6,6 @@ import {
} from "@/features/local/utils/timeUtils";
import { ReactNode } from "react";
import { useCalendarItemsContext } from "../../context/calendarItemsContext";
-import { getStatus } from "./getStatus";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useCanvasAssignmentsQuery } from "@/features/canvas/hooks/canvasAssignmentHooks";
import { useCanvasPagesQuery } from "@/features/canvas/hooks/canvasPageHooks";
@@ -16,7 +15,13 @@ import {
useRosterGroupSetsQuery,
useRosterStudentsQuery,
} from "@/features/canvas/roster/rosterHooks";
-import { getScheduleEntryStatus, SyncField } from "./getAssignmentSyncStatus";
+import {
+ getScheduleEntryStatus,
+ getSyncStatus,
+ SyncField,
+} from "./getAssignmentSyncStatus";
+import { useCanvasModulesWithItemsQuery } from "@/features/canvas/hooks/canvasModuleHooks";
+import { canvasModuleNames } from "@/features/canvas/services/canvasModuleService";
import { AssignmentScheduleEntry } from "@/features/local/assignments/models/localAssignment";
import { useCalendarQuizQuestions } from "../../context/CalendarQuizQuestionsProvider";
@@ -52,6 +57,15 @@ export function useTodaysItems(day: string) {
const roster = { students: rosterStudents, groupSets: rosterGroupSets };
// filled in the background for this week's and the next three weeks' quizzes
const quizQuestions = useCalendarQuizQuestions();
+ const { data: canvasModules } = useCanvasModulesWithItemsQuery();
+ const module = (
+ file: string,
+ type: TodayItem["type"],
+ canvasItem: { id?: number; url?: string } | undefined,
+ ) =>
+ canvasModules && canvasItem
+ ? { file, canvas: canvasModuleNames(canvasModules, type, canvasItem) }
+ : undefined;
const assignments: TodayItem[] = todaysModules
? Object.keys(todaysModules).flatMap((moduleName) =>
@@ -63,7 +77,7 @@ export function useTodaysItems(day: string) {
type: "assignment" as const,
item: assignment,
moduleName,
- ...getStatus({
+ ...getSyncStatus({
item: assignment,
canvasItem: canvasAssignment,
type: "assignment",
@@ -73,6 +87,7 @@ export function useTodaysItems(day: string) {
quizzes: canvasQuizzes,
},
roster,
+ module: module(moduleName, "assignment", canvasAssignment),
}),
};
}),
@@ -111,12 +126,13 @@ export function useTodaysItems(day: string) {
type: "quiz" as const,
item: quiz,
moduleName,
- ...getStatus({
+ ...getSyncStatus({
item: quiz,
canvasItem: canvasQuiz,
type: "quiz",
settings,
canvasQuestions: quizQuestions[quiz.name],
+ module: module(moduleName, "quiz", canvasQuiz),
}),
};
}),
@@ -131,11 +147,12 @@ export function useTodaysItems(day: string) {
type: "page" as const,
item: page,
moduleName,
- ...getStatus({
+ ...getSyncStatus({
item: page,
canvasItem: canvasPage,
type: "page",
settings,
+ module: module(moduleName, "page", canvasPage),
}),
};
}),
diff --git a/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/AssignmentFooterButtons.tsx b/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/AssignmentFooterButtons.tsx
index 633559fb..7d9aa18e 100644
--- a/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/AssignmentFooterButtons.tsx
+++ b/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/AssignmentFooterButtons.tsx
@@ -2,6 +2,7 @@ import {
EditorFooter,
FooterAction,
} from "@/app/course/[courseName]/EditorFooter";
+import { useCanvasModuleNames } from "@/features/canvas/hooks/canvasModuleHooks";
import { getSyncReport } from "@/app/course/[courseName]/calendar/day/getAssignmentSyncStatus";
import { ClassroomIcon, TrashIcon } from "@/components/icons/ActionIcons";
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
@@ -71,6 +72,10 @@ export function AssignmentFooterButtons({
const assignmentInCanvas = canvasAssignments?.find(
(a) => a.name === assignmentName,
);
+ const { names: canvasModules } = useCanvasModuleNames(
+ "assignment",
+ assignmentInCanvas,
+ );
const anythingIsLoading =
addToCanvas.isPending ||
@@ -92,13 +97,16 @@ export function AssignmentFooterButtons({
quizzes: canvasQuizzes,
},
roster: { students: rosterStudents, groupSets: rosterGroupSets },
+ module: canvasModules && { file: moduleName, canvas: canvasModules },
}).differences
: [],
[
assignment,
assignmentInCanvas,
canvasAssignments,
+ canvasModules,
canvasQuizzes,
+ moduleName,
rosterGroupSets,
rosterStudents,
settings,
@@ -159,6 +167,7 @@ export function AssignmentFooterButtons({
updateAssignment.mutate({
canvasAssignmentId: assignmentInCanvas.id,
assignment,
+ moduleName,
})
}
onViewInCanvas={() => {
diff --git a/src/app/course/[courseName]/modules/[moduleName]/compare/CompareWithCanvas.tsx b/src/app/course/[courseName]/modules/[moduleName]/compare/CompareWithCanvas.tsx
index 9eb60a6d..8dfaff7d 100644
--- a/src/app/course/[courseName]/modules/[moduleName]/compare/CompareWithCanvas.tsx
+++ b/src/app/course/[courseName]/modules/[moduleName]/compare/CompareWithCanvas.tsx
@@ -25,6 +25,7 @@ import {
useCanvasQuizzesQuery,
useUpdateQuizInCanvasMutation,
} from "@/features/canvas/hooks/canvasQuizHooks";
+import { useCanvasModuleNames } from "@/features/canvas/hooks/canvasModuleHooks";
import { checkQuizQuestions } from "@/app/course/[courseName]/calendar/day/quizQuestionSync";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
@@ -47,6 +48,7 @@ import {
} from "@/services/utils/htmlTextDiff";
const sectionLabels: Record = {
+ modules: "Modules",
dates: "Dates",
grading: "Grading",
groups: "Groups",
@@ -81,14 +83,18 @@ export function CompareAssignmentWithCanvas({
const { data: rosterGroupSets } = useRosterGroupSetsQuery();
const update = useUpdateAssignmentInCanvasMutation();
+ const canvasAssignment = canvasAssignments?.find(
+ (a) => a.name === assignmentName,
+ );
+ const { names: canvasModules, refetch: refetchModules } =
+ useCanvasModuleNames("assignment", canvasAssignment);
+
// never compare against a stale copy
useEffect(() => {
refetch();
- }, [refetch]);
+ refetchModules();
+ }, [refetch, refetchModules]);
- const canvasAssignment = canvasAssignments?.find(
- (a) => a.name === assignmentName,
- );
const report = useMemo(
() =>
getSyncReport({
@@ -101,12 +107,15 @@ export function CompareAssignmentWithCanvas({
quizzes: canvasQuizzes,
},
roster: { students: rosterStudents, groupSets: rosterGroupSets },
+ module: canvasModules && { file: moduleName, canvas: canvasModules },
}),
[
assignment,
canvasAssignment,
canvasAssignments,
+ canvasModules,
canvasQuizzes,
+ moduleName,
rosterGroupSets,
rosterStudents,
settings,
@@ -128,13 +137,20 @@ export function CompareAssignmentWithCanvas({
canvasAssignment &&
`${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${canvasAssignment.id}`
}
- onRefresh={() => refetch()}
+ onRefresh={() => {
+ refetch();
+ refetchModules();
+ }}
onUpdate={() =>
canvasAssignment &&
- update.mutate({ assignment, canvasAssignmentId: canvasAssignment.id })
+ update.mutate({
+ assignment,
+ canvasAssignmentId: canvasAssignment.id,
+ moduleName,
+ })
}
updating={update.isPending}
- updateNote="Update Canvas pushes the file's dates, group settings, rubric and description. Submissions and grades are untouched."
+ updateNote="Update Canvas pushes the file's dates, group settings, rubric and description, and moves the assignment to the file's module. Submissions and grades are untouched."
/>
);
}
@@ -157,11 +173,15 @@ export function CompareQuizWithCanvas({
const { data: canvasAssignments } = useCanvasAssignmentsQuery();
const update = useUpdateQuizInCanvasMutation();
+ const canvasQuiz = canvasQuizzes?.find((q) => q.title === quizName);
+ const { names: canvasModules, refetch: refetchModules } =
+ useCanvasModuleNames("quiz", canvasQuiz);
+
useEffect(() => {
refetch();
- }, [refetch]);
+ refetchModules();
+ }, [refetch, refetchModules]);
- const canvasQuiz = canvasQuizzes?.find((q) => q.title === quizName);
const {
data: canvasQuestions,
isFetching: questionsFetching,
@@ -182,12 +202,15 @@ export function CompareQuizWithCanvas({
quizzes: canvasQuizzes,
},
canvasQuestions,
+ module: canvasModules && { file: moduleName, canvas: canvasModules },
}),
[
canvasAssignments,
+ canvasModules,
canvasQuestions,
canvasQuiz,
canvasQuizzes,
+ moduleName,
quiz,
settings,
],
@@ -213,16 +236,18 @@ export function CompareQuizWithCanvas({
}
onRefresh={() => {
refetch();
+ refetchModules();
if (canvasQuiz) {
refetchQuestions();
refetchVersions();
}
}}
onUpdate={() =>
- canvasQuiz && update.mutate({ quiz, canvasQuizId: canvasQuiz.id })
+ canvasQuiz &&
+ update.mutate({ quiz, canvasQuizId: canvasQuiz.id, moduleName })
}
updating={update.isPending}
- updateNote="Update Canvas pushes the file's settings and, if any question differs, replaces the questions in Canvas with the file's. When it replaces them on a published quiz, students keep getting the old questions until you click Save it now on the quiz page in Canvas (the API can't do that). If students have started, it warns first: they keep the questions they got and are not regraded. It refuses to change the point total they were scored out of."
+ updateNote="Update Canvas pushes the file's settings, moves the quiz to the file's module and, if any question differs, replaces the questions in Canvas with the file's. When it replaces them on a published quiz, students keep getting the old questions until you click Save it now on the quiz page in Canvas (the API can't do that). If students have started, it warns first: they keep the questions they got and are not regraded. It refuses to change the point total they were scored out of."
>
{versions?.map((v, i) => (
p.title === pageName);
+ const { names: canvasModules, refetch: refetchModules } =
+ useCanvasModuleNames("page", canvasPage);
+
useEffect(() => {
refetch();
- }, [refetch]);
+ refetchModules();
+ }, [refetch, refetchModules]);
- const canvasPage = canvasPages?.find((p) => p.title === pageName);
const report = useMemo(
() =>
getSyncReport({
@@ -272,8 +301,17 @@ export function ComparePageWithCanvas({
assignments: canvasAssignments,
quizzes: canvasQuizzes,
},
+ module: canvasModules && { file: moduleName, canvas: canvasModules },
}),
- [canvasAssignments, canvasPage, canvasQuizzes, page, settings],
+ [
+ canvasAssignments,
+ canvasModules,
+ canvasPage,
+ canvasQuizzes,
+ moduleName,
+ page,
+ settings,
+ ],
);
return (
@@ -290,13 +328,16 @@ export function ComparePageWithCanvas({
canvasPage &&
`${baseCanvasUrl}/courses/${settings.canvasId}/pages/${canvasPage.url}`
}
- onRefresh={() => refetch()}
+ onRefresh={() => {
+ refetch();
+ refetchModules();
+ }}
onUpdate={() =>
canvasPage &&
- update.mutate({ page, canvasPageId: canvasPage.page_id })
+ update.mutate({ page, canvasPage, moduleName })
}
updating={update.isPending}
- updateNote="Update in Canvas replaces the page's content with the file's."
+ updateNote="Update in Canvas replaces the page's content with the file's and moves the page to the file's module."
/>
);
}
diff --git a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx
index af0f994b..3d1b9c1b 100644
--- a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx
+++ b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx
@@ -20,6 +20,7 @@ import {
} from "@/features/local/pages/pageHooks";
import { useCanvasAssignmentsQuery } from "@/features/canvas/hooks/canvasAssignmentHooks";
import { useCanvasQuizzesQuery } from "@/features/canvas/hooks/canvasQuizHooks";
+import { useCanvasModuleNames } from "@/features/canvas/hooks/canvasModuleHooks";
import { getSyncReport } from "@/app/course/[courseName]/calendar/day/getAssignmentSyncStatus";
import { getCompareUrl, getCourseUrl } from "@/services/urlUtils";
import { useNavigate } from "@tanstack/react-router";
@@ -51,6 +52,7 @@ export default function EditPageButtons({
);
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
+ const { names: canvasModules } = useCanvasModuleNames("page", pageInCanvas);
const { data: canvasAssignments } = useCanvasAssignmentsQuery();
const { data: canvasQuizzes } = useCanvasQuizzesQuery();
const differences = useMemo(
@@ -65,9 +67,18 @@ export default function EditPageButtons({
assignments: canvasAssignments,
quizzes: canvasQuizzes,
},
+ module: canvasModules && { file: moduleName, canvas: canvasModules },
}).differences.filter((d) => d.key !== "published")
: [],
- [canvasAssignments, canvasQuizzes, page, pageInCanvas, settings],
+ [
+ canvasAssignments,
+ canvasModules,
+ canvasQuizzes,
+ moduleName,
+ page,
+ pageInCanvas,
+ settings,
+ ],
);
const requestIsPending =
@@ -121,7 +132,8 @@ export default function EditPageButtons({
pageInCanvas &&
updatePageInCanvas.mutate({
page,
- canvasPageId: pageInCanvas.page_id,
+ canvasPage: pageInCanvas,
+ moduleName,
})
}
extraActions={extraActions}
diff --git a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizButton.tsx b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizButton.tsx
index c16472f9..3ebae311 100644
--- a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizButton.tsx
+++ b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizButton.tsx
@@ -2,6 +2,7 @@ import {
EditorFooter,
FooterAction,
} from "@/app/course/[courseName]/EditorFooter";
+import { useCanvasModuleNames } from "@/features/canvas/hooks/canvasModuleHooks";
import { getSyncReport } from "@/app/course/[courseName]/calendar/day/getAssignmentSyncStatus";
import { TrashIcon } from "@/components/icons/ActionIcons";
import { useCanvasAssignmentsQuery } from "@/features/canvas/hooks/canvasAssignmentHooks";
@@ -53,6 +54,7 @@ export function QuizButtons({
);
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
+ const { names: canvasModules } = useCanvasModuleNames("quiz", quizInCanvas);
const { data: canvasQuestions } = useCanvasQuizQuestionsQuery(
quizInCanvas?.id,
);
@@ -70,12 +72,15 @@ export function QuizButtons({
quizzes: canvasQuizzes,
},
canvasQuestions,
+ module: canvasModules && { file: moduleName, canvas: canvasModules },
}).differences
: [],
[
canvasAssignments,
+ canvasModules,
canvasQuestions,
canvasQuizzes,
+ moduleName,
quiz,
quizInCanvas,
settings,
@@ -128,9 +133,13 @@ export function QuizButtons({
onAdd={() => addToCanvas.mutate({ quiz, moduleName })}
onUpdate={() =>
quizInCanvas &&
- updateInCanvas.mutate({ quiz, canvasQuizId: quizInCanvas.id })
+ updateInCanvas.mutate({
+ quiz,
+ canvasQuizId: quizInCanvas.id,
+ moduleName,
+ })
}
- updateTitle="Pushes settings and, if any question differs, replaces the questions in Canvas with the file's. If it replaced them on a published quiz, click Save it now in Canvas afterwards so students get them. Warns first if students have started."
+ updateTitle="Pushes settings, moves the quiz to this file's module and, if any question differs, replaces the questions in Canvas with the file's. If it replaced them on a published quiz, click Save it now in Canvas afterwards so students get them. Warns first if students have started."
extraActions={extraActions}
toggleHelp={toggleHelp}
previousUrl={previousUrl}
diff --git a/src/features/canvas/hooks/canvasAssignmentHooks.ts b/src/features/canvas/hooks/canvasAssignmentHooks.ts
index cead312a..e9dc2474 100644
--- a/src/features/canvas/hooks/canvasAssignmentHooks.ts
+++ b/src/features/canvas/hooks/canvasAssignmentHooks.ts
@@ -1,11 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import {
- useAddCanvasModuleMutation,
- useCanvasModulesQuery,
+ useAddToCanvasModule,
+ usePlaceInCanvasModule,
} from "./canvasModuleHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
-import { canvasModuleService } from "../services/canvasModuleService";
import { canvasAssignmentService } from "../services/canvasAssignmentService";
import { showErrorToast } from "@/app/MyToaster";
import { useCanvasLinkTargets } from "./useCanvasLinkTargets";
@@ -29,8 +28,7 @@ export const useCanvasAssignmentsQuery = () => {
export const useAddAssignmentToCanvasMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
- const { data: canvasModules } = useCanvasModulesQuery();
- const addModule = useAddCanvasModuleMutation();
+ const addToModule = useAddToCanvasModule();
const queryClient = useQueryClient();
const canvasLinkTargets = useCanvasLinkTargets();
const getPublishOptions = useAssignmentPublishOptions();
@@ -46,11 +44,6 @@ export const useAddAssignmentToCanvasMutation = () => {
assignment: LocalAssignment;
moduleName: string;
}) => {
- if (!canvasModules) {
- // console.log("cannot add assignment until modules loaded");
- throw new Error("cannot add assignment until modules loaded");
- }
-
const assignmentGroup = settings.assignmentGroups.find(
(g) => g.name === assignment.localAssignmentGroupName,
);
@@ -64,18 +57,11 @@ export const useAddAssignmentToCanvasMutation = () => {
canvasLinkTargets,
getPublishOptions(assignment),
);
- const canvasModule = canvasModules.find((c) => c.name === moduleName);
- const moduleId = canvasModule
- ? canvasModule.id
- : await addModule.mutateAsync(moduleName);
-
- await canvasModuleService.createModuleItem(
- settings.canvasId,
- moduleId,
- assignment.name,
- "Assignment",
- canvasAssignmentId,
- );
+ await addToModule(moduleName, {
+ type: "Assignment",
+ id: canvasAssignmentId,
+ title: assignment.name,
+ });
},
onSuccess: (_data, variables) => {
commitAfterPublish(`assignment "${variables.assignment.name}"`);
@@ -96,6 +82,7 @@ export const useUpdateAssignmentInCanvasMutation = () => {
const canvasLinkTargets = useCanvasLinkTargets();
const getPublishOptions = useAssignmentPublishOptions();
const prepareImages = usePrepareImagesForCanvas();
+ const placeInModule = usePlaceInCanvasModule();
const commitAfterPublish = useCommitAfterPublish();
@@ -103,9 +90,11 @@ export const useUpdateAssignmentInCanvasMutation = () => {
mutationFn: async ({
assignment,
canvasAssignmentId,
+ moduleName,
}: {
assignment: LocalAssignment;
canvasAssignmentId: number;
+ moduleName: string;
}) => {
const assignmentGroup = settings.assignmentGroups.find(
(g) => g.name === assignment.localAssignmentGroupName,
@@ -120,6 +109,11 @@ export const useUpdateAssignmentInCanvasMutation = () => {
canvasLinkTargets,
getPublishOptions(assignment),
);
+ await placeInModule(moduleName, {
+ type: "Assignment",
+ id: canvasAssignmentId,
+ title: assignment.name,
+ });
},
onSuccess: (_data, variables) => {
commitAfterPublish(`assignment "${variables.assignment.name}"`);
diff --git a/src/features/canvas/hooks/canvasModuleHooks.ts b/src/features/canvas/hooks/canvasModuleHooks.ts
index 3e9799bc..561f60a8 100644
--- a/src/features/canvas/hooks/canvasModuleHooks.ts
+++ b/src/features/canvas/hooks/canvasModuleHooks.ts
@@ -1,7 +1,13 @@
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { canvasModuleService } from "../services/canvasModuleService";
+import {
+ canvasModuleService,
+ canvasModuleNames,
+ ModuleItemContent,
+} from "../services/canvasModuleService";
import { IModuleItem } from "@/features/local/modules/IModuleItem";
+import toast from "react-hot-toast";
+import { useMemo } from "react";
export const canvasCourseModuleKeys = {
modules: (canvasId: number) => ["canvas", canvasId, "module list"] as const,
@@ -16,6 +22,33 @@ export const useCanvasModulesQuery = () => {
});
};
+/** Every Canvas module with its items, for finding which modules hold an item. */
+export const useCanvasModulesWithItemsQuery = () => {
+ const { data: settings } = useLocalCourseSettingsQuery();
+ return useQuery({
+ // under the module list's key, so whatever refreshes the list refreshes this
+ queryKey: [...canvasCourseModuleKeys.modules(settings.canvasId), "items"],
+ queryFn: () =>
+ canvasModuleService.getCourseModulesWithItems(settings.canvasId),
+ });
+};
+
+/**
+ * For comparing an item's module: the names of the Canvas modules holding it,
+ * undefined while they load or when the item isn't in Canvas.
+ */
+export const useCanvasModuleNames = (
+ type: Parameters[1],
+ canvasItem: Parameters[2] | undefined
+) => {
+ const { data: modules, refetch } = useCanvasModulesWithItemsQuery();
+ const names = useMemo(
+ () => modules && canvasItem && canvasModuleNames(modules, type, canvasItem),
+ [modules, type, canvasItem]
+ );
+ return { names, refetch };
+};
+
export const useAddCanvasModuleMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
@@ -30,6 +63,74 @@ export const useAddCanvasModuleMutation = () => {
});
};
+/**
+ * Finds a Canvas module's id by name, creating the module if Canvas has none.
+ * Loads the module list first (not just whatever is cached), so a missing
+ * module isn't created twice.
+ */
+export const useCanvasModuleIdByName = () => {
+ const { data: settings } = useLocalCourseSettingsQuery();
+ const queryClient = useQueryClient();
+ const addModule = useAddCanvasModuleMutation();
+ return async (moduleName: string) => {
+ const modules = await queryClient.ensureQueryData({
+ queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
+ queryFn: () => canvasModuleService.getCourseModules(settings.canvasId),
+ });
+ return (
+ modules.find((m) => m.name === moduleName)?.id ??
+ (await addModule.mutateAsync(moduleName))
+ );
+ };
+};
+
+/** For Add to Canvas: adds a new item to the module named after its folder. */
+export const useAddToCanvasModule = () => {
+ const { data: settings } = useLocalCourseSettingsQuery();
+ const queryClient = useQueryClient();
+ const moduleIdByName = useCanvasModuleIdByName();
+ return async (moduleName: string, item: ModuleItemContent) => {
+ await canvasModuleService.addModuleItem(
+ settings.canvasId,
+ await moduleIdByName(moduleName),
+ item
+ );
+ queryClient.invalidateQueries({
+ queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
+ });
+ };
+};
+
+/**
+ * For Update in Canvas: makes the Canvas module named after an item's folder
+ * the only one holding it, so moving a file to another module moves it in
+ * Canvas too.
+ */
+export const usePlaceInCanvasModule = () => {
+ const { data: settings } = useLocalCourseSettingsQuery();
+ const queryClient = useQueryClient();
+ const moduleIdByName = useCanvasModuleIdByName();
+ return async (moduleName: string, item: ModuleItemContent) => {
+ const { placed, removed } = await canvasModuleService.placeInModule(
+ settings.canvasId,
+ await moduleIdByName(moduleName),
+ item
+ );
+ const changes = [
+ placed === "moved" && `moved it to the "${moduleName}" module`,
+ placed === "added" && `added it to the "${moduleName}" module`,
+ removed > 0 &&
+ `removed it from ${removed} other module${removed === 1 ? "" : "s"}`,
+ ].filter(Boolean);
+ if (changes.length > 0) {
+ toast.success(`In Canvas, ${changes.join(" and ")}: "${item.title}".`);
+ queryClient.invalidateQueries({
+ queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
+ });
+ }
+ };
+};
+
export const useReorderCanvasModuleItemsMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
diff --git a/src/features/canvas/hooks/canvasPageHooks.ts b/src/features/canvas/hooks/canvasPageHooks.ts
index 58794ded..f51a14e0 100644
--- a/src/features/canvas/hooks/canvasPageHooks.ts
+++ b/src/features/canvas/hooks/canvasPageHooks.ts
@@ -1,11 +1,10 @@
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
- useCanvasModulesQuery,
- useAddCanvasModuleMutation,
+ useAddToCanvasModule,
+ usePlaceInCanvasModule,
} from "./canvasModuleHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
-import { canvasModuleService } from "../services/canvasModuleService";
import { canvasPageService } from "../services/canvasPageService";
import { useCanvasLinkTargets } from "./useCanvasLinkTargets";
import { useCommitAfterPublish } from "@/features/local/git/gitHooks";
@@ -30,8 +29,7 @@ export const useCanvasPagesQuery = () => {
export const useCreateCanvasPageMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
- const { data: canvasModules } = useCanvasModulesQuery();
- const addModule = useAddCanvasModuleMutation();
+ const addToModule = useAddToCanvasModule();
const canvasLinkTargets = useCanvasLinkTargets();
const prepareImages = usePrepareImagesForCanvas();
@@ -45,10 +43,6 @@ export const useCreateCanvasPageMutation = () => {
page: LocalCoursePage;
moduleName: string;
}) => {
- if (!canvasModules) {
- console.log("cannot add page until modules loaded");
- return;
- }
const publishSettings = await prepareImages([page.text]);
const canvasPage = await canvasPageService.create(
settings.canvasId,
@@ -56,18 +50,12 @@ export const useCreateCanvasPageMutation = () => {
publishSettings,
canvasLinkTargets
);
-
- const canvasModule = canvasModules.find((c) => c.name === moduleName);
- const moduleId = canvasModule
- ? canvasModule.id
- : await addModule.mutateAsync(moduleName);
-
- await canvasModuleService.createPageModuleItem(
- settings.canvasId,
- moduleId,
- page.name,
- canvasPage
- );
+ await addToModule(moduleName, {
+ type: "Page",
+ id: canvasPage.page_id,
+ title: page.name,
+ pageUrl: canvasPage.url,
+ });
return canvasPage;
},
onSuccess: (_data, variables) => {
@@ -85,22 +73,32 @@ export const useUpdateCanvasPageMutation = () => {
const canvasLinkTargets = useCanvasLinkTargets();
const prepareImages = usePrepareImagesForCanvas();
const commitAfterPublish = useCommitAfterPublish();
+ const placeInModule = usePlaceInCanvasModule();
return useMutation({
mutationFn: async ({
page,
- canvasPageId,
+ canvasPage,
+ moduleName,
}: {
page: LocalCoursePage;
- canvasPageId: number;
- }) =>
- canvasPageService.update(
+ canvasPage: { page_id: number; url: string };
+ moduleName: string;
+ }) => {
+ await canvasPageService.update(
settings.canvasId,
- canvasPageId,
+ canvasPage.page_id,
page,
await prepareImages([page.text]),
canvasLinkTargets
- ),
+ );
+ await placeInModule(moduleName, {
+ type: "Page",
+ id: canvasPage.page_id,
+ title: page.name,
+ pageUrl: canvasPage.url,
+ });
+ },
onSuccess: (_data, variables) => {
commitAfterPublish(`page "${variables.page.name}"`);
queryClient.invalidateQueries({
diff --git a/src/features/canvas/hooks/canvasQuizHooks.ts b/src/features/canvas/hooks/canvasQuizHooks.ts
index 3122509f..5371d505 100644
--- a/src/features/canvas/hooks/canvasQuizHooks.ts
+++ b/src/features/canvas/hooks/canvasQuizHooks.ts
@@ -1,11 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
- useAddCanvasModuleMutation,
- useCanvasModulesQuery,
+ useAddToCanvasModule,
+ usePlaceInCanvasModule,
} from "./canvasModuleHooks";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
-import { canvasModuleService } from "../services/canvasModuleService";
import { canvasQuizService } from "../services/canvasQuizService";
import { useCanvasLinkTargets } from "./useCanvasLinkTargets";
import { useCommitAfterPublish } from "@/features/local/git/gitHooks";
@@ -78,8 +77,7 @@ export const useCanvasAttemptQuestionsQuery = (attemptId: number | undefined) =>
export const useAddQuizToCanvasMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
- const { data: canvasModules } = useCanvasModulesQuery();
- const addModule = useAddCanvasModuleMutation();
+ const addToModule = useAddToCanvasModule();
const canvasLinkTargets = useCanvasLinkTargets();
const prepareImages = usePrepareImagesForCanvas();
@@ -93,10 +91,6 @@ export const useAddQuizToCanvasMutation = () => {
quiz: LocalQuiz;
moduleName: string;
}) => {
- if (!canvasModules) {
- console.log("cannot add quiz until modules loaded");
- return;
- }
const assignmentGroup = settings.assignmentGroups.find(
(g) => g.name === quiz.localAssignmentGroupName
);
@@ -108,19 +102,11 @@ export const useAddQuizToCanvasMutation = () => {
assignmentGroup?.canvasId,
canvasLinkTargets
);
-
- const canvasModule = canvasModules.find((c) => c.name === moduleName);
- const moduleId = canvasModule
- ? canvasModule.id
- : await addModule.mutateAsync(moduleName);
-
- await canvasModuleService.createModuleItem(
- settings.canvasId,
- moduleId,
- quiz.name,
- "Quiz",
- canvasQuizId
- );
+ await addToModule(moduleName, {
+ type: "Quiz",
+ id: canvasQuizId,
+ title: quiz.name,
+ });
},
onSuccess: (_data, variables) => {
commitAfterPublish(`quiz "${variables.quiz.name}"`);
@@ -149,6 +135,7 @@ export const useUpdateQuizInCanvasMutation = () => {
const queryClient = useQueryClient();
const canvasLinkTargets = useCanvasLinkTargets();
const prepareImages = usePrepareImagesForCanvas();
+ const placeInModule = usePlaceInCanvasModule();
const commitAfterPublish = useCommitAfterPublish();
@@ -156,9 +143,11 @@ export const useUpdateQuizInCanvasMutation = () => {
mutationFn: async ({
quiz,
canvasQuizId,
+ moduleName,
}: {
quiz: LocalQuiz;
canvasQuizId: number;
+ moduleName: string;
}) => {
const versions = await canvasQuizService.getVersionsTaken(
settings.canvasId,
@@ -197,6 +186,11 @@ export const useUpdateQuizInCanvasMutation = () => {
).every((q) => q.same),
}
);
+ await placeInModule(moduleName, {
+ type: "Quiz",
+ id: canvasQuizId,
+ title: quiz.name,
+ });
const updated = summary.questionsReplaced
? `Updated "${quiz.name}" in Canvas: settings, ${summary.questionsRemoved} questions removed, ${summary.questionsAdded} added.`
: `Updated "${quiz.name}" in Canvas: settings. Its questions already matched the file, so they were left alone.`;
diff --git a/src/features/canvas/services/canvasModuleService.test.ts b/src/features/canvas/services/canvasModuleService.test.ts
new file mode 100644
index 00000000..255222f8
--- /dev/null
+++ b/src/features/canvas/services/canvasModuleService.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { canvasModuleNames, canvasModuleService } from "./canvasModuleService";
+import { CanvasModule } from "../models/modules/canvasModule";
+import { axiosClient } from "@/services/axiosUtils";
+import {
+ rateLimitAwareDelete,
+ rateLimitAwarePost,
+} from "./canvasWebRequestUtils";
+
+vi.mock("@/services/axiosUtils", () => ({
+ axiosClient: { get: vi.fn(), put: vi.fn() },
+}));
+vi.mock("./canvasWebRequestUtils", () => ({
+ rateLimitAwarePost: vi.fn(),
+ rateLimitAwareDelete: vi.fn(),
+}));
+vi.mock("./canvasServiceUtils", () => ({
+ canvasApi: "https://test.instructure.com/api/v1",
+ paginatedRequest: vi.fn(),
+}));
+
+const inModules = (...moduleIds: number[]) =>
+ vi.mocked(axiosClient.get).mockResolvedValue({
+ data: {
+ items: moduleIds.map((module_id, i) => ({
+ current: { id: 500 + i, module_id },
+ })),
+ },
+ });
+
+describe("placeInModule", () => {
+ const quiz = { type: "Quiz" as const, id: 7, title: "Quiz 1" };
+ beforeEach(() => vi.clearAllMocks());
+
+ it("leaves an item alone when it is only in the module", async () => {
+ inModules(9);
+
+ expect(await canvasModuleService.placeInModule(42, 9, quiz)).toEqual({
+ placed: "already",
+ removed: 0,
+ });
+ expect(axiosClient.put).not.toHaveBeenCalled();
+ expect(rateLimitAwarePost).not.toHaveBeenCalled();
+ expect(rateLimitAwareDelete).not.toHaveBeenCalled();
+ });
+
+ it("removes an item from other modules when it is also in the module", async () => {
+ inModules(3, 9);
+
+ expect(await canvasModuleService.placeInModule(42, 9, quiz)).toEqual({
+ placed: "already",
+ removed: 1,
+ });
+ expect(vi.mocked(rateLimitAwareDelete).mock.calls).toEqual([
+ ["https://test.instructure.com/api/v1/courses/42/modules/3/items/500"],
+ ]);
+ });
+
+ it("moves an item from another module", async () => {
+ inModules(3);
+
+ expect(await canvasModuleService.placeInModule(42, 9, quiz)).toEqual({
+ placed: "moved",
+ removed: 0,
+ });
+ expect(vi.mocked(axiosClient.put).mock.calls).toEqual([
+ [
+ "https://test.instructure.com/api/v1/courses/42/modules/3/items/500",
+ { module_item: { module_id: 9 } },
+ ],
+ ]);
+ });
+
+ it("moves one copy and removes the rest when it is in other modules", async () => {
+ inModules(3, 4);
+
+ expect(await canvasModuleService.placeInModule(42, 9, quiz)).toEqual({
+ placed: "moved",
+ removed: 1,
+ });
+ expect(vi.mocked(axiosClient.put).mock.calls[0][0]).toBe(
+ "https://test.instructure.com/api/v1/courses/42/modules/3/items/500",
+ );
+ expect(vi.mocked(rateLimitAwareDelete).mock.calls).toEqual([
+ ["https://test.instructure.com/api/v1/courses/42/modules/4/items/501"],
+ ]);
+ });
+
+ it("adds an item that is in no module", async () => {
+ inModules();
+
+ expect(await canvasModuleService.placeInModule(42, 9, quiz)).toEqual({
+ placed: "added",
+ removed: 0,
+ });
+ expect(vi.mocked(rateLimitAwarePost).mock.calls).toEqual([
+ [
+ "https://test.instructure.com/api/v1/courses/42/modules/9/items",
+ { module_item: { title: "Quiz 1", type: "Quiz", content_id: 7 } },
+ ],
+ ]);
+ });
+
+ it("finds a page by its id and adds it by its url", async () => {
+ inModules();
+
+ await canvasModuleService.placeInModule(42, 9, {
+ type: "Page",
+ id: 12,
+ title: "Schedule",
+ pageUrl: "schedule",
+ });
+ expect(vi.mocked(axiosClient.get).mock.calls[0]).toEqual([
+ "https://test.instructure.com/api/v1/courses/42/module_item_sequence",
+ { params: { asset_type: "Page", asset_id: 12 } },
+ ]);
+ expect(vi.mocked(rateLimitAwarePost).mock.calls[0][1]).toEqual({
+ module_item: { title: "Schedule", type: "Page", page_url: "schedule" },
+ });
+ });
+});
+
+describe("canvasModuleNames", () => {
+ const module = (name: string, items: object[]) =>
+ ({ name, items }) as unknown as CanvasModule;
+ const modules = [
+ module("Week 1", [
+ { type: "Assignment", content_id: 7 },
+ { type: "Page", page_url: "schedule" },
+ ]),
+ module("Week 2", [{ type: "Quiz", content_id: 7 }]),
+ ];
+
+ it("finds an assignment or quiz by type and id", () => {
+ expect(canvasModuleNames(modules, "assignment", { id: 7 })).toEqual(["Week 1"]);
+ expect(canvasModuleNames(modules, "quiz", { id: 7 })).toEqual(["Week 2"]);
+ });
+
+ it("finds a page by its url", () => {
+ expect(canvasModuleNames(modules, "page", { url: "schedule" })).toEqual(["Week 1"]);
+ });
+
+ it("is empty for an item in no module", () => {
+ expect(canvasModuleNames(modules, "quiz", { id: 8 })).toEqual([]);
+ });
+});
diff --git a/src/features/canvas/services/canvasModuleService.ts b/src/features/canvas/services/canvasModuleService.ts
index e05c0049..dd82893f 100644
--- a/src/features/canvas/services/canvasModuleService.ts
+++ b/src/features/canvas/services/canvasModuleService.ts
@@ -1,9 +1,30 @@
import { CanvasModuleItem } from "@/features/canvas/models/modules/canvasModuleItems";
-import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { CanvasModule } from "@/features/canvas/models/modules/canvasModule";
import { axiosClient } from "@/services/axiosUtils";
-import { rateLimitAwarePost } from "./canvasWebRequestUtils";
+import { rateLimitAwareDelete, rateLimitAwarePost } from "./canvasWebRequestUtils";
+
+/** A Canvas item to put in a module; a page is added by its url. */
+export interface ModuleItemContent {
+ type: "Assignment" | "Quiz" | "Page";
+ id: number;
+ title: string;
+ pageUrl?: string;
+}
+
+/** The names of the Canvas modules that hold an item; a page is matched by its url. */
+export function canvasModuleNames(
+ modules: CanvasModule[],
+ type: "assignment" | "quiz" | "page",
+ canvasItem: { id?: number; url?: string },
+): string[] {
+ const holds = (i: CanvasModuleItem) =>
+ type === "page"
+ ? i.type === "Page" && i.page_url === canvasItem.url
+ : i.type === (type === "quiz" ? "Quiz" : "Assignment") &&
+ i.content_id === canvasItem.id;
+ return modules.filter((m) => m.items?.some(holds)).map((m) => m.name);
+}
export const canvasModuleService = {
async updateModuleItem(
@@ -28,31 +49,55 @@ export const canvasModuleService = {
return response.data;
},
- async createModuleItem(
+ async addModuleItem(
canvasCourseId: number,
canvasModuleId: number,
- title: string,
- type: "Assignment" | "Quiz",
- contentId: number | string
+ item: ModuleItemContent
) {
- console.log(`Creating new module item ${title}`);
- const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
- const body = { module_item: { title, type, content_id: contentId } };
- await rateLimitAwarePost(url, body);
+ await rateLimitAwarePost(
+ `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`,
+ {
+ module_item:
+ item.type === "Page"
+ ? { title: item.title, type: "Page", page_url: item.pageUrl }
+ : { title: item.title, type: item.type, content_id: item.id },
+ }
+ );
},
- async createPageModuleItem(
+ /**
+ * Makes a module the only one holding an item: keeps or moves one copy into
+ * it (adding one when the item is in no module) and removes every other.
+ */
+ async placeInModule(
canvasCourseId: number,
canvasModuleId: number,
- title: string,
- canvasPage: CanvasPage
- ) {
- console.log(`Creating new module item ${title}`);
- const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
- const body = {
- module_item: { title, type: "Page", page_url: canvasPage.url },
- };
- await rateLimitAwarePost(url, body);
+ item: ModuleItemContent
+ ): Promise<{ placed: "already" | "moved" | "added"; removed: number }> {
+ const { data } = await axiosClient.get<{
+ items: { current?: { id: number; module_id: number } }[];
+ }>(`${canvasApi}/courses/${canvasCourseId}/module_item_sequence`, {
+ params: { asset_type: item.type, asset_id: item.id },
+ });
+ const placements = data.items.flatMap((i) => (i.current ? [i.current] : []));
+ const itemUrl = (p: { id: number; module_id: number }) =>
+ `${canvasApi}/courses/${canvasCourseId}/modules/${p.module_id}/items/${p.id}`;
+
+ const kept =
+ placements.find((p) => p.module_id === canvasModuleId) ?? placements[0];
+ const extras = placements.filter((p) => p !== kept);
+ for (const extra of extras) await rateLimitAwareDelete(itemUrl(extra));
+
+ if (!kept) {
+ await canvasModuleService.addModuleItem(canvasCourseId, canvasModuleId, item);
+ return { placed: "added", removed: 0 };
+ }
+ if (kept.module_id === canvasModuleId)
+ return { placed: "already", removed: extras.length };
+ await axiosClient.put(itemUrl(kept), {
+ module_item: { module_id: canvasModuleId },
+ });
+ return { placed: "moved", removed: extras.length };
},
async getCourseModules(canvasCourseId: number) {
@@ -61,6 +106,25 @@ export const canvasModuleService = {
return response;
},
+ /** Every module with its items; Canvas leaves the items out of a large module, so those are fetched on their own. */
+ async getCourseModulesWithItems(canvasCourseId: number) {
+ const modules = await paginatedRequest({
+ url: `${canvasApi}/courses/${canvasCourseId}/modules?include[]=items`,
+ });
+ return Promise.all(
+ modules.map(async (m) =>
+ m.items
+ ? m
+ : {
+ ...m,
+ items: await paginatedRequest({
+ url: `${canvasApi}/courses/${canvasCourseId}/modules/${m.id}/items`,
+ }),
+ }
+ )
+ );
+ },
+
async createModule(canvasCourseId: number, moduleName: string) {
const url = `${canvasApi}/courses/${canvasCourseId}/modules`;
const body = {