mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-25 23:28:33 -06:00
working pages and app router
This commit is contained in:
1
nextjs-pages/.env.test
Normal file
1
nextjs-pages/.env.test
Normal file
@@ -0,0 +1 @@
|
|||||||
|
STORAGE_DIRECTORY="./temp/canvasManagerStorage"
|
||||||
3
nextjs-pages/.eslintrc.json
Normal file
3
nextjs-pages/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
39
nextjs-pages/.gitignore
vendored
Normal file
39
nextjs-pages/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
storage/*
|
||||||
40
nextjs-pages/README.md
Normal file
40
nextjs-pages/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||||
|
|
||||||
|
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||||
6
nextjs-pages/next.config.mjs
Normal file
6
nextjs-pages/next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7971
nextjs-pages/package-lock.json
generated
Normal file
7971
nextjs-pages/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
nextjs-pages/package.json
Normal file
37
nextjs-pages/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-manager",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.6.0",
|
||||||
|
"@tanstack/react-query": "^5.54.1",
|
||||||
|
"axios": "^1.7.5",
|
||||||
|
"isomorphic-dompurify": "^2.15.0",
|
||||||
|
"marked": "^14.1.0",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"next": "14.2.8",
|
||||||
|
"react-error-boundary": "^4.0.13",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"yaml": "^2.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tanstack/react-query-devtools": "^5.54.1",
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.8",
|
||||||
|
"vitest": "^2.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
nextjs-pages/postcss.config.mjs
Normal file
8
nextjs-pages/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
nextjs-pages/public/favicon.ico
Normal file
BIN
nextjs-pages/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
10
nextjs-pages/src/components/Spinner.tsx
Normal file
10
nextjs-pages/src/components/Spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import "./spinner.css"
|
||||||
|
|
||||||
|
export const Spinner = () => {
|
||||||
|
return (
|
||||||
|
<div className="text-center m-3">
|
||||||
|
<span className="loader"></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { CourseContext } from "./courseContext";
|
||||||
|
|
||||||
|
export default function CourseContextProvider({
|
||||||
|
localCourseName,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
localCourseName: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CourseContext.Provider
|
||||||
|
value={{
|
||||||
|
courseName: localCourseName,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CourseContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
nextjs-pages/src/components/contexts/DraggingContextProvider.tsx
Normal file
120
nextjs-pages/src/components/contexts/DraggingContextProvider.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
import { ReactNode, useCallback, DragEvent } from "react";
|
||||||
|
import { DraggingContext } from "./draggingContext";
|
||||||
|
import { useUpdateQuizMutation } from "@/hooks/localCourse/quizHooks";
|
||||||
|
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||||
|
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
|
||||||
|
import {
|
||||||
|
getDateFromStringOrThrow,
|
||||||
|
dateToMarkdownString,
|
||||||
|
} from "@/models/local/timeUtils";
|
||||||
|
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
|
||||||
|
import { useUpdateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks";
|
||||||
|
import { useUpdatePageMutation } from "@/hooks/localCourse/pageHooks";
|
||||||
|
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
|
||||||
|
|
||||||
|
export default function DraggingContextProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const updateQuizMutation = useUpdateQuizMutation();
|
||||||
|
const updateAssignmentMutation = useUpdateAssignmentMutation();
|
||||||
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
|
const { data: settings } = useLocalCourseSettingsQuery();
|
||||||
|
|
||||||
|
const itemDrop = useCallback(
|
||||||
|
(e: DragEvent<HTMLDivElement>, day: string | undefined) => {
|
||||||
|
const itemBeingDragged = JSON.parse(
|
||||||
|
e.dataTransfer.getData("draggableItem")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (itemBeingDragged && day) {
|
||||||
|
const dayAsDate = getDateFromStringOrThrow(day, "in drop callback");
|
||||||
|
dayAsDate.setHours(settings.defaultDueTime.hour);
|
||||||
|
dayAsDate.setMinutes(settings.defaultDueTime.minute);
|
||||||
|
dayAsDate.setSeconds(0);
|
||||||
|
|
||||||
|
console.log("dropped on day", dayAsDate, day);
|
||||||
|
if (itemBeingDragged.type === "quiz") {
|
||||||
|
console.log("dropping quiz");
|
||||||
|
const previousQuiz = itemBeingDragged.item as LocalQuiz;
|
||||||
|
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
...previousQuiz,
|
||||||
|
dueAt: dateToMarkdownString(dayAsDate),
|
||||||
|
lockAt: getLaterDate(previousQuiz.lockAt, dayAsDate),
|
||||||
|
};
|
||||||
|
updateQuizMutation.mutate({
|
||||||
|
quiz: quiz,
|
||||||
|
quizName: quiz.name,
|
||||||
|
moduleName: itemBeingDragged.sourceModuleName,
|
||||||
|
});
|
||||||
|
} else if (itemBeingDragged.type === "assignment") {
|
||||||
|
updateAssignment(dayAsDate);
|
||||||
|
} else if (itemBeingDragged.type === "page") {
|
||||||
|
console.log("dropped page");
|
||||||
|
const previousPage = itemBeingDragged.item as LocalCoursePage;
|
||||||
|
const page: LocalCoursePage = {
|
||||||
|
...previousPage,
|
||||||
|
dueAt: dateToMarkdownString(dayAsDate),
|
||||||
|
};
|
||||||
|
updatePageMutation.mutate({
|
||||||
|
page,
|
||||||
|
moduleName: itemBeingDragged.sourceModuleName,
|
||||||
|
pageName: page.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAssignment(dayAsDate: Date) {
|
||||||
|
const previousAssignment = itemBeingDragged.item as LocalAssignment;
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
...previousAssignment,
|
||||||
|
dueAt: dateToMarkdownString(dayAsDate),
|
||||||
|
lockAt:
|
||||||
|
previousAssignment.lockAt &&
|
||||||
|
(getDateFromStringOrThrow(
|
||||||
|
previousAssignment.lockAt,
|
||||||
|
"lockAt date"
|
||||||
|
) > dayAsDate
|
||||||
|
? previousAssignment.lockAt
|
||||||
|
: dateToMarkdownString(dayAsDate)),
|
||||||
|
};
|
||||||
|
updateAssignmentMutation.mutate({
|
||||||
|
assignment,
|
||||||
|
moduleName: itemBeingDragged.sourceModuleName,
|
||||||
|
assignmentName: assignment.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
settings.defaultDueTime.hour,
|
||||||
|
settings.defaultDueTime.minute,
|
||||||
|
updateAssignmentMutation,
|
||||||
|
updatePageMutation,
|
||||||
|
updateQuizMutation,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggingContext.Provider
|
||||||
|
value={{
|
||||||
|
itemDrop,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DraggingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function getLaterDate(
|
||||||
|
firstDate: string | undefined,
|
||||||
|
dayAsDate: Date
|
||||||
|
): string | undefined {
|
||||||
|
return (
|
||||||
|
firstDate &&
|
||||||
|
(getDateFromStringOrThrow(firstDate, "lockAt date") > dayAsDate
|
||||||
|
? firstDate
|
||||||
|
: dateToMarkdownString(dayAsDate))
|
||||||
|
);
|
||||||
|
}
|
||||||
18
nextjs-pages/src/components/contexts/courseContext.ts
Normal file
18
nextjs-pages/src/components/contexts/courseContext.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export interface CourseContextInterface {
|
||||||
|
courseName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue: CourseContextInterface = {
|
||||||
|
courseName: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CourseContext =
|
||||||
|
createContext<CourseContextInterface>(defaultValue);
|
||||||
|
|
||||||
|
export function useCourseContext() {
|
||||||
|
return useContext(CourseContext);
|
||||||
|
}
|
||||||
|
|
||||||
21
nextjs-pages/src/components/contexts/draggingContext.tsx
Normal file
21
nextjs-pages/src/components/contexts/draggingContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
import { IModuleItem } from "@/models/local/IModuleItem";
|
||||||
|
import { createContext, useContext, DragEvent } from "react";
|
||||||
|
|
||||||
|
export interface DraggableItem {
|
||||||
|
item: IModuleItem;
|
||||||
|
sourceModuleName: string;
|
||||||
|
type: "quiz" | "assignment" | "page";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraggingContextInterface {
|
||||||
|
itemDrop: (e: DragEvent<HTMLDivElement>,droppedOnDay?: string) => void;
|
||||||
|
}
|
||||||
|
const defaultDraggingValue: DraggingContextInterface = {
|
||||||
|
itemDrop: () => { },
|
||||||
|
};
|
||||||
|
export const DraggingContext = createContext<DraggingContextInterface>(defaultDraggingValue);
|
||||||
|
|
||||||
|
export function useDraggingContext() {
|
||||||
|
return useContext(DraggingContext);
|
||||||
|
}
|
||||||
17
nextjs-pages/src/components/courses/CourseList.tsx
Normal file
17
nextjs-pages/src/components/courses/CourseList.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
import { useLocalCourseNamesQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function CourseList() {
|
||||||
|
const { data: courses } = useLocalCourseNamesQuery();
|
||||||
|
console.log(courses);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{courses.map((c) => (
|
||||||
|
<Link href={`/course/${c}`} key={c} shallow={true}>
|
||||||
|
{c}{" "}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
nextjs-pages/src/components/courses/CourseSettings.tsx
Normal file
8
nextjs-pages/src/components/courses/CourseSettings.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||||
|
|
||||||
|
export default function CourseSettings() {
|
||||||
|
const { data: settings } = useLocalCourseSettingsQuery();
|
||||||
|
return <div>{settings.name}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CalendarMonthModel } from "./calendarMonthUtils";
|
||||||
|
import { DayOfWeek } from "@/models/local/localCourse";
|
||||||
|
import Day from "./Day";
|
||||||
|
|
||||||
|
export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const isInPast =
|
||||||
|
new Date(month.year, month.month - 1, 1) < new Date(Date.now());
|
||||||
|
const monthName = new Date(month.year, month.month - 1, 1).toLocaleString(
|
||||||
|
"default",
|
||||||
|
{ month: "long" }
|
||||||
|
);
|
||||||
|
const toggleCollapse = () => setIsCollapsed(!isCollapsed);
|
||||||
|
// const collapseClass = isInPast ? "collapse _hide" : "collapse _show";
|
||||||
|
const weekDaysList: DayOfWeek[] = Object.values(DayOfWeek);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="text-center text-2xl">
|
||||||
|
{/* <button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link"
|
||||||
|
onClick={toggleCollapse}
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
aria-controls={monthName}
|
||||||
|
> */}
|
||||||
|
{monthName}
|
||||||
|
{/* </button> */}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div id={monthName}>
|
||||||
|
<div className="grid grid-cols-7 text-center fw-bold">
|
||||||
|
{weekDaysList.map((day) => (
|
||||||
|
<div key={day} className={""}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{month.daysByWeek.map((week, weekIndex) => (
|
||||||
|
<CalendarWeek key={weekIndex} week={week} monthNumber={month.month} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function CalendarWeek({
|
||||||
|
week,
|
||||||
|
monthNumber,
|
||||||
|
}: {
|
||||||
|
week: string[]; //date strings
|
||||||
|
monthNumber: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-7 m-3">
|
||||||
|
{week.map((day, dayIndex) => (
|
||||||
|
<Day key={dayIndex} day={day} month={monthNumber} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
import { getDateFromStringOrThrow } from "@/models/local/timeUtils";
|
||||||
|
import { getMonthsBetweenDates } from "./calendarMonthUtils";
|
||||||
|
import { CalendarMonth } from "./CalendarMonth";
|
||||||
|
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export default function CourseCalendar() {
|
||||||
|
const { data: settings } = useLocalCourseSettingsQuery();
|
||||||
|
|
||||||
|
const startDateTime = useMemo(
|
||||||
|
() => getDateFromStringOrThrow(settings.startDate, "course start date"),
|
||||||
|
[settings.startDate]
|
||||||
|
);
|
||||||
|
const endDateTime = useMemo(
|
||||||
|
() => getDateFromStringOrThrow(settings.endDate, "course end date"),
|
||||||
|
[settings.endDate]
|
||||||
|
);
|
||||||
|
const months = useMemo(
|
||||||
|
() => getMonthsBetweenDates(startDateTime, endDateTime),
|
||||||
|
[endDateTime, startDateTime]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
h-full
|
||||||
|
overflow-y-scroll
|
||||||
|
border-4
|
||||||
|
border-slate-600
|
||||||
|
rounded-xl
|
||||||
|
bg-slate-950
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{months.map((month) => (
|
||||||
|
<CalendarMonth key={month.month + "" + month.year} month={month} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
nextjs-pages/src/components/courses/calendar/Day.tsx
Normal file
38
nextjs-pages/src/components/courses/calendar/Day.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
import { useModuleNamesQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||||
|
import DayItemsInModule from "./DayItemsInModule";
|
||||||
|
import { getDateFromStringOrThrow } from "@/models/local/timeUtils";
|
||||||
|
import { useDraggingContext } from "@/components/contexts/draggingContext";
|
||||||
|
|
||||||
|
export default function Day({ day, month }: { day: string; month: number }) {
|
||||||
|
const { data: moduleNames } = useModuleNamesQuery();
|
||||||
|
|
||||||
|
const dayAsDate = getDateFromStringOrThrow(
|
||||||
|
day,
|
||||||
|
"calculating same month in day"
|
||||||
|
);
|
||||||
|
const isInSameMonth = dayAsDate.getMonth() + 1 != month;
|
||||||
|
const backgroundClass = isInSameMonth ? "" : "bg-slate-900";
|
||||||
|
const { itemDrop } = useDraggingContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"border border-slate-600 rounded-lg p-2 pb-4 m-1 " + backgroundClass
|
||||||
|
}
|
||||||
|
onDrop={(e) => {
|
||||||
|
itemDrop(e, day);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{dayAsDate.getDate()}
|
||||||
|
{moduleNames.map((moduleName) => (
|
||||||
|
<DayItemsInModule
|
||||||
|
key={"" + day + month + moduleName}
|
||||||
|
moduleName={moduleName}
|
||||||
|
day={day}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { getDateFromStringOrThrow } from "@/models/local/timeUtils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
usePageNamesQuery,
|
||||||
|
usePagesQueries,
|
||||||
|
} from "@/hooks/localCourse/pageHooks";
|
||||||
|
import {
|
||||||
|
useQuizNamesQuery,
|
||||||
|
useQuizzesQueries,
|
||||||
|
} from "@/hooks/localCourse/quizHooks";
|
||||||
|
import {
|
||||||
|
useAssignmentNamesQuery,
|
||||||
|
useAssignmentsQueries,
|
||||||
|
} from "@/hooks/localCourse/assignmentHooks";
|
||||||
|
import { useCourseContext } from "@/components/contexts/courseContext";
|
||||||
|
|
||||||
|
export default function DayItemsInModule({
|
||||||
|
day,
|
||||||
|
moduleName,
|
||||||
|
}: {
|
||||||
|
day: string;
|
||||||
|
moduleName: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className="list-disc ms-4">
|
||||||
|
<Assignments moduleName={moduleName} day={day} />
|
||||||
|
<Quizzes moduleName={moduleName} day={day} />
|
||||||
|
<Pages moduleName={moduleName} day={day} />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pages({ moduleName, day }: { moduleName: string; day: string }) {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
const { data: pageNames } = usePageNamesQuery(moduleName);
|
||||||
|
const { data: pages } = usePagesQueries(moduleName, pageNames);
|
||||||
|
const todaysPages = useMemo(
|
||||||
|
() =>
|
||||||
|
pages.filter((p) => {
|
||||||
|
const dueDate = getDateFromStringOrThrow(
|
||||||
|
p.dueAt,
|
||||||
|
"due at for page in day"
|
||||||
|
);
|
||||||
|
const dayAsDate = getDateFromStringOrThrow(
|
||||||
|
day,
|
||||||
|
"in pages in DayItemsInModule"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
dueDate.getFullYear() === dayAsDate.getFullYear() &&
|
||||||
|
dueDate.getMonth() === dayAsDate.getMonth() &&
|
||||||
|
dueDate.getDate() === dayAsDate.getDate()
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[day, pages]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{todaysPages.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.name}
|
||||||
|
role="button"
|
||||||
|
draggable="true"
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
"draggableItem",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "page",
|
||||||
|
item: p,
|
||||||
|
sourceModuleName: moduleName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
"/course/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/page/" +
|
||||||
|
encodeURIComponent(p.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Quizzes({ moduleName, day }: { moduleName: string; day: string }) {
|
||||||
|
const { data: quizNames } = useQuizNamesQuery(moduleName);
|
||||||
|
const { data: quizzes } = useQuizzesQueries(moduleName, quizNames);
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
|
||||||
|
const todaysQuizzes = useMemo(
|
||||||
|
() =>
|
||||||
|
quizzes.filter((q) => {
|
||||||
|
const dueDate = getDateFromStringOrThrow(
|
||||||
|
q.dueAt,
|
||||||
|
"due at for quiz in day"
|
||||||
|
);
|
||||||
|
const dayAsDate = getDateFromStringOrThrow(
|
||||||
|
day,
|
||||||
|
"in quizzes in DayItemsInModule"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
dueDate.getFullYear() === dayAsDate.getFullYear() &&
|
||||||
|
dueDate.getMonth() === dayAsDate.getMonth() &&
|
||||||
|
dueDate.getDate() === dayAsDate.getDate()
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[day, quizzes]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{todaysQuizzes.map((q) => (
|
||||||
|
<li
|
||||||
|
key={q.name}
|
||||||
|
role="button"
|
||||||
|
draggable="true"
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
"draggableItem",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "quiz",
|
||||||
|
item: q,
|
||||||
|
sourceModuleName: moduleName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onDragEnd={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
"/course/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/quiz/" +
|
||||||
|
encodeURIComponent(q.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{q.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assignments({ moduleName, day }: { moduleName: string; day: string }) {
|
||||||
|
const { data: assignmentNames } = useAssignmentNamesQuery(moduleName);
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
const { data: assignments } = useAssignmentsQueries(
|
||||||
|
moduleName,
|
||||||
|
assignmentNames
|
||||||
|
);
|
||||||
|
const todaysAssignments = useMemo(
|
||||||
|
() =>
|
||||||
|
assignments.filter((a) => {
|
||||||
|
const dueDate = getDateFromStringOrThrow(
|
||||||
|
a.dueAt,
|
||||||
|
"due at for assignment in day"
|
||||||
|
);
|
||||||
|
const dayAsDate = getDateFromStringOrThrow(
|
||||||
|
day,
|
||||||
|
"in assignment in DayItemsInModule"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
dueDate.getFullYear() === dayAsDate.getFullYear() &&
|
||||||
|
dueDate.getMonth() === dayAsDate.getMonth() &&
|
||||||
|
dueDate.getDate() === dayAsDate.getDate()
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[assignments, day]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{todaysAssignments.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.name}
|
||||||
|
role="button"
|
||||||
|
draggable="true"
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
"draggableItem",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assignment",
|
||||||
|
item: a,
|
||||||
|
sourceModuleName: moduleName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
"/course/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/assignment/" +
|
||||||
|
encodeURIComponent(a.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{a.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
dateToMarkdownString,
|
||||||
|
getDateFromStringOrThrow,
|
||||||
|
} from "@/models/local/timeUtils";
|
||||||
|
|
||||||
|
export interface CalendarMonthModel {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
weeks: number[][];
|
||||||
|
daysByWeek: string[][]; //iso date is memo-izable
|
||||||
|
}
|
||||||
|
|
||||||
|
function weeksInMonth(year: number, month: number): number {
|
||||||
|
const firstDayOfMonth = new Date(year, month - 1, 1).getDay();
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
const longDaysInMonth = daysInMonth + firstDayOfMonth;
|
||||||
|
let weeks = Math.floor(longDaysInMonth / 7);
|
||||||
|
if (longDaysInMonth % 7 > 0) {
|
||||||
|
weeks += 1;
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCalendarMonth(year: number, month: number): CalendarMonthModel {
|
||||||
|
const weeksNumber = weeksInMonth(year, month);
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
let currentDay = 1;
|
||||||
|
const firstDayOfMonth = new Date(year, month - 1, 1).getDay();
|
||||||
|
|
||||||
|
const daysByWeek = Array.from({ length: weeksNumber }).map((_, weekIndex) =>
|
||||||
|
Array.from({ length: 7 }).map((_, dayIndex) => {
|
||||||
|
if (weekIndex === 0 && dayIndex < firstDayOfMonth) {
|
||||||
|
return dateToMarkdownString(
|
||||||
|
new Date(year, month - 1, dayIndex - firstDayOfMonth + 1, 12, 0, 0)
|
||||||
|
);
|
||||||
|
} else if (currentDay <= daysInMonth) {
|
||||||
|
return dateToMarkdownString(new Date(year, month - 1, currentDay++, 12, 0, 0));
|
||||||
|
} else {
|
||||||
|
currentDay++;
|
||||||
|
return dateToMarkdownString(
|
||||||
|
new Date(year, month, currentDay - daysInMonth - 1, 12, 0, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const weeks = daysByWeek.map((week) =>
|
||||||
|
week.map((day) =>
|
||||||
|
getDateFromStringOrThrow(day, "calculating weeks").getDate()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { year, month, weeks, daysByWeek };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthsBetweenDates(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): CalendarMonthModel[] {
|
||||||
|
const monthsInTerm =
|
||||||
|
1 +
|
||||||
|
(endDate.getFullYear() - startDate.getFullYear()) * 12 +
|
||||||
|
endDate.getMonth() -
|
||||||
|
startDate.getMonth();
|
||||||
|
|
||||||
|
return Array.from({ length: monthsInTerm }, (_, monthDiff) => {
|
||||||
|
const month = ((startDate.getMonth() + monthDiff) % 12) + 1;
|
||||||
|
const year =
|
||||||
|
startDate.getFullYear() +
|
||||||
|
Math.floor((startDate.getMonth() + monthDiff) / 12);
|
||||||
|
return createCalendarMonth(year, month);
|
||||||
|
});
|
||||||
|
}
|
||||||
54
nextjs-pages/src/components/editor/InnerMonacoEditor.tsx
Normal file
54
nextjs-pages/src/components/editor/InnerMonacoEditor.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
import loader from "@monaco-editor/loader";
|
||||||
|
import { editor } from "monaco-editor/esm/vs/editor/editor.api";
|
||||||
|
|
||||||
|
export default function InnerMonacoEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void; // must be memoized
|
||||||
|
}) {
|
||||||
|
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (divRef.current && !editorRef.current) {
|
||||||
|
loader.init().then((monaco) => {
|
||||||
|
if (divRef.current && !editorRef.current) {
|
||||||
|
const properties: editor.IStandaloneEditorConstructionOptions = {
|
||||||
|
value: value,
|
||||||
|
language: "markdown",
|
||||||
|
tabSize: 2,
|
||||||
|
theme: "vs-dark",
|
||||||
|
minimap: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
lineNumbers: "off",
|
||||||
|
wordWrap: "on",
|
||||||
|
automaticLayout: true,
|
||||||
|
fontFamily: "Roboto-mono",
|
||||||
|
fontSize: 16,
|
||||||
|
padding: {
|
||||||
|
top: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
editorRef.current = monaco.editor.create(divRef.current, properties);
|
||||||
|
editorRef.current.onDidChangeModelContent((e) => {
|
||||||
|
onChange(editorRef.current?.getModel()?.getValue() ?? "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [onChange, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="Editor"
|
||||||
|
ref={divRef}
|
||||||
|
style={{ height: "100%", overflow: "hidden" }}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.Editor {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
13
nextjs-pages/src/components/editor/MonacoEditor.tsx
Executable file
13
nextjs-pages/src/components/editor/MonacoEditor.tsx
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const InnerMonacoEditor = dynamic(() => import("./InnerMonacoEditor"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MonacoEditor: React.FC<{
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}> = ({ value, onChange }) => {
|
||||||
|
return <InnerMonacoEditor value={value} onChange={onChange} />;
|
||||||
|
};
|
||||||
91
nextjs-pages/src/components/modules/ExpandableModule.tsx
Normal file
91
nextjs-pages/src/components/modules/ExpandableModule.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
useAssignmentNamesQuery,
|
||||||
|
useAssignmentsQueries,
|
||||||
|
} from "@/hooks/localCourse/assignmentHooks";
|
||||||
|
import {
|
||||||
|
usePageNamesQuery,
|
||||||
|
usePagesQueries,
|
||||||
|
} from "@/hooks/localCourse/pageHooks";
|
||||||
|
import {
|
||||||
|
useQuizNamesQuery,
|
||||||
|
useQuizzesQueries,
|
||||||
|
} from "@/hooks/localCourse/quizHooks";
|
||||||
|
import { IModuleItem } from "@/models/local/IModuleItem";
|
||||||
|
import { getDateFromStringOrThrow } from "@/models/local/timeUtils";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function ExpandableModule({
|
||||||
|
moduleName,
|
||||||
|
}: {
|
||||||
|
moduleName: string;
|
||||||
|
}) {
|
||||||
|
const { data: assignmentNames } = useAssignmentNamesQuery(moduleName);
|
||||||
|
const { data: quizNames } = useQuizNamesQuery(moduleName);
|
||||||
|
const { data: pageNames } = usePageNamesQuery(moduleName);
|
||||||
|
|
||||||
|
const { data: assignments } = useAssignmentsQueries(
|
||||||
|
moduleName,
|
||||||
|
assignmentNames
|
||||||
|
);
|
||||||
|
const { data: quizzes } = useQuizzesQueries(moduleName, quizNames);
|
||||||
|
const { data: pages } = usePagesQueries(moduleName, pageNames);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const moduleItems: {
|
||||||
|
type: "assignment" | "quiz" | "page";
|
||||||
|
item: IModuleItem;
|
||||||
|
}[] = assignments
|
||||||
|
.map(
|
||||||
|
(
|
||||||
|
a
|
||||||
|
): {
|
||||||
|
type: "assignment" | "quiz" | "page";
|
||||||
|
item: IModuleItem;
|
||||||
|
} => ({
|
||||||
|
type: "assignment",
|
||||||
|
item: a,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.concat(quizzes.map((q) => ({ type: "quiz", item: q })))
|
||||||
|
.concat(pages.map((p) => ({ type: "page", item: p })))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
getDateFromStringOrThrow(
|
||||||
|
a.item.dueAt,
|
||||||
|
"item due date in expandable module"
|
||||||
|
).getTime() -
|
||||||
|
getDateFromStringOrThrow(
|
||||||
|
b.item.dueAt,
|
||||||
|
"item due date in expandable module"
|
||||||
|
).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800 rounded-lg p-3 border border-slate-600 mb-3">
|
||||||
|
<div
|
||||||
|
className="font-bold "
|
||||||
|
role="button"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
>
|
||||||
|
{moduleName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
`
|
||||||
|
overflow-hidden
|
||||||
|
` + (expanded ? " max-h-[30vh]" : " max-h-0")
|
||||||
|
// transition-all duration-1000 ease-in
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
transition: "max-height 1s cubic-bezier(0, 1, 0, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<hr />
|
||||||
|
{moduleItems.map(({ type, item }) => (
|
||||||
|
<div key={item.name}>{item.name}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
nextjs-pages/src/components/modules/ModuleList.tsx
Normal file
14
nextjs-pages/src/components/modules/ModuleList.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
import { useModuleNamesQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||||
|
import ExpandableModule from "./ExpandableModule";
|
||||||
|
|
||||||
|
export default function ModuleList() {
|
||||||
|
const { data: moduleNames } = useModuleNamesQuery();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{moduleNames.map((m) => (
|
||||||
|
<ExpandableModule key={m} moduleName={m}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
|
||||||
|
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function AssignmentPreview({
|
||||||
|
assignment,
|
||||||
|
}: {
|
||||||
|
assignment: LocalAssignment;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-1 text-end pe-3">Due Date</div>
|
||||||
|
<div className="flex-1">{assignment.dueAt}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-1 text-end pe-3">Lock Date</div>
|
||||||
|
<div className="flex-1">{assignment.lockAt}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-1 text-end pe-3">Assignment Group Name</div>
|
||||||
|
<div className="flex-1">{assignment.localAssignmentGroupName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-1 text-end pe-3">Submission Types</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<ul className="">
|
||||||
|
{assignment.submissionTypes.map((t) => (
|
||||||
|
<li key={t}>{t}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-1 text-end pe-3">File Upload Types</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<ul className="">
|
||||||
|
{assignment.allowedFileUploadExtensions.map((t) => (
|
||||||
|
<li key={t}>{t}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<br />
|
||||||
|
<hr />
|
||||||
|
<br />
|
||||||
|
<section>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: markdownToHTMLSafe(assignment.description),
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
import { MonacoEditor } from "@/components/editor/MonacoEditor";
|
||||||
|
import {
|
||||||
|
useAssignmentQuery,
|
||||||
|
useUpdateAssignmentMutation,
|
||||||
|
} from "@/hooks/localCourse/assignmentHooks";
|
||||||
|
import { localAssignmentMarkdown } from "@/models/local/assignment/localAssignment";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import AssignmentPreview from "./AssignmentPreview";
|
||||||
|
|
||||||
|
export default function EditAssignment({
|
||||||
|
moduleName,
|
||||||
|
assignmentName,
|
||||||
|
}: {
|
||||||
|
assignmentName: string;
|
||||||
|
moduleName: string;
|
||||||
|
}) {
|
||||||
|
const { data: assignment } = useAssignmentQuery(moduleName, assignmentName);
|
||||||
|
const updateAssignment = useUpdateAssignmentMutation();
|
||||||
|
|
||||||
|
const [assignmentText, setAssignmentText] = useState(
|
||||||
|
localAssignmentMarkdown.toMarkdown(assignment)
|
||||||
|
);
|
||||||
|
console.log(assignmentText);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delay = 500;
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
const updatedAssignment =
|
||||||
|
localAssignmentMarkdown.parseMarkdown(assignmentText);
|
||||||
|
if (
|
||||||
|
localAssignmentMarkdown.toMarkdown(assignment) !==
|
||||||
|
localAssignmentMarkdown.toMarkdown(updatedAssignment)
|
||||||
|
) {
|
||||||
|
console.log("updating assignment");
|
||||||
|
try {
|
||||||
|
updateAssignment.mutate({
|
||||||
|
assignment: updatedAssignment,
|
||||||
|
moduleName,
|
||||||
|
assignmentName,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
assignment,
|
||||||
|
assignmentName,
|
||||||
|
assignmentText,
|
||||||
|
moduleName,
|
||||||
|
updateAssignment,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="columns-2 min-h-0 flex-1">
|
||||||
|
<div className="flex-1 h-full">
|
||||||
|
<MonacoEditor value={assignmentText} onChange={setAssignmentText} />
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<div className="text-red-300">{error && error}</div>
|
||||||
|
<AssignmentPreview assignment={assignment} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Add to canvas....
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
nextjs-pages/src/components/modules/page/EditPage.tsx
Normal file
40
nextjs-pages/src/components/modules/page/EditPage.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MonacoEditor } from "@/components/editor/MonacoEditor";
|
||||||
|
import { usePageQuery } from "@/hooks/localCourse/pageHooks";
|
||||||
|
import { localPageMarkdownUtils } from "@/models/local/page/localCoursePage";
|
||||||
|
import { useState } from "react";
|
||||||
|
import PagePreview from "./PagePreview";
|
||||||
|
|
||||||
|
export default function EditPage({
|
||||||
|
moduleName,
|
||||||
|
pageName,
|
||||||
|
}: {
|
||||||
|
pageName: string;
|
||||||
|
moduleName: string;
|
||||||
|
}) {
|
||||||
|
const { data: page } = usePageQuery(moduleName, pageName);
|
||||||
|
const [pageText, setPageText] = useState(
|
||||||
|
localPageMarkdownUtils.toMarkdown(page)
|
||||||
|
);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="columns-2 min-h-0 flex-1">
|
||||||
|
<div className="flex-1 h-full">
|
||||||
|
<MonacoEditor value={pageText} onChange={setPageText} />
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<div className="text-red-300">{error && error}</div>
|
||||||
|
<PagePreview page={page} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Add to canvas....
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
nextjs-pages/src/components/modules/page/PagePreview.tsx
Normal file
13
nextjs-pages/src/components/modules/page/PagePreview.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
|
||||||
|
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function PagePreview({ page }: { page: LocalCoursePage }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: markdownToHTMLSafe(page.text),
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
nextjs-pages/src/components/modules/quiz/EditQuiz.tsx
Normal file
66
nextjs-pages/src/components/modules/quiz/EditQuiz.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
import { MonacoEditor } from "@/components/editor/MonacoEditor";
|
||||||
|
import {
|
||||||
|
useQuizQuery,
|
||||||
|
useUpdateQuizMutation,
|
||||||
|
} from "@/hooks/localCourse/quizHooks";
|
||||||
|
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import QuizPreview from "./QuizPreview";
|
||||||
|
|
||||||
|
export default function EditQuiz({
|
||||||
|
moduleName,
|
||||||
|
quizName,
|
||||||
|
}: {
|
||||||
|
quizName: string;
|
||||||
|
moduleName: string;
|
||||||
|
}) {
|
||||||
|
const { data: quiz } = useQuizQuery(moduleName, quizName);
|
||||||
|
const updateQuizMutation = useUpdateQuizMutation();
|
||||||
|
const [quizText, setQuizText] = useState(quizMarkdownUtils.toMarkdown(quiz));
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delay = 500;
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
if (
|
||||||
|
quizMarkdownUtils.toMarkdown(quiz) !==
|
||||||
|
quizMarkdownUtils.toMarkdown(quizMarkdownUtils.parseMarkdown(quizText))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const updatedQuiz = quizMarkdownUtils.parseMarkdown(quizText);
|
||||||
|
updateQuizMutation.mutate({
|
||||||
|
quiz: updatedQuiz,
|
||||||
|
moduleName,
|
||||||
|
quizName,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [moduleName, quiz, quizName, quizText, updateQuizMutation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="columns-2 min-h-0 flex-1">
|
||||||
|
<div className="flex-1 h-full">
|
||||||
|
<MonacoEditor value={quizText} onChange={setQuizText} />
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<div className="text-red-300">{error && error}</div>
|
||||||
|
<QuizPreview quiz={quiz} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Add to canvas....
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
nextjs-pages/src/components/modules/quiz/QuizPreview.tsx
Normal file
133
nextjs-pages/src/components/modules/quiz/QuizPreview.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
|
||||||
|
import {
|
||||||
|
LocalQuizQuestion,
|
||||||
|
QuestionType,
|
||||||
|
} from "@/models/local/quiz/localQuizQuestion";
|
||||||
|
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
|
||||||
|
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||||
|
|
||||||
|
export default function QuizPreview({ quiz }: { quiz: LocalQuiz }) {
|
||||||
|
return (
|
||||||
|
<div style={{ overflow: "scroll", height: "100%" }}>
|
||||||
|
<div className="columns-2">
|
||||||
|
<div className="text-end">Name</div>
|
||||||
|
<div>{quiz.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="columns-2">
|
||||||
|
<div className="text-end">Due Date</div>
|
||||||
|
<div>{quiz.dueAt}</div>
|
||||||
|
</div>
|
||||||
|
<div className="columns-2">
|
||||||
|
<div className="text-end">Lock At</div>
|
||||||
|
<div>{quiz.lockAt}</div>
|
||||||
|
</div>
|
||||||
|
<div className="columns-2">
|
||||||
|
<div className="text-end">Shuffle Answers</div>
|
||||||
|
<div>{quiz.shuffleAnswers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="columns-2">
|
||||||
|
<div className="text-end">Allowed Attempts</div>
|
||||||
|
<div>{quiz.allowedAttempts}</div>
|
||||||
|
</div>
|
||||||
|
<div className="columns-2">
|
||||||
|
<div className="text-end">One Question at a Time</div>
|
||||||
|
<div>{quiz.oneQuestionAtATime}</div>
|
||||||
|
</div>
|
||||||
|
<div className="columns-2">
|
||||||
|
<div className="text-end">Assignment Group Name</div>
|
||||||
|
<div>{quiz.localAssignmentGroupName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{quiz.description}
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
{quiz.questions.map((question, i) => (
|
||||||
|
<QuizQuestionPreview
|
||||||
|
key={quizQuestionMarkdownUtils.toMarkdown(question)}
|
||||||
|
question={question}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuizQuestionPreview({ question }: { question: LocalQuizQuestion }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded">
|
||||||
|
<div>Points: {question.points}</div>
|
||||||
|
<div>Type: {question.questionType}</div>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: markdownToHTMLSafe(question.text) }}
|
||||||
|
></div>
|
||||||
|
{question.questionType === QuestionType.MATCHING && (
|
||||||
|
<div>
|
||||||
|
{question.answers.map((answer) => (
|
||||||
|
<div
|
||||||
|
key={JSON.stringify(answer)}
|
||||||
|
className="mx-3 mb-1 bg-dark px-2 rounded border flex row"
|
||||||
|
>
|
||||||
|
<div className="col text-right my-auto p-1">{answer.text} - </div>
|
||||||
|
<div className="col my-auto">{answer.matchedText}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{question.matchDistractors.map((distractor) => (
|
||||||
|
<div
|
||||||
|
key={distractor}
|
||||||
|
className="mx-3 mb-1 bg-dark px-2 rounded border flex row"
|
||||||
|
>
|
||||||
|
DISTRACTOR: {distractor}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{question.questionType !== QuestionType.MATCHING && (
|
||||||
|
<div>
|
||||||
|
{question.answers.map((answer) => (
|
||||||
|
<div
|
||||||
|
key={JSON.stringify(answer)}
|
||||||
|
className="mx-3 mb-1 bg-dark px-2 rounded flex flex-row border"
|
||||||
|
>
|
||||||
|
{answer.correct ? (
|
||||||
|
<svg
|
||||||
|
style={{ width: "1em" }}
|
||||||
|
className="me-1 my-auto"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 12.6111L8.92308 17.5L20 6.5"
|
||||||
|
stroke="green"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="mr-1 my-auto" style={{ width: "1em" }}>
|
||||||
|
{question.questionType === QuestionType.MULTIPLE_ANSWERS && (
|
||||||
|
<span>[ ]</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="markdownQuizAnswerPreview p-1"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: markdownToHTMLSafe(answer.text),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
nextjs-pages/src/components/providers.tsx
Normal file
24
nextjs-pages/src/components/providers.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
QueryClientProvider,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import { getQueryClient } from "./providersQueryClientUtils";
|
||||||
|
|
||||||
|
|
||||||
|
export default function Providers({ children }: { children: ReactNode }) {
|
||||||
|
// NOTE: Avoid useState when initializing the query client if you don't
|
||||||
|
// have a suspense boundary between this and the code that may
|
||||||
|
// suspend because React will throw away the client on the initial
|
||||||
|
// render if it suspends and there is no boundary
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
nextjs-pages/src/components/providersQueryClientUtils.ts
Normal file
31
nextjs-pages/src/components/providersQueryClientUtils.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { isServer, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function makeQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// With SSR, we usually want to set some default staleTime
|
||||||
|
// above 0 to avoid refetching immediately on the client
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let browserQueryClient: QueryClient | undefined = undefined;
|
||||||
|
|
||||||
|
export function getQueryClient() {
|
||||||
|
if (isServer) {
|
||||||
|
// Server: always make a new query client
|
||||||
|
return makeQueryClient();
|
||||||
|
} else {
|
||||||
|
// Browser: make a new query client if we don't already have one
|
||||||
|
// This is very important, so we don't re-make a new client if React
|
||||||
|
// suspends during the initial render. This may not be needed if we
|
||||||
|
// have a suspense boundary BELOW the creation of the query client
|
||||||
|
if (!browserQueryClient) browserQueryClient = makeQueryClient();
|
||||||
|
return browserQueryClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
nextjs-pages/src/components/spinner.css
Normal file
56
nextjs-pages/src/components/spinner.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.loader {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
border: 3px solid;
|
||||||
|
border-color: #6c757d #6c757d transparent transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: rotation 2s linear infinite;
|
||||||
|
}
|
||||||
|
.loader::after,
|
||||||
|
.loader::before {
|
||||||
|
content: '';
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
border: 3px solid;
|
||||||
|
border-color: transparent transparent #092565 #092565;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: rotationBack 1s linear infinite;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
/* #092565 */
|
||||||
|
/* #3a0647 */
|
||||||
|
.loader::before {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-color: #6c757d #6c757d transparent transparent;
|
||||||
|
animation: rotation 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotation {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes rotationBack {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(-360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
12
nextjs-pages/src/components/undefinedToNull.ts
Normal file
12
nextjs-pages/src/components/undefinedToNull.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function undefinedWithNull<T>(obj: T): T {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(undefinedWithNull) as unknown as T;
|
||||||
|
} else if (obj && typeof obj === 'object') {
|
||||||
|
return Object.keys(obj).reduce((acc, key) => {
|
||||||
|
const value = (obj as Record<string, unknown>)[key];
|
||||||
|
acc[key] = value === undefined ? null : undefinedWithNull(value);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, unknown>) as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
12
nextjs-pages/src/hooks/cavnasCouresHooks.ts
Normal file
12
nextjs-pages/src/hooks/cavnasCouresHooks.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { canvasService } from "@/services/canvas/canvasService";
|
||||||
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const canvasCourseKeys = {
|
||||||
|
courseDetails: (canavasId: number) => ["canvas course", canavasId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCanvasCourseQuery = (canvasId: number) =>
|
||||||
|
useSuspenseQuery({
|
||||||
|
queryKey: canvasCourseKeys.courseDetails(canvasId),
|
||||||
|
queryFn: async () => await canvasService.getCourse(canvasId),
|
||||||
|
});
|
||||||
141
nextjs-pages/src/hooks/hookHydration.ts
Normal file
141
nextjs-pages/src/hooks/hookHydration.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { localCourseKeys } from "./localCourse/localCourseKeys";
|
||||||
|
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
|
||||||
|
// https://tanstack.com/query/latest/docs/framework/react/guides/ssr
|
||||||
|
export const hydrateCourses = async (queryClient: QueryClient) => {
|
||||||
|
const allCourseNames = await fileStorageService.getCourseNames();
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.allCourses,
|
||||||
|
queryFn: () => allCourseNames,
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
allCourseNames.map(async (c) => await hydrateCourse(queryClient, c))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hydrateCourse = async (
|
||||||
|
queryClient: QueryClient,
|
||||||
|
courseName: string
|
||||||
|
) => {
|
||||||
|
const settings = await fileStorageService.getCourseSettings(courseName);
|
||||||
|
const moduleNames = await fileStorageService.getModuleNames(courseName);
|
||||||
|
const modulesData = await Promise.all(
|
||||||
|
moduleNames.map(async (moduleName) => {
|
||||||
|
const [assignmentNames, pageNames, quizNames] = await Promise.all([
|
||||||
|
await fileStorageService.getAssignmentNames(courseName, moduleName),
|
||||||
|
await fileStorageService.getPageNames(courseName, moduleName),
|
||||||
|
await fileStorageService.getQuizNames(courseName, moduleName),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [assignments, quizzes, pages] = await Promise.all([
|
||||||
|
await Promise.all(
|
||||||
|
assignmentNames.map(
|
||||||
|
async (assignmentName) =>
|
||||||
|
await fileStorageService.getAssignment(
|
||||||
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
assignmentName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
await Promise.all(
|
||||||
|
quizNames.map(
|
||||||
|
async (quizName) =>
|
||||||
|
await fileStorageService.getQuiz(courseName, moduleName, quizName)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
await Promise.all(
|
||||||
|
pageNames.map(
|
||||||
|
async (pageName) =>
|
||||||
|
await fileStorageService.getPage(courseName, moduleName, pageName)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
moduleName,
|
||||||
|
assignmentNames,
|
||||||
|
pageNames,
|
||||||
|
quizNames,
|
||||||
|
assignments,
|
||||||
|
quizzes,
|
||||||
|
pages,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.settings(courseName),
|
||||||
|
queryFn: () => settings,
|
||||||
|
});
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.moduleNames(courseName),
|
||||||
|
queryFn: () => moduleNames,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
modulesData.map(
|
||||||
|
async ({
|
||||||
|
moduleName,
|
||||||
|
assignmentNames,
|
||||||
|
pageNames,
|
||||||
|
quizNames,
|
||||||
|
assignments,
|
||||||
|
quizzes,
|
||||||
|
pages,
|
||||||
|
}) => {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.assignmentNames(courseName, moduleName),
|
||||||
|
queryFn: () => assignmentNames,
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
assignments.map(
|
||||||
|
async (assignment) =>
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.assignment(
|
||||||
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
assignment.name
|
||||||
|
),
|
||||||
|
queryFn: () => assignment,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.quizNames(courseName, moduleName),
|
||||||
|
queryFn: () => quizNames,
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
quizzes.map(
|
||||||
|
async (quiz) =>
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.quiz(
|
||||||
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
quiz.name
|
||||||
|
),
|
||||||
|
queryFn: () => quiz,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.pageNames(courseName, moduleName),
|
||||||
|
queryFn: () => pageNames,
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
pages.map(
|
||||||
|
async (page) =>
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: localCourseKeys.page(
|
||||||
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
page.name
|
||||||
|
),
|
||||||
|
queryFn: () => page,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
120
nextjs-pages/src/hooks/localCourse/assignmentHooks.ts
Normal file
120
nextjs-pages/src/hooks/localCourse/assignmentHooks.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
import axios from "axios";
|
||||||
|
import { localCourseKeys } from "./localCourseKeys";
|
||||||
|
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
|
||||||
|
import {
|
||||||
|
useSuspenseQuery,
|
||||||
|
useSuspenseQueries,
|
||||||
|
useQueryClient,
|
||||||
|
useMutation,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { useCourseContext } from "@/components/contexts/courseContext";
|
||||||
|
|
||||||
|
export const useAssignmentNamesQuery = (moduleName: string) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQuery({
|
||||||
|
queryKey: localCourseKeys.assignmentNames(courseName, moduleName),
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/assignments";
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAssignmentQueryConfig = (
|
||||||
|
courseName: string,
|
||||||
|
moduleName: string,
|
||||||
|
assignmentName: string
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
queryKey: localCourseKeys.assignment(
|
||||||
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
assignmentName
|
||||||
|
),
|
||||||
|
queryFn: async (): Promise<LocalAssignment> => {
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/assignments/" +
|
||||||
|
encodeURIComponent(assignmentName);
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export const useAssignmentQuery = (
|
||||||
|
moduleName: string,
|
||||||
|
assignmentName: string
|
||||||
|
) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
|
||||||
|
return useSuspenseQuery(
|
||||||
|
getAssignmentQueryConfig(courseName, moduleName, assignmentName)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAssignmentsQueries = (
|
||||||
|
moduleName: string,
|
||||||
|
assignmentNames: string[]
|
||||||
|
) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQueries({
|
||||||
|
queries: assignmentNames.map((name) =>
|
||||||
|
getAssignmentQueryConfig(courseName, moduleName, name)
|
||||||
|
),
|
||||||
|
combine: (results) => ({
|
||||||
|
data: results.map((r) => r.data),
|
||||||
|
pending: results.some((r) => r.isPending),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateAssignmentMutation = () => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
assignment,
|
||||||
|
moduleName,
|
||||||
|
assignmentName,
|
||||||
|
}: {
|
||||||
|
assignment: LocalAssignment;
|
||||||
|
moduleName: string;
|
||||||
|
assignmentName: string;
|
||||||
|
}) => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
localCourseKeys.assignment(courseName, moduleName, assignmentName),
|
||||||
|
assignment
|
||||||
|
);
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/assignments/" +
|
||||||
|
encodeURIComponent(assignmentName);
|
||||||
|
await axios.put(url, assignment);
|
||||||
|
},
|
||||||
|
onSuccess: (_, { moduleName, assignmentName }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: localCourseKeys.assignment(
|
||||||
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
assignmentName
|
||||||
|
),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: localCourseKeys.assignmentNames(courseName, moduleName),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
70
nextjs-pages/src/hooks/localCourse/localCourseKeys.ts
Normal file
70
nextjs-pages/src/hooks/localCourse/localCourseKeys.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export const localCourseKeys = {
|
||||||
|
allCourses: ["all courses"] as const,
|
||||||
|
settings: (courseName: string) =>
|
||||||
|
["course details", courseName, "settings"] as const,
|
||||||
|
moduleNames: (courseName: string) =>
|
||||||
|
[
|
||||||
|
"course details",
|
||||||
|
courseName,
|
||||||
|
"modules",
|
||||||
|
{ type: "names" } as const,
|
||||||
|
] as const,
|
||||||
|
assignmentNames: (courseName: string, moduleName: string) =>
|
||||||
|
[
|
||||||
|
"course details",
|
||||||
|
courseName,
|
||||||
|
"modules",
|
||||||
|
moduleName,
|
||||||
|
"assignments",
|
||||||
|
{ type: "names" },
|
||||||
|
] as const,
|
||||||
|
quizNames: (courseName: string, moduleName: string) =>
|
||||||
|
[
|
||||||
|
"course details",
|
||||||
|
courseName,
|
||||||
|
"modules",
|
||||||
|
moduleName,
|
||||||
|
"quizzes",
|
||||||
|
{ type: "names" },
|
||||||
|
] as const,
|
||||||
|
pageNames: (courseName: string, moduleName: string) =>
|
||||||
|
[
|
||||||
|
"course details",
|
||||||
|
courseName,
|
||||||
|
"modules",
|
||||||
|
moduleName,
|
||||||
|
"pages",
|
||||||
|
{ type: "names" },
|
||||||
|
] as const,
|
||||||
|
assignment: (
|
||||||
|
courseName: string,
|
||||||
|
moduleName: string,
|
||||||
|
assignmentName: string
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
"course details",
|
||||||
|
courseName,
|
||||||
|
"modules",
|
||||||
|
moduleName,
|
||||||
|
"assignments",
|
||||||
|
assignmentName,
|
||||||
|
] as const,
|
||||||
|
quiz: (courseName: string, moduleName: string, quizName: string) =>
|
||||||
|
[
|
||||||
|
"course details",
|
||||||
|
courseName,
|
||||||
|
"modules",
|
||||||
|
moduleName,
|
||||||
|
"quizzes",
|
||||||
|
quizName,
|
||||||
|
] as const,
|
||||||
|
page: (courseName: string, moduleName: string, pageName: string) =>
|
||||||
|
[
|
||||||
|
"course details",
|
||||||
|
courseName,
|
||||||
|
"modules",
|
||||||
|
moduleName,
|
||||||
|
"pages",
|
||||||
|
pageName,
|
||||||
|
] as const,
|
||||||
|
};
|
||||||
91
nextjs-pages/src/hooks/localCourse/localCoursesHooks.ts
Normal file
91
nextjs-pages/src/hooks/localCourse/localCoursesHooks.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
import { LocalCourseSettings } from "@/models/local/localCourse";
|
||||||
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { localCourseKeys } from "./localCourseKeys";
|
||||||
|
import { useCourseContext } from "@/components/contexts/courseContext";
|
||||||
|
|
||||||
|
export const useLocalCourseNamesQuery = () =>
|
||||||
|
useSuspenseQuery({
|
||||||
|
queryKey: localCourseKeys.allCourses,
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
const url = `/api/courses`;
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useLocalCourseSettingsQuery = () => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQuery({
|
||||||
|
queryKey: localCourseKeys.settings(courseName),
|
||||||
|
queryFn: async (): Promise<LocalCourseSettings> => {
|
||||||
|
const url = `/api/courses/${courseName}/settings`;
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useModuleNamesQuery = () => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQuery({
|
||||||
|
queryKey: localCourseKeys.moduleNames(courseName),
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
const url = `/api/courses/${courseName}/modules`;
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// dangerous? really slowed down page...
|
||||||
|
// maybe it only slowed down with react query devtools...
|
||||||
|
// export const useModuleDataQuery = (moduleName: string) => {
|
||||||
|
// console.log("running");
|
||||||
|
// const { data: assignmentNames } = useAssignmentNamesQuery(moduleName);
|
||||||
|
// const { data: quizNames } = useQuizNamesQuery(moduleName);
|
||||||
|
// const { data: pageNames } = usePageNamesQuery(moduleName);
|
||||||
|
|
||||||
|
// const { data: assignments } = useAssignmentsQueries(
|
||||||
|
// moduleName,
|
||||||
|
// assignmentNames
|
||||||
|
// );
|
||||||
|
// const { data: quizzes } = useQuizzesQueries(moduleName, quizNames);
|
||||||
|
// const { data: pages } = usePagesQueries(moduleName, pageNames);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// assignments,
|
||||||
|
// quizzes,
|
||||||
|
// pages,
|
||||||
|
// };
|
||||||
|
// // return useMemo(
|
||||||
|
// // () => ({
|
||||||
|
// // assignments,
|
||||||
|
// // quizzes,
|
||||||
|
// // pages,
|
||||||
|
// // }),
|
||||||
|
// // [assignments, pages, quizzes]
|
||||||
|
// // );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export const useUpdateCourseMutation = (courseName: string) => {
|
||||||
|
// const queryClient = useQueryClient();
|
||||||
|
// return useMutation({
|
||||||
|
// mutationFn: async (body: {
|
||||||
|
// updatedCourse: LocalCourse;
|
||||||
|
// previousCourse: LocalCourse;
|
||||||
|
// }) => {
|
||||||
|
// const url = `/api/courses/${courseName}`;
|
||||||
|
// await axios.put(url, body);
|
||||||
|
// },
|
||||||
|
// onSuccess: () => {
|
||||||
|
// queryClient.invalidateQueries({
|
||||||
|
// queryKey: localCourseKeys.settings(courseName),
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
// scope: {
|
||||||
|
// id: "all courses",
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// };
|
||||||
108
nextjs-pages/src/hooks/localCourse/pageHooks.ts
Normal file
108
nextjs-pages/src/hooks/localCourse/pageHooks.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
useSuspenseQueries,
|
||||||
|
useSuspenseQuery,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { localCourseKeys } from "./localCourseKeys";
|
||||||
|
import { useCourseContext } from "@/components/contexts/courseContext";
|
||||||
|
|
||||||
|
export const usePageNamesQuery = (moduleName: string) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQuery({
|
||||||
|
queryKey: localCourseKeys.pageNames(courseName, moduleName),
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/pages";
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const usePageQuery = (moduleName: string, pageName: string) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQuery(getPageQueryConfig(courseName, moduleName, pageName));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePagesQueries = (moduleName: string, pageNames: string[]) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQueries({
|
||||||
|
queries: pageNames.map((name) =>
|
||||||
|
getPageQueryConfig(courseName, moduleName, name)
|
||||||
|
),
|
||||||
|
combine: (results) => ({
|
||||||
|
data: results.map((r) => r.data),
|
||||||
|
pending: results.some((r) => r.isPending),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPageQueryConfig(
|
||||||
|
courseName: string,
|
||||||
|
moduleName: string,
|
||||||
|
pageName: string
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
queryKey: localCourseKeys.page(courseName, moduleName, pageName),
|
||||||
|
queryFn: async (): Promise<LocalCoursePage> => {
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/pages/" +
|
||||||
|
encodeURIComponent(pageName);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("error getting page", e, url);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdatePageMutation = () => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
page,
|
||||||
|
moduleName,
|
||||||
|
pageName,
|
||||||
|
}: {
|
||||||
|
page: LocalCoursePage;
|
||||||
|
moduleName: string;
|
||||||
|
pageName: string;
|
||||||
|
}) => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
localCourseKeys.page(courseName, moduleName, pageName),
|
||||||
|
page
|
||||||
|
);
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/pages/" +
|
||||||
|
encodeURIComponent(pageName);
|
||||||
|
await axios.put(url, page);
|
||||||
|
},
|
||||||
|
onSuccess: (_, { moduleName, pageName }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: localCourseKeys.page(courseName, moduleName, pageName),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: localCourseKeys.pageNames(courseName, moduleName),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
104
nextjs-pages/src/hooks/localCourse/quizHooks.ts
Normal file
104
nextjs-pages/src/hooks/localCourse/quizHooks.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
useSuspenseQueries,
|
||||||
|
useSuspenseQuery,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { localCourseKeys } from "./localCourseKeys";
|
||||||
|
import { useCourseContext } from "@/components/contexts/courseContext";
|
||||||
|
|
||||||
|
export const useQuizNamesQuery = (moduleName: string) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQuery({
|
||||||
|
queryKey: localCourseKeys.quizNames(courseName, moduleName),
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/quizzes";
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useQuizQuery = (moduleName: string, quizName: string) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQuery(getQuizQueryConfig(courseName, moduleName, quizName));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useQuizzesQueries = (moduleName: string, quizNames: string[]) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
return useSuspenseQueries({
|
||||||
|
queries: quizNames.map((name) =>
|
||||||
|
getQuizQueryConfig(courseName, moduleName, name)
|
||||||
|
),
|
||||||
|
combine: (results) => ({
|
||||||
|
data: results.map((r) => r.data),
|
||||||
|
pending: results.some((r) => r.isPending),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function getQuizQueryConfig(
|
||||||
|
courseName: string,
|
||||||
|
moduleName: string,
|
||||||
|
quizName: string
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
queryKey: localCourseKeys.quiz(courseName, moduleName, quizName),
|
||||||
|
queryFn: async (): Promise<LocalQuiz> => {
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/quizzes/" +
|
||||||
|
encodeURIComponent(quizName);
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateQuizMutation = () => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
quiz,
|
||||||
|
moduleName,
|
||||||
|
quizName,
|
||||||
|
}: {
|
||||||
|
quiz: LocalQuiz;
|
||||||
|
moduleName: string;
|
||||||
|
quizName: string;
|
||||||
|
}) => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
localCourseKeys.quiz(courseName, moduleName, quizName),
|
||||||
|
quiz
|
||||||
|
);
|
||||||
|
const url =
|
||||||
|
"/api/courses/" +
|
||||||
|
encodeURIComponent(courseName) +
|
||||||
|
"/modules/" +
|
||||||
|
encodeURIComponent(moduleName) +
|
||||||
|
"/quizzes/" +
|
||||||
|
encodeURIComponent(quizName);
|
||||||
|
await axios.put(url, quiz);
|
||||||
|
},
|
||||||
|
onSuccess: (_, { moduleName, quizName }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: localCourseKeys.quiz(courseName, moduleName, quizName),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: localCourseKeys.quizNames(courseName, moduleName),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { CanvasDiscussionTopicModel } from "../discussions/canvasDiscussionModelTopic";
|
||||||
|
import { CanvasSubmissionModel } from "../submissions/canvasSubmissionModel";
|
||||||
|
import { CanvasAssignmentDate } from "./canvasAssignmentDate";
|
||||||
|
import { CanvasAssignmentOverride } from "./canvasAssignmentOverride";
|
||||||
|
import { CanvasExternalToolTagAttributes } from "./canvasExternalToolTagAttributes";
|
||||||
|
import { CanvasLockInfo } from "./canvasLockInfo";
|
||||||
|
import { CanvasRubricCriteria } from "./canvasRubricCriteria";
|
||||||
|
import { CanvasTurnitinSettings } from "./canvasTurnitinSettings";
|
||||||
|
|
||||||
|
export interface CanvasAssignment {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string; // ISO 8601 date string
|
||||||
|
has_overrides: boolean;
|
||||||
|
course_id: number;
|
||||||
|
html_url: string;
|
||||||
|
submissions_download_url: string;
|
||||||
|
assignment_group_id: number;
|
||||||
|
due_date_required: boolean;
|
||||||
|
max_name_length: number;
|
||||||
|
peer_reviews: boolean;
|
||||||
|
automatic_peer_reviews: boolean;
|
||||||
|
position: number;
|
||||||
|
grading_type: string;
|
||||||
|
published: boolean;
|
||||||
|
unpublishable: boolean;
|
||||||
|
only_visible_to_overrides: boolean;
|
||||||
|
locked_for_user: boolean;
|
||||||
|
moderated_grading: boolean;
|
||||||
|
grader_count: number;
|
||||||
|
allowed_attempts: number;
|
||||||
|
is_quiz_assignment: boolean;
|
||||||
|
submission_types: string[];
|
||||||
|
updated_at?: string; // ISO 8601 date string
|
||||||
|
due_at?: string; // ISO 8601 date string
|
||||||
|
lock_at?: string; // ISO 8601 date string
|
||||||
|
unlock_at?: string; // ISO 8601 date string
|
||||||
|
all_dates?: CanvasAssignmentDate[];
|
||||||
|
allowed_extensions?: string[];
|
||||||
|
turnitin_enabled?: boolean;
|
||||||
|
vericite_enabled?: boolean;
|
||||||
|
turnitin_settings?: CanvasTurnitinSettings;
|
||||||
|
grade_group_students_individually?: boolean;
|
||||||
|
external_tool_tag_attributes?: CanvasExternalToolTagAttributes;
|
||||||
|
peer_review_count?: number;
|
||||||
|
peer_reviews_assign_at?: string; // ISO 8601 date string
|
||||||
|
intra_group_peer_reviews?: boolean;
|
||||||
|
group_category_id?: number;
|
||||||
|
needs_grading_count?: number;
|
||||||
|
needs_grading_count_by_section?: {
|
||||||
|
section_id: string;
|
||||||
|
needs_grading_count: number;
|
||||||
|
}[];
|
||||||
|
post_to_sis?: boolean;
|
||||||
|
integration_id?: string;
|
||||||
|
integration_data?: any;
|
||||||
|
muted?: boolean;
|
||||||
|
points_possible?: number;
|
||||||
|
has_submitted_submissions?: boolean;
|
||||||
|
grading_standard_id?: number;
|
||||||
|
lock_info?: CanvasLockInfo;
|
||||||
|
lock_explanation?: string;
|
||||||
|
quiz_id?: number;
|
||||||
|
anonymous_submissions?: boolean;
|
||||||
|
discussion_topic?: CanvasDiscussionTopicModel;
|
||||||
|
freeze_on_copy?: boolean;
|
||||||
|
frozen?: boolean;
|
||||||
|
frozen_attributes?: string[];
|
||||||
|
submission?: CanvasSubmissionModel;
|
||||||
|
use_rubric_for_grading?: boolean;
|
||||||
|
rubric_settings?: any;
|
||||||
|
rubric?: CanvasRubricCriteria[];
|
||||||
|
assignment_visibility?: number[];
|
||||||
|
overrides?: CanvasAssignmentOverride[];
|
||||||
|
omit_from_final_grade?: boolean;
|
||||||
|
final_grader_id?: number;
|
||||||
|
grader_comments_visible_to_graders?: boolean;
|
||||||
|
graders_anonymous_to_graders?: boolean;
|
||||||
|
grader_names_visible_to_final_grader?: boolean;
|
||||||
|
anonymous_grading?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface CanvasAssignmentDate {
|
||||||
|
title: string;
|
||||||
|
id?: number;
|
||||||
|
base?: boolean;
|
||||||
|
due_at?: string; // ISO 8601 date string
|
||||||
|
unlock_at?: string; // ISO 8601 date string
|
||||||
|
lock_at?: string; // ISO 8601 date string
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface CanvasAssignmentOverride {
|
||||||
|
id: number;
|
||||||
|
assignment_id: number;
|
||||||
|
course_section_id: number;
|
||||||
|
title: string;
|
||||||
|
student_ids?: number[];
|
||||||
|
group_id?: number;
|
||||||
|
due_at?: string; // ISO 8601 date string
|
||||||
|
all_day?: boolean;
|
||||||
|
all_day_date?: string; // ISO 8601 date string
|
||||||
|
unlock_at?: string; // ISO 8601 date string
|
||||||
|
lock_at?: string; // ISO 8601 date string
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface CanvasExternalToolTagAttributes {
|
||||||
|
url: string;
|
||||||
|
resource_link_id: string;
|
||||||
|
new_tab?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface CanvasLockInfo {
|
||||||
|
asset_string: string;
|
||||||
|
unlock_at?: string; // ISO 8601 date string
|
||||||
|
lock_at?: string; // ISO 8601 date string
|
||||||
|
context_module?: any;
|
||||||
|
manually_locked?: boolean;
|
||||||
|
}
|
||||||
13
nextjs-pages/src/models/canvas/assignments/canvasRubric.ts
Normal file
13
nextjs-pages/src/models/canvas/assignments/canvasRubric.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface CanvasRubric {
|
||||||
|
id?: number;
|
||||||
|
title: string;
|
||||||
|
context_id: number;
|
||||||
|
context_type: string;
|
||||||
|
points_possible: number;
|
||||||
|
reusable: boolean;
|
||||||
|
read_only: boolean;
|
||||||
|
hide_score_total?: boolean;
|
||||||
|
// Uncomment and define if needed
|
||||||
|
// data: CanvasRubricCriteria[];
|
||||||
|
// free_form_criterion_comments?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface CanvasRubricAssociation {
|
||||||
|
id: number;
|
||||||
|
rubric_id: number;
|
||||||
|
association_id: number;
|
||||||
|
association_type: string;
|
||||||
|
use_for_grading: boolean;
|
||||||
|
summary_data?: string;
|
||||||
|
purpose: string;
|
||||||
|
hide_score_total?: boolean;
|
||||||
|
hide_points: boolean;
|
||||||
|
hide_outcome_results: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface CanvasRubricCriteria {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
long_description: string;
|
||||||
|
points?: number;
|
||||||
|
learning_outcome_id?: string;
|
||||||
|
vendor_guid?: string;
|
||||||
|
criterion_use_range?: boolean;
|
||||||
|
ratings?: {
|
||||||
|
points: number;
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
long_description: string;
|
||||||
|
}[];
|
||||||
|
ignore_for_scoring?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface CanvasTurnitinSettings {
|
||||||
|
originality_report_visibility: string;
|
||||||
|
s_paper_check: boolean;
|
||||||
|
internet_check: boolean;
|
||||||
|
journal_check: boolean;
|
||||||
|
exclude_biblio: boolean;
|
||||||
|
exclude_quoted: boolean;
|
||||||
|
exclude_small_matches_type?: boolean;
|
||||||
|
exclude_small_matches_value?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface CanvasCalendarLinkModel {
|
||||||
|
ics: string;
|
||||||
|
}
|
||||||
56
nextjs-pages/src/models/canvas/courses/canvasCourseModel.ts
Normal file
56
nextjs-pages/src/models/canvas/courses/canvasCourseModel.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { CanvasEnrollmentModel } from "../enrollments/canvasEnrollmentModel";
|
||||||
|
import { CanvasCalendarLinkModel } from "./canvasCalendarLinkModel";
|
||||||
|
import { CanvasCourseProgressModel } from "./canvasCourseProgressModel";
|
||||||
|
import { CanvasTermModel } from "./canvasTermModel";
|
||||||
|
|
||||||
|
export interface CanvasCourseModel {
|
||||||
|
id: number;
|
||||||
|
sis_course_id: string;
|
||||||
|
uuid: string;
|
||||||
|
integration_id: string;
|
||||||
|
name: string;
|
||||||
|
course_code: string;
|
||||||
|
workflow_state: string;
|
||||||
|
account_id: number;
|
||||||
|
root_account_id: number;
|
||||||
|
enrollment_term_id: number;
|
||||||
|
created_at: string; // ISO 8601 date string
|
||||||
|
locale: string;
|
||||||
|
calendar: CanvasCalendarLinkModel;
|
||||||
|
default_view: string;
|
||||||
|
syllabus_body: string;
|
||||||
|
permissions: { [key: string]: boolean };
|
||||||
|
storage_quota_mb: number;
|
||||||
|
storage_quota_used_mb: number;
|
||||||
|
license: string;
|
||||||
|
course_format: string;
|
||||||
|
time_zone: string;
|
||||||
|
sis_import_id?: number;
|
||||||
|
grading_standard_id?: number;
|
||||||
|
start_at?: string; // ISO 8601 date string
|
||||||
|
end_at?: string; // ISO 8601 date string
|
||||||
|
enrollments?: CanvasEnrollmentModel[];
|
||||||
|
total_students?: number;
|
||||||
|
needs_grading_count?: number;
|
||||||
|
term?: CanvasTermModel;
|
||||||
|
course_progress?: CanvasCourseProgressModel;
|
||||||
|
apply_assignment_group_weights?: boolean;
|
||||||
|
is_public?: boolean;
|
||||||
|
is_public_to_auth_users?: boolean;
|
||||||
|
public_syllabus?: boolean;
|
||||||
|
public_syllabus_to_auth?: boolean;
|
||||||
|
public_description?: string;
|
||||||
|
hide_final_grades?: boolean;
|
||||||
|
allow_student_assignment_edits?: boolean;
|
||||||
|
allow_wiki_comments?: boolean;
|
||||||
|
allow_student_forum_attachments?: boolean;
|
||||||
|
open_enrollment?: boolean;
|
||||||
|
self_enrollment?: boolean;
|
||||||
|
restrict_enrollments_to_course_dates?: boolean;
|
||||||
|
access_restricted_by_date?: boolean;
|
||||||
|
blueprint?: boolean;
|
||||||
|
blueprint_restrictions?: { [key: string]: boolean };
|
||||||
|
blueprint_restrictions_by_object_type?: {
|
||||||
|
[key: string]: { [key: string]: boolean };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CanvasCourseProgressModel {
|
||||||
|
requirement_count?: number;
|
||||||
|
requirement_completed_count?: number;
|
||||||
|
next_requirement_url?: string;
|
||||||
|
completed_at?: string; // ISO 8601 date string
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface CanvasCourseSettingsModel {
|
||||||
|
allow_final_grade_override: boolean;
|
||||||
|
allow_student_discussion_topics: boolean;
|
||||||
|
allow_student_forum_attachments: boolean;
|
||||||
|
allow_student_discussion_editing: boolean;
|
||||||
|
grading_standard_enabled: boolean;
|
||||||
|
allow_student_organized_groups: boolean;
|
||||||
|
hide_final_grades: boolean;
|
||||||
|
hide_distribution_graphs: boolean;
|
||||||
|
lock_all_announcements: boolean;
|
||||||
|
restrict_student_past_view: boolean;
|
||||||
|
restrict_student_future_view: boolean;
|
||||||
|
show_announcements_on_home_page: boolean;
|
||||||
|
home_page_announcement_limit: number;
|
||||||
|
grading_standard_id?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CanvasTermModel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
start_at?: string; // ISO 8601 date string
|
||||||
|
end_at?: string; // ISO 8601 date string
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
|
||||||
|
import { CanvasFileAttachmentModel } from "./canvasFileAttachmentModel";
|
||||||
|
|
||||||
|
export interface CanvasDiscussionTopicModel {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
html_url: string;
|
||||||
|
read_state: string;
|
||||||
|
subscription_hold: string;
|
||||||
|
assignment_id: number;
|
||||||
|
lock_explanation: string;
|
||||||
|
user_name: string;
|
||||||
|
topic_children: number[];
|
||||||
|
podcast_url: string;
|
||||||
|
discussion_type: string;
|
||||||
|
attachments: CanvasFileAttachmentModel[];
|
||||||
|
permissions: { [key: string]: boolean };
|
||||||
|
author: CanvasUserDisplayModel;
|
||||||
|
unread_count?: number;
|
||||||
|
subscribed?: boolean;
|
||||||
|
posted_at?: string; // ISO 8601 date string
|
||||||
|
last_reply_at?: string; // ISO 8601 date string
|
||||||
|
require_initial_post?: boolean;
|
||||||
|
user_can_see_posts?: boolean;
|
||||||
|
discussion_subentry_count?: number;
|
||||||
|
delayed_post_at?: string; // ISO 8601 date string
|
||||||
|
published?: boolean;
|
||||||
|
lock_at?: string; // ISO 8601 date string
|
||||||
|
locked?: boolean;
|
||||||
|
pinned?: boolean;
|
||||||
|
locked_for_user?: boolean;
|
||||||
|
lock_info?: any;
|
||||||
|
group_topic_children?: any;
|
||||||
|
root_topic_id?: number;
|
||||||
|
group_category_id?: number;
|
||||||
|
allow_rating?: boolean;
|
||||||
|
only_graders_can_rate?: boolean;
|
||||||
|
sort_by_rating?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CanvasFileAttachmentModel {
|
||||||
|
content_type: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface CanvasEnrollmentTermModel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sis_term_id?: string;
|
||||||
|
sis_import_id?: number;
|
||||||
|
start_at?: string; // ISO 8601 date string
|
||||||
|
end_at?: string; // ISO 8601 date string
|
||||||
|
grading_period_group_id?: number;
|
||||||
|
workflow_state?: string;
|
||||||
|
overrides?: {
|
||||||
|
[key: string]: {
|
||||||
|
start_at?: string; // ISO 8601 date string
|
||||||
|
end_at?: string; // ISO 8601 date string
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
|
||||||
|
import { CanvasGradeModel } from "./canvasGradeModel";
|
||||||
|
|
||||||
|
export interface CanvasEnrollmentModel {
|
||||||
|
id: number;
|
||||||
|
course_id: number;
|
||||||
|
enrollment_state: string;
|
||||||
|
type: string;
|
||||||
|
user_id: number;
|
||||||
|
role: string;
|
||||||
|
role_id: number;
|
||||||
|
html_url: string;
|
||||||
|
grades: CanvasGradeModel;
|
||||||
|
user: CanvasUserDisplayModel;
|
||||||
|
override_grade: string;
|
||||||
|
sis_course_id?: string;
|
||||||
|
course_integration_id?: string;
|
||||||
|
course_section_id?: number;
|
||||||
|
section_integration_id?: string;
|
||||||
|
sis_account_id?: string;
|
||||||
|
sis_section_id?: string;
|
||||||
|
sis_user_id?: string;
|
||||||
|
limit_privileges_to_course_section?: boolean;
|
||||||
|
sis_import_id?: number;
|
||||||
|
root_account_id?: number;
|
||||||
|
associated_user_id?: number;
|
||||||
|
created_at?: string; // ISO 8601 date string
|
||||||
|
updated_at?: string; // ISO 8601 date string
|
||||||
|
start_at?: string; // ISO 8601 date string
|
||||||
|
end_at?: string; // ISO 8601 date string
|
||||||
|
last_activity_at?: string; // ISO 8601 date string
|
||||||
|
last_attended_at?: string; // ISO 8601 date string
|
||||||
|
total_activity_time?: number;
|
||||||
|
override_score?: number;
|
||||||
|
unposted_current_grade?: string;
|
||||||
|
unposted_final_grade?: string;
|
||||||
|
unposted_current_score?: string;
|
||||||
|
unposted_final_score?: string;
|
||||||
|
has_grading_periods?: boolean;
|
||||||
|
totals_for_all_grading_periods_option?: boolean;
|
||||||
|
current_grading_period_title?: string;
|
||||||
|
current_grading_period_id?: number;
|
||||||
|
current_period_override_grade?: string;
|
||||||
|
current_period_override_score?: number;
|
||||||
|
current_period_unposted_final_score?: number;
|
||||||
|
current_period_unposted_current_grade?: string;
|
||||||
|
current_period_unposted_final_grade?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface CanvasGradeModel {
|
||||||
|
html_url?: string;
|
||||||
|
current_grade?: number;
|
||||||
|
final_grade?: number;
|
||||||
|
current_score?: number;
|
||||||
|
final_score?: number;
|
||||||
|
unposted_current_grade?: number;
|
||||||
|
unposted_final_grade?: number;
|
||||||
|
unposted_current_score?: number;
|
||||||
|
unposted_final_score?: number;
|
||||||
|
}
|
||||||
18
nextjs-pages/src/models/canvas/modules/canvasModule.ts
Normal file
18
nextjs-pages/src/models/canvas/modules/canvasModule.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { CanvasModuleItem } from "./canvasModuleItems";
|
||||||
|
|
||||||
|
export interface CanvasModule {
|
||||||
|
id: number;
|
||||||
|
workflow_state: string;
|
||||||
|
position: number;
|
||||||
|
name: string;
|
||||||
|
unlock_at?: string; // ISO 8601 date string
|
||||||
|
require_sequential_progress?: boolean;
|
||||||
|
prerequisite_module_ids?: number[];
|
||||||
|
items_count: number;
|
||||||
|
items_url: string;
|
||||||
|
items?: CanvasModuleItem[];
|
||||||
|
state?: string;
|
||||||
|
completed_at?: string; // ISO 8601 date string
|
||||||
|
publish_final_grade?: boolean;
|
||||||
|
published?: boolean;
|
||||||
|
}
|
||||||
26
nextjs-pages/src/models/canvas/modules/canvasModuleItems.ts
Normal file
26
nextjs-pages/src/models/canvas/modules/canvasModuleItems.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export interface CanvasModuleItem {
|
||||||
|
id: number;
|
||||||
|
module_id: number;
|
||||||
|
position: number;
|
||||||
|
title: string;
|
||||||
|
indent?: number;
|
||||||
|
type: string;
|
||||||
|
content_id?: number;
|
||||||
|
html_url: string;
|
||||||
|
url?: string;
|
||||||
|
page_url?: string;
|
||||||
|
external_url?: string;
|
||||||
|
new_tab: boolean;
|
||||||
|
completion_requirement?: {
|
||||||
|
type: string;
|
||||||
|
min_score?: number;
|
||||||
|
completed?: boolean;
|
||||||
|
};
|
||||||
|
published?: boolean;
|
||||||
|
content_details?: {
|
||||||
|
due_at?: string; // ISO 8601 date string
|
||||||
|
lock_at?: string; // ISO 8601 date string
|
||||||
|
points_possible: number;
|
||||||
|
locked_for_user: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
16
nextjs-pages/src/models/canvas/pages/canvasPageModel.ts
Normal file
16
nextjs-pages/src/models/canvas/pages/canvasPageModel.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface CanvasPage {
|
||||||
|
page_id: number;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
published: boolean;
|
||||||
|
front_page: boolean;
|
||||||
|
body?: string;
|
||||||
|
// Uncomment and define if needed
|
||||||
|
// created_at: string; // ISO 8601 date string
|
||||||
|
// updated_at: string; // ISO 8601 date string
|
||||||
|
// editing_roles: string;
|
||||||
|
// last_edited_by: UserDisplayModel;
|
||||||
|
// locked_for_user: boolean;
|
||||||
|
// lock_info?: LockInfoModel;
|
||||||
|
// lock_explanation?: string;
|
||||||
|
}
|
||||||
44
nextjs-pages/src/models/canvas/quizzes/canvasQuizModel.ts
Normal file
44
nextjs-pages/src/models/canvas/quizzes/canvasQuizModel.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { CanvasLockInfo } from "../assignments/canvasLockInfo";
|
||||||
|
import { CanvasQuizPermissions } from "./canvasQuizPermission";
|
||||||
|
|
||||||
|
export interface CanvasQuiz {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
html_url: string;
|
||||||
|
mobile_url: string;
|
||||||
|
preview_url?: string;
|
||||||
|
description: string;
|
||||||
|
quiz_type: string;
|
||||||
|
assignment_group_id?: number;
|
||||||
|
time_limit?: number;
|
||||||
|
shuffle_answers?: boolean;
|
||||||
|
hide_results?: string;
|
||||||
|
show_correct_answers?: boolean;
|
||||||
|
show_correct_answers_last_attempt?: boolean;
|
||||||
|
show_correct_answers_at?: string; // ISO 8601 date string
|
||||||
|
hide_correct_answers_at?: string; // ISO 8601 date string
|
||||||
|
one_time_results?: boolean;
|
||||||
|
scoring_policy?: string;
|
||||||
|
allowed_attempts: number;
|
||||||
|
one_question_at_a_time?: boolean;
|
||||||
|
question_count?: number;
|
||||||
|
points_possible?: number;
|
||||||
|
cant_go_back?: boolean;
|
||||||
|
access_code?: string;
|
||||||
|
ip_filter?: string;
|
||||||
|
due_at?: string; // ISO 8601 date string
|
||||||
|
lock_at?: string; // ISO 8601 date string
|
||||||
|
unlock_at?: string; // ISO 8601 date string
|
||||||
|
published?: boolean;
|
||||||
|
unpublishable?: boolean;
|
||||||
|
locked_for_user?: boolean;
|
||||||
|
lock_info?: CanvasLockInfo;
|
||||||
|
lock_explanation?: string;
|
||||||
|
speedgrader_url?: string;
|
||||||
|
quiz_extensions_url?: string;
|
||||||
|
permissions: CanvasQuizPermissions;
|
||||||
|
all_dates?: any; // Depending on the structure of the dates, this could be further specified
|
||||||
|
version_number?: number;
|
||||||
|
question_types?: string[];
|
||||||
|
anonymous_submissions?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface CanvasQuizPermissions {
|
||||||
|
read: boolean;
|
||||||
|
submit: boolean;
|
||||||
|
create: boolean;
|
||||||
|
manage: boolean;
|
||||||
|
read_statistics: boolean;
|
||||||
|
review_grades: boolean;
|
||||||
|
update: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { CanvasAssignment } from "../assignments/canvasAssignment";
|
||||||
|
import { CanvasCourseModel } from "../courses/canvasCourseModel";
|
||||||
|
import { CanvasUserModel } from "../users/canvasUserModel";
|
||||||
|
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
|
||||||
|
|
||||||
|
export interface CanvasSubmissionModel {
|
||||||
|
assignment_id: number;
|
||||||
|
grade: string;
|
||||||
|
html_url: string;
|
||||||
|
preview_url: string;
|
||||||
|
submission_type: string;
|
||||||
|
user_id: number;
|
||||||
|
user: CanvasUserModel;
|
||||||
|
workflow_state: string;
|
||||||
|
late_policy_status: string;
|
||||||
|
assignment?: CanvasAssignment;
|
||||||
|
course?: CanvasCourseModel;
|
||||||
|
attempt?: number;
|
||||||
|
body?: string;
|
||||||
|
grade_matches_current_submission?: boolean;
|
||||||
|
score?: number;
|
||||||
|
submission_comments?: {
|
||||||
|
id: number;
|
||||||
|
author_id: number;
|
||||||
|
author_name: string;
|
||||||
|
author: CanvasUserDisplayModel;
|
||||||
|
comment: string;
|
||||||
|
created_at: string; // ISO 8601 date string
|
||||||
|
edited_at?: string; // ISO 8601 date string
|
||||||
|
media_comment?: {
|
||||||
|
content_type: string;
|
||||||
|
display_name: string;
|
||||||
|
media_id: string;
|
||||||
|
media_type: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
submitted_at?: string; // ISO 8601 date string
|
||||||
|
url?: string;
|
||||||
|
grader_id?: number;
|
||||||
|
graded_at?: string; // ISO 8601 date string
|
||||||
|
late?: boolean;
|
||||||
|
assignment_visible?: boolean;
|
||||||
|
excused?: boolean;
|
||||||
|
missing?: boolean;
|
||||||
|
points_deducted?: number;
|
||||||
|
seconds_late?: number;
|
||||||
|
extra_attempts?: number;
|
||||||
|
anonymous_id?: string;
|
||||||
|
}
|
||||||
21
nextjs-pages/src/models/canvas/users/canvasUserModel.ts
Normal file
21
nextjs-pages/src/models/canvas/users/canvasUserModel.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { CanvasEnrollmentModel } from "../enrollments/canvasEnrollmentModel";
|
||||||
|
|
||||||
|
export interface CanvasUserModel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sortable_name: string;
|
||||||
|
short_name: string;
|
||||||
|
sis_user_id: string;
|
||||||
|
integration_id: string;
|
||||||
|
login_id: string;
|
||||||
|
avatar_url: string;
|
||||||
|
enrollments: CanvasEnrollmentModel[];
|
||||||
|
email: string;
|
||||||
|
locale: string;
|
||||||
|
effective_locale: string;
|
||||||
|
time_zone: string;
|
||||||
|
bio: string;
|
||||||
|
permissions: { [key: string]: boolean };
|
||||||
|
sis_import_id?: number;
|
||||||
|
last_login?: string; // ISO 8601 date string
|
||||||
|
}
|
||||||
9
nextjs-pages/src/models/canvas/users/userDisplayModel.ts
Normal file
9
nextjs-pages/src/models/canvas/users/userDisplayModel.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface CanvasUserDisplayModel {
|
||||||
|
avatar_image_url: string;
|
||||||
|
html_url: string;
|
||||||
|
anonymous_id: string;
|
||||||
|
id?: number;
|
||||||
|
short_name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
pronouns?: string;
|
||||||
|
}
|
||||||
4
nextjs-pages/src/models/local/IModuleItem.ts
Normal file
4
nextjs-pages/src/models/local/IModuleItem.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface IModuleItem {
|
||||||
|
name: string;
|
||||||
|
dueAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export enum AssignmentSubmissionType {
|
||||||
|
ONLINE_TEXT_ENTRY = "online_text_entry",
|
||||||
|
ONLINE_UPLOAD = "online_upload",
|
||||||
|
ONLINE_QUIZ = "online_quiz",
|
||||||
|
DISCUSSION_TOPIC = "discussion_topic",
|
||||||
|
ONLINE_URL = "online_url",
|
||||||
|
NONE = "none",
|
||||||
|
}
|
||||||
21
nextjs-pages/src/models/local/assignment/localAssignment.ts
Normal file
21
nextjs-pages/src/models/local/assignment/localAssignment.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { IModuleItem } from "../IModuleItem";
|
||||||
|
import { AssignmentSubmissionType } from "./assignmentSubmissionType";
|
||||||
|
import { RubricItem } from "./rubricItem";
|
||||||
|
import { assignmentMarkdownParser } from "./utils/assignmentMarkdownParser";
|
||||||
|
import { assignmentMarkdownSerializer } from "./utils/assignmentMarkdownSerializer";
|
||||||
|
|
||||||
|
export interface LocalAssignment extends IModuleItem {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
lockAt?: string; // 08/21/2023 23:59:00
|
||||||
|
dueAt: string; // 08/21/2023 23:59:00
|
||||||
|
localAssignmentGroupName?: string;
|
||||||
|
submissionTypes: AssignmentSubmissionType[];
|
||||||
|
allowedFileUploadExtensions: string[];
|
||||||
|
rubric: RubricItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localAssignmentMarkdown = {
|
||||||
|
parseMarkdown: assignmentMarkdownParser.parseMarkdown,
|
||||||
|
toMarkdown: assignmentMarkdownSerializer.toMarkdown,
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface LocalAssignmentGroup {
|
||||||
|
canvasId?: number;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
10
nextjs-pages/src/models/local/assignment/rubricItem.ts
Normal file
10
nextjs-pages/src/models/local/assignment/rubricItem.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface RubricItem {
|
||||||
|
label: string;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const rubricItemIsExtraCredit = (item: RubricItem) => {
|
||||||
|
const extraCredit = '(extra credit)';
|
||||||
|
return item.label.toLowerCase().includes(extraCredit.toLowerCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
verifyDateOrThrow,
|
||||||
|
verifyDateStringOrUndefined,
|
||||||
|
} from "../../timeUtils";
|
||||||
|
import { AssignmentSubmissionType } from "../assignmentSubmissionType";
|
||||||
|
import { LocalAssignment } from "../localAssignment";
|
||||||
|
import { RubricItem } from "../rubricItem";
|
||||||
|
import { extractLabelValue } from "./markdownUtils";
|
||||||
|
|
||||||
|
const parseFileUploadExtensions = (input: string) => {
|
||||||
|
const allowedFileUploadExtensions: string[] = [];
|
||||||
|
const regex = /- (.+)/;
|
||||||
|
|
||||||
|
const words = input.split("AllowedFileUploadExtensions:");
|
||||||
|
if (words.length < 2) return allowedFileUploadExtensions;
|
||||||
|
|
||||||
|
const inputAfterSubmissionTypes = words[1];
|
||||||
|
const lines = inputAfterSubmissionTypes
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = regex.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
if (line === "") continue;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedFileUploadExtensions.push(match[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowedFileUploadExtensions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseIndividualRubricItemMarkdown = (rawMarkdown: string) => {
|
||||||
|
const pointsPattern = /\s*-\s*(-?\d+(?:\.\d+)?)\s*pt(s)?:/;
|
||||||
|
const match = pointsPattern.exec(rawMarkdown);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Points not found: ${rawMarkdown}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = parseFloat(match[1]);
|
||||||
|
const label = rawMarkdown.split(": ").slice(1).join(": ");
|
||||||
|
|
||||||
|
const item: RubricItem = { points, label };
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSettings = (input: string) => {
|
||||||
|
const name = extractLabelValue(input, "Name");
|
||||||
|
const rawLockAt = extractLabelValue(input, "LockAt");
|
||||||
|
const rawDueAt = extractLabelValue(input, "DueAt");
|
||||||
|
const assignmentGroupName = extractLabelValue(input, "AssignmentGroupName");
|
||||||
|
const submissionTypes = parseSubmissionTypes(input);
|
||||||
|
const fileUploadExtensions = parseFileUploadExtensions(input);
|
||||||
|
|
||||||
|
const dueAt = verifyDateOrThrow(rawDueAt, "DueAt");
|
||||||
|
const lockAt = verifyDateStringOrUndefined(rawLockAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
assignmentGroupName,
|
||||||
|
submissionTypes,
|
||||||
|
fileUploadExtensions,
|
||||||
|
dueAt,
|
||||||
|
lockAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSubmissionTypes = (input: string): AssignmentSubmissionType[] => {
|
||||||
|
const submissionTypes: AssignmentSubmissionType[] = [];
|
||||||
|
const regex = /- (.+)/;
|
||||||
|
|
||||||
|
const words = input.split("SubmissionTypes:");
|
||||||
|
if (words.length < 2) return submissionTypes;
|
||||||
|
|
||||||
|
const inputAfterSubmissionTypes = words[1]; // doesn't consider other settings that follow...
|
||||||
|
const lines = inputAfterSubmissionTypes
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = regex.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
if (line === "") continue;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeString = match[1].trim();
|
||||||
|
const type = Object.values(AssignmentSubmissionType).find(
|
||||||
|
(t) => t === typeString
|
||||||
|
);
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
submissionTypes.push(type);
|
||||||
|
} else {
|
||||||
|
console.warn(`Unknown submission type: ${typeString}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return submissionTypes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRubricMarkdown = (rawMarkdown: string) => {
|
||||||
|
if (!rawMarkdown.trim()) return [];
|
||||||
|
|
||||||
|
const lines = rawMarkdown.trim().split("\n");
|
||||||
|
return lines.map(parseIndividualRubricItemMarkdown);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignmentMarkdownParser = {
|
||||||
|
parseRubricMarkdown,
|
||||||
|
parseMarkdown(input: string): LocalAssignment {
|
||||||
|
const settingsString = input.split("---")[0];
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
assignmentGroupName,
|
||||||
|
submissionTypes,
|
||||||
|
fileUploadExtensions,
|
||||||
|
dueAt,
|
||||||
|
lockAt,
|
||||||
|
} = parseSettings(settingsString);
|
||||||
|
|
||||||
|
const description = input
|
||||||
|
.split("---\n")
|
||||||
|
.slice(1)
|
||||||
|
.join("---\n")
|
||||||
|
.split("## Rubric")[0]
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const rubricString = input.split("## Rubric\n")[1];
|
||||||
|
const rubric = parseRubricMarkdown(rubricString);
|
||||||
|
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
name: name.trim(),
|
||||||
|
localAssignmentGroupName: assignmentGroupName.trim(),
|
||||||
|
submissionTypes: submissionTypes,
|
||||||
|
allowedFileUploadExtensions: fileUploadExtensions,
|
||||||
|
dueAt: dueAt,
|
||||||
|
lockAt: lockAt,
|
||||||
|
rubric: rubric,
|
||||||
|
description: description,
|
||||||
|
};
|
||||||
|
return assignment;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { AssignmentSubmissionType } from "../assignmentSubmissionType";
|
||||||
|
import { LocalAssignment } from "../localAssignment";
|
||||||
|
import { RubricItem } from "../rubricItem";
|
||||||
|
|
||||||
|
const assignmentRubricToMarkdown = (assignment: LocalAssignment) => {
|
||||||
|
return assignment.rubric
|
||||||
|
.map((item: RubricItem) => {
|
||||||
|
const pointLabel = item.points > 1 ? "pts" : "pt";
|
||||||
|
return `- ${item.points}${pointLabel}: ${item.label}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsToMarkdown = (assignment: LocalAssignment) => {
|
||||||
|
const printableDueDate = assignment.dueAt.toString().replace("\u202F", " ");
|
||||||
|
const printableLockAt =
|
||||||
|
assignment.lockAt?.toString().replace("\u202F", " ") || "";
|
||||||
|
|
||||||
|
const submissionTypesMarkdown = assignment.submissionTypes
|
||||||
|
.map((submissionType: AssignmentSubmissionType) => `- ${submissionType}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const allowedFileUploadExtensionsMarkdown =
|
||||||
|
assignment.allowedFileUploadExtensions
|
||||||
|
.map((fileExtension: string) => `- ${fileExtension}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const settingsMarkdown = [
|
||||||
|
`Name: ${assignment.name}`,
|
||||||
|
`LockAt: ${printableLockAt}`,
|
||||||
|
`DueAt: ${printableDueDate}`,
|
||||||
|
`AssignmentGroupName: ${assignment.localAssignmentGroupName}`,
|
||||||
|
`SubmissionTypes:\n${submissionTypesMarkdown}`,
|
||||||
|
`AllowedFileUploadExtensions:\n${allowedFileUploadExtensionsMarkdown}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return settingsMarkdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignmentMarkdownSerializer = {
|
||||||
|
toMarkdown(assignment: LocalAssignment): string {
|
||||||
|
const settingsMarkdown = settingsToMarkdown(assignment);
|
||||||
|
const rubricMarkdown = assignmentRubricToMarkdown(assignment);
|
||||||
|
const assignmentMarkdown = `${settingsMarkdown}\n---\n\n${assignment.description}\n\n## Rubric\n\n${rubricMarkdown}`;
|
||||||
|
|
||||||
|
return assignmentMarkdown;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const extractLabelValue = (input: string, label: string) => {
|
||||||
|
const pattern = new RegExp(`${label}: (.*?)\n`);
|
||||||
|
const match = pattern.exec(input);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
63
nextjs-pages/src/models/local/localCourse.ts
Normal file
63
nextjs-pages/src/models/local/localCourse.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { LocalAssignmentGroup } from "./assignment/localAssignmentGroup";
|
||||||
|
import { LocalModule } from "./localModules";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
|
||||||
|
export interface LocalCourse {
|
||||||
|
modules: LocalModule[];
|
||||||
|
settings: LocalCourseSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleTimeOnly {
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalCourseSettings {
|
||||||
|
name: string;
|
||||||
|
assignmentGroups: LocalAssignmentGroup[];
|
||||||
|
daysOfWeek: DayOfWeek[];
|
||||||
|
canvasId?: number;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
defaultDueTime: SimpleTimeOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DayOfWeek {
|
||||||
|
Sunday = "Sunday",
|
||||||
|
Monday = "Monday",
|
||||||
|
Tuesday = "Tuesday",
|
||||||
|
Wednesday = "Wednesday",
|
||||||
|
Thursday = "Thursday",
|
||||||
|
Friday = "Friday",
|
||||||
|
Saturday = "Saturday",
|
||||||
|
}
|
||||||
|
export const localCourseYamlUtils = {
|
||||||
|
parseSettingYaml: (settingsString: string): LocalCourseSettings => {
|
||||||
|
const settings = parse(settingsString);
|
||||||
|
return lowercaseFirstLetter(settings);
|
||||||
|
},
|
||||||
|
settingsToYaml: (settings: LocalCourseSettings) => {
|
||||||
|
return stringify(settings);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function lowercaseFirstLetter<T>(obj: T): T {
|
||||||
|
if (obj === null || typeof obj !== "object") return obj as T;
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) return obj.map(lowercaseFirstLetter) as unknown as T;
|
||||||
|
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
Object.keys(obj).forEach((key) => {
|
||||||
|
const value = (obj as Record<string, any>)[key];
|
||||||
|
const newKey = key.charAt(0).toLowerCase() + key.slice(1);
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
result[newKey] = lowercaseFirstLetter(value);
|
||||||
|
} else {
|
||||||
|
result[newKey] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
75
nextjs-pages/src/models/local/localModules.ts
Normal file
75
nextjs-pages/src/models/local/localModules.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { LocalAssignment } from "./assignment/localAssignment";
|
||||||
|
import { IModuleItem } from "./IModuleItem";
|
||||||
|
import { LocalCoursePage } from "./page/localCoursePage";
|
||||||
|
import { LocalQuiz } from "./quiz/localQuiz";
|
||||||
|
import { getDateFromString } from "./timeUtils";
|
||||||
|
|
||||||
|
export interface LocalModule {
|
||||||
|
name: string;
|
||||||
|
assignments: LocalAssignment[];
|
||||||
|
quizzes: LocalQuiz[];
|
||||||
|
pages: LocalCoursePage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocalModuleUtils = {
|
||||||
|
getSortedModuleItems(module: LocalModule): IModuleItem[] {
|
||||||
|
return [...module.assignments, ...module.quizzes, ...module.pages].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(getDateFromString(a.dueAt)?.getTime() ?? 0) -
|
||||||
|
(getDateFromString(b.dueAt)?.getTime() ?? 0)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
equals(module1: LocalModule, module2: LocalModule): boolean {
|
||||||
|
return (
|
||||||
|
module1.name.toLowerCase() === module2.name.toLowerCase() &&
|
||||||
|
LocalModuleUtils.compareCollections(
|
||||||
|
module1.assignments.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
module2.assignments.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
) &&
|
||||||
|
LocalModuleUtils.compareCollections(
|
||||||
|
module1.quizzes.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
module2.quizzes.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
) &&
|
||||||
|
LocalModuleUtils.compareCollections(
|
||||||
|
module1.pages.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
module2.pages.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
compareCollections<T>(first: T[], second: T[]): boolean {
|
||||||
|
if (first.length !== second.length) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < first.length; i++) {
|
||||||
|
if (JSON.stringify(first[i]) !== JSON.stringify(second[i])) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
getHashCode(module: LocalModule): number {
|
||||||
|
const hash = new Map<string, number>();
|
||||||
|
hash.set(module.name.toLowerCase(), 1);
|
||||||
|
LocalModuleUtils.addRangeToHash(
|
||||||
|
hash,
|
||||||
|
module.assignments.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
|
LocalModuleUtils.addRangeToHash(
|
||||||
|
hash,
|
||||||
|
module.quizzes.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
|
LocalModuleUtils.addRangeToHash(
|
||||||
|
hash,
|
||||||
|
module.pages.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
return Array.from(hash.values()).reduce((acc, val) => acc + val, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
addRangeToHash<T>(hash: Map<string, number>, items: T[]): void {
|
||||||
|
for (const item of items) {
|
||||||
|
hash.set(JSON.stringify(item), 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
33
nextjs-pages/src/models/local/page/localCoursePage.ts
Normal file
33
nextjs-pages/src/models/local/page/localCoursePage.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { extractLabelValue } from "../assignment/utils/markdownUtils";
|
||||||
|
import { IModuleItem } from "../IModuleItem";
|
||||||
|
import { verifyDateOrThrow } from "../timeUtils";
|
||||||
|
|
||||||
|
export interface LocalCoursePage extends IModuleItem {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
dueAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localPageMarkdownUtils = {
|
||||||
|
toMarkdown: (page: LocalCoursePage) => {
|
||||||
|
const printableDueDate = verifyDateOrThrow(page.dueAt, "page DueDateForOrdering")
|
||||||
|
const settingsMarkdown = `Name: ${page.name}\nDueDateForOrdering: ${printableDueDate}\n---\n`;
|
||||||
|
return settingsMarkdown + page.text;
|
||||||
|
},
|
||||||
|
|
||||||
|
parseMarkdown: (pageMarkdown: string) => {
|
||||||
|
const rawSettings = pageMarkdown.split("---")[0];
|
||||||
|
const name = extractLabelValue(rawSettings, "Name");
|
||||||
|
const rawDate = extractLabelValue(rawSettings, "DueDateForOrdering");
|
||||||
|
const dueAt = verifyDateOrThrow(rawDate, "page DueDateForOrdering");
|
||||||
|
|
||||||
|
const text = pageMarkdown.split("---\n")[1];
|
||||||
|
|
||||||
|
const page: LocalCoursePage = {
|
||||||
|
name,
|
||||||
|
dueAt,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
return page;
|
||||||
|
},
|
||||||
|
};
|
||||||
22
nextjs-pages/src/models/local/quiz/localQuiz.ts
Normal file
22
nextjs-pages/src/models/local/quiz/localQuiz.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { IModuleItem } from "../IModuleItem";
|
||||||
|
import { LocalQuizQuestion } from "./localQuizQuestion";
|
||||||
|
import { quizMarkdownUtils } from "./utils/quizMarkdownUtils";
|
||||||
|
|
||||||
|
export interface LocalQuiz extends IModuleItem {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
password?: string;
|
||||||
|
lockAt?: string; // ISO 8601 date string
|
||||||
|
dueAt: string; // ISO 8601 date string
|
||||||
|
shuffleAnswers: boolean;
|
||||||
|
showCorrectAnswers: boolean;
|
||||||
|
oneQuestionAtATime: boolean;
|
||||||
|
localAssignmentGroupName?: string;
|
||||||
|
allowedAttempts: number;
|
||||||
|
questions: LocalQuizQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localQuizMarkdownUtils = {
|
||||||
|
parseMarkdown: quizMarkdownUtils.parseMarkdown,
|
||||||
|
toMarkdown: quizMarkdownUtils.toMarkdown,
|
||||||
|
};
|
||||||
18
nextjs-pages/src/models/local/quiz/localQuizQuestion.ts
Normal file
18
nextjs-pages/src/models/local/quiz/localQuizQuestion.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { LocalQuizQuestionAnswer } from "./localQuizQuestionAnswer";
|
||||||
|
|
||||||
|
export interface LocalQuizQuestion {
|
||||||
|
text: string;
|
||||||
|
questionType: QuestionType;
|
||||||
|
points: number;
|
||||||
|
answers: LocalQuizQuestionAnswer[];
|
||||||
|
matchDistractors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum QuestionType {
|
||||||
|
MULTIPLE_ANSWERS = "multiple_answers",
|
||||||
|
MULTIPLE_CHOICE = "multiple_choice",
|
||||||
|
ESSAY = "essay",
|
||||||
|
SHORT_ANSWER = "short_answer",
|
||||||
|
MATCHING = "matching",
|
||||||
|
NONE = "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface LocalQuizQuestionAnswer {
|
||||||
|
correct: boolean;
|
||||||
|
text: string;
|
||||||
|
matchedText?: string;
|
||||||
|
}
|
||||||
142
nextjs-pages/src/models/local/quiz/utils/quizMarkdownUtils.ts
Normal file
142
nextjs-pages/src/models/local/quiz/utils/quizMarkdownUtils.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { verifyDateOrThrow, verifyDateStringOrUndefined } from "../../timeUtils";
|
||||||
|
import { LocalQuiz } from "../localQuiz";
|
||||||
|
import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils";
|
||||||
|
|
||||||
|
const extractLabelValue = (input: string, label: string): string => {
|
||||||
|
const pattern = new RegExp(`${label}: (.*?)\n`);
|
||||||
|
const match = pattern.exec(input);
|
||||||
|
return match ? match[1].trim() : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractDescription = (input: string): string => {
|
||||||
|
const pattern = new RegExp("Description: (.*?)$", "s");
|
||||||
|
const match = pattern.exec(input);
|
||||||
|
return match ? match[1].trim() : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBooleanOrThrow = (value: string, label: string): boolean => {
|
||||||
|
if (value.toLowerCase() === "true") return true;
|
||||||
|
if (value.toLowerCase() === "false") return false;
|
||||||
|
throw new Error(`Error with ${label}: ${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBooleanOrDefault = (
|
||||||
|
value: string,
|
||||||
|
label: string,
|
||||||
|
defaultValue: boolean
|
||||||
|
): boolean => {
|
||||||
|
if (value.toLowerCase() === "true") return true;
|
||||||
|
if (value.toLowerCase() === "false") return false;
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNumberOrThrow = (value: string, label: string): number => {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (isNaN(parsed)) {
|
||||||
|
throw new Error(`Error with ${label}: ${value}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
const getQuizWithOnlySettings = (settings: string): LocalQuiz => {
|
||||||
|
const name = extractLabelValue(settings, "Name");
|
||||||
|
|
||||||
|
const rawShuffleAnswers = extractLabelValue(settings, "ShuffleAnswers");
|
||||||
|
const shuffleAnswers = parseBooleanOrThrow(
|
||||||
|
rawShuffleAnswers,
|
||||||
|
"ShuffleAnswers"
|
||||||
|
);
|
||||||
|
|
||||||
|
const password = extractLabelValue(settings, "Password") || undefined;
|
||||||
|
|
||||||
|
const rawShowCorrectAnswers = extractLabelValue(
|
||||||
|
settings,
|
||||||
|
"ShowCorrectAnswers"
|
||||||
|
);
|
||||||
|
const showCorrectAnswers = parseBooleanOrDefault(
|
||||||
|
rawShowCorrectAnswers,
|
||||||
|
"ShowCorrectAnswers",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawOneQuestionAtATime = extractLabelValue(
|
||||||
|
settings,
|
||||||
|
"OneQuestionAtATime"
|
||||||
|
);
|
||||||
|
const oneQuestionAtATime = parseBooleanOrThrow(
|
||||||
|
rawOneQuestionAtATime,
|
||||||
|
"OneQuestionAtATime"
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawAllowedAttempts = extractLabelValue(settings, "AllowedAttempts");
|
||||||
|
const allowedAttempts = parseNumberOrThrow(
|
||||||
|
rawAllowedAttempts,
|
||||||
|
"AllowedAttempts"
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawDueAt = extractLabelValue(settings, "DueAt");
|
||||||
|
const dueAt = verifyDateOrThrow(rawDueAt, "DueAt");
|
||||||
|
|
||||||
|
|
||||||
|
const rawLockAt = extractLabelValue(settings, "LockAt");
|
||||||
|
const lockAt = verifyDateStringOrUndefined(rawLockAt);
|
||||||
|
|
||||||
|
const description = extractDescription(settings);
|
||||||
|
const localAssignmentGroupName = extractLabelValue(
|
||||||
|
settings,
|
||||||
|
"AssignmentGroup"
|
||||||
|
);
|
||||||
|
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
password,
|
||||||
|
lockAt,
|
||||||
|
dueAt,
|
||||||
|
shuffleAnswers,
|
||||||
|
showCorrectAnswers,
|
||||||
|
oneQuestionAtATime,
|
||||||
|
localAssignmentGroupName,
|
||||||
|
allowedAttempts,
|
||||||
|
questions: [],
|
||||||
|
};
|
||||||
|
return quiz;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quizMarkdownUtils = {
|
||||||
|
toMarkdown(quiz: LocalQuiz): string {
|
||||||
|
const questionMarkdownArray = quiz.questions.map((q) =>
|
||||||
|
quizQuestionMarkdownUtils.toMarkdown(q)
|
||||||
|
);
|
||||||
|
const questionDelimiter = "\n\n---\n\n";
|
||||||
|
const questionMarkdown = questionMarkdownArray.join(questionDelimiter);
|
||||||
|
|
||||||
|
return `Name: ${quiz.name}
|
||||||
|
LockAt: ${quiz.lockAt ?? ""}
|
||||||
|
DueAt: ${quiz.dueAt}
|
||||||
|
Password: ${quiz.password ?? ""}
|
||||||
|
ShuffleAnswers: ${quiz.shuffleAnswers.toString().toLowerCase()}
|
||||||
|
ShowCorrectAnswers: ${quiz.showCorrectAnswers.toString().toLowerCase()}
|
||||||
|
OneQuestionAtATime: ${quiz.oneQuestionAtATime.toString().toLowerCase()}
|
||||||
|
AssignmentGroup: ${quiz.localAssignmentGroupName}
|
||||||
|
AllowedAttempts: ${quiz.allowedAttempts}
|
||||||
|
Description: ${quiz.description}
|
||||||
|
---
|
||||||
|
${questionMarkdown}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
parseMarkdown(input: string): LocalQuiz {
|
||||||
|
const splitInput = input.split("---\n");
|
||||||
|
const settings = splitInput[0];
|
||||||
|
const quizWithoutQuestions = getQuizWithOnlySettings(settings);
|
||||||
|
|
||||||
|
const rawQuestions = splitInput.slice(1);
|
||||||
|
const questions = rawQuestions
|
||||||
|
.filter((str) => str.trim().length > 0)
|
||||||
|
.map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...quizWithoutQuestions,
|
||||||
|
questions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { QuestionType } from "../localQuizQuestion";
|
||||||
|
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
||||||
|
|
||||||
|
export const quizQuestionAnswerMarkdownUtils = {
|
||||||
|
// getHtmlText(): string {
|
||||||
|
// return MarkdownService.render(this.text);
|
||||||
|
// }
|
||||||
|
|
||||||
|
parseMarkdown(input: string, questionType: string): LocalQuizQuestionAnswer {
|
||||||
|
const isCorrect = input.startsWith("*") || input[1] === "*";
|
||||||
|
|
||||||
|
if (questionType === QuestionType.MATCHING) {
|
||||||
|
const matchingPattern = /^\^ ?/;
|
||||||
|
const textWithoutMatchDelimiter = input
|
||||||
|
.replace(matchingPattern, "")
|
||||||
|
.trim();
|
||||||
|
const [text, ...matchedParts] = textWithoutMatchDelimiter.split("-");
|
||||||
|
const answer: LocalQuizQuestionAnswer = {
|
||||||
|
correct: true,
|
||||||
|
text: text.trim(),
|
||||||
|
matchedText: matchedParts.join("-").trim(),
|
||||||
|
};
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startingQuestionPattern = /^(\*?[a-z]?\))|\[\s*\]|\[\*\]|\^ /;
|
||||||
|
|
||||||
|
let replaceCount = 0;
|
||||||
|
const text = input
|
||||||
|
.replace(startingQuestionPattern, (m) => (replaceCount++ === 0 ? "" : m))
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const answer: LocalQuizQuestionAnswer = {
|
||||||
|
correct: isCorrect,
|
||||||
|
text: text,
|
||||||
|
};
|
||||||
|
return answer;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { LocalQuiz } from "../localQuiz";
|
||||||
|
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
|
||||||
|
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
||||||
|
import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
|
||||||
|
|
||||||
|
const _validFirstAnswerDelimiters = ["*a)", "a)", "*)", ")", "[ ]", "[*]", "^"];
|
||||||
|
|
||||||
|
const getAnswerStringsWithMultilineSupport = (
|
||||||
|
linesWithoutPoints: string[],
|
||||||
|
questionIndex: number
|
||||||
|
) => {
|
||||||
|
const indexOfAnswerStart = linesWithoutPoints.findIndex((l) =>
|
||||||
|
_validFirstAnswerDelimiters.some((prefix) =>
|
||||||
|
l.trimStart().startsWith(prefix)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (indexOfAnswerStart === -1) {
|
||||||
|
const debugLine = linesWithoutPoints.find((l) => l.trim().length > 0);
|
||||||
|
throw Error(
|
||||||
|
`question ${
|
||||||
|
questionIndex + 1
|
||||||
|
}: no answers when detecting question type on ${debugLine}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerLinesRaw = linesWithoutPoints.slice(indexOfAnswerStart);
|
||||||
|
|
||||||
|
const answerStartPattern = /^(\*?[a-z]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
|
||||||
|
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
|
||||||
|
const isNewAnswer = answerStartPattern.test(line);
|
||||||
|
if (isNewAnswer) {
|
||||||
|
acc.push(line);
|
||||||
|
} else if (acc.length !== 0) {
|
||||||
|
acc[acc.length - 1] += "\n" + line;
|
||||||
|
} else {
|
||||||
|
acc.push(line);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
return answerLines;
|
||||||
|
};
|
||||||
|
const getQuestionType = (
|
||||||
|
linesWithoutPoints: string[],
|
||||||
|
questionIndex: number
|
||||||
|
): QuestionType => {
|
||||||
|
if (linesWithoutPoints.length === 0) return QuestionType.NONE;
|
||||||
|
if (
|
||||||
|
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() === "essay"
|
||||||
|
)
|
||||||
|
return QuestionType.ESSAY;
|
||||||
|
if (
|
||||||
|
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
|
||||||
|
"short answer"
|
||||||
|
)
|
||||||
|
return QuestionType.SHORT_ANSWER;
|
||||||
|
if (
|
||||||
|
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
|
||||||
|
"short_answer"
|
||||||
|
)
|
||||||
|
return QuestionType.SHORT_ANSWER;
|
||||||
|
|
||||||
|
const answerLines = getAnswerStringsWithMultilineSupport(
|
||||||
|
linesWithoutPoints,
|
||||||
|
questionIndex
|
||||||
|
);
|
||||||
|
const firstAnswerLine = answerLines[0];
|
||||||
|
const isMultipleChoice = ["a)", "*a)", "*)", ")"].some((prefix) =>
|
||||||
|
firstAnswerLine.startsWith(prefix)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE;
|
||||||
|
|
||||||
|
const isMultipleAnswer = ["[ ]", "[*]"].some((prefix) =>
|
||||||
|
firstAnswerLine.startsWith(prefix)
|
||||||
|
);
|
||||||
|
if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS;
|
||||||
|
|
||||||
|
const isMatching = firstAnswerLine.startsWith("^");
|
||||||
|
if (isMatching) return QuestionType.MATCHING;
|
||||||
|
|
||||||
|
return QuestionType.NONE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAnswers = (
|
||||||
|
linesWithoutPoints: string[],
|
||||||
|
questionIndex: number,
|
||||||
|
questionType: string
|
||||||
|
): LocalQuizQuestionAnswer[] => {
|
||||||
|
const answerLines = getAnswerStringsWithMultilineSupport(
|
||||||
|
linesWithoutPoints,
|
||||||
|
questionIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
const answers = answerLines.map((a, i) =>
|
||||||
|
quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType)
|
||||||
|
);
|
||||||
|
return answers;
|
||||||
|
};
|
||||||
|
const getAnswerMarkdown = (
|
||||||
|
question: LocalQuizQuestion,
|
||||||
|
answer: LocalQuizQuestionAnswer,
|
||||||
|
index: number
|
||||||
|
): string => {
|
||||||
|
const multilineMarkdownCompatibleText = answer.text.startsWith("```")
|
||||||
|
? "\n" + answer.text
|
||||||
|
: answer.text;
|
||||||
|
|
||||||
|
if (question.questionType === "multiple_answers") {
|
||||||
|
const correctIndicator = answer.correct ? "*" : " ";
|
||||||
|
const questionTypeIndicator = `[${correctIndicator}] `;
|
||||||
|
|
||||||
|
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
||||||
|
} else if (question.questionType === "matching") {
|
||||||
|
return `^ ${answer.text} - ${answer.matchedText}`;
|
||||||
|
} else {
|
||||||
|
const questionLetter = String.fromCharCode(97 + index);
|
||||||
|
const correctIndicator = answer.correct ? "*" : "";
|
||||||
|
const questionTypeIndicator = `${correctIndicator}${questionLetter}) `;
|
||||||
|
|
||||||
|
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quizQuestionMarkdownUtils = {
|
||||||
|
toMarkdown(question: LocalQuizQuestion): string {
|
||||||
|
const answerArray = question.answers.map((a, i) =>
|
||||||
|
getAnswerMarkdown(question, a, i)
|
||||||
|
);
|
||||||
|
|
||||||
|
const distractorText =
|
||||||
|
question.questionType === QuestionType.MATCHING
|
||||||
|
? question.matchDistractors?.map((d) => `\n^ - ${d}`).join("") ?? ""
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const answersText = answerArray.join("\n");
|
||||||
|
const questionTypeIndicator =
|
||||||
|
question.questionType === "essay" ||
|
||||||
|
question.questionType === "short_answer"
|
||||||
|
? question.questionType
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `Points: ${question.points}\n${question.text}\n${answersText}${distractorText}${questionTypeIndicator}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion {
|
||||||
|
const lines = input.trim().split("\n");
|
||||||
|
const firstLineIsPoints = lines[0].toLowerCase().includes("points: ");
|
||||||
|
|
||||||
|
const textHasPoints =
|
||||||
|
lines.length > 0 &&
|
||||||
|
lines[0].includes(": ") &&
|
||||||
|
lines[0].split(": ").length > 1 &&
|
||||||
|
!isNaN(parseFloat(lines[0].split(": ")[1]));
|
||||||
|
|
||||||
|
const points =
|
||||||
|
firstLineIsPoints && textHasPoints
|
||||||
|
? parseFloat(lines[0].split(": ")[1])
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines;
|
||||||
|
|
||||||
|
const { linesWithoutAnswers } = linesWithoutPoints.reduce(
|
||||||
|
({ linesWithoutAnswers, taking }, currentLine) => {
|
||||||
|
if (!taking)
|
||||||
|
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
||||||
|
|
||||||
|
const lineIsAnswer = _validFirstAnswerDelimiters.some((prefix) =>
|
||||||
|
currentLine.trimStart().startsWith(prefix)
|
||||||
|
);
|
||||||
|
if (lineIsAnswer)
|
||||||
|
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
||||||
|
|
||||||
|
return {
|
||||||
|
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
|
||||||
|
taking: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ linesWithoutAnswers: [] as string[], taking: true }
|
||||||
|
);
|
||||||
|
const questionType = getQuestionType(linesWithoutPoints, questionIndex);
|
||||||
|
|
||||||
|
const questionTypesWithoutAnswers = [
|
||||||
|
"essay",
|
||||||
|
"short answer",
|
||||||
|
"short_answer",
|
||||||
|
];
|
||||||
|
|
||||||
|
const descriptionLines = questionTypesWithoutAnswers.includes(
|
||||||
|
questionType.toLowerCase()
|
||||||
|
)
|
||||||
|
? linesWithoutAnswers
|
||||||
|
.slice(0, linesWithoutPoints.length)
|
||||||
|
.filter(
|
||||||
|
(line, index) =>
|
||||||
|
!questionTypesWithoutAnswers.includes(line.toLowerCase())
|
||||||
|
)
|
||||||
|
: linesWithoutAnswers;
|
||||||
|
|
||||||
|
const description = descriptionLines.join("\n");
|
||||||
|
|
||||||
|
const typesWithAnswers = [
|
||||||
|
"multiple_choice",
|
||||||
|
"multiple_answers",
|
||||||
|
"matching",
|
||||||
|
];
|
||||||
|
const answers = typesWithAnswers.includes(questionType)
|
||||||
|
? getAnswers(linesWithoutPoints, questionIndex, questionType)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const answersWithoutDistractors =
|
||||||
|
questionType === QuestionType.MATCHING
|
||||||
|
? answers.filter((a) => a.text)
|
||||||
|
: answers;
|
||||||
|
|
||||||
|
const distractors =
|
||||||
|
questionType === QuestionType.MATCHING
|
||||||
|
? answers.filter((a) => !a.text).map((a) => a.matchedText ?? "")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const question: LocalQuizQuestion = {
|
||||||
|
text: description,
|
||||||
|
questionType,
|
||||||
|
points,
|
||||||
|
answers: answersWithoutDistractors,
|
||||||
|
matchDistractors: distractors,
|
||||||
|
};
|
||||||
|
return question;
|
||||||
|
},
|
||||||
|
};
|
||||||
159
nextjs-pages/src/models/local/tests/assignmentMarkdown.test.ts
Normal file
159
nextjs-pages/src/models/local/tests/assignmentMarkdown.test.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { LocalAssignment } from "../assignment/localAssignment";
|
||||||
|
import { AssignmentSubmissionType } from "../assignment/assignmentSubmissionType";
|
||||||
|
import { assignmentMarkdownSerializer } from "../assignment/utils/assignmentMarkdownSerializer";
|
||||||
|
import { assignmentMarkdownParser } from "../assignment/utils/assignmentMarkdownParser";
|
||||||
|
|
||||||
|
describe("AssignmentMarkdownTests", () => {
|
||||||
|
it("can parse assignment settings", () => {
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
name: "test assignment",
|
||||||
|
description: "here is the description",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD],
|
||||||
|
localAssignmentGroupName: "Final Project",
|
||||||
|
rubric: [
|
||||||
|
{ points: 4, label: "do task 1" },
|
||||||
|
{ points: 2, label: "do task 2" },
|
||||||
|
],
|
||||||
|
allowedFileUploadExtensions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentMarkdown =
|
||||||
|
assignmentMarkdownSerializer.toMarkdown(assignment);
|
||||||
|
const parsedAssignment =
|
||||||
|
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
|
||||||
|
|
||||||
|
expect(parsedAssignment).toEqual(assignment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignment with empty rubric can be parsed", () => {
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
name: "test assignment",
|
||||||
|
description: "here is the description",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD],
|
||||||
|
localAssignmentGroupName: "Final Project",
|
||||||
|
rubric: [],
|
||||||
|
allowedFileUploadExtensions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentMarkdown =
|
||||||
|
assignmentMarkdownSerializer.toMarkdown(assignment);
|
||||||
|
const parsedAssignment =
|
||||||
|
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
|
||||||
|
|
||||||
|
expect(parsedAssignment).toEqual(assignment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignment with empty submission types can be parsed", () => {
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
name: "test assignment",
|
||||||
|
description: "here is the description",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
submissionTypes: [],
|
||||||
|
localAssignmentGroupName: "Final Project",
|
||||||
|
rubric: [
|
||||||
|
{ points: 4, label: "do task 1" },
|
||||||
|
{ points: 2, label: "do task 2" },
|
||||||
|
],
|
||||||
|
allowedFileUploadExtensions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentMarkdown =
|
||||||
|
assignmentMarkdownSerializer.toMarkdown(assignment);
|
||||||
|
const parsedAssignment =
|
||||||
|
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
|
||||||
|
|
||||||
|
expect(parsedAssignment).toEqual(assignment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignment without lockAt date can be parsed", () => {
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
name: "test assignment",
|
||||||
|
description: "here is the description",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: undefined,
|
||||||
|
submissionTypes: [],
|
||||||
|
localAssignmentGroupName: "Final Project",
|
||||||
|
rubric: [
|
||||||
|
{ points: 4, label: "do task 1" },
|
||||||
|
{ points: 2, label: "do task 2" },
|
||||||
|
],
|
||||||
|
allowedFileUploadExtensions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentMarkdown =
|
||||||
|
assignmentMarkdownSerializer.toMarkdown(assignment);
|
||||||
|
const parsedAssignment =
|
||||||
|
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
|
||||||
|
|
||||||
|
expect(parsedAssignment).toEqual(assignment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignment without description can be parsed", () => {
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
name: "test assignment",
|
||||||
|
description: "",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
submissionTypes: [],
|
||||||
|
localAssignmentGroupName: "Final Project",
|
||||||
|
rubric: [
|
||||||
|
{ points: 4, label: "do task 1" },
|
||||||
|
{ points: 2, label: "do task 2" },
|
||||||
|
],
|
||||||
|
allowedFileUploadExtensions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentMarkdown =
|
||||||
|
assignmentMarkdownSerializer.toMarkdown(assignment);
|
||||||
|
const parsedAssignment =
|
||||||
|
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
|
||||||
|
|
||||||
|
expect(parsedAssignment).toEqual(assignment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignments can have three dashes", () => {
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
name: "test assignment",
|
||||||
|
description: "test assignment\n---\nsomestuff",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
submissionTypes: [],
|
||||||
|
localAssignmentGroupName: "Final Project",
|
||||||
|
rubric: [],
|
||||||
|
allowedFileUploadExtensions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentMarkdown =
|
||||||
|
assignmentMarkdownSerializer.toMarkdown(assignment);
|
||||||
|
const parsedAssignment =
|
||||||
|
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
|
||||||
|
|
||||||
|
expect(parsedAssignment).toEqual(assignment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignments can restrict upload types", () => {
|
||||||
|
const assignment: LocalAssignment = {
|
||||||
|
name: "test assignment",
|
||||||
|
description: "here is the description",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD],
|
||||||
|
allowedFileUploadExtensions: ["pdf", "txt"],
|
||||||
|
localAssignmentGroupName: "Final Project",
|
||||||
|
rubric: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentMarkdown =
|
||||||
|
assignmentMarkdownSerializer.toMarkdown(assignment);
|
||||||
|
const parsedAssignment =
|
||||||
|
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
|
||||||
|
|
||||||
|
expect(parsedAssignment).toEqual(assignment);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
nextjs-pages/src/models/local/tests/pageMarkdown.test.ts
Normal file
18
nextjs-pages/src/models/local/tests/pageMarkdown.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { LocalCoursePage, localPageMarkdownUtils } from "../page/localCoursePage";
|
||||||
|
|
||||||
|
describe("PageMarkdownTests", () => {
|
||||||
|
it("can parse page", () => {
|
||||||
|
const page: LocalCoursePage = {
|
||||||
|
name: "test title",
|
||||||
|
text: "test text content",
|
||||||
|
dueAt: "07/09/2024 23:59:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageMarkdownString = localPageMarkdownUtils.toMarkdown(page);
|
||||||
|
|
||||||
|
const parsedPage = localPageMarkdownUtils.parseMarkdown(pageMarkdownString);
|
||||||
|
|
||||||
|
expect(parsedPage).toEqual(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { QuestionType } from "../../quiz/localQuizQuestion";
|
||||||
|
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
|
||||||
|
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
|
||||||
|
|
||||||
|
describe("MatchingTests", () => {
|
||||||
|
it("can parse matching question", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description:
|
||||||
|
---
|
||||||
|
Match the following terms & definitions
|
||||||
|
|
||||||
|
^ statement - a single command to be executed
|
||||||
|
^ identifier - name of a variable
|
||||||
|
^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(firstQuestion.questionType).toBe(QuestionType.MATCHING);
|
||||||
|
expect(firstQuestion.text).not.toContain("statement");
|
||||||
|
expect(firstQuestion.answers[0].matchedText).toBe(
|
||||||
|
"a single command to be executed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can create markdown for matching question", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description:
|
||||||
|
---
|
||||||
|
Match the following terms & definitions
|
||||||
|
|
||||||
|
^ statement - a single command to be executed
|
||||||
|
^ identifier - name of a variable
|
||||||
|
^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const questionMarkdown = quizQuestionMarkdownUtils.toMarkdown(
|
||||||
|
quiz.questions[0]
|
||||||
|
);
|
||||||
|
const expectedMarkdown = `Points: 1
|
||||||
|
Match the following terms & definitions
|
||||||
|
|
||||||
|
^ statement - a single command to be executed
|
||||||
|
^ identifier - name of a variable
|
||||||
|
^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.)`;
|
||||||
|
|
||||||
|
expect(questionMarkdown).toContain(expectedMarkdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whitespace is optional", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description:
|
||||||
|
---
|
||||||
|
Match the following terms & definitions
|
||||||
|
|
||||||
|
^statement - a single command to be executed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
expect(quiz.questions[0].answers[0].text).toBe("statement");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can have distractors", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description:
|
||||||
|
---
|
||||||
|
Match the following terms & definitions
|
||||||
|
|
||||||
|
^ statement - a single command to be executed
|
||||||
|
^ - this is the distractor
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
expect(quiz.questions[0].matchDistractors).toEqual([
|
||||||
|
"this is the distractor",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can have distractors and be persisted", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description:
|
||||||
|
---
|
||||||
|
Match the following terms & definitions
|
||||||
|
|
||||||
|
^ statement - a single command to be executed
|
||||||
|
^ - this is the distractor
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
|
||||||
|
expect(quizMarkdown).toContain(
|
||||||
|
"^ statement - a single command to be executed\n^ - this is the distractor"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { LocalQuiz } from "../../quiz/localQuiz";
|
||||||
|
import { QuestionType } from "../../quiz/localQuizQuestion";
|
||||||
|
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
|
||||||
|
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
|
||||||
|
|
||||||
|
describe("MultipleAnswersTests", () => {
|
||||||
|
it("quiz markdown includes multiple answer question", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "desc",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: false,
|
||||||
|
showCorrectAnswers: false,
|
||||||
|
localAssignmentGroupName: "someId",
|
||||||
|
allowedAttempts: -1,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "oneline question",
|
||||||
|
points: 1,
|
||||||
|
questionType: QuestionType.MULTIPLE_ANSWERS,
|
||||||
|
answers: [
|
||||||
|
{ correct: true, text: "true" },
|
||||||
|
{ correct: true, text: "false" },
|
||||||
|
{ correct: false, text: "neither" },
|
||||||
|
],
|
||||||
|
matchDistractors: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const expectedQuestionString = `Points: 1
|
||||||
|
oneline question
|
||||||
|
[*] true
|
||||||
|
[*] false
|
||||||
|
[ ] neither`;
|
||||||
|
expect(markdown).toContain(expectedQuestionString);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse question with multiple answers", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
[*] click
|
||||||
|
[*] focus
|
||||||
|
[*] mousedown
|
||||||
|
[ ] submit
|
||||||
|
[ ] change
|
||||||
|
[ ] mouseout
|
||||||
|
[ ] keydown
|
||||||
|
---
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(firstQuestion.points).toBe(1);
|
||||||
|
expect(firstQuestion.questionType).toBe(QuestionType.MULTIPLE_ANSWERS);
|
||||||
|
expect(firstQuestion.text).toContain(
|
||||||
|
"Which events are triggered when the user clicks on an input field?"
|
||||||
|
);
|
||||||
|
expect(firstQuestion.answers[0].text).toBe("click");
|
||||||
|
expect(firstQuestion.answers[0].correct).toBe(true);
|
||||||
|
expect(firstQuestion.answers[3].correct).toBe(false);
|
||||||
|
expect(firstQuestion.answers[3].text).toBe("submit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can use braces in answer for multiple answer", () => {
|
||||||
|
const rawMarkdownQuestion = `
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
[*] \`int[] theThing()\`
|
||||||
|
[ ] keydown
|
||||||
|
`;
|
||||||
|
|
||||||
|
const question = quizQuestionMarkdownUtils.parseMarkdown(
|
||||||
|
rawMarkdownQuestion,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(question.answers[0].text).toBe("`int[] theThing()`");
|
||||||
|
expect(question.answers.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can use braces in answer for multiple answer with multiline", () => {
|
||||||
|
const rawMarkdownQuestion = `
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
[*]
|
||||||
|
\`\`\`
|
||||||
|
int[] myNumbers = new int[] { };
|
||||||
|
DoSomething(ref myNumbers);
|
||||||
|
static void DoSomething(ref int[] numbers)
|
||||||
|
{
|
||||||
|
// do something
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
|
||||||
|
const question = quizQuestionMarkdownUtils.parseMarkdown(
|
||||||
|
rawMarkdownQuestion,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(question.answers[0].text).toBe(`\`\`\`
|
||||||
|
int[] myNumbers = new int[] { };
|
||||||
|
DoSomething(ref myNumbers);
|
||||||
|
static void DoSomething(ref int[] numbers)
|
||||||
|
{
|
||||||
|
// do something
|
||||||
|
}
|
||||||
|
\`\`\``);
|
||||||
|
expect(question.answers.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { LocalQuiz } from "../../quiz/localQuiz";
|
||||||
|
import { LocalQuizQuestion, QuestionType } from "../../quiz/localQuizQuestion";
|
||||||
|
import { LocalQuizQuestionAnswer } from "../../quiz/localQuizQuestionAnswer";
|
||||||
|
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
|
||||||
|
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
|
||||||
|
|
||||||
|
describe("MultipleChoiceTests", () => {
|
||||||
|
it("quiz markdown includes multiple choice question", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "desc",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: false,
|
||||||
|
showCorrectAnswers: false,
|
||||||
|
localAssignmentGroupName: "someId",
|
||||||
|
allowedAttempts: -1,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
points: 2,
|
||||||
|
text: `
|
||||||
|
\`some type\` of question
|
||||||
|
|
||||||
|
with many
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
lines
|
||||||
|
\`\`\`
|
||||||
|
`,
|
||||||
|
questionType: QuestionType.MULTIPLE_CHOICE,
|
||||||
|
answers: [
|
||||||
|
{ correct: true, text: "true" },
|
||||||
|
{ correct: false, text: "false\n\nendline" },
|
||||||
|
],
|
||||||
|
matchDistractors: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const expectedQuestionString = `
|
||||||
|
Points: 2
|
||||||
|
|
||||||
|
\`some type\` of question
|
||||||
|
|
||||||
|
with many
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
lines
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
*a) true
|
||||||
|
b) false
|
||||||
|
|
||||||
|
endline`;
|
||||||
|
expect(markdown).toContain(expectedQuestionString);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("letter optional for multiple choice", () => {
|
||||||
|
const questionMarkdown = `
|
||||||
|
Points: 2
|
||||||
|
\`some type\` of question
|
||||||
|
*) true
|
||||||
|
) false
|
||||||
|
`;
|
||||||
|
|
||||||
|
const question = quizQuestionMarkdownUtils.parseMarkdown(
|
||||||
|
questionMarkdown,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(question.answers.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { LocalQuiz } from "../../quiz/localQuiz";
|
||||||
|
import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils";
|
||||||
|
import { QuestionType } from "@/models/local/quiz/localQuizQuestion";
|
||||||
|
|
||||||
|
// Test suite for deterministic checks on LocalQuiz
|
||||||
|
describe("QuizDeterministicChecks", () => {
|
||||||
|
it("SerializationIsDeterministic_EmptyQuiz", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SerializationIsDeterministic_ShowCorrectAnswers", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
showCorrectAnswers: false,
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SerializationIsDeterministic_ShortAnswer", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "test short answer",
|
||||||
|
questionType: QuestionType.SHORT_ANSWER,
|
||||||
|
points: 1,
|
||||||
|
answers: [],
|
||||||
|
matchDistractors: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SerializationIsDeterministic_Essay", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "test essay",
|
||||||
|
questionType: QuestionType.ESSAY,
|
||||||
|
points: 1,
|
||||||
|
matchDistractors: [],
|
||||||
|
answers: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SerializationIsDeterministic_MultipleAnswer", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "test multiple answer",
|
||||||
|
questionType: QuestionType.MULTIPLE_ANSWERS,
|
||||||
|
points: 1,
|
||||||
|
matchDistractors: [],
|
||||||
|
answers: [
|
||||||
|
{ text: "yes", correct: true },
|
||||||
|
{ text: "no", correct: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SerializationIsDeterministic_MultipleChoice", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
password: undefined,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "test multiple choice",
|
||||||
|
questionType: QuestionType.MULTIPLE_CHOICE,
|
||||||
|
points: 1,
|
||||||
|
matchDistractors: [],
|
||||||
|
answers: [
|
||||||
|
{ text: "yes", correct: true },
|
||||||
|
{ text: "no", correct: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SerializationIsDeterministic_Matching", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
password: undefined,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "test matching",
|
||||||
|
questionType: QuestionType.MATCHING,
|
||||||
|
points: 1,
|
||||||
|
matchDistractors: [],
|
||||||
|
answers: [
|
||||||
|
{ text: "yes", correct: true, matchedText: "testing yes" },
|
||||||
|
{ text: "no", correct: true, matchedText: "testing no" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { LocalQuiz } from "../../quiz/localQuiz";
|
||||||
|
import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils";
|
||||||
|
import { QuestionType } from "@/models/local/quiz/localQuizQuestion";
|
||||||
|
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
|
||||||
|
|
||||||
|
// Test suite for QuizMarkdown
|
||||||
|
describe("QuizMarkdownTests", () => {
|
||||||
|
it("can serialize quiz to markdown", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: `
|
||||||
|
# quiz description
|
||||||
|
|
||||||
|
this is my description in markdown
|
||||||
|
|
||||||
|
\`here is code\`
|
||||||
|
`,
|
||||||
|
lockAt: new Date(8640000000000000).toISOString(), // DateTime.MaxValue equivalent in TypeScript
|
||||||
|
dueAt: new Date(8640000000000000).toISOString(),
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: false,
|
||||||
|
localAssignmentGroupName: "someId",
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: false,
|
||||||
|
questions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
|
||||||
|
expect(markdown).toContain("Name: Test Quiz");
|
||||||
|
expect(markdown).toContain(quiz.description);
|
||||||
|
expect(markdown).toContain("ShuffleAnswers: true");
|
||||||
|
expect(markdown).toContain("OneQuestionAtATime: false");
|
||||||
|
expect(markdown).toContain("AssignmentGroup: someId");
|
||||||
|
expect(markdown).toContain("AllowedAttempts: -1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse markdown quiz with no questions", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
|
||||||
|
const expectedDescription = `
|
||||||
|
this is the
|
||||||
|
multi line
|
||||||
|
description`;
|
||||||
|
|
||||||
|
expect(quiz.name).toBe("Test Quiz");
|
||||||
|
expect(quiz.shuffleAnswers).toBe(true);
|
||||||
|
expect(quiz.oneQuestionAtATime).toBe(false);
|
||||||
|
expect(quiz.allowedAttempts).toBe(-1);
|
||||||
|
expect(quiz.description.trim()).toBe(expectedDescription.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse markdown quiz with password", () => {
|
||||||
|
const password = "this-is-the-password";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
Password: ${password}
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
|
||||||
|
expect(quiz.password).toBe(password);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse markdown quiz and configure to show correct answers", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
ShowCorrectAnswers: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
|
||||||
|
expect(quiz.showCorrectAnswers).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse quiz with questions", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Points: 2
|
||||||
|
\`some type\` of question
|
||||||
|
|
||||||
|
with many
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
lines
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
*a) true
|
||||||
|
b) false
|
||||||
|
|
||||||
|
endline`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(firstQuestion.questionType).toBe(QuestionType.MULTIPLE_CHOICE);
|
||||||
|
expect(firstQuestion.points).toBe(2);
|
||||||
|
expect(firstQuestion.text).toContain("```");
|
||||||
|
expect(firstQuestion.text).toContain("`some type` of question");
|
||||||
|
expect(firstQuestion.answers[0].text).toBe("true");
|
||||||
|
expect(firstQuestion.answers[0].correct).toBe(true);
|
||||||
|
expect(firstQuestion.answers[1].correct).toBe(false);
|
||||||
|
expect(firstQuestion.answers[1].text).toContain("endline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse multiple questions", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
[*] click
|
||||||
|
---
|
||||||
|
Points: 2
|
||||||
|
\`some type\` of question
|
||||||
|
*a) true
|
||||||
|
b) false
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
expect(firstQuestion.points).toBe(1);
|
||||||
|
expect(firstQuestion.questionType).toBe(QuestionType.MULTIPLE_ANSWERS);
|
||||||
|
|
||||||
|
const secondQuestion = quiz.questions[1];
|
||||||
|
expect(secondQuestion.points).toBe(2);
|
||||||
|
expect(secondQuestion.questionType).toBe(QuestionType.MULTIPLE_CHOICE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short answer to markdown is correct", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
short answer
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
const questionMarkdown =
|
||||||
|
quizQuestionMarkdownUtils.toMarkdown(firstQuestion);
|
||||||
|
const expectedMarkdown = `Points: 1
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
short_answer`;
|
||||||
|
expect(questionMarkdown).toContain(expectedMarkdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negative points is allowed", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Points: -4
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
short answer
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
expect(firstQuestion.points).toBe(-4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floating point points is allowed", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Points: 4.56
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
short answer
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
expect(firstQuestion.points).toBe(4.56);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { QuestionType } from "../../quiz/localQuizQuestion";
|
||||||
|
import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils";
|
||||||
|
import { quizQuestionMarkdownUtils } from "../../quiz/utils/quizQuestionMarkdownUtils";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("TextAnswerTests", () => {
|
||||||
|
it("can parse essay", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
essay
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(firstQuestion.points).toBe(1);
|
||||||
|
expect(firstQuestion.questionType).toBe(QuestionType.ESSAY);
|
||||||
|
expect(firstQuestion.text).not.toContain("essay");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse short answer", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
short answer
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(firstQuestion.points).toBe(1);
|
||||||
|
expect(firstQuestion.questionType).toBe(QuestionType.SHORT_ANSWER);
|
||||||
|
expect(firstQuestion.text).not.toContain("short answer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short answer to markdown is correct", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
short answer
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
const questionMarkdown =
|
||||||
|
quizQuestionMarkdownUtils.toMarkdown(firstQuestion);
|
||||||
|
const expectedMarkdown = `Points: 1
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
short_answer`;
|
||||||
|
expect(questionMarkdown).toContain(expectedMarkdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("essay question to markdown is correct", () => {
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
Name: Test Quiz
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: this is the
|
||||||
|
multi line
|
||||||
|
description
|
||||||
|
---
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
essay
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
const questionMarkdown =
|
||||||
|
quizQuestionMarkdownUtils.toMarkdown(firstQuestion);
|
||||||
|
const expectedMarkdown = `Points: 1
|
||||||
|
Which events are triggered when the user clicks on an input field?
|
||||||
|
essay`;
|
||||||
|
expect(questionMarkdown).toContain(expectedMarkdown);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
nextjs-pages/src/models/local/tests/rubricMarkdown.test.ts
Normal file
97
nextjs-pages/src/models/local/tests/rubricMarkdown.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { RubricItem, rubricItemIsExtraCredit } from "../assignment/rubricItem";
|
||||||
|
import { assignmentMarkdownParser } from "../assignment/utils/assignmentMarkdownParser";
|
||||||
|
|
||||||
|
describe("RubricMarkdownTests", () => {
|
||||||
|
it("can parse one item", () => {
|
||||||
|
const rawRubric = `
|
||||||
|
- 2pts: this is the task
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rubric: RubricItem[] =
|
||||||
|
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
|
||||||
|
expect(rubric.length).toBe(1);
|
||||||
|
expect(rubricItemIsExtraCredit(rubric[0])).toBe(false);
|
||||||
|
expect(rubric[0].label).toBe("this is the task");
|
||||||
|
expect(rubric[0].points).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse multiple items", () => {
|
||||||
|
const rawRubric = `
|
||||||
|
- 2pts: this is the task
|
||||||
|
- 3pts: this is the other task
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rubric: RubricItem[] =
|
||||||
|
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
|
||||||
|
expect(rubric.length).toBe(2);
|
||||||
|
expect(rubricItemIsExtraCredit(rubric[0])).toBe(false);
|
||||||
|
expect(rubric[1].label).toBe("this is the other task");
|
||||||
|
expect(rubric[1].points).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse single point", () => {
|
||||||
|
const rawRubric = `
|
||||||
|
- 1pt: this is the task
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rubric: RubricItem[] =
|
||||||
|
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
|
||||||
|
|
||||||
|
expect(rubricItemIsExtraCredit(rubric[0])).toBe(false);
|
||||||
|
expect(rubric[0].label).toBe("this is the task");
|
||||||
|
expect(rubric[0].points).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse single extra credit (lower case)", () => {
|
||||||
|
const rawRubric = `
|
||||||
|
- 1pt: (extra credit) this is the task
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rubric: RubricItem[] =
|
||||||
|
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
|
||||||
|
expect(rubricItemIsExtraCredit(rubric[0])).toBe(true);
|
||||||
|
expect(rubric[0].label).toBe("(extra credit) this is the task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse single extra credit (upper case)", () => {
|
||||||
|
const rawRubric = `
|
||||||
|
- 1pt: (Extra Credit) this is the task
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rubric: RubricItem[] =
|
||||||
|
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
|
||||||
|
expect(rubricItemIsExtraCredit(rubric[0])).toBe(true);
|
||||||
|
expect(rubric[0].label).toBe("(Extra Credit) this is the task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse floating point numbers", () => {
|
||||||
|
const rawRubric = `
|
||||||
|
- 1.5pt: this is the task
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rubric: RubricItem[] =
|
||||||
|
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
|
||||||
|
expect(rubric[0].points).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse negative numbers", () => {
|
||||||
|
const rawRubric = `
|
||||||
|
- -2pt: this is the task
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rubric: RubricItem[] =
|
||||||
|
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
|
||||||
|
expect(rubric[0].points).toBe(-2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse negative floating point numbers", () => {
|
||||||
|
const rawRubric = `
|
||||||
|
- -2895.00053pt: this is the task
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rubric: RubricItem[] =
|
||||||
|
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
|
||||||
|
expect(rubric[0].points).toBe(-2895.00053);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
nextjs-pages/src/models/local/tests/timeUtils.test.ts
Normal file
50
nextjs-pages/src/models/local/tests/timeUtils.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { dateToMarkdownString, getDateFromString } from "../timeUtils";
|
||||||
|
|
||||||
|
describe("Can properly handle expected date formats", () => {
|
||||||
|
it("can use AM/PM dates", () => {
|
||||||
|
const dateString = "8/27/2024 1:00:00 AM";
|
||||||
|
const dateObject = getDateFromString(dateString);
|
||||||
|
expect(dateObject).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
it("can use 24 hour dates", () => {
|
||||||
|
const dateString = "8/27/2024 23:95:00";
|
||||||
|
const dateObject = getDateFromString(dateString);
|
||||||
|
expect(dateObject).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
it("can use ISO format", () => {
|
||||||
|
const dateString = "2024-08-26T00:00:00.0000000";
|
||||||
|
const dateObject = getDateFromString(dateString);
|
||||||
|
expect(dateObject).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
it("can get correct time from format", () => {
|
||||||
|
const dateString = "08/28/2024 23:59:00";
|
||||||
|
const dateObject = getDateFromString(dateString);
|
||||||
|
|
||||||
|
expect(dateObject?.getDate()).toBe(28);
|
||||||
|
expect(dateObject?.getMonth()).toBe(8 - 1); // 0 based
|
||||||
|
expect(dateObject?.getFullYear()).toBe(2024);
|
||||||
|
expect(dateObject?.getMinutes()).toBe(59);
|
||||||
|
expect(dateObject?.getHours()).toBe(23);
|
||||||
|
expect(dateObject?.getSeconds()).toBe(0);
|
||||||
|
});
|
||||||
|
it("can get correct time from format", () => {
|
||||||
|
const dateString = "8/27/2024 1:00:00 AM";
|
||||||
|
const dateObject = getDateFromString(dateString);
|
||||||
|
|
||||||
|
expect(dateObject?.getDate()).toBe(27);
|
||||||
|
expect(dateObject?.getMonth()).toBe(8 - 1); // 0 based
|
||||||
|
expect(dateObject?.getFullYear()).toBe(2024);
|
||||||
|
expect(dateObject?.getMinutes()).toBe(0);
|
||||||
|
expect(dateObject?.getHours()).toBe(1);
|
||||||
|
expect(dateObject?.getSeconds()).toBe(0);
|
||||||
|
});
|
||||||
|
it("can get correct time from format", () => {
|
||||||
|
const dateString = "08/27/2024 23:59:00";
|
||||||
|
const dateObject = getDateFromString(dateString);
|
||||||
|
|
||||||
|
expect(dateObject).not.toBeUndefined()
|
||||||
|
const updatedString = dateToMarkdownString(dateObject!)
|
||||||
|
expect(updatedString).toBe(dateString)
|
||||||
|
});
|
||||||
|
});
|
||||||
93
nextjs-pages/src/models/local/timeUtils.ts
Normal file
93
nextjs-pages/src/models/local/timeUtils.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const _getDateFromAMPM = (
|
||||||
|
datePart: string,
|
||||||
|
timePartWithMeridian: string
|
||||||
|
): Date | undefined => {
|
||||||
|
const [month, day, year] = datePart.split("/").map(Number);
|
||||||
|
const [timePart, meridian] = timePartWithMeridian.split(" ");
|
||||||
|
const [hours, minutes, seconds] = timePart.split(":").map(Number);
|
||||||
|
|
||||||
|
let adjustedHours = hours;
|
||||||
|
if (meridian) {
|
||||||
|
const upperMeridian = meridian.toUpperCase();
|
||||||
|
if (upperMeridian === "PM" && hours < 12) {
|
||||||
|
adjustedHours += 12;
|
||||||
|
} else if (upperMeridian === "AM" && hours === 12) {
|
||||||
|
adjustedHours = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(year, month - 1, day, adjustedHours, minutes, seconds);
|
||||||
|
return isNaN(date.getTime()) ? undefined : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getDateFromMilitary = (
|
||||||
|
datePart: string,
|
||||||
|
timePart: string
|
||||||
|
): Date | undefined => {
|
||||||
|
const [month, day, year] = datePart.split("/").map(Number);
|
||||||
|
const [hours, minutes, seconds] = timePart.split(":").map(Number);
|
||||||
|
|
||||||
|
const date = new Date(year, month - 1, day, hours, minutes, seconds);
|
||||||
|
return isNaN(date.getTime()) ? undefined : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getDateFromISO = (value: string): Date | undefined => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return isNaN(date.getTime()) ? undefined : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDateFromString = (value: string): Date | undefined => {
|
||||||
|
const ampmDateRegex =
|
||||||
|
/^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2}:\d{2}\s{1}[APap][Mm]$/; //"M/D/YYYY h:mm:ss AM/PM"
|
||||||
|
const militaryDateRegex = /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2}:\d{2}$/; //"MM/DD/YYYY HH:mm:ss"
|
||||||
|
const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)$/; //"2024-08-26T00:00:00.0000000"
|
||||||
|
|
||||||
|
if (isoDateRegex.test(value)) {
|
||||||
|
return _getDateFromISO(value);
|
||||||
|
} else if (ampmDateRegex.test(value)) {
|
||||||
|
const [datePart, timePartWithMeridian] = value.split(/[\s\u202F]+/);
|
||||||
|
return _getDateFromAMPM(datePart, timePartWithMeridian);
|
||||||
|
} else if (militaryDateRegex.test(value)) {
|
||||||
|
const [datePart, timePart] = value.split(" ");
|
||||||
|
return _getDateFromMilitary(datePart, timePart);
|
||||||
|
} else {
|
||||||
|
console.log("invalid date format", value);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDateFromStringOrThrow = (
|
||||||
|
value: string,
|
||||||
|
labelForError: string
|
||||||
|
): Date => {
|
||||||
|
const d = getDateFromString(value);
|
||||||
|
if (!d) throw Error(`Invalid date format for ${labelForError}, ${value}`);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyDateStringOrUndefined = (
|
||||||
|
value: string
|
||||||
|
): string | undefined => {
|
||||||
|
const date = getDateFromString(value);
|
||||||
|
return date ? dateToMarkdownString(date) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyDateOrThrow = (
|
||||||
|
value: string,
|
||||||
|
labelForError: string
|
||||||
|
): string => {
|
||||||
|
const myDate = getDateFromString(value);
|
||||||
|
if (!myDate) throw new Error(`Invalid format for ${labelForError}: ${value}`);
|
||||||
|
return dateToMarkdownString(myDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dateToMarkdownString = (date: Date) => {
|
||||||
|
const stringDay = String(date.getDate()).padStart(2, "0");
|
||||||
|
const stringMonth = String(date.getMonth() + 1).padStart(2, "0"); // Months are zero-based
|
||||||
|
const stringYear = date.getFullYear();
|
||||||
|
const stringHours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const stringMinutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const stringSeconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${stringMonth}/${stringDay}/${stringYear} ${stringHours}:${stringMinutes}:${stringSeconds}`;
|
||||||
|
};
|
||||||
14
nextjs-pages/src/pages/_app.tsx
Normal file
14
nextjs-pages/src/pages/_app.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Providers from "@/components/providers";
|
||||||
|
import "@/styles/globals.css";
|
||||||
|
import type { AppProps } from "next/app";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<Providers>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</Providers>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
nextjs-pages/src/pages/_document.tsx
Normal file
13
nextjs-pages/src/pages/_document.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Html, Head, Main, NextScript } from "next/document";
|
||||||
|
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<Html lang="en">
|
||||||
|
<Head />
|
||||||
|
<body className="bg-slate-900 h-screen p-1 text-slate-300">
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user