mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-26 23:58:31 -06:00
moving v2 to top level
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
import { MonacoEditor } from "@/components/editor/MonacoEditor";
|
||||
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import QuizPreview from "./QuizPreview";
|
||||
import { QuizButtons } from "./QuizButton";
|
||||
import ClientOnly from "@/components/ClientOnly";
|
||||
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getModuleItemUrl } from "@/services/urlUtils";
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import {
|
||||
useQuizQuery,
|
||||
useUpdateQuizMutation,
|
||||
} from "@/hooks/localCourse/quizHooks";
|
||||
import { useAuthoritativeUpdates } from "../../../../utils/useAuthoritativeUpdates";
|
||||
|
||||
const helpString = `QUESTION REFERENCE
|
||||
---
|
||||
Points: 2
|
||||
this is a question?
|
||||
*a) correct
|
||||
b) not correct
|
||||
---
|
||||
points: 1
|
||||
question goes here
|
||||
[*] correct
|
||||
[ ] not correct
|
||||
[] not correct
|
||||
---
|
||||
the points default to 1?
|
||||
*a) true
|
||||
b) false
|
||||
---
|
||||
Markdown is supported
|
||||
|
||||
- like
|
||||
- this
|
||||
- list
|
||||
|
||||
[*] true
|
||||
[ ] false
|
||||
---
|
||||
This is a one point essay question
|
||||
essay
|
||||
---
|
||||
points: 4
|
||||
this is a short answer question
|
||||
short_answer
|
||||
---
|
||||
points: 4
|
||||
the underscore is optional
|
||||
short answer
|
||||
---
|
||||
this is a matching question
|
||||
^ left answer - right dropdown
|
||||
^ other thing - another option`;
|
||||
|
||||
export default function EditQuiz({
|
||||
moduleName,
|
||||
quizName,
|
||||
}: {
|
||||
quizName: string;
|
||||
moduleName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { courseName } = useCourseContext();
|
||||
const [quiz, { dataUpdatedAt: serverDataUpdatedAt, isFetching }] =
|
||||
useQuizQuery(moduleName, quizName);
|
||||
const updateQuizMutation = useUpdateQuizMutation();
|
||||
const { clientIsAuthoritative, text, textUpdate, monacoKey } =
|
||||
useAuthoritativeUpdates({
|
||||
serverUpdatedAt: serverDataUpdatedAt,
|
||||
startingText: quizMarkdownUtils.toMarkdown(quiz),
|
||||
});
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const delay = 1000;
|
||||
const handler = setTimeout(async () => {
|
||||
if (isFetching || updateQuizMutation.isPending) {
|
||||
console.log("network requests in progress, not updating page");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
quizMarkdownUtils.toMarkdown(quiz) !==
|
||||
quizMarkdownUtils.toMarkdown(quizMarkdownUtils.parseMarkdown(text))
|
||||
) {
|
||||
if (clientIsAuthoritative) {
|
||||
const updatedQuiz = quizMarkdownUtils.parseMarkdown(text);
|
||||
await updateQuizMutation
|
||||
.mutateAsync({
|
||||
quiz: updatedQuiz,
|
||||
moduleName,
|
||||
quizName: updatedQuiz.name,
|
||||
previousModuleName: moduleName,
|
||||
previousQuizName: quizName,
|
||||
courseName,
|
||||
})
|
||||
.then(() => {
|
||||
if (updatedQuiz.name !== quizName)
|
||||
router.replace(
|
||||
getModuleItemUrl(
|
||||
courseName,
|
||||
moduleName,
|
||||
"quiz",
|
||||
updatedQuiz.name
|
||||
)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
"client not authoritative, updating client with server quiz"
|
||||
);
|
||||
textUpdate(quizMarkdownUtils.toMarkdown(quiz), true);
|
||||
}
|
||||
}
|
||||
setError("");
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
}
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [
|
||||
clientIsAuthoritative,
|
||||
courseName,
|
||||
isFetching,
|
||||
moduleName,
|
||||
quiz,
|
||||
quizName,
|
||||
router,
|
||||
text,
|
||||
textUpdate,
|
||||
updateQuizMutation,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col align-middle px-1">
|
||||
<div className={"min-h-96 h-full flex flex-row w-full"}>
|
||||
{showHelp && (
|
||||
<pre className=" max-w-96">
|
||||
<code>{helpString}</code>
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex-1 h-full">
|
||||
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
|
||||
</div>
|
||||
<div className="flex-1 h-full">
|
||||
<div className="text-red-300">{error && error}</div>
|
||||
<QuizPreview moduleName={moduleName} quizName={quizName} />
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<SuspenseAndErrorHandling>
|
||||
<QuizButtons
|
||||
moduleName={moduleName}
|
||||
quizName={quizName}
|
||||
toggleHelp={() => setShowHelp((h) => !h)}
|
||||
/>
|
||||
</SuspenseAndErrorHandling>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import Modal, { useModal } from "@/components/Modal";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
useCanvasQuizzesQuery,
|
||||
useAddQuizToCanvasMutation,
|
||||
useDeleteQuizFromCanvasMutation,
|
||||
} from "@/hooks/canvas/canvasQuizHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import {
|
||||
useDeleteQuizMutation,
|
||||
useQuizQuery,
|
||||
} from "@/hooks/localCourse/quizHooks";
|
||||
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function QuizButtons({
|
||||
moduleName,
|
||||
quizName,
|
||||
toggleHelp,
|
||||
}: {
|
||||
quizName: string;
|
||||
moduleName: string;
|
||||
toggleHelp: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { courseName } = useCourseContext();
|
||||
const [settings] = useLocalCourseSettingsQuery();
|
||||
const { data: canvasQuizzes } = useCanvasQuizzesQuery();
|
||||
|
||||
const [quiz] = useQuizQuery(moduleName, quizName);
|
||||
const addToCanvas = useAddQuizToCanvasMutation();
|
||||
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
|
||||
const deleteLocal = useDeleteQuizMutation();
|
||||
const modal = useModal();
|
||||
|
||||
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-row justify-between">
|
||||
<div>
|
||||
<button onClick={toggleHelp}>Toggle Help</button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 justify-end">
|
||||
{(addToCanvas.isPending || deleteFromCanvas.isPending) && <Spinner />}
|
||||
{quizInCanvas && !quizInCanvas.published && (
|
||||
<div className="text-rose-300 my-auto">Not Published</div>
|
||||
)}
|
||||
{!quizInCanvas && (
|
||||
<button
|
||||
disabled={addToCanvas.isPending}
|
||||
onClick={() => addToCanvas.mutate({ quiz, moduleName })}
|
||||
>
|
||||
Add to canvas
|
||||
</button>
|
||||
)}
|
||||
{quizInCanvas && (
|
||||
<a
|
||||
className="btn"
|
||||
target="_blank"
|
||||
href={`${baseCanvasUrl}/courses/${settings.canvasId}/quizzes/${quizInCanvas.id}`}
|
||||
>
|
||||
View in Canvas
|
||||
</a>
|
||||
)}
|
||||
{quizInCanvas && (
|
||||
<button
|
||||
className="btn-danger"
|
||||
disabled={deleteFromCanvas.isPending}
|
||||
onClick={() => deleteFromCanvas.mutate(quizInCanvas.id)}
|
||||
>
|
||||
Delete from Canvas
|
||||
</button>
|
||||
)}
|
||||
{!quizInCanvas && (
|
||||
<Modal
|
||||
modalControl={modal}
|
||||
buttonText="Delete Localy"
|
||||
buttonClass="btn-danger"
|
||||
modalWidth="w-1/5"
|
||||
>
|
||||
{({ closeModal }) => (
|
||||
<div>
|
||||
<div className="text-center">
|
||||
Are you sure you want to delete this quiz locally?
|
||||
</div>
|
||||
<br />
|
||||
<div className="flex justify-around gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
await deleteLocal.mutateAsync({ moduleName, quizName, courseName });
|
||||
router.push(getCourseUrl(courseName));
|
||||
}}
|
||||
className="btn-danger"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button onClick={closeModal}>No</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import CheckIcon from "@/components/icons/CheckIcon";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useQuizQuery } from "@/hooks/localCourse/quizHooks";
|
||||
import {
|
||||
LocalQuizQuestion,
|
||||
QuestionType,
|
||||
} from "@/models/local/quiz/localQuizQuestion";
|
||||
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||
|
||||
export default function QuizPreview({
|
||||
moduleName,
|
||||
quizName,
|
||||
}: {
|
||||
quizName: string;
|
||||
moduleName: string;
|
||||
}) {
|
||||
const [quiz] = useQuizQuery(moduleName, quizName);
|
||||
const [settings] = useLocalCourseSettingsQuery();
|
||||
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">Points</div>
|
||||
<div>
|
||||
{quiz.questions.reduce((sum, question) => sum + question.points, 0)}
|
||||
</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 markdownPreview"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHTMLSafe(quiz.description, settings),
|
||||
}}
|
||||
></div>
|
||||
<div className="p-3 rounded-md bg-slate-950 m-5 flex flex-col gap-3">
|
||||
{quiz.questions.map((question, i) => (
|
||||
<QuizQuestionPreview key={i} question={question} />
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuizQuestionPreview({ question }: { question: LocalQuizQuestion }) {
|
||||
const [settings] = useLocalCourseSettingsQuery();
|
||||
return (
|
||||
<div className="rounded bg-slate-900 px-2">
|
||||
<div className="flex flex-row justify-between text-slate-400">
|
||||
<div>{question.questionType}</div>
|
||||
<div className="">
|
||||
{question.points} {question.points === 1 ? " Point" : " Points"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ms-4 mb-2 markdownPreview"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHTMLSafe(question.text, settings) }}
|
||||
></div>
|
||||
{question.questionType === QuestionType.MATCHING && (
|
||||
<div>
|
||||
{question.answers.map((answer) => (
|
||||
<div
|
||||
key={JSON.stringify(answer)}
|
||||
className="mx-3 mb-1 bg-dark rounded border border-slate-600 flex flex-row"
|
||||
>
|
||||
<div className="text-right my-auto">{answer.text} - </div>
|
||||
<div className="">{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 pt-1 border-t border-slate-700 flex flex-row"
|
||||
>
|
||||
<div className="w-8 flex flex-col justify-center">
|
||||
{answer.correct ? (
|
||||
<CheckIcon />
|
||||
) : question.questionType === QuestionType.MULTIPLE_ANSWERS ? (
|
||||
<span className="mx-auto">{"[ ]"}</span>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="markdownQuizAnswerPreview"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHTMLSafe(answer.text, settings),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import React, { ReactNode, Suspense } from "react";
|
||||
|
||||
export default function layout({ children }: { children: ReactNode }) {
|
||||
return <Suspense>{children}</Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import React from "react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import EditQuiz from "./EditQuiz";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ quizName: string; moduleName: string }>;
|
||||
}) {
|
||||
const { moduleName, quizName } = await params;
|
||||
const decodedQuizName = decodeURIComponent(quizName)
|
||||
const decodedModuleName = decodeURIComponent(moduleName)
|
||||
return <EditQuiz quizName={decodedQuizName} moduleName={decodedModuleName} />;
|
||||
}
|
||||
Reference in New Issue
Block a user