mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-25 23:28:33 -06:00
Merge pull request #19 from teichert/feature-navigation-buttons
(feature) adds buttons to manager UI to navigate forward or backward between assignments/pages and lectures
This commit is contained in:
@@ -24,6 +24,7 @@ import { useCourseListInTermQuery } from "@/features/canvas/hooks/canvasCourseHo
|
|||||||
import { useCanvasTermsQuery } from "@/features/canvas/hooks/canvasHooks";
|
import { useCanvasTermsQuery } from "@/features/canvas/hooks/canvasHooks";
|
||||||
import { useDirectoryExistsQuery } from "@/features/local/utils/storageDirectoryHooks";
|
import { useDirectoryExistsQuery } from "@/features/local/utils/storageDirectoryHooks";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const sampleCompose = `services:
|
const sampleCompose = `services:
|
||||||
canvas_manager:
|
canvas_manager:
|
||||||
image: alexmickelson/canvas_management:2 # pull this image regularly
|
image: alexmickelson/canvas_management:2 # pull this image regularly
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/app/course/[courseName]/hooks/navigationLogic.test.ts
Normal file
70
src/app/course/[courseName]/hooks/navigationLogic.test.ts
Normal 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, courseName);
|
||||||
|
expect(lectures).toHaveLength(3);
|
||||||
|
expect(lectures[0].url).toContain(encodeURIComponent("01/01/2023"));
|
||||||
|
expect(lectures[0].type).toBe("lecture");
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/app/course/[courseName]/hooks/navigationLogic.ts
Normal file
83
src/app/course/[courseName]/hooks/navigationLogic.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/app/course/[courseName]/hooks/useItemNavigation.ts
Normal file
14
src/app/course/[courseName]/hooks/useItemNavigation.ts
Normal 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);
|
||||||
|
}
|
||||||
24
src/app/course/[courseName]/hooks/useOrderedCourseItems.ts
Normal file
24
src/app/course/[courseName]/hooks/useOrderedCourseItems.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { useCourseContext } from "../../context/courseContext";
|
|||||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks";
|
import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useItemNavigation } from "../../hooks/useItemNavigation";
|
||||||
|
import ItemNavigationButtons from "../../components/ItemNavigationButtons";
|
||||||
|
|
||||||
export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
||||||
const { courseName } = useCourseContext();
|
const { courseName } = useCourseContext();
|
||||||
@@ -17,6 +19,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
const deleteLecture = useDeleteLectureMutation();
|
const deleteLecture = useDeleteLectureMutation();
|
||||||
|
const { previousUrl, nextUrl } = useItemNavigation("lecture", lectureDay);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-row justify-end gap-3">
|
<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}>
|
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||||
Go Back
|
Go Back
|
||||||
</Link>
|
</Link>
|
||||||
|
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||||
{isLoading && <Spinner />}
|
{isLoading && <Spinner />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { getCourseUrl } from "@/services/urlUtils";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||||
|
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||||
|
|
||||||
export function AssignmentFooterButtons({
|
export function AssignmentFooterButtons({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -42,6 +44,11 @@ export function AssignmentFooterButtons({
|
|||||||
const deleteLocal = useDeleteAssignmentMutation();
|
const deleteLocal = useDeleteAssignmentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
|
const { previousUrl, nextUrl } = useItemNavigation(
|
||||||
|
"assignment",
|
||||||
|
assignmentName,
|
||||||
|
moduleName
|
||||||
|
);
|
||||||
|
|
||||||
const assignmentInCanvas = canvasAssignments?.find(
|
const assignmentInCanvas = canvasAssignments?.find(
|
||||||
(a) => a.name === assignmentName
|
(a) => a.name === assignmentName
|
||||||
@@ -155,6 +162,7 @@ export function AssignmentFooterButtons({
|
|||||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||||
Go Back
|
Go Back
|
||||||
</Link>
|
</Link>
|
||||||
|
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { getCourseUrl } from "@/services/urlUtils";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||||
|
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||||
|
|
||||||
export default function EditPageButtons({
|
export default function EditPageButtons({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -36,6 +38,11 @@ export default function EditPageButtons({
|
|||||||
const deletePageLocal = useDeletePageMutation();
|
const deletePageLocal = useDeletePageMutation();
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { previousUrl, nextUrl } = useItemNavigation(
|
||||||
|
"page",
|
||||||
|
pageName,
|
||||||
|
moduleName
|
||||||
|
);
|
||||||
|
|
||||||
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
|
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
|
||||||
|
|
||||||
@@ -125,6 +132,7 @@ export default function EditPageButtons({
|
|||||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||||
Go Back
|
Go Back
|
||||||
</Link>
|
</Link>
|
||||||
|
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
import { getCourseUrl } from "@/services/urlUtils";
|
import { getCourseUrl } from "@/services/urlUtils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||||
|
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||||
|
|
||||||
export function QuizButtons({
|
export function QuizButtons({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -35,6 +37,11 @@ export function QuizButtons({
|
|||||||
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
|
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
|
||||||
const deleteLocal = useDeleteQuizMutation();
|
const deleteLocal = useDeleteQuizMutation();
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
|
const { previousUrl, nextUrl } = useItemNavigation(
|
||||||
|
"quiz",
|
||||||
|
quizName,
|
||||||
|
moduleName
|
||||||
|
);
|
||||||
|
|
||||||
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
|
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
|
||||||
|
|
||||||
@@ -111,6 +118,7 @@ export function QuizButtons({
|
|||||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||||
Go Back
|
Go Back
|
||||||
</Link>
|
</Link>
|
||||||
|
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,15 +52,18 @@ marked.use({ extensions: [mermaidExtension] });
|
|||||||
// The renderer only applies to markdown tables.
|
// The renderer only applies to markdown tables.
|
||||||
marked.use({
|
marked.use({
|
||||||
renderer: {
|
renderer: {
|
||||||
tablecell({ text, header, align }) {
|
tablecell(token) {
|
||||||
|
const content = this.parser.parseInline(token.tokens);
|
||||||
|
const { header, align } = token;
|
||||||
const type = header ? "th" : "td";
|
const type = header ? "th" : "td";
|
||||||
const alignAttr = align ? ` align="${align}"` : "";
|
const alignAttr = align ? ` align="${align}"` : "";
|
||||||
const scopeAttr = header ? ' scope="col"' : "";
|
const scopeAttr = header ? ' scope="col"' : "";
|
||||||
return `<${type}${scopeAttr}${alignAttr}>${text}</${type}>\n`;
|
return `<${type}${scopeAttr}${alignAttr}>${content}</${type}>\n`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export function extractImageSources(htmlString: string) {
|
export function extractImageSources(htmlString: string) {
|
||||||
const srcUrls = [];
|
const srcUrls = [];
|
||||||
const regex = /<img[^>]+src=["']?([^"'>]+)["']?/g;
|
const regex = /<img[^>]+src=["']?([^"'>]+)["']?/g;
|
||||||
|
|||||||
16
src/services/markdownReferenceLinks.test.ts
Normal file
16
src/services/markdownReferenceLinks.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { markdownToHtmlNoImages } from './htmlMarkdownUtils';
|
||||||
|
|
||||||
|
describe('markdownToHtmlNoImages reference links', () => {
|
||||||
|
it('renders reference links inside a table', () => {
|
||||||
|
const markdown = `
|
||||||
|
| Header |
|
||||||
|
| --- |
|
||||||
|
| [QuickStart][Fort1] |
|
||||||
|
|
||||||
|
[Fort1]: https://example.com/fort1
|
||||||
|
`;
|
||||||
|
const html = markdownToHtmlNoImages(markdown);
|
||||||
|
expect(html).toContain('<a href="https://example.com/fort1">QuickStart</a>');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user