add previous and next buttons (lectures are separate from assignments/quizzes/pages)

This commit is contained in:
Adam Teichert
2025-12-16 22:27:04 -07:00
parent 859bdf01f2
commit cef2323886
9 changed files with 243 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import Link from "next/link";
export default function ItemNavigationButtons({
previousUrl,
nextUrl,
}: {
previousUrl: string | null;
nextUrl: string | null;
}) {
return (
<>
{previousUrl && (
<Link className="btn" href={previousUrl} shallow={true}>
Previous
</Link>
)}
{nextUrl && (
<Link className="btn" href={nextUrl} shallow={true}>
Next
</Link>
)}
</>
);
}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import {
getOrderedItems,
getOrderedLectures,
getNavigationLinks,
OrderedCourseItem,
} from "./navigationLogic";
import { CalendarItemsInterface } from "../context/calendarItemsContext";
describe("navigationLogic", () => {
const courseName = "testCourse";
it("getOrderedItems should order items by date, then alphabetically by name", () => {
const createMock = (
date: string,
name: string,
key: "assignments" | "quizzes" | "pages"
) =>
({
[date]: { "Module 1": { [key]: [{ name }] } },
} as unknown as CalendarItemsInterface);
const orderedItems = getOrderedItems(
courseName,
createMock("2023-01-01", "Z Assignment", "assignments"),
createMock("2023-01-01", "A Quiz", "quizzes"),
createMock("2023-01-02", "B Assignment", "assignments"),
createMock("2023-01-02", "A Page", "pages")
);
expect(orderedItems.map((i) => `${i.date} ${i.name}`)).toEqual([
"2023-01-01 A Quiz",
"2023-01-01 Z Assignment",
"2023-01-02 A Page",
"2023-01-02 B Assignment",
]);
});
it("getNavigationLinks should handle wrapping and normal navigation", () => {
const items: OrderedCourseItem[] = [
{ type: "assignment", name: "1", moduleName: "M", date: "D", url: "u1" },
{ type: "quiz", name: "2", moduleName: "M", date: "D", url: "u2" },
{ type: "page", name: "3", moduleName: "M", date: "D", url: "u3" },
];
// Forward wrap (last -> first)
expect(getNavigationLinks(items, "page", "3", "M").nextUrl).toBe("u1");
// Backward wrap (first -> last)
expect(getNavigationLinks(items, "assignment", "1", "M").previousUrl).toBe(
"u3"
);
// Normal navigation (middle)
const middle = getNavigationLinks(items, "quiz", "2", "M");
expect(middle.previousUrl).toBe("u1");
expect(middle.nextUrl).toBe("u3");
});
it("getOrderedLectures should flatten weeks and generate correct URLs", () => {
const weeks = [
{ lectures: [{ date: "01/01/2023" }] },
{ lectures: [{ date: "01/02/2023" }, { date: "01/03/2023" }] },
];
const lectures = getOrderedLectures(weeks as any, courseName);
expect(lectures).toHaveLength(3);
expect(lectures[0].url).toContain(encodeURIComponent("01/01/2023"));
expect(lectures[0].type).toBe("lecture");
});
});

View File

@@ -0,0 +1,83 @@
import { CalendarItemsInterface } from "../context/calendarItemsContext";
import { getLectureUrl, getModuleItemUrl } from "@/services/urlUtils";
export type CourseItemType = "assignment" | "quiz" | "page" | "lecture";
export interface OrderedCourseItem {
type: CourseItemType;
name: string;
moduleName?: string;
date: string;
url: string;
}
export function getOrderedItems(
courseName: string,
...calendars: CalendarItemsInterface[]
): OrderedCourseItem[] {
const itemTypes = [
{ key: "assignments" as const, type: "assignment" as const },
{ key: "quizzes" as const, type: "quiz" as const },
{ key: "pages" as const, type: "page" as const },
];
return calendars
.flatMap((calendar) =>
Object.entries(calendar).flatMap(([date, modules]) =>
Object.entries(modules).flatMap(([moduleName, moduleData]) =>
itemTypes.flatMap(({ key, type }) =>
(moduleData[key] || []).map((item) => ({
type,
name: item.name,
moduleName,
date,
url: getModuleItemUrl(courseName, moduleName, type, item.name),
}))
)
)
)
)
.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.name.localeCompare(b.name);
});
}
export function getOrderedLectures(
weeks: { lectures: { date: string }[] }[],
courseName: string
): OrderedCourseItem[] {
return weeks
.flatMap((week) => week.lectures)
.map((lecture) => ({
type: "lecture",
name: lecture.date,
date: lecture.date,
url: getLectureUrl(courseName, lecture.date),
}));
}
export function getNavigationLinks(
list: OrderedCourseItem[],
type: CourseItemType,
name: string,
moduleName?: string
) {
const index = list.findIndex((item) => {
if (type === "lecture") return item.date === name;
return (
item.name === name && item.type === type && item.moduleName === moduleName
);
});
if (index === -1) return { previousUrl: null, nextUrl: null };
const previousIndex = (index - 1 + list.length) % list.length;
const nextIndex = (index + 1) % list.length;
return {
previousUrl: list[previousIndex].url,
nextUrl: list[nextIndex].url,
};
}

View File

@@ -0,0 +1,14 @@
import { useOrderedCourseItems } from "./useOrderedCourseItems";
import { getNavigationLinks, CourseItemType } from "./navigationLogic";
export function useItemNavigation(
type: CourseItemType,
name: string,
moduleName?: string
) {
const { orderedItems, orderedLectures } = useOrderedCourseItems();
const list = type === "lecture" ? orderedLectures : orderedItems;
return getNavigationLinks(list, type, name, moduleName);
}

View File

@@ -0,0 +1,24 @@
import {
useCourseAssignmentsByModuleByDateQuery,
useCoursePagesByModuleByDateQuery,
useCourseQuizzesByModuleByDateQuery,
} from "@/features/local/modules/localCourseModuleHooks";
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
import { useCourseContext } from "../context/courseContext";
import { getOrderedItems, getOrderedLectures } from "./navigationLogic";
export function useOrderedCourseItems() {
const { courseName } = useCourseContext();
const { data: weeks } = useLecturesSuspenseQuery();
const orderedItems = getOrderedItems(
courseName,
useCourseAssignmentsByModuleByDateQuery(),
useCourseQuizzesByModuleByDateQuery(),
useCoursePagesByModuleByDateQuery()
);
const orderedLectures = getOrderedLectures(weeks, courseName);
return { orderedItems, orderedLectures };
}

View File

@@ -9,6 +9,8 @@ import { useCourseContext } from "../../context/courseContext";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks";
import Link from "next/link";
import { useItemNavigation } from "../../hooks/useItemNavigation";
import ItemNavigationButtons from "../../components/ItemNavigationButtons";
export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
const { courseName } = useCourseContext();
@@ -17,6 +19,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
const [isLoading, setIsLoading] = useState(false);
const modal = useModal();
const deleteLecture = useDeleteLectureMutation();
const { previousUrl, nextUrl } = useItemNavigation("lecture", lectureDay);
return (
<div className="p-5 flex flex-row justify-end gap-3">
@@ -61,6 +64,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Go Back
</Link>
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
{isLoading && <Spinner />}
</div>
);

View File

@@ -17,6 +17,8 @@ import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
export function AssignmentFooterButtons({
moduleName,
@@ -42,6 +44,11 @@ export function AssignmentFooterButtons({
const deleteLocal = useDeleteAssignmentMutation();
const [isLoading, setIsLoading] = useState(false);
const modal = useModal();
const { previousUrl, nextUrl } = useItemNavigation(
"assignment",
assignmentName,
moduleName
);
const assignmentInCanvas = canvasAssignments?.find(
(a) => a.name === assignmentName
@@ -155,6 +162,7 @@ export function AssignmentFooterButtons({
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Go Back
</Link>
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
</div>
</div>
);

View File

@@ -17,6 +17,8 @@ import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
export default function EditPageButtons({
moduleName,
@@ -36,6 +38,11 @@ export default function EditPageButtons({
const deletePageLocal = useDeletePageMutation();
const modal = useModal();
const [loading, setLoading] = useState(false);
const { previousUrl, nextUrl } = useItemNavigation(
"page",
pageName,
moduleName
);
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
@@ -125,6 +132,7 @@ export default function EditPageButtons({
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Go Back
</Link>
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
</div>
);
}

View File

@@ -15,6 +15,8 @@ import {
import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
export function QuizButtons({
moduleName,
@@ -35,6 +37,11 @@ export function QuizButtons({
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
const deleteLocal = useDeleteQuizMutation();
const modal = useModal();
const { previousUrl, nextUrl } = useItemNavigation(
"quiz",
quizName,
moduleName
);
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
@@ -111,6 +118,7 @@ export function QuizButtons({
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Go Back
</Link>
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
</div>
</div>
);