From 0325bb68f5c6a470e9b2bdb6ea4950959354c9b2 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 7 Nov 2024 11:17:05 +0000 Subject: [PATCH 1/9] Added the audio player to the level exam --- src/exams/Level/index.tsx | 914 ++++++++++++++++++++------------------ 1 file changed, 477 insertions(+), 437 deletions(-) diff --git a/src/exams/Level/index.tsx b/src/exams/Level/index.tsx index ef35f4e3..7ac86d13 100644 --- a/src/exams/Level/index.tsx +++ b/src/exams/Level/index.tsx @@ -18,502 +18,542 @@ import { Tab } from "@headlessui/react"; import Modal from "@/components/Modal"; import { typeCheckWordsMC } from "@/utils/type.check"; import SectionNavbar from "../Navigation/SectionNavbar"; +import AudioPlayer from "@/components/Low/AudioPlayer"; interface Props { - exam: LevelExam; - showSolutions?: boolean; - onFinish: (userSolutions: UserSolution[]) => void; - preview?: boolean; - partDividers?: boolean; + exam: LevelExam; + showSolutions?: boolean; + onFinish: (userSolutions: UserSolution[]) => void; + preview?: boolean; + partDividers?: boolean; } export default function Level({ exam, showSolutions = false, onFinish, preview = false }: Props) { - const levelBgColor = "bg-ielts-level-light"; + const levelBgColor = "bg-ielts-level-light"; - const examState = useExamStore((state) => state); - const persistentExamState = usePersistentExamStore((state) => state); + const examState = useExamStore((state) => state); + const persistentExamState = usePersistentExamStore((state) => state); - const { - userSolutions, - hasExamEnded, - partIndex, - exerciseIndex, - questionIndex, - shuffles, - currentSolution, - setBgColor, - setUserSolutions, - setHasExamEnded, - setPartIndex, - setExerciseIndex, - setQuestionIndex, - setShuffles, - setCurrentSolution - } = !preview ? examState : persistentExamState; + const { + userSolutions, + hasExamEnded, + partIndex, + exerciseIndex, + questionIndex, + shuffles, + currentSolution, + setBgColor, + setUserSolutions, + setHasExamEnded, + setPartIndex, + setExerciseIndex, + setQuestionIndex, + setShuffles, + setCurrentSolution + } = !preview ? examState : persistentExamState; - // In case client want to switch back - const textRenderDisabled = true; + // In case client want to switch back + const textRenderDisabled = true; - const [showSubmissionModal, setShowSubmissionModal] = useState(false); - const [showQuestionsModal, setShowQuestionsModal] = useState(false); - const [continueAnyways, setContinueAnyways] = useState(false); - const [textRender, setTextRender] = useState(false); - const [changedPrompt, setChangedPrompt] = useState(false); - const [nextExerciseCalled, setNextExerciseCalled] = useState(false); - const [currentSolutionSet, setCurrentSolutionSet] = useState(false); + const [timesListened, setTimesListened] = useState(0); + const [showSubmissionModal, setShowSubmissionModal] = useState(false); + const [showQuestionsModal, setShowQuestionsModal] = useState(false); + const [continueAnyways, setContinueAnyways] = useState(false); + const [textRender, setTextRender] = useState(false); + const [changedPrompt, setChangedPrompt] = useState(false); + const [nextExerciseCalled, setNextExerciseCalled] = useState(false); + const [currentSolutionSet, setCurrentSolutionSet] = useState(false); - const [seenParts, setSeenParts] = useState>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0])); + const [seenParts, setSeenParts] = useState>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0])); - const [questionModalKwargs, setQuestionModalKwargs] = useState<{ - type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined; - }>({ - type: "blankQuestions", - onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } } - }); + const [questionModalKwargs, setQuestionModalKwargs] = useState<{ + type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined; + }>({ + type: "blankQuestions", + onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } } + }); - const [currentExercise, setCurrentExercise] = useState(undefined); - const [showPartDivider, setShowPartDivider] = useState(typeof exam.parts[0].intro === "string" && !showSolutions); - const [startNow, setStartNow] = useState(!showSolutions); + const [currentExercise, setCurrentExercise] = useState(undefined); + const [showPartDivider, setShowPartDivider] = useState(typeof exam.parts[0].intro === "string" && !showSolutions); + const [startNow, setStartNow] = useState(!showSolutions); - useEffect(() => { - if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) { - setCurrentExercise(exam.parts[0].exercises[0]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentExercise, partIndex, exerciseIndex]); + useEffect(() => { + if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) { + setCurrentExercise(exam.parts[0].exercises[0]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentExercise, partIndex, exerciseIndex]); - const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); + const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); - const [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | undefined>(undefined); - const [contextWordLines, setContextWordLines] = useState(undefined); - const [totalLines, setTotalLines] = useState(0); + const [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | undefined>(undefined); + const [contextWordLines, setContextWordLines] = useState(undefined); + const [totalLines, setTotalLines] = useState(0); - const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined) + const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined) - useEffect(() => { - if (typeof currentSolution !== "undefined") { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]); - setCurrentSolutionSet(true); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise]) + useEffect(() => { + if (typeof currentSolution !== "undefined") { + setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]); + setCurrentSolutionSet(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise]) - useEffect(() => { - if (typeof currentSolution !== "undefined") { - setCurrentSolution(undefined); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSolution]); + useEffect(() => { + if (typeof currentSolution !== "undefined") { + setCurrentSolution(undefined); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentSolution]); - useEffect(() => { - if (showSolutions) { - const solutionShuffles = userSolutions.map(solution => ({ - exerciseID: solution.exercise, - shuffles: solution.shuffleMaps || [] - })); - setShuffles(solutionShuffles); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useEffect(() => { + if (showSolutions) { + const solutionShuffles = userSolutions.map(solution => ({ + exerciseID: solution.exercise, + shuffles: solution.shuffleMaps || [] + })); + setShuffles(solutionShuffles); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const getExercise = () => { - let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; - exercise = { - ...exercise, - userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [], - }; - exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles); - return exercise; - }; + const getExercise = () => { + let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; + exercise = { + ...exercise, + userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [], + }; + exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles); + return exercise; + }; - useEffect(() => { - setCurrentExercise(getExercise()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [partIndex, exerciseIndex, questionIndex]); + useEffect(() => { + setCurrentExercise(getExercise()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [partIndex, exerciseIndex, questionIndex]); - const next = () => { - setNextExerciseCalled(true); - } + const next = () => { + setNextExerciseCalled(true); + } - const nextExercise = () => { - scrollToTop(); + const nextExercise = () => { + scrollToTop(); - if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { - setExerciseIndex(exerciseIndex + 1); - setCurrentSolutionSet(false); - return; - } + if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { + setExerciseIndex(exerciseIndex + 1); + setCurrentSolutionSet(false); + return; + } - if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) { - modalKwargs(); - setShowQuestionsModal(true); - return; - } + if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) { + modalKwargs(); + setShowQuestionsModal(true); + return; + } - if (partIndex + 1 < exam.parts.length && !hasExamEnded) { - if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) { - modalKwargs(); - setShowQuestionsModal(true); - return; - } + if (partIndex + 1 < exam.parts.length && !hasExamEnded) { + if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) { + modalKwargs(); + setShowQuestionsModal(true); + return; + } - if (!showSolutions && exam.parts[0].intro && !seenParts.has(partIndex + 1)) { - setShowPartDivider(true); - setBgColor(levelBgColor); - } + if (!showSolutions && exam.parts[0].intro && !seenParts.has(partIndex + 1)) { + setShowPartDivider(true); + setBgColor(levelBgColor); + } - setSeenParts(prev => new Set(prev).add(partIndex + 1)); + setSeenParts(prev => new Set(prev).add(partIndex + 1)); - if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context && !textRenderDisabled) { - setTextRender(true); - } - setPartIndex(partIndex + 1); - setExerciseIndex(0); - setQuestionIndex(0); - setCurrentSolutionSet(false); - return; - } + if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context && !textRenderDisabled) { + setTextRender(true); + } - if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways && !showSolutions) { - modalKwargs(); - setShowQuestionsModal(true); - } + setTimesListened(0); + setPartIndex(partIndex + 1); + setExerciseIndex(0); + setQuestionIndex(0); + setCurrentSolutionSet(false); + return; + } - setHasExamEnded(false); - setCurrentSolutionSet(false); - if (typeof showSolutionsSave !== "undefined") { - onFinish(showSolutionsSave); - } else { - onFinish(userSolutions); - } - } + if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways && !showSolutions) { + modalKwargs(); + setShowQuestionsModal(true); + } - useEffect(() => { - if (nextExerciseCalled && currentSolutionSet) { - nextExercise(); - setNextExerciseCalled(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nextExerciseCalled, currentSolutionSet]) + setHasExamEnded(false); + setCurrentSolutionSet(false); + if (typeof showSolutionsSave !== "undefined") { + onFinish(showSolutionsSave); + } else { + onFinish(userSolutions); + } + } - const previousExercise = (solution?: UserSolution) => { - scrollToTop(); + useEffect(() => { + if (nextExerciseCalled && currentSolutionSet) { + nextExercise(); + setNextExerciseCalled(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nextExerciseCalled, currentSolutionSet]) - if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) { - setTextRender(true); - return; - } + const previousExercise = (solution?: UserSolution) => { + scrollToTop(); - if (questionIndex == 0) { - setPartIndex(partIndex - 1); - if (!seenParts.has(partIndex - 1)) { - setBgColor(levelBgColor); - setShowPartDivider(true); - setQuestionIndex(0); - setSeenParts(prev => new Set(prev).add(partIndex - 1)); - return; - } + if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) { + setTextRender(true); + return; + } - const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1; - const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex]; - setExerciseIndex(lastExerciseIndex); + if (questionIndex == 0) { + setPartIndex(partIndex - 1); + if (!seenParts.has(partIndex - 1)) { + setBgColor(levelBgColor); + setShowPartDivider(true); + setQuestionIndex(0); + setSeenParts(prev => new Set(prev).add(partIndex - 1)); + return; + } - if (lastExercise.type === "multipleChoice") { - setQuestionIndex(lastExercise.questions.length - 1) - } else { - setQuestionIndex(0) - } - return; - } + const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1; + const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex]; + setExerciseIndex(lastExerciseIndex); - setExerciseIndex(exerciseIndex - 1); - if (exerciseIndex - 1 === -1) { - setPartIndex(partIndex - 1); - const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1; - const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex]; - if (previousExercise.type === "multipleChoice") { - setQuestionIndex(previousExercise.questions.length - 1) - } - } + if (lastExercise.type === "multipleChoice") { + setQuestionIndex(lastExercise.questions.length - 1) + } else { + setQuestionIndex(0) + } + return; + } - }; + setExerciseIndex(exerciseIndex - 1); + if (exerciseIndex - 1 === -1) { + setPartIndex(partIndex - 1); + const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1; + const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex]; + if (previousExercise.type === "multipleChoice") { + setQuestionIndex(previousExercise.questions.length - 1) + } + } - const calculateExerciseIndex = () => { - return exam.parts.reduce((acc, curr, index) => { - if (index < partIndex) { - return acc + countExercises(curr.exercises) - } - return acc; - }, 0) + (questionIndex + 1); - }; + }; - const renderText = () => ( - <> -
- <> -
- {textRender && !textRenderDisabled ? ( - <> -

- Please read the following excerpt attentively, you will then be asked questions about the text you've read. -

- You will be allowed to read the text while doing the exercises - - ) : ( -

- Answer the questions on the right based on what you've read. -

- )} -
- {exam.parts[partIndex].context && - } -
- -
- {textRender && !textRenderDisabled && ( -
- + const calculateExerciseIndex = () => { + return exam.parts.reduce((acc, curr, index) => { + if (index < partIndex) { + return acc + countExercises(curr.exercises) + } + return acc; + }, 0) + (questionIndex + 1); + }; - -
- )} - - ); + const renderAudioPlayer = () => ( +
+ {exam?.parts[partIndex]?.audio?.source ? ( + <> +
+

Please listen to the following audio attentively.

+ + {(() => { + const audioRepeatTimes = exam?.parts[partIndex]?.audio?.repeatableTimes; + return audioRepeatTimes && audioRepeatTimes > 0 + ? `You will only be allowed to listen to the audio ${audioRepeatTimes - timesListened} time(s).` + : "You may listen to the audio as many times as you would like."; + })()} + +
+
+ setTimesListened((prev) => prev + 1)} + disabled={exam?.parts[partIndex]?.audio?.repeatableTimes != null && + timesListened === exam.parts[partIndex]?.audio?.repeatableTimes} + disablePause + /> +
+ + ) : ( + This section will be displayed the audio once it has been generated. + )} - const partLabel = () => { - const partCategory = exam.parts[partIndex].category ? ` (${exam.parts[partIndex].category})` : ''; - if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words)) - return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})${partCategory}\n\n${currentExercise.prompt}` +
+ ); - if (currentExercise?.type === "multipleChoice") { - return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})${partCategory}\n\n${currentExercise.prompt}` - } + const renderText = () => ( + <> +
+ <> +
+ {textRender && !textRenderDisabled ? ( + <> +

+ Please read the following excerpt attentively, you will then be asked questions about the text you've read. +

+ You will be allowed to read the text while doing the exercises + + ) : ( +

+ Answer the questions on the right based on what you've read. +

+ )} +
+ {(exam.parts[partIndex].context || exam.parts[partIndex].text) && + } +
+ +
+ {textRender && !textRenderDisabled && ( +
+ - if (typeof exam.parts[partIndex].context === "string") { - const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise; - return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})${partCategory}\n\n${nextExercise.prompt}` - } - } + +
+ )} + + ); - const answeredEveryQuestion = (partIndex: number) => { - return exam.parts[partIndex].exercises.every((exercise) => { - const userSolution = userSolutions.find(x => x.exercise === exercise.id); - switch (exercise.type) { - case 'multipleChoice': - return userSolution?.solutions.length === exercise.questions.length; - case 'fillBlanks': - return userSolution?.solutions.length === exercise.words.length; - case 'writeBlanks': - return userSolution?.solutions.length === exercise.solutions.length; - case 'matchSentences': - return userSolution?.solutions.length === exercise.sentences.length; - case 'trueFalse': - return userSolution?.solutions.length === exercise.questions.length; - } - return false; - }); - } + const partLabel = () => { + const partCategory = exam.parts[partIndex].category ? ` (${exam.parts[partIndex].category})` : ''; + if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words)) + return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})${partCategory}\n\n${currentExercise.prompt}` - useEffect(() => { - const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; + if (currentExercise?.type === "multipleChoice") { + return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})${partCategory}\n\n${currentExercise.prompt}` + } - const findMatch = (index: number) => { - if (currentExercise && currentExercise.type === "multipleChoice" && currentExercise!.questions[index]) { - const match = currentExercise!.questions[index].prompt.match(regex); - if (match) { - return { match: match[1], originalLine: match[2] } - } - } - return; - } + if (typeof exam.parts[partIndex].context === "string") { + const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise; + return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})${partCategory}\n\n${nextExercise.prompt}` + } + } - // if the client for some whatever random reason decides - // to add more questions update this - const numberOfQuestions = 2; + const answeredEveryQuestion = (partIndex: number) => { + return exam.parts[partIndex].exercises.every((exercise) => { + const userSolution = userSolutions.find(x => x.exercise === exercise.id); + switch (exercise.type) { + case 'multipleChoice': + return userSolution?.solutions.length === exercise.questions.length; + case 'fillBlanks': + return userSolution?.solutions.length === exercise.words.length; + case 'writeBlanks': + return userSolution?.solutions.length === exercise.solutions.length; + case 'matchSentences': + return userSolution?.solutions.length === exercise.sentences.length; + case 'trueFalse': + return userSolution?.solutions.length === exercise.questions.length; + } + return false; + }); + } - if (exam.parts[partIndex].context) { - const hits = Array.from({ length: numberOfQuestions }).reduce<{ match: string, originalLine: string }[]>((acc, _, i) => { - const result = findMatch(questionIndex + i); - if (!!result) { - acc.push(result); - } - return acc; - }, []); + useEffect(() => { + const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; - if (hits.length > 0) { - setContextWords(hits) - } - } + const findMatch = (index: number) => { + if (currentExercise && currentExercise.type === "multipleChoice" && currentExercise!.questions[index]) { + const match = currentExercise!.questions[index].prompt.match(regex); + if (match) { + return { match: match[1], originalLine: match[2] } + } + } + return; + } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentExercise, questionIndex, totalLines]); + // if the client for some whatever random reason decides + // to add more questions update this + const numberOfQuestions = 2; - useEffect(() => { - if ( - exerciseIndex !== -1 && currentExercise && - currentExercise.type === "multipleChoice" && - exam.parts[partIndex].context && contextWordLines - ) { - if (contextWordLines.length > 0) { - contextWordLines.forEach((n, i) => { - if (contextWords && contextWords[i] && n !== -1) { - const updatedPrompt = currentExercise!.questions[questionIndex + i].prompt.replace( - `in line ${contextWords[i].originalLine}`, - `in line ${n}` - ); - currentExercise!.questions[questionIndex + i].prompt = updatedPrompt; - } - }) - setChangedPrompt(true); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contextWordLines]); + if (exam.parts[partIndex].context) { + const hits = Array.from({ length: numberOfQuestions }).reduce<{ match: string, originalLine: string }[]>((acc, _, i) => { + const result = findMatch(questionIndex + i); + if (!!result) { + acc.push(result); + } + return acc; + }, []); + + if (hits.length > 0) { + setContextWords(hits) + } + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentExercise, questionIndex, totalLines]); + + useEffect(() => { + if ( + exerciseIndex !== -1 && currentExercise && + currentExercise.type === "multipleChoice" && + exam.parts[partIndex].context && contextWordLines + ) { + if (contextWordLines.length > 0) { + contextWordLines.forEach((n, i) => { + if (contextWords && contextWords[i] && n !== -1) { + const updatedPrompt = currentExercise!.questions[questionIndex + i].prompt.replace( + `in line ${contextWords[i].originalLine}`, + `in line ${n}` + ); + currentExercise!.questions[questionIndex + i].prompt = updatedPrompt; + } + }) + setChangedPrompt(true); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contextWordLines]); - useEffect(() => { - if (continueAnyways) { - setContinueAnyways(false); - nextExercise(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [continueAnyways]); + useEffect(() => { + if (continueAnyways) { + setContinueAnyways(false); + nextExercise(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [continueAnyways]); - const modalKwargs = () => { - const kwargs: { type: "module" | "blankQuestions" | "submit", unanswered: boolean, onClose: (next?: boolean) => void; } = { - type: "blankQuestions", - unanswered: false, - onClose: function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } } - }; + const modalKwargs = () => { + const kwargs: { type: "module" | "blankQuestions" | "submit", unanswered: boolean, onClose: (next?: boolean) => void; } = { + type: "blankQuestions", + unanswered: false, + onClose: function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } } + }; - if (partIndex === exam.parts.length - 1) { - kwargs.type = "submit" - kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex)); - kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } }; - } - setQuestionModalKwargs(kwargs); - } + if (partIndex === exam.parts.length - 1) { + kwargs.type = "submit" + kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex)); + kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } }; + } + setQuestionModalKwargs(kwargs); + } - const mcNavKwargs = { - userSolutions: userSolutions, - exam: exam, - partIndex: partIndex, - showSolutions: showSolutions, - setExerciseIndex: setExerciseIndex, - setPartIndex: setPartIndex, - runOnClick: setQuestionIndex - } + const mcNavKwargs = { + userSolutions: userSolutions, + exam: exam, + partIndex: partIndex, + showSolutions: showSolutions, + setExerciseIndex: setExerciseIndex, + setPartIndex: setPartIndex, + runOnClick: setQuestionIndex + } - const memoizedRender = useMemo(() => { - setChangedPrompt(false); - return ( - <> - {textRender && !textRenderDisabled ? - renderText() : - <> - {exam.parts[partIndex]?.context && renderText()} - {(showSolutions) ? - currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) : - currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise) - } - - } - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [textRender, currentExercise, changedPrompt]); + const memoizedRender = useMemo(() => { + setChangedPrompt(false); + return ( + <> + {textRender && !textRenderDisabled ? + renderText() : + <> + {exam.parts[partIndex]?.context && renderText()} + {exam.parts[partIndex]?.audio && renderAudioPlayer()} + {(showSolutions) ? + currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) : + currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise) + } + + } + + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [textRender, currentExercise, changedPrompt]); - return ( - <> -
- { }} - title={"Confirm Submission"} - > - <> -

Are you sure you want to proceed with the submission?

-
- - -
- -
- - { - !(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) && - - } - {(showPartDivider || startNow) ? - { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} - /> : ( - <> - {exam.parts[0].intro && ( - { - setExerciseIndex(0); - setQuestionIndex(0); - if (!seenParts.has(index)) { - setShowPartDivider(true); - setBgColor(levelBgColor); - setSeenParts(prev => new Set(prev).add(index)); - } - } - } /> - )} - x.exercises))} - disableTimer={showSolutions} - showTimer={false} - {...mcNavKwargs} - /> -
- {memoizedRender} -
- - )} -
- - ); + return ( + <> +
+ { }} + title={"Confirm Submission"} + > + <> +

Are you sure you want to proceed with the submission?

+
+ + +
+ +
+ + { + !(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) && + + } + {(showPartDivider || startNow) ? + { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} + /> : ( + <> + {exam.parts[0].intro && ( + { + setExerciseIndex(0); + setQuestionIndex(0); + if (!seenParts.has(index)) { + setShowPartDivider(true); + setBgColor(levelBgColor); + setSeenParts(prev => new Set(prev).add(index)); + } + } + } /> + )} + x.exercises))} + disableTimer={showSolutions} + showTimer={false} + {...mcNavKwargs} + /> +
+ {memoizedRender} +
+ + )} +
+ + ); } From fae7b729fed824e57910a63f7126a9a5cbfd1e80 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 7 Nov 2024 12:08:28 +0000 Subject: [PATCH 2/9] Updated the assignments active filter to work with the startDate --- src/pages/generation.tsx | 27 ++++++++++++++----- src/utils/assignments.ts | 57 ++++++++++++++++++---------------------- src/utils/exams.be.ts | 32 ++++++++++++---------- 3 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index 5d37c93f..6108abc8 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -18,26 +18,39 @@ import ExamEditor from "@/components/ExamEditor"; import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit"; import { redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; +import { Module } from "@/interfaces"; +import { getExam, getExams } from "@/utils/exams.be"; +import { Exam } from "@/interfaces/exam"; +import { useEffect } from "react"; -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { +export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { const user = await requestUser(req, res) if (!user) return redirect("/login") if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) return redirect("/") + const { id, module } = query as { id?: string, module?: Module } + if (!id || !module) return { props: serialize({ user }) }; + + const exam = await getExam(module, id) + if (!exam) return redirect("/generation") + return { - props: serialize({user}), + props: serialize({ user, exam }), }; }, sessionOptions); -export default function Generation({ user }: { user: User; }) { +export default function Generation({ user, exam }: { user: User; exam?: Exam }) { const { title, currentModule, dispatch } = useExamEditorStore(); const updateRoot = (updates: Partial) => { - dispatch({ type: 'UPDATE_ROOT', payload: { updates } }); - }; + dispatch({ type: 'UPDATE_ROOT', payload: { updates } }); + }; + useEffect(() => { + if (exam) { } + }, [exam]) return ( <> @@ -60,7 +73,7 @@ export default function Generation({ user }: { user: User; }) { placeholder="Insert a title here" name="title" label="Title" - onChange={(title) => updateRoot({title})} + onChange={(title) => updateRoot({ title })} roundness="xl" defaultValue={title} required @@ -69,7 +82,7 @@ export default function Generation({ user }: { user: User; }) { updateRoot({currentModule})} + onChange={(currentModule) => updateRoot({ currentModule })} className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> {[...MODULE_ARRAY].map((x) => ( diff --git a/src/utils/assignments.ts b/src/utils/assignments.ts index 0aa2d438..58552816 100644 --- a/src/utils/assignments.ts +++ b/src/utils/assignments.ts @@ -1,5 +1,5 @@ import moment from "moment"; -import {Assignment} from "@/interfaces/results"; +import { Assignment } from "@/interfaces/results"; // export const futureAssignmentFilter = (a: Assignment) => { // if(a.archived) return false; @@ -15,43 +15,38 @@ import {Assignment} from "@/interfaces/results"; // } export const futureAssignmentFilter = (a: Assignment) => { - const currentDate = moment(); - if(moment(a.endDate).isBefore(currentDate)) return false; - if(a.archived) return false; + const currentDate = moment(); + if (moment(a.endDate).isBefore(currentDate)) return false; + if (a.archived) return false; - if(a.autoStart && a.autoStartDate && moment(a.autoStartDate).isBefore(currentDate)) return false; + if (a.autoStart && a.autoStartDate && moment(a.autoStartDate).isBefore(currentDate)) return false; - if(!a.start) { - if(moment(a.startDate).isBefore(currentDate)) return false; - return true; - } - return false; + if (!a.start) { + if (moment(a.startDate).isBefore(currentDate)) return false; + return true; + } + return false; } export const pastAssignmentFilter = (a: Assignment) => { - const currentDate = moment(); - if(a.archived) { - return false; - } + const currentDate = moment(); + if (a.archived) { + return false; + } - return moment(a.endDate).isBefore(currentDate); + return moment(a.endDate).isBefore(currentDate); } export const archivedAssignmentFilter = (a: Assignment) => a.archived; export const activeAssignmentFilter = (a: Assignment) => { - const currentDate = moment(); - if(moment(a.endDate).isBefore(currentDate)) return false; - if(a.archived) return false; + const currentDate = moment(); + if (moment(a.endDate).isBefore(currentDate) || a.archived) return false; - if(a.start) return true; + if (a.start) return true; + if (a.autoStart && a.autoStartDate) return moment(a.autoStartDate).isBefore(currentDate); - if(a.autoStart && a.autoStartDate) { - return moment(a.autoStartDate).isBefore(currentDate); - } - - // if(currentDate.isAfter(moment(a.startDate))) return true; - return false; + return currentDate.isAfter(moment(a.startDate)); }; // export const unstartedAssignmentFilter = (a: Assignment) => { @@ -69,9 +64,9 @@ export const activeAssignmentFilter = (a: Assignment) => { // } export const startHasExpiredAssignmentFilter = (a: Assignment) => { - const currentDate = moment(); - if(a.archived) return false; - if(a.start) return false; - if(currentDate.isAfter(moment(a.startDate)) && currentDate.isBefore(moment(a.endDate))) return true; - return false; -} \ No newline at end of file + const currentDate = moment(); + if (a.archived) return false; + if (a.start) return false; + if (currentDate.isAfter(moment(a.startDate)) && currentDate.isBefore(moment(a.endDate))) return true; + return false; +} diff --git a/src/utils/exams.be.ts b/src/utils/exams.be.ts index dedc4d4c..a3320c3d 100644 --- a/src/utils/exams.be.ts +++ b/src/utils/exams.be.ts @@ -1,13 +1,13 @@ -import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and} from "firebase/firestore"; -import {groupBy, shuffle} from "lodash"; -import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam"; -import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user"; -import {Module} from "@/interfaces"; -import {getCorporateUser} from "@/resources/user"; -import {getUserCorporate} from "./groups.be"; -import {Db, ObjectId} from "mongodb"; +import { collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and } from "firebase/firestore"; +import { groupBy, shuffle } from "lodash"; +import { Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam"; +import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user"; +import { Module } from "@/interfaces"; +import { getCorporateUser } from "@/resources/user"; +import { getUserCorporate } from "./groups.be"; +import { Db, ObjectId } from "mongodb"; import client from "@/lib/mongodb"; -import {MODULE_ARRAY} from "./moduleUtils"; +import { MODULE_ARRAY } from "./moduleUtils"; import { mapBy } from "."; const db = client.db(process.env.MONGODB_DB); @@ -21,7 +21,7 @@ export async function getSpecificExams(ids: string[]) { async (module) => await db .collection(module) - .find({id: {$in: ids}}) + .find({ id: { $in: ids } }) .toArray(), ), ) @@ -30,7 +30,11 @@ export async function getSpecificExams(ids: string[]) { return exams; } -export const getExamsByIds = async (ids: {module: Module; id: string}[]) => { +export const getExam = async (module: Module, id: string) => { + return await db.collection(module).findOne({ id }) ?? undefined +}; + +export const getExamsByIds = async (ids: { module: Module; id: string }[]) => { const groupedByModule = groupBy(ids, "module"); const exams: Exam[] = ( await Promise.all( @@ -38,7 +42,7 @@ export const getExamsByIds = async (ids: {module: Module; id: string}[]) => { async (m) => await db .collection(m) - .find({id: {$in: mapBy(groupedByModule[m], 'id')}}) + .find({ id: { $in: mapBy(groupedByModule[m], 'id') } }) .toArray(), ), ) @@ -121,7 +125,7 @@ const filterByOwners = async (exams: Exam[], userID?: string) => { const filterByDifficulty = async (db: Db, exams: Exam[], module: Module, userID?: string) => { if (!userID) return exams; - const user = await db.collection("users").findOne({id: userID}); + const user = await db.collection("users").findOne({ id: userID }); if (!user) return exams; const difficulty = user.levels[module] <= 3 ? "easy" : user.levels[module] <= 6 ? "medium" : "hard"; @@ -135,7 +139,7 @@ const filterByPreference = async (db: Db, exams: Exam[], module: Module, userID? if (!userID) return exams; - const user = await db.collection("users").findOne({id: userID}); + const user = await db.collection("users").findOne({ id: userID }); if (!user) return exams; if (!["developer", "student"].includes(user.type)) return exams; From 78cf011bf7800675550aa445b9dac97dd582873a Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 7 Nov 2024 12:51:14 +0000 Subject: [PATCH 3/9] Solved a problem with the assignees stuff related to the query --- src/pages/official-exam.tsx | 62 ++++++++++++++++++------------------- src/utils/assignments.be.ts | 30 +++++++++--------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/pages/official-exam.tsx b/src/pages/official-exam.tsx index 17635064..7613a935 100644 --- a/src/pages/official-exam.tsx +++ b/src/pages/official-exam.tsx @@ -4,34 +4,34 @@ import Layout from "@/components/High/Layout"; import Button from "@/components/Low/Button"; import Separator from "@/components/Low/Separator"; import ProfileSummary from "@/components/ProfileSummary"; -import {Session} from "@/hooks/useSessions"; -import {Grading} from "@/interfaces"; -import {EntityWithRoles} from "@/interfaces/entity"; -import {Exam} from "@/interfaces/exam"; +import { Session } from "@/hooks/useSessions"; +import { Grading } from "@/interfaces"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { Exam } from "@/interfaces/exam"; import { InviteWithEntity } from "@/interfaces/invite"; -import {Assignment} from "@/interfaces/results"; -import {Stat, User} from "@/interfaces/user"; -import {sessionOptions} from "@/lib/session"; +import { Assignment } from "@/interfaces/results"; +import { Stat, User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; import useExamStore from "@/stores/examStore"; -import {findBy, mapBy, redirect, serialize} from "@/utils"; +import { findBy, mapBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; -import {activeAssignmentFilter} from "@/utils/assignments"; -import {getAssignmentsByAssignee} from "@/utils/assignments.be"; -import {getEntitiesWithRoles} from "@/utils/entities.be"; -import {getExamsByIds} from "@/utils/exams.be"; -import {sortByModule} from "@/utils/moduleUtils"; -import {checkAccess} from "@/utils/permissions"; -import {getSessionsByUser} from "@/utils/sessions.be"; +import { activeAssignmentFilter } from "@/utils/assignments"; +import { getAssignmentsByAssignee } from "@/utils/assignments.be"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { getExamsByIds } from "@/utils/exams.be"; +import { sortByModule } from "@/utils/moduleUtils"; +import { checkAccess } from "@/utils/permissions"; +import { getSessionsByUser } from "@/utils/sessions.be"; import axios from "axios"; import clsx from "clsx"; -import {withIronSessionSsr} from "iron-session/next"; -import {uniqBy} from "lodash"; +import { withIronSessionSsr } from "iron-session/next"; +import { uniqBy } from "lodash"; import moment from "moment"; import Head from "next/head"; -import {useRouter} from "next/router"; +import { useRouter } from "next/router"; import { useMemo, useState } from "react"; -import {BsArrowRepeat} from "react-icons/bs"; -import {ToastContainer} from "react-toastify"; +import { BsArrowRepeat } from "react-icons/bs"; +import { ToastContainer } from "react-toastify"; interface Props { user: User; @@ -44,7 +44,7 @@ interface Props { grading: Grading; } -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { +export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) const destination = Buffer.from(req.url || "/").toString("base64") if (!user) return redirect(`/login?destination=${destination}`) @@ -55,21 +55,21 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { const entityIDS = mapBy(user.entities, "id") || []; const entities = await getEntitiesWithRoles(entityIDS); - const assignments = await getAssignmentsByAssignee(user.id, {archived: {$ne: true}}); + const assignments = await getAssignmentsByAssignee(user.id, { archived: { $ne: true } }); const sessions = await getSessionsByUser(user.id, 0, { "assignment.id": { $in: mapBy(assignments, 'id') } }); const examIDs = uniqBy( assignments.flatMap((a) => - a.exams.filter((e) => e.assignee === user.id).map((e) => ({module: e.module, id: e.id, key: `${e.module}_${e.id}`})), + a.exams.filter((e) => e.assignee === user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })), ), "key", ); const exams = await getExamsByIds(examIDs); - return {props: serialize({user, entities, assignments, exams, sessions})}; + return { props: serialize({ user, entities, assignments, exams, sessions }) }; }, sessionOptions); -export default function OfficialExam({user, entities, assignments, sessions, exams}: Props) { +export default function OfficialExam({ user, entities, assignments, sessions, exams }: Props) { const [isLoading, setIsLoading] = useState(false) const router = useRouter(); @@ -98,7 +98,7 @@ export default function OfficialExam({user, entities, assignments, sessions, exa }; const loadSession = async (session: Session) => { - state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []}))); + state.setShuffles(session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] }))); state.setSelectedModules(session.selectedModules); state.setExam(session.exam); state.setExams(session.exams); @@ -115,11 +115,11 @@ export default function OfficialExam({user, entities, assignments, sessions, exa router.push("/exam"); }; - const logout = async () => { - axios.post("/api/logout").finally(() => { - setTimeout(() => router.reload(), 500); - }); - }; + const logout = async () => { + axios.post("/api/logout").finally(() => { + setTimeout(() => router.reload(), 500); + }); + }; const studentAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]); const assignmentSessions = useMemo(() => sessions.filter(s => mapBy(studentAssignments, 'id').includes(s.assignment?.id || "")), [sessions, studentAssignments]) diff --git a/src/utils/assignments.be.ts b/src/utils/assignments.be.ts index 4a21dc3f..c4fbd1d7 100644 --- a/src/utils/assignments.be.ts +++ b/src/utils/assignments.be.ts @@ -1,19 +1,19 @@ import client from "@/lib/mongodb"; -import {Assignment} from "@/interfaces/results"; -import {getAllAssignersByCorporate} from "@/utils/groups.be"; -import {Type} from "@/interfaces/user"; +import { Assignment } from "@/interfaces/results"; +import { getAllAssignersByCorporate } from "@/utils/groups.be"; +import { Type } from "@/interfaces/user"; const db = client.db(process.env.MONGODB_DB); export const getAssignmentsByAssigner = async (id: string, startDate?: Date, endDate?: Date) => { - let query: any = {assigner: id}; + let query: any = { assigner: id }; if (startDate) { - query.startDate = {$gte: startDate.toISOString()}; + query.startDate = { $gte: startDate.toISOString() }; } if (endDate) { - query.endDate = {$lte: endDate.toISOString()}; + query.endDate = { $lte: endDate.toISOString() }; } return await db.collection("assignments").find(query).toArray(); @@ -24,39 +24,39 @@ export const getAssignments = async () => { }; export const getAssignment = async (id: string) => { - return await db.collection("assignments").findOne({id}); + return await db.collection("assignments").findOne({ id }); }; -export const getAssignmentsByAssignee = async (id: string, filter?: {[key in keyof Partial]: any}) => { +export const getAssignmentsByAssignee = async (id: string, filter?: { [key in keyof Partial]: any }) => { return await db .collection("assignments") - .find({assignees: [id], ...(!filter ? {} : filter)}) + .find({ assignees: id, ...(!filter ? {} : filter) }) .toArray(); }; export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => { - return await db.collection("assignments").find({assigner: id}).toArray(); + return await db.collection("assignments").find({ assigner: id }).toArray(); }; export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date, endDate?: Date) => { return await db .collection("assignments") .find({ - assigner: {$in: ids}, - ...(!!startDate ? {startDate: {$gte: startDate.toISOString()}} : {}), - ...(!!endDate ? {endDate: {$lte: endDate.toISOString()}} : {}), + assigner: { $in: ids }, + ...(!!startDate ? { startDate: { $gte: startDate.toISOString() } } : {}), + ...(!!endDate ? { endDate: { $lte: endDate.toISOString() } } : {}), }) .toArray(); }; export const getEntityAssignments = async (id: string) => { - return await db.collection("assignments").find({entity: id}).toArray(); + return await db.collection("assignments").find({ entity: id }).toArray(); }; export const getEntitiesAssignments = async (ids: string[]) => { return await db .collection("assignments") - .find({entity: {$in: ids}}) + .find({ entity: { $in: ids } }) .toArray(); }; From 7ae91d7bc1a1bfda742f76ab010b4b2875c7cb20 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 7 Nov 2024 14:55:44 +0000 Subject: [PATCH 4/9] Updated some troubles related to the Level Exam --- .../Medium/ModuleTitle/MCQuestionGrid.tsx | 157 +++++++++--------- src/components/Medium/ModuleTitle/index.tsx | 22 ++- src/pages/official-exam.tsx | 8 +- 3 files changed, 92 insertions(+), 95 deletions(-) diff --git a/src/components/Medium/ModuleTitle/MCQuestionGrid.tsx b/src/components/Medium/ModuleTitle/MCQuestionGrid.tsx index ba0d67c6..11673641 100644 --- a/src/components/Medium/ModuleTitle/MCQuestionGrid.tsx +++ b/src/components/Medium/ModuleTitle/MCQuestionGrid.tsx @@ -3,105 +3,96 @@ import Modal from "@/components/Modal"; import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { BsFillGrid3X3GapFill } from "react-icons/bs"; interface Props { - showSolutions: boolean; - runOnClick: ((index: number) => void) | undefined; + exam: LevelExam + showSolutions: boolean; + runOnClick: ((index: number) => void) | undefined; } -const MCQuestionGrid: React.FC = ({showSolutions, runOnClick}) => { - const [isOpen, setIsOpen] = useState(false); +const MCQuestionGrid: React.FC = ({ exam, showSolutions, runOnClick }) => { + const [isOpen, setIsOpen] = useState(false); - const { + const { userSolutions, partIndex: sectionIndex, - exerciseIndex, - exam + exerciseIndex, } = useExamStore((state) => state); - const isMultipleChoiceLevelExercise = () => { - if (exam?.module === 'level' && typeof sectionIndex === "number" && sectionIndex > -1) { - const currentExercise = (exam as LevelExam).parts[sectionIndex].exercises[exerciseIndex]; - return currentExercise && currentExercise.type === 'multipleChoice'; - } - return false; - }; + const currentExercise = useMemo(() => (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise, [exam, exerciseIndex, sectionIndex]) + const userSolution = useMemo(() => userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!, [currentExercise.id, userSolutions]) + const answeredQuestions = useMemo(() => new Set(userSolution.solutions.map(sol => sol.question.toString())), [userSolution.solutions]) + const exerciseOffset = useMemo(() => Number(currentExercise.questions[0].id), [currentExercise.questions]) + const lastExercise = useMemo(() => exerciseOffset + (currentExercise.questions.length - 1), + [currentExercise.questions.length, exerciseOffset]); - if (!isMultipleChoiceLevelExercise() && !userSolutions) return null; + const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => { + const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => { + if (foundMap) return foundMap; + return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null; + }, null as ShuffleMap | null); + const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution; - const currentExercise = (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise; - const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!; - const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString())); - const exerciseOffset = Number(currentExercise.questions[0].id); - const lastExercise = exerciseOffset + (currentExercise.questions.length - 1); + if (!userSolutions) return ""; - const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => { - const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => { - if (foundMap) return foundMap; - return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null; - }, null as ShuffleMap | null); - const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution; + if (!userQuestionSolution) { + return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700"; + } - if (!userSolutions) return ""; + return userQuestionSolution === newSolution ? + "!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" : + "!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark"; + } - if (!userQuestionSolution) { - return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700"; - } + return ( + <> + + setIsOpen(false)} + className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all" + > + <> +

{`Part ${sectionIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}

+
+ {currentExercise.questions.map((_, index) => { + const questionNumber = exerciseOffset + index; + const isAnswered = answeredQuestions.has(questionNumber.toString()); + const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution; - return userQuestionSolution === newSolution ? - "!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" : - "!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark"; - } - - return ( - <> - - setIsOpen(false)} - className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all" - > - <> -

{`Part ${sectionIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}

-
- {currentExercise.questions.map((_, index) => { - const questionNumber = exerciseOffset + index; - const isAnswered = answeredQuestions.has(questionNumber.toString()); - const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution; - - const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option; - return ( - - ); - })} -
-

- Click a question number to jump to that question -

- -
- - ); + const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option; + return ( + + ); + })} +
+

+ Click a question number to jump to that question +

+ +
+ + ); } export default MCQuestionGrid; diff --git a/src/components/Medium/ModuleTitle/index.tsx b/src/components/Medium/ModuleTitle/index.tsx index f529b6f9..c22ed521 100644 --- a/src/components/Medium/ModuleTitle/index.tsx +++ b/src/components/Medium/ModuleTitle/index.tsx @@ -1,11 +1,11 @@ import { Module } from "@/interfaces"; import { moduleLabels } from "@/utils/moduleUtils"; import clsx from "clsx"; -import { ReactNode, useState } from "react"; +import { ReactNode, useMemo, useState } from "react"; import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs"; import ProgressBar from "../../Low/ProgressBar"; import Timer from "../Timer"; -import { Exercise } from "@/interfaces/exam"; +import { Exercise, LevelExam } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import React from "react"; import MCQuestionGrid from "./MCQuestionGrid"; @@ -38,9 +38,7 @@ export default function ModuleTitle({ showSolutions = false, runOnClick = undefined }: Props) { - const { - exam - } = useExamStore((state) => state); + const { exam, partIndex, exerciseIndex: examExerciseIndex, userSolutions } = useExamStore((state) => state); const moduleIcon: { [key in Module]: ReactNode } = { reading: , @@ -50,6 +48,14 @@ export default function ModuleTitle({ level: , }; + const showGrid = useMemo(() => + exam?.module === "level" + && partIndex > -1 + && exam.parts[partIndex].exercises[examExerciseIndex].type === "multipleChoice" + && !!userSolutions, + [exam, examExerciseIndex, partIndex, userSolutions] + ) + return ( <> {showTimer && } @@ -67,7 +73,7 @@ export default function ModuleTitle({ return (
{partInstructions.split("\\n").map((line, lineIndex) => ( - not correct')}}> + not correct') }}> ))}
); @@ -87,9 +93,9 @@ export default function ModuleTitle({
- {exam?.module === "level" && } + {showGrid && } ); -} \ No newline at end of file +} diff --git a/src/pages/official-exam.tsx b/src/pages/official-exam.tsx index 7613a935..241742a8 100644 --- a/src/pages/official-exam.tsx +++ b/src/pages/official-exam.tsx @@ -13,7 +13,7 @@ import { Assignment } from "@/interfaces/results"; import { Stat, User } from "@/interfaces/user"; import { sessionOptions } from "@/lib/session"; import useExamStore from "@/stores/examStore"; -import { findBy, mapBy, redirect, serialize } from "@/utils"; +import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; import { activeAssignmentFilter } from "@/utils/assignments"; import { getAssignmentsByAssignee } from "@/utils/assignments.be"; @@ -60,7 +60,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const examIDs = uniqBy( assignments.flatMap((a) => - a.exams.filter((e) => e.assignee === user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })), + filterBy(a.exams, 'assignee', user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })), ), "key", ); @@ -93,7 +93,7 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex state.setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module')); state.setAssignment(assignment); - router.push("/exam"); + router.push(`/exam?assignment=${assignment.id}`); } }; @@ -112,7 +112,7 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex state.setShowSolutions(false); state.setQuestionIndex(session.questionIndex); - router.push("/exam"); + router.push(`/exam?assignment=${session.assignment?.id}`); }; const logout = async () => { From 065497dfa334e42132519d277a54a12453459a8a Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 7 Nov 2024 22:51:45 +0000 Subject: [PATCH 5/9] Updated the code to return to official-exam if they came from that page --- src/exams/Finish.tsx | 39 +++++++++++------------ src/pages/(exam)/ExamPage.tsx | 58 ++++++++++++++++++----------------- src/pages/exam.tsx | 38 ++++++++++++----------- src/pages/official-exam.tsx | 6 ++-- 4 files changed, 74 insertions(+), 67 deletions(-) diff --git a/src/exams/Finish.tsx b/src/exams/Finish.tsx index 9d89242d..8c3f9a3b 100644 --- a/src/exams/Finish.tsx +++ b/src/exams/Finish.tsx @@ -1,14 +1,14 @@ import Button from "@/components/Low/Button"; import ModuleTitle from "@/components/Medium/ModuleTitle"; -import {moduleResultText} from "@/constants/ielts"; -import {Module} from "@/interfaces"; -import {User} from "@/interfaces/user"; +import { moduleResultText } from "@/constants/ielts"; +import { Module } from "@/interfaces"; +import { User } from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; -import {calculateBandScore, getGradingLabel} from "@/utils/score"; +import { calculateBandScore, getGradingLabel } from "@/utils/score"; import clsx from "clsx"; import Link from "next/link"; -import {useRouter} from "next/router"; -import {Fragment, useEffect, useState} from "react"; +import { useRouter } from "next/router"; +import { Fragment, useEffect, useState } from "react"; import { BsArrowCounterclockwise, BsBan, @@ -21,14 +21,14 @@ import { BsPen, BsShareFill, } from "react-icons/bs"; -import {LevelScore} from "@/constants/ielts"; -import {getLevelScore} from "@/utils/score"; -import {capitalize} from "lodash"; +import { LevelScore } from "@/constants/ielts"; +import { getLevelScore } from "@/utils/score"; +import { capitalize } from "lodash"; import Modal from "@/components/Modal"; -import {UserSolution} from "@/interfaces/exam"; +import { UserSolution } from "@/interfaces/exam"; import ai_usage from "@/utils/ai.detection"; import useGradingSystem from "@/hooks/useGrading"; -import {Assignment} from "@/interfaces/results"; +import { Assignment } from "@/interfaces/results"; interface Score { module: Module; @@ -49,20 +49,23 @@ interface Props { isLoading: boolean; assignment?: Assignment; onViewResults: (moduleIndex?: number) => void; + destination?: string } -export default function Finish({user, scores, modules, information, solutions, isLoading, assignment, onViewResults}: Props) { +export default function Finish({ user, scores, modules, information, solutions, isLoading, assignment, onViewResults, destination }: Props) { const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedScore, setSelectedScore] = useState(scores.find((x) => x.module === modules[0])!); const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false); const aiUsage = Math.round(ai_usage(solutions) * 100); const exams = useExamStore((state) => state.exams); - const {gradingSystem} = useGradingSystem(); + const { gradingSystem } = useGradingSystem(); + + const router = useRouter() useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]); - const moduleColors: {[key in Module]: {progress: string; inner: string}} = { + const moduleColors: { [key in Module]: { progress: string; inner: string } } = { reading: { progress: "text-ielts-reading", inner: "bg-ielts-reading-light", @@ -286,10 +289,8 @@ export default function Finish({user, scores, modules, information, solutions, i
@@ -325,7 +326,7 @@ export default function Finish({user, scores, modules, information, solutions, i )}
- + diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 79069e30..480310ac 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ -import {Module} from "@/interfaces"; -import {useEffect, useState} from "react"; +import { Module } from "@/interfaces"; +import { useEffect, useState } from "react"; import AbandonPopup from "@/components/AbandonPopup"; import Layout from "@/components/High/Layout"; @@ -12,15 +12,15 @@ import Selection from "@/exams/Selection"; import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; import useUser from "@/hooks/useUser"; -import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam"; -import {Stat, User} from "@/interfaces/user"; +import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam"; +import { Stat, User } from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; -import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; -import {defaultExamUserSolutions, getExam} from "@/utils/exams"; +import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; +import { defaultExamUserSolutions, getExam } from "@/utils/exams"; import axios from "axios"; -import {useRouter} from "next/router"; -import {toast, ToastContainer} from "react-toastify"; -import {v4 as uuidv4} from "uuid"; +import { useRouter } from "next/router"; +import { toast, ToastContainer } from "react-toastify"; +import { v4 as uuidv4 } from "uuid"; import useSessions from "@/hooks/useSessions"; import ShortUniqueId from "short-unique-id"; import clsx from "clsx"; @@ -31,10 +31,11 @@ import { mapBy } from "@/utils"; interface Props { page: "exams" | "exercises"; user: User; + destination?: string hideSidebar?: boolean } -export default function ExamPage({page, user, hideSidebar = false}: Props) { +export default function ExamPage({ page, user, destination = "/exam", hideSidebar = false }: Props) { const [variant, setVariant] = useState("full"); const [avoidRepeated, setAvoidRepeated] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false); @@ -49,18 +50,18 @@ export default function ExamPage({page, user, hideSidebar = false}: Props) { const assignment = useExamStore((state) => state.assignment); const initialTimeSpent = useExamStore((state) => state.timeSpent); - const {exam, setExam} = useExamStore((state) => state); - const {exams, setExams} = useExamStore((state) => state); - const {sessionId, setSessionId} = useExamStore((state) => state); - const {partIndex, setPartIndex} = useExamStore((state) => state); - const {moduleIndex, setModuleIndex} = useExamStore((state) => state); - const {questionIndex, setQuestionIndex} = useExamStore((state) => state); - const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); - const {userSolutions, setUserSolutions} = useExamStore((state) => state); - const {showSolutions, setShowSolutions} = useExamStore((state) => state); - const {selectedModules, setSelectedModules} = useExamStore((state) => state); - const {inactivity, setInactivity} = useExamStore((state) => state); - const {bgColor, setBgColor} = useExamStore((state) => state); + const { exam, setExam } = useExamStore((state) => state); + const { exams, setExams } = useExamStore((state) => state); + const { sessionId, setSessionId } = useExamStore((state) => state); + const { partIndex, setPartIndex } = useExamStore((state) => state); + const { moduleIndex, setModuleIndex } = useExamStore((state) => state); + const { questionIndex, setQuestionIndex } = useExamStore((state) => state); + const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state); + const { userSolutions, setUserSolutions } = useExamStore((state) => state); + const { showSolutions, setShowSolutions } = useExamStore((state) => state); + const { selectedModules, setSelectedModules } = useExamStore((state) => state); + const { inactivity, setInactivity } = useExamStore((state) => state); + const { bgColor, setBgColor } = useExamStore((state) => state); const setShuffleMaps = useExamStore((state) => state.setShuffles); const router = useRouter(); @@ -262,11 +263,11 @@ export default function ExamPage({page, user, hideSidebar = false}: Props) { date: new Date().getTime(), isDisabled: solution.isDisabled, shuffleMaps: solution.shuffleMaps, - ...(assignment ? {assignment: assignment.id} : {}), + ...(assignment ? { assignment: assignment.id } : {}), })); axios - .post<{ok: boolean}>("/api/stats", newStats) + .post<{ ok: boolean }>("/api/stats", newStats) .then((response) => setHasBeenUploaded(response.data.ok)) .catch(() => setHasBeenUploaded(false)); } @@ -329,7 +330,7 @@ export default function ExamPage({page, user, hideSidebar = false}: Props) { ), }), ); - return Object.assign(exam, {parts}); + return Object.assign(exam, { parts }); } const exercises = exam.exercises.map((x) => @@ -337,7 +338,7 @@ export default function ExamPage({page, user, hideSidebar = false}: Props) { userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions, }), ); - return Object.assign(exam, {exercises}); + return Object.assign(exam, { exercises }); }; const onFinish = async (solutions: UserSolution[]) => { @@ -392,7 +393,7 @@ export default function ExamPage({page, user, hideSidebar = false}: Props) { correct: number; }[] => { const scores: { - [key in Module]: {total: number; missing: number; correct: number}; + [key in Module]: { total: number; missing: number; correct: number }; } = { reading: { total: 0, @@ -434,7 +435,7 @@ export default function ExamPage({page, user, hideSidebar = false}: Props) { return Object.keys(scores) .filter((x) => scores[x as Module].total > 0) - .map((x) => ({module: x as Module, ...scores[x as Module]})); + .map((x) => ({ module: x as Module, ...scores[x as Module] })); }; const renderScreen = () => { @@ -465,6 +466,7 @@ export default function ExamPage({page, user, hideSidebar = false}: Props) { timeSpent, inactivity: totalInactivity, }} + destination={destination} onViewResults={(index?: number) => { if (exams[0].module === "level") { const levelExam = exams[0] as LevelExam; diff --git a/src/pages/exam.tsx b/src/pages/exam.tsx index f3b3c526..e77f90c3 100644 --- a/src/pages/exam.tsx +++ b/src/pages/exam.tsx @@ -1,11 +1,11 @@ /* eslint-disable @next/next/no-img-element */ -import {withIronSessionSsr} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import { withIronSessionSsr } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; import ExamPage from "./(exam)/ExamPage"; import Head from "next/head"; -import {User} from "@/interfaces/user"; +import { User } from "@/interfaces/user"; import { filterBy, findBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be"; @@ -21,35 +21,36 @@ import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be"; import { Session } from "@/hooks/useSessions"; import moment from "moment"; -export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { +export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { const user = await requestUser(req, res) - const destination = Buffer.from(req.url || "/").toString("base64") - if (!user) return redirect(`/login?destination=${destination}`) + const loginDestination = Buffer.from(req.url || "/").toString("base64") + if (!user) return redirect(`/login?destination=${loginDestination}`) if (shouldRedirectHome(user)) return redirect("/") - const {assignment: assignmentID} = query as {assignment?: string} + const { assignment: assignmentID, destination } = query as { assignment?: string, destination?: string } + const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined if (assignmentID) { const assignment = await getAssignment(assignmentID) - if (!assignment) return redirect("/exam") + if (!assignment) return redirect(destinationURL || "/exam") if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type)) - return redirect("/exam") + return redirect(destinationURL || "/exam") if (filterBy(assignment.results, 'user', user.id).length > 0) - return redirect("/exam") + return redirect(destinationURL || "/exam") const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) const session = await getSessionByAssignment(assignmentID) return { - props: serialize({user, assignment, exams, session: session ?? undefined}) + props: serialize({ user, assignment, exams, destinationURL, session: session ?? undefined }) } } return { - props: serialize({user}), + props: serialize({ user, destinationURL }), }; }, sessionOptions); @@ -58,9 +59,10 @@ interface Props { assignment?: Assignment exams?: Exam[] session?: Session + destinationURL?: string } -export default function Page({user, assignment, exams = [], session}: Props) { +export default function Page({ user, assignment, exams = [], destinationURL = "/exam", session }: Props) { const router = useRouter() const state = useExamStore((state) => state) @@ -82,12 +84,12 @@ export default function Page({user, assignment, exams = [], session}: Props) { router.replace(router.asPath) } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [assignment, exams, session]) useEffect(() => { if (assignment && exams.length > 0 && !state.assignment && !!session) { - state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []}))); + state.setShuffles(session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] }))); state.setSelectedModules(session.selectedModules); state.setExam(session.exam); state.setExams(session.exams); @@ -103,7 +105,7 @@ export default function Page({user, assignment, exams = [], session}: Props) { router.replace(router.asPath) } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [assignment, exams, session]) return ( @@ -117,7 +119,7 @@ export default function Page({user, assignment, exams = [], session}: Props) { - + ); } diff --git a/src/pages/official-exam.tsx b/src/pages/official-exam.tsx index 241742a8..bd069c4a 100644 --- a/src/pages/official-exam.tsx +++ b/src/pages/official-exam.tsx @@ -69,6 +69,8 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { return { props: serialize({ user, entities, assignments, exams, sessions }) }; }, sessionOptions); +const destination = Buffer.from("/official-exam").toString("base64") + export default function OfficialExam({ user, entities, assignments, sessions, exams }: Props) { const [isLoading, setIsLoading] = useState(false) @@ -93,7 +95,7 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex state.setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module')); state.setAssignment(assignment); - router.push(`/exam?assignment=${assignment.id}`); + router.push(`/exam?assignment=${assignment.id}&destination=${destination}`); } }; @@ -112,7 +114,7 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex state.setShowSolutions(false); state.setQuestionIndex(session.questionIndex); - router.push(`/exam?assignment=${session.assignment?.id}`); + router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`); }; const logout = async () => { From f0a97d42a46c48457219875ea98f018d7aefd8df Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Fri, 8 Nov 2024 09:44:28 +0000 Subject: [PATCH 6/9] Updated the generation page to now work with the entity permission system --- src/pages/generation.tsx | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index 6108abc8..e6d2f988 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -10,38 +10,56 @@ import clsx from "clsx"; import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { capitalize } from "lodash"; import Input from "@/components/Low/Input"; -import { checkAccess } from "@/utils/permissions"; +import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { User } from "@/interfaces/user"; import useExamEditorStore from "@/stores/examEditor"; import ExamEditorStore from "@/stores/examEditor/types"; import ExamEditor from "@/components/ExamEditor"; import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit"; -import { redirect, serialize } from "@/utils"; +import { mapBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; import { Module } from "@/interfaces"; import { getExam, getExams } from "@/utils/exams.be"; import { Exam } from "@/interfaces/exam"; import { useEffect } from "react"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { isAdmin } from "@/utils/users"; + +type Permission = { [key in Module]: boolean } export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { const user = await requestUser(req, res) if (!user) return redirect("/login") - if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) - return redirect("/") + if (shouldRedirectHome(user)) return redirect("/") + + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs) + + const permissions: Permission = { + reading: findAllowedEntities(user, entities, `generate_reading`).length > 0, + listening: findAllowedEntities(user, entities, `generate_listening`).length > 0, + writing: findAllowedEntities(user, entities, `generate_writing`).length > 0, + speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0, + level: findAllowedEntities(user, entities, `generate_level`).length > 0, + } + + if (Object.keys(permissions).every(p => !permissions[p as Module])) return redirect("/") const { id, module } = query as { id?: string, module?: Module } - if (!id || !module) return { props: serialize({ user }) }; + if (!id || !module) return { props: serialize({ user, permissions }) }; + + if (!permissions[module]) return redirect("/generation") const exam = await getExam(module, id) if (!exam) return redirect("/generation") return { - props: serialize({ user, exam }), + props: serialize({ user, exam, permissions }), }; }, sessionOptions); -export default function Generation({ user, exam }: { user: User; exam?: Exam }) { +export default function Generation({ user, exam, permissions }: { user: User; exam?: Exam, permissions: Permission }) { const { title, currentModule, dispatch } = useExamEditorStore(); const updateRoot = (updates: Partial) => { @@ -84,7 +102,7 @@ export default function Generation({ user, exam }: { user: User; exam?: Exam }) value={currentModule} onChange={(currentModule) => updateRoot({ currentModule })} className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> - {[...MODULE_ARRAY].map((x) => ( + {[...MODULE_ARRAY].filter(m => permissions[m]).map((x) => ( {({ checked }) => ( Date: Sat, 9 Nov 2024 18:11:18 +0000 Subject: [PATCH 7/9] Removed redirection from the official-exam to records --- src/components/High/AssignmentCard.tsx | 15 +++++++-------- src/pages/exam.tsx | 3 ++- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/High/AssignmentCard.tsx b/src/components/High/AssignmentCard.tsx index 6720c0e1..d7427e5f 100644 --- a/src/components/High/AssignmentCard.tsx +++ b/src/components/High/AssignmentCard.tsx @@ -73,20 +73,19 @@ export default function AssignmentCard({ user, assignment, session, startAssignm className={clsx( "-md:hidden h-full w-full max-w-[50%] cursor-pointer" )}> - +
)} )} {assignment.results.map((r) => r.user).includes(user.id) && ( + )} + {activeAssignmentFilter(assignment) && !assignment.results.map((r) => r.user).includes(user.id) && ( <>
Submitted diff --git a/src/dashboards/AssignmentCreator.tsx b/src/dashboards/AssignmentCreator.tsx index c3bdd792..89c58598 100644 --- a/src/dashboards/AssignmentCreator.tsx +++ b/src/dashboards/AssignmentCreator.tsx @@ -1,27 +1,27 @@ import Input from "@/components/Low/Input"; import Modal from "@/components/Modal"; -import {Module} from "@/interfaces"; +import { Module } from "@/interfaces"; import clsx from "clsx"; -import {useEffect, useMemo, useState} from "react"; -import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; -import {generate} from "random-words"; -import {capitalize} from "lodash"; +import { useEffect, useMemo, useState } from "react"; +import { BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle } from "react-icons/bs"; +import { generate } from "random-words"; +import { capitalize } from "lodash"; import useUsers from "@/hooks/useUsers"; -import {Group, User} from "@/interfaces/user"; +import { Group, User } from "@/interfaces/user"; import ProgressBar from "@/components/Low/ProgressBar"; -import {calculateAverageLevel} from "@/utils/score"; +import { calculateAverageLevel } from "@/utils/score"; import Button from "@/components/Low/Button"; import ReactDatePicker from "react-datepicker"; import moment from "moment"; import axios from "axios"; -import {getExam} from "@/utils/exams"; -import {toast} from "react-toastify"; -import {Assignment} from "@/interfaces/results"; +import { getExam } from "@/utils/exams"; +import { toast } from "react-toastify"; +import { Assignment } from "@/interfaces/results"; import Checkbox from "@/components/Low/Checkbox"; -import {InstructorGender, Variant} from "@/interfaces/exam"; +import { InstructorGender, Variant } from "@/interfaces/exam"; import Select from "@/components/Low/Select"; import useExams from "@/hooks/useExams"; -import {useListSearch} from "@/hooks/useListSearch"; +import { useListSearch } from "@/hooks/useListSearch"; interface Props { isCreating: boolean; @@ -34,7 +34,7 @@ interface Props { const SIZE = 12; -export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) { +export default function AssignmentCreator({ isCreating, assignment, user, groups, users, cancelCreation }: Props) { const [studentsPage, setStudentsPage] = useState(0); const [teachersPage, setTeachersPage] = useState(0); @@ -43,14 +43,14 @@ export default function AssignmentCreator({isCreating, assignment, user, groups, const [teachers, setTeachers] = useState(!!assignment ? assignment.teachers || [] : [...(user.type === "teacher" ? [user.id] : [])]); const [name, setName] = useState( assignment?.name || - generate({ - minLength: 6, - maxLength: 8, - min: 2, - max: 3, - join: " ", - formatter: capitalize, - }), + generate({ + minLength: 6, + maxLength: 8, + min: 2, + max: 3, + join: " ", + formatter: capitalize, + }), ); const [isLoading, setIsLoading] = useState(false); const [startDate, setStartDate] = useState(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "hour").toDate()); @@ -65,18 +65,17 @@ export default function AssignmentCreator({isCreating, assignment, user, groups, const [released, setReleased] = useState(assignment?.released || false); const [autoStart, setAutostart] = useState(assignment?.autoStart || false); - const [autoStartDate, setAutoStartDate] = useState(assignment ? moment(assignment.autoStartDate).toDate() : new Date()); const [useRandomExams, setUseRandomExams] = useState(true); - const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]); + const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]); - const {exams} = useExams(); + const { exams } = useExams(); const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]); const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]); - const {rows: filteredStudentsRows, renderSearch: renderStudentSearch, text: studentText} = useListSearch([["name"], ["email"]], userStudents); - const {rows: filteredTeachersRows, renderSearch: renderTeacherSearch, text: teacherText} = useListSearch([["name"], ["email"]], userTeachers); + const { rows: filteredStudentsRows, renderSearch: renderStudentSearch, text: studentText } = useListSearch([["name"], ["email"]], userStudents); + const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch, text: teacherText } = useListSearch([["name"], ["email"]], userTeachers); useEffect(() => setStudentsPage(0), [studentText]); const studentRows = useMemo( @@ -131,7 +130,6 @@ export default function AssignmentCreator({isCreating, assignment, user, groups, instructorGender, released, autoStart, - autoStartDate, }) .then(() => { toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); @@ -306,24 +304,6 @@ export default function AssignmentCreator({isCreating, assignment, user, groups, onChange={(date) => setEndDate(date)} />
- {autoStart && ( -
- - moment(date).isSameOrAfter(new Date())} - dateFormat="dd/MM/yyyy HH:mm" - selected={autoStartDate} - showTimeSelect - onChange={(date) => setAutoStartDate(date)} - /> -
- )} {selectedModules.includes("speaking") && ( @@ -337,9 +317,9 @@ export default function AssignmentCreator({isCreating, assignment, user, groups, onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} disabled={!selectedModules.includes("speaking") || !!assignment} options={[ - {value: "male", label: "Male"}, - {value: "female", label: "Female"}, - {value: "varied", label: "Varied"}, + { value: "male", label: "Male" }, + { value: "female", label: "Female" }, + { value: "varied", label: "Varied" }, ]} /> @@ -362,12 +342,12 @@ export default function AssignmentCreator({isCreating, assignment, user, groups, }} onChange={(value) => value - ? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}]) + ? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), { id: value.value!, module }]) : setExamIDs((prev) => prev.filter((x) => x.module !== module)) } options={exams .filter((x) => !x.isDiagnostic && x.module === module) - .map((x) => ({value: x.id, label: x.id}))} + .map((x) => ({ value: x.id, label: x.id }))} /> ))} @@ -394,7 +374,7 @@ export default function AssignmentCreator({isCreating, assignment, user, groups, "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "transition duration-300 ease-in-out", users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) && - "!bg-mti-purple-light !text-white", + "!bg-mti-purple-light !text-white", )}> {g.name} @@ -475,7 +455,7 @@ export default function AssignmentCreator({isCreating, assignment, user, groups, "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "transition duration-300 ease-in-out", users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) && - "!bg-mti-purple-light !text-white", + "!bg-mti-purple-light !text-white", )}> {g.name} diff --git a/src/interfaces/results.ts b/src/interfaces/results.ts index cd9ff373..58832a4f 100644 --- a/src/interfaces/results.ts +++ b/src/interfaces/results.ts @@ -1,8 +1,8 @@ -import {Module} from "@/interfaces"; -import {InstructorGender} from "./exam"; -import {Stat} from "./user"; +import { Module } from "@/interfaces"; +import { InstructorGender } from "./exam"; +import { Stat } from "./user"; -export type UserResults = {[key in Module]: ModuleResult}; +export type UserResults = { [key in Module]: ModuleResult }; interface ModuleResult { exams: string[]; @@ -22,7 +22,7 @@ export interface Assignment { assigner: string; assignees: string[]; results: AssignmentResult[]; - exams: {id: string; module: Module; assignee: string}[]; + exams: { id: string; module: Module; assignee: string }[]; instructorGender?: InstructorGender; startDate: Date; endDate: Date; @@ -32,9 +32,8 @@ export interface Assignment { // unless start is active, the assignment is not visible to the assignees // start date now works as a limit time to start the exam start?: boolean; - autoStartDate?: Date; autoStart?: boolean; entity?: string; } -export type AssignmentWithCorporateId = Assignment & {corporateId: string}; +export type AssignmentWithCorporateId = Assignment & { corporateId: string }; diff --git a/src/pages/assignments/creator/[id].tsx b/src/pages/assignments/creator/[id].tsx index 4f3e8bca..0f8dcdf1 100644 --- a/src/pages/assignments/creator/[id].tsx +++ b/src/pages/assignments/creator/[id].tsx @@ -6,43 +6,43 @@ import ProgressBar from "@/components/Low/ProgressBar"; import Select from "@/components/Low/Select"; import Separator from "@/components/Low/Separator"; import useExams from "@/hooks/useExams"; -import {useListSearch} from "@/hooks/useListSearch"; +import { useListSearch } from "@/hooks/useListSearch"; import usePagination from "@/hooks/usePagination"; -import {Module} from "@/interfaces"; -import {EntityWithRoles} from "@/interfaces/entity"; -import {InstructorGender, Variant} from "@/interfaces/exam"; -import {Assignment} from "@/interfaces/results"; -import {Group, User} from "@/interfaces/user"; -import {sessionOptions} from "@/lib/session"; -import {mapBy, redirect, serialize} from "@/utils"; +import { Module } from "@/interfaces"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { InstructorGender, Variant } from "@/interfaces/exam"; +import { Assignment } from "@/interfaces/results"; +import { Group, User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; +import { mapBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; -import {getAssignment} from "@/utils/assignments.be"; -import {getEntitiesWithRoles} from "@/utils/entities.be"; -import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; -import {checkAccess, doesEntityAllow, findAllowedEntities} from "@/utils/permissions"; -import {calculateAverageLevel} from "@/utils/score"; -import {getEntitiesUsers, getUsers} from "@/utils/users.be"; +import { getAssignment } from "@/utils/assignments.be"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import { checkAccess, doesEntityAllow, findAllowedEntities } from "@/utils/permissions"; +import { calculateAverageLevel } from "@/utils/score"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import axios from "axios"; import clsx from "clsx"; -import {withIronSessionSsr} from "iron-session/next"; -import {capitalize} from "lodash"; +import { withIronSessionSsr } from "iron-session/next"; +import { capitalize } from "lodash"; import moment from "moment"; import Head from "next/head"; import Link from "next/link"; -import {useRouter} from "next/router"; -import {generate} from "random-words"; -import {useEffect, useMemo, useState} from "react"; +import { useRouter } from "next/router"; +import { generate } from "random-words"; +import { useEffect, useMemo, useState } from "react"; import ReactDatePicker from "react-datepicker"; -import {BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; -import {toast} from "react-toastify"; +import { BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle } from "react-icons/bs"; +import { toast } from "react-toastify"; -export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { +export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { const user = await requestUser(req, res) if (!user) return redirect("/login") res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); - const {id} = params as {id: string}; + const { id } = params as { id: string }; const entityIDS = mapBy(user.entities, "id") || []; const assignment = await getAssignment(id); @@ -59,7 +59,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); - return {props: serialize({user, users, entities: allowedEntities, assignment, groups})}; + return { props: serialize({ user, users, entities: allowedEntities, assignment, groups }) }; }, sessionOptions); interface Props { @@ -72,7 +72,7 @@ interface Props { const SIZE = 9; -export default function AssignmentsPage({assignment, user, users, entities, groups}: Props) { +export default function AssignmentsPage({ assignment, user, users, entities, groups }: Props) { const [selectedModules, setSelectedModules] = useState(assignment.exams.map((e) => e.module)); const [assignees, setAssignees] = useState(assignment.assignees); const [teachers, setTeachers] = useState(assignment.teachers || []); @@ -90,12 +90,11 @@ export default function AssignmentsPage({assignment, user, users, entities, grou const [released, setReleased] = useState(assignment.released || false); const [autoStart, setAutostart] = useState(assignment.autoStart || false); - const [autoStartDate, setAutoStartDate] = useState(moment(assignment.autoStartDate).toDate()); const [useRandomExams, setUseRandomExams] = useState(true); - const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]); + const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]); - const {exams} = useExams(); + const { exams } = useExams(); const router = useRouter(); const classrooms = useMemo(() => groups.filter((e) => e.entity === entity), [entity, groups]); @@ -103,11 +102,11 @@ export default function AssignmentsPage({assignment, user, users, entities, grou const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]); const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]); - const {rows: filteredStudentsRows, renderSearch: renderStudentSearch} = useListSearch([["name"], ["email"]], userStudents); - const {rows: filteredTeachersRows, renderSearch: renderTeacherSearch} = useListSearch([["name"], ["email"]], userTeachers); + const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } = useListSearch([["name"], ["email"]], userStudents); + const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers); - const {items: studentRows, renderMinimal: renderStudentPagination} = usePagination(filteredStudentsRows, SIZE); - const {items: teacherRows, renderMinimal: renderTeacherPagination} = usePagination(filteredTeachersRows, SIZE); + const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE); + const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE); useEffect(() => { setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); @@ -148,7 +147,6 @@ export default function AssignmentsPage({assignment, user, users, entities, grou instructorGender, released, autoStart, - autoStartDate, }) .then(() => { toast.success(`The assignment "${name}" has been updated successfully!`); @@ -316,9 +314,9 @@ export default function AssignmentsPage({assignment, user, users, entities, grou setName(e)} defaultValue={name} label="Assignment Name" required /> setName(e)} defaultValue={name} label="Assignment Name" required />