Merge remote-tracking branch 'origin/develop' into feature/ExamGenRework

This commit is contained in:
Carlos-Mesquita
2024-11-12 14:28:51 +00:00
27 changed files with 836 additions and 755 deletions

View File

@@ -11,6 +11,7 @@ import MCDropdown from "./MCDropdown";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id,
type,
isPractice = false,
prompt,
solutions,
text,
@@ -20,8 +21,9 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
preview,
onNext,
onBack,
disableProgressButtons = false
}) => {
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
@@ -38,7 +40,12 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : (x as { letter: string; word: string });
};
@@ -55,16 +62,16 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setOpenDropdownId(null);
}
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setOpenDropdownId(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
}, []);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
@@ -105,18 +112,18 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const styles = clsx(
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit inline-block",
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit inline-block",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
);
const currentSelection = words.find((x) => {
if (typeof x !== "string" && "id" in x) {
return (x as FillBlanksMCOption).id.toString() == id.toString();
}
return false;
}) as FillBlanksMCOption;
return variant === "mc" ? (
<MCDropdown
id={id}
@@ -126,7 +133,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
className="inline-block py-2 px-1 align-middle"
width={220}
isOpen={openDropdownId === id}
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
onToggle={() => setOpenDropdownId(prevId => prevId === id ? null : id)}
/>
) : (
<input
@@ -141,7 +148,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
},
[variant, words, answers, openDropdownId],
);
const memoizedLines = useMemo(() => {
return text.split("\\n").map((line, index) => (
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
@@ -163,29 +170,33 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice })}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Previous Page
</Button>
<Button
color="purple"
onClick={() => {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
}}
className="max-w-[200px] self-end w-full">
Next Page
</Button>
</div>
)
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Previous Page
</Button>
{!disableProgressButtons && progressButtons()}
<Button
color="purple"
onClick={() => {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
}}
className="max-w-[200px] self-end w-full">
Next Page
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
{variant !== "mc" && (
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
@@ -224,25 +235,8 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
</div>
)}
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Previous Page
</Button>
<Button
color="purple"
onClick={() => {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
}}
className="max-w-[200px] self-end w-full">
Next Page
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
};

View File

@@ -1,14 +1,14 @@
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {useEffect, useState} from "react";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import { useEffect, useState } from "react";
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation";
import { downloadBlob } from "@/utils/evaluation";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
@@ -24,16 +24,17 @@ export default function InteractiveSpeaking({
userSolutions,
onNext,
onBack,
isPractice = false,
preview = false
}: InteractiveSpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
const [answers, setAnswers] = useState<{ prompt: string; blob: string; questionIndex: number }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
@@ -52,8 +53,9 @@ export default function InteractiveSpeaking({
onBack({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 100, total: 100, missing: 0},
score: { correct: 100, total: 100, missing: 0 },
type,
isPractice
});
};
@@ -74,8 +76,9 @@ export default function InteractiveSpeaking({
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 100, total: 100, missing: 0},
score: { correct: 100, total: 100, missing: 0 },
type,
isPractice
});
};
@@ -100,7 +103,7 @@ export default function InteractiveSpeaking({
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 100, total: 100, missing: 0},
score: { correct: 100, total: 100, missing: 0 },
type,
});
}
@@ -142,10 +145,11 @@ export default function InteractiveSpeaking({
{
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 100, total: 100, missing: 0},
score: { correct: 100, total: 100, missing: 0 },
module: "speaking",
exam: examID,
type,
isPractice
},
]);
@@ -181,7 +185,7 @@ export default function InteractiveSpeaking({
audio
key={questionIndex}
onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
@@ -301,12 +305,12 @@ export default function InteractiveSpeaking({
</Button>
{preview ? (
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
) : (
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
)}
</div>
</div>

View File

@@ -1,18 +1,18 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
import { MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam";
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import { Fragment, useEffect, useState } from "react";
import LineTo from "react-lineto";
import {CommonProps} from ".";
import { CommonProps } from ".";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
import { DndContext, DragEndEvent, useDraggable, useDroppable } from "@dnd-kit/core";
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
function DroppableQuestionArea({ question, answer }: { question: MatchSentenceExerciseSentence; answer?: string }) {
const { isOver, setNodeRef } = useDroppable({ id: `droppable_sentence_${question.id}` });
return (
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
@@ -35,16 +35,16 @@ function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerc
);
}
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
const {attributes, listeners, setNodeRef, transform} = useDraggable({
function DraggableOptionArea({ option }: { option: MatchSentenceExerciseOption }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: `draggable_option_${option.id}`,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 99,
}
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 99,
}
: undefined;
return (
@@ -63,15 +63,26 @@ function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
);
}
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
export default function MatchSentences({
id,
options,
type,
prompt,
sentences,
userSolutions,
onNext,
onBack,
isPractice = false,
disableProgressButtons = false
}: MatchSentencesExercise & CommonProps) {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
@@ -80,7 +91,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
const optionID = event.active.id.toString().replace("draggable_option_", "");
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), { question: sentenceID, option: optionID }]);
}
};
@@ -91,34 +102,43 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
{!disableProgressButtons && progressButtons()}
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -151,22 +171,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
</DndContext>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -1,12 +1,12 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import { CommonProps } from ".";
import Button from "../Low/Button";
import {v4} from "uuid";
import { v4 } from "uuid";
function Question({
id,
@@ -72,10 +72,20 @@ function Question({
);
}
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
export default function MultipleChoice({
id,
prompt,
type,
questions,
userSolutions,
isPractice = false,
onNext,
onBack,
disableProgressButtons = false
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions || []);
const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
const { questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution } = useExamStore(
(state) => state,
);
@@ -84,16 +94,16 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
};
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
@@ -127,12 +137,17 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
return isSolutionCorrect || false;
}).length;
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const next = () => {
if (questionIndex + 1 >= questions.length - 1) {
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
} else {
setQuestionIndex(questionIndex + 2);
}
@@ -141,7 +156,7 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
const back = () => {
if (questionIndex === 0) {
onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
} else {
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
setQuestionIndex(questionIndex - 2);
@@ -150,72 +165,74 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
scrollToTop();
};
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Back
</Button>
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Back
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
{exam &&
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
{exam &&
exam.module === "level" &&
partIndex === exam.parts.length - 1 &&
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
questionIndex + 1 >= questions.length - 1
? "Submit"
: "Next"}
</Button>
? "Submit"
: "Next"}
</Button>
</div>
)
const renderAllQuestions = () =>
questions.map(question => (
<div
key={question.id} className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...question}
userSolution={answers.find((x) => question.id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, question)}
/>
</div>
))
<div className="flex flex-col gap-4 mt-4 mb-20">
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
/>
)}
</div>
{questionIndex + 1 < questions.length && (
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...questions[questionIndex + 1]}
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
/>
</div>
const renderTwoQuestions = () => (
<>
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
/>
)}
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Back
</Button>
{questionIndex + 1 < questions.length && (
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...questions[questionIndex + 1]}
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
/>
</div>
)}
</>
)
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
{exam &&
exam.module === "level" &&
partIndex === exam.parts.length - 1 &&
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
questionIndex + 1 >= questions.length - 1
? "Submit"
: "Next"}
</Button>
return (
<div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()}
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
{disableProgressButtons ? renderAllQuestions() : renderTwoQuestions()}
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -14,7 +14,7 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
ssr: false,
});
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack, preview = false }: SpeakingExercise & CommonProps) {
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, isPractice = false, onNext, onBack, preview = false }: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
@@ -81,7 +81,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: { correct: 0, total: 100, missing: 0 },
type,
type, isPractice
});
};
@@ -90,7 +90,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: { correct: 0, total: 100, missing: 0 },
type,
type, isPractice
});
};

View File

@@ -1,17 +1,28 @@
import {TrueFalseExercise} from "@/interfaces/exam";
import { TrueFalseExercise } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {Fragment, useEffect, useState} from "react";
import {CommonProps} from ".";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import { CommonProps } from ".";
import Button from "../Low/Button";
export default function TrueFalse({id, type, prompt, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
export default function TrueFalse({
id,
type,
prompt,
questions,
userSolutions,
isPractice = false,
onNext,
onBack,
disableProgressButtons = false
}: TrueFalseExercise & CommonProps) {
const [answers, setAnswers] = useState<{ id: string; solution: "true" | "false" | "not_given" }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
@@ -26,11 +37,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
).length;
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
@@ -41,29 +52,38 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
return;
}
setAnswers((prev) => [...prev.filter((x) => x.id !== questionId), {id: questionId, solution}]);
setAnswers((prev) => [...prev.filter((x) => x.id !== questionId), { id: questionId, solution }]);
};
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
{!disableProgressButtons && progressButtons()}
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -123,22 +143,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -1,12 +1,12 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WriteBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
import { WriteBlanksExercise } from "@/interfaces/exam";
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import { Fragment, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import { CommonProps } from ".";
import { toast } from "react-toastify";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
@@ -29,7 +29,7 @@ function Blank({
useEffect(() => {
const words = userInput.split(" ");
if (words.length > maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
toast.warning(`You have reached your word limit of ${maxWords} words!`, { toastId: "word-limit" });
setUserInput(words.join(" ").trim());
}
}, [maxWords, userInput]);
@@ -46,13 +46,25 @@ function Blank({
);
}
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
export default function WriteBlanks({
id,
prompt,
type,
maxWords,
solutions,
userSolutions,
isPractice = false,
text,
onNext,
onBack,
disableProgressButtons = false
}: WriteBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const {hasExamEnded, setCurrentSolution} = useExamStore((state) => state);
const { hasExamEnded, setCurrentSolution } = useExamStore((state) => state);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
@@ -67,14 +79,19 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
return {total, correct, missing};
return { total, correct, missing };
};
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const renderLines = (line: string) => {
return (
<span className="text-base leading-5">
@@ -82,7 +99,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const setUserSolution = (solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution }]);
};
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
@@ -91,26 +108,30 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
);
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
{!disableProgressButtons && progressButtons()}
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<span key={index}>
@@ -129,22 +150,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
</span>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -1,10 +1,10 @@
/* eslint-disable @next/next/no-img-element */
import {WritingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import React, {Fragment, useEffect, useRef, useState} from "react";
import {toast} from "react-toastify";
import { WritingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import React, { Fragment, useEffect, useRef, useState } from "react";
import { toast } from "react-toastify";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
import { Dialog, Transition } from "@headlessui/react";
import useExamStore from "@/stores/examStore";
export default function Writing({
@@ -16,6 +16,7 @@ export default function Writing({
wordCounter,
attachment,
userSolutions,
isPractice = false,
onNext,
onBack,
enableNavigation = false
@@ -25,7 +26,7 @@ export default function Writing({
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const [saveTimer, setSaveTimer] = useState(0);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
@@ -42,7 +43,7 @@ export default function Writing({
if (inputText.length > 0 && saveTimer % 10 === 0) {
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
{ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing" },
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -66,7 +67,7 @@ export default function Writing({
useEffect(() => {
if (hasExamEnded)
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
onNext({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing", isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
@@ -78,7 +79,7 @@ export default function Writing({
} else {
setIsSubmitEnabled(true);
if (wordCounter.limit < words.length) {
toast.warning(`You have reached your word limit of ${wordCounter.limit} words!`, {toastId: "word-limit"});
toast.warning(`You have reached your word limit of ${wordCounter.limit} words!`, { toastId: "word-limit" });
setInputText(words.slice(0, words.length - 1).join(" "));
}
}
@@ -91,7 +92,7 @@ export default function Writing({
color="purple"
variant="outline"
onClick={() =>
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
onBack({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, isPractice })
}
className="max-w-[200px] self-end w-full">
Back
@@ -102,10 +103,10 @@ export default function Writing({
onClick={() =>
onNext({
exercise: id,
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
score: {correct: 100, total: 100, missing: 0},
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
score: { correct: 100, total: 100, missing: 0 },
type,
module: "writing",
module: "writing", isPractice
})
}
className="max-w-[200px] self-end w-full">
@@ -177,7 +178,7 @@ export default function Writing({
color="purple"
variant="outline"
onClick={() =>
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
onBack({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, isPractice })
}
className="max-w-[200px] self-end w-full">
Back
@@ -188,10 +189,10 @@ export default function Writing({
onClick={() =>
onNext({
exercise: id,
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
score: {correct: 100, total: 100, missing: 0},
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
score: { correct: 100, total: 100, missing: 0 },
type,
module: "writing",
module: "writing", isPractice
})
}
className="max-w-[200px] self-end w-full">

View File

@@ -19,13 +19,14 @@ import Speaking from "./Speaking";
import TrueFalse from "./TrueFalse";
import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), { ssr: false });
export interface CommonProps {
examID?: string;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
enableNavigation?: boolean;
disableProgressButtons?: boolean
preview?: boolean;
}
@@ -35,21 +36,22 @@ export const renderExercise = (
onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void,
enableNavigation?: boolean,
disableProgressButtons?: boolean,
preview?: boolean,
) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <FillBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
case "trueFalse":
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <TrueFalse disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview}/>;
return <MatchSentences disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
case "multipleChoice":
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <MultipleChoice disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <WriteBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} enableNavigation={enableNavigation} preview={preview}/>;
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} enableNavigation={enableNavigation} preview={preview} />;
case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
case "interactiveSpeaking":

View File

@@ -6,6 +6,7 @@ import { sortByModuleName } from "@/utils/moduleUtils";
import clsx from "clsx";
import moment from "moment";
import { useRouter } from "next/router";
import { useMemo } from "react";
import Button from "../Low/Button";
import ModuleBadge from "../ModuleBadge";
@@ -20,6 +21,8 @@ interface Props {
export default function AssignmentCard({ user, assignment, session, startAssignment, resumeAssignment }: Props) {
const router = useRouter()
const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id])
return (
<div
className={clsx(
@@ -45,7 +48,7 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
))}
</div>
{futureAssignmentFilter(assignment) && (
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
<Button
color="rose"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
@@ -54,7 +57,7 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
Not yet started
</Button>
)}
{activeAssignmentFilter(assignment) && !assignment.results.map((r) => r.user).includes(user.id) && (
{activeAssignmentFilter(assignment) && !hasBeenSubmitted && (
<>
<div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
@@ -94,7 +97,7 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
)}
</>
)}
{assignment.results.map((r) => r.user).includes(user.id) && (
{hasBeenSubmitted && (
<Button
color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"

View File

@@ -23,6 +23,7 @@ interface Props {
showSolutions?: boolean;
currentExercise?: Exercise;
runOnClick?: ((questionIndex: number) => void) | undefined;
indexLabel?: string
}
export default function ModuleTitle({
@@ -36,7 +37,8 @@ export default function ModuleTitle({
partLabel,
showTimer = true,
showSolutions = false,
runOnClick = undefined
runOnClick = undefined,
indexLabel = "Question"
}: Props) {
const { exam, partIndex, exerciseIndex: examExerciseIndex, userSolutions } = useExamStore((state) => state);
@@ -88,7 +90,7 @@ export default function ModuleTitle({
{examLabel ? examLabel : (module === "level" ? "Placement Test" : `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`)}
</span>
<span className="text-sm font-semibold self-end">
Question {exerciseIndex}/{totalExercises}
{indexLabel} {exerciseIndex}/{totalExercises}
</span>
</div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />

View File

@@ -1,19 +1,19 @@
import React from "react";
import {BsClock, BsXCircle} from "react-icons/bs";
import { BsClock, BsXCircle } from "react-icons/bs";
import clsx from "clsx";
import {Stat, User} from "@/interfaces/user";
import {Module, Step} from "@/interfaces";
import { Stat, User } from "@/interfaces/user";
import { Module, Step } from "@/interfaces";
import ai_usage from "@/utils/ai.detection";
import {calculateBandScore} from "@/utils/score";
import { calculateBandScore } from "@/utils/score";
import moment from "moment";
import {Assignment} from "@/interfaces/results";
import {uuidv4} from "@firebase/util";
import {useRouter} from "next/router";
import {uniqBy} from "lodash";
import {sortByModule} from "@/utils/moduleUtils";
import {convertToUserSolutions} from "@/utils/stats";
import {getExamById} from "@/utils/exams";
import {Exam, UserSolution} from "@/interfaces/exam";
import { Assignment } from "@/interfaces/results";
import { uuidv4 } from "@firebase/util";
import { useRouter } from "next/router";
import { uniqBy } from "lodash";
import { sortByModule } from "@/utils/moduleUtils";
import { convertToUserSolutions } from "@/utils/stats";
import { getExamById } from "@/utils/exams";
import { Exam, UserSolution } from "@/interfaces/exam";
import ModuleBadge from "../ModuleBadge";
const formatTimestamp = (timestamp: string | number) => {
@@ -23,9 +23,9 @@ const formatTimestamp = (timestamp: string | number) => {
return date.format(formatter);
};
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; 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,
@@ -54,7 +54,7 @@ const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number;
},
};
stats.forEach((x) => {
stats.filter(x => !x.isPractice).forEach((x) => {
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
@@ -64,7 +64,7 @@ const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number;
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] }));
};
interface StatsGridItemProps {
@@ -133,7 +133,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
correct / total < 0.3 && "text-mti-rose",
);
const {timeSpent, inactivity, session} = stats[0];
const { timeSpent, inactivity, session } = stats[0];
const selectExam = () => {
if (
@@ -247,7 +247,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
<div className={clsx("grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2", examNumber !== undefined && "pr-10")}>
{!!assignment &&
(assignment.released || assignment.released === undefined) &&
aggregatedLevels.map(({module, level}) => <ModuleBadge key={module} module={module} level={level} />)}
aggregatedLevels.map(({ module, level }) => <ModuleBadge key={module} module={module} level={level} />)}
</div>
{assignment && (
@@ -270,9 +270,9 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
typeof selectedTrainingExams !== "undefined" &&
typeof timestamp === "string" &&
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
"border-2 border-slate-600",
typeof timestamp === "string" &&
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
"border-2 border-slate-600",
)}
onClick={() => {
if (!!assignment && !assignment.released) return;
@@ -280,8 +280,8 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
return;
}}
style={{
...(width !== undefined && {width}),
...(height !== undefined && {height}),
...(width !== undefined && { width }),
...(height !== undefined && { height }),
}}
data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."}
role="button">
@@ -297,8 +297,8 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
)}
data-tip="Your screen size is too small to view previous exams."
style={{
...(width !== undefined && {width}),
...(height !== undefined && {height}),
...(width !== undefined && { width }),
...(height !== undefined && { height }),
}}
role="button">
{content}

View File

@@ -1,15 +1,15 @@
import {FillBlanksExercise, FillBlanksMCOption, ShuffleMap} from "@/interfaces/exam";
import { FillBlanksExercise, FillBlanksMCOption, ShuffleMap } from "@/interfaces/exam";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import { CommonProps } from ".";
import { Fragment } from "react";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import { typeCheckWordsMC } from "@/utils/type.check";
export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
export default function FillBlanksSolutions({ id, type, prompt, solutions, words, text, onNext, onBack, disableProgressButtons = false }: FillBlanksExercise & CommonProps) {
const storeUserSolutions = useExamStore((state) => state.userSolutions);
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
@@ -42,7 +42,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, words,
return false;
}).length;
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
const renderLines = (line: string) => {
@@ -81,20 +81,20 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, words,
typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase()
: "letter" in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: "options" in w
? w.id === userSolution.questionId
: false,
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: "options" in w
? w.id === userSolution.questionId
: false,
);
const userSolutionText =
typeof userSolutionWord === "string"
? userSolutionWord
: userSolutionWord && "letter" in userSolutionWord
? userSolutionWord.word
: userSolutionWord && "options" in userSolutionWord
? userSolution.solution
: userSolution.solution;
? userSolutionWord.word
: userSolutionWord && "options" in userSolutionWord
? userSolution.solution
: userSolution.solution;
let correct;
let solutionText;
@@ -149,27 +149,31 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, words,
);
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
{!disableProgressButtons && progressButtons()}
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{correctUserSolutions &&
text.split("\\n").map((line, index) => (
@@ -195,23 +199,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, words,
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -1,11 +1,11 @@
import {MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
import { MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam";
import clsx from "clsx";
import LineTo from "react-lineto";
import {CommonProps} from ".";
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import { CommonProps } from ".";
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react";
import {Fragment} from "react";
import { Fragment } from "react";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
@@ -15,7 +15,7 @@ function QuestionSolutionArea({
userSolution,
}: {
question: MatchSentenceExerciseSentence;
userSolution?: {question: string; option: string};
userSolution?: { question: string; option: string };
}) {
return (
<div className="grid grid-cols-3 gap-4">
@@ -26,8 +26,8 @@ function QuestionSolutionArea({
!userSolution
? "bg-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString()
? "bg-mti-purple"
: "bg-mti-rose",
? "bg-mti-purple"
: "bg-mti-rose",
"transition duration-300 ease-in-out",
)}>
{question.id}
@@ -40,8 +40,8 @@ function QuestionSolutionArea({
!userSolution
? "border-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString()
? "border-mti-purple"
: "border-mti-rose",
? "border-mti-purple"
: "border-mti-rose",
)}>
<span className="line-through">
{userSolution && userSolution?.option.toString() !== question.solution.toString() && `Paragraph ${userSolution.option}`}
@@ -61,8 +61,9 @@ export default function MatchSentencesSolutions({
userSolutions,
onNext,
onBack,
disableProgressButtons = false
}: MatchSentencesExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const calculateScore = () => {
const total = sentences.length;
@@ -71,30 +72,34 @@ export default function MatchSentencesSolutions({
).length;
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
{!disableProgressButtons && progressButtons()}
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -128,23 +133,7 @@ export default function MatchSentencesSolutions({
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -1,11 +1,11 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import { CommonProps } from ".";
import Button from "../Low/Button";
import {v4} from "uuid";
import { v4 } from "uuid";
function Question({
id,
@@ -14,8 +14,8 @@ function Question({
solution,
options,
userSolution,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const {userSolutions} = useExamStore((state) => state);
}: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) {
const { userSolutions } = useExamStore((state) => state);
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap;
@@ -89,8 +89,8 @@ function Question({
);
}
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack, disableProgressButtons = false }: MultipleChoiceExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const stats = useExamStore((state) => state.userSolutions);
@@ -107,12 +107,12 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
}
}).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
const next = () => {
if (questionIndex + 1 >= questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else {
setQuestionIndex(questionIndex + 2);
}
@@ -120,50 +120,68 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
const back = () => {
if (questionIndex === 0) {
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else {
setQuestionIndex(questionIndex - 2);
}
};
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
const renderAllQuestions = () =>
questions.map(question => (
<div
key={question.id} className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...question}
userSolution={userSolutions.find((x) => question.id === x.question)?.option}
/>
</div>
))
const renderTwoQuestions = () => (
<>
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
/>
)}
</div>
<div className="flex flex-col gap-4 w-full h-full mb-20 mt-4">
<div className="flex flex-col gap-4 mt-2">
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
{userSolutions && questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
/>
)}
</div>
{userSolutions && questionIndex + 1 < questions.length && (
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...questions[questionIndex + 1]}
userSolution={userSolutions.find((x) => questions[questionIndex + 1].id === x.question)?.option}
/>
</div>
)}
{questionIndex + 1 < questions.length && (
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...questions[questionIndex + 1]}
userSolution={userSolutions.find((x) => questions[questionIndex + 1].id === x.question)?.option}
/>
</div>
)}
</>
)
return (
<div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()}
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
{disableProgressButtons ? renderAllQuestions() : renderTwoQuestions()}
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
@@ -181,20 +199,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -1,15 +1,15 @@
import {FillBlanksExercise, TrueFalseExercise} from "@/interfaces/exam";
import { FillBlanksExercise, TrueFalseExercise } from "@/interfaces/exam";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import { CommonProps } from ".";
import { Fragment } from "react";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
type Solution = "true" | "false" | "not_given";
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
export default function TrueFalseSolution({ prompt, type, id, questions, userSolutions, onNext, onBack, disableProgressButtons = false }: TrueFalseExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const calculateScore = () => {
const total = questions.length || 0;
@@ -18,7 +18,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => {
@@ -39,27 +39,31 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
return "gray";
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
{!disableProgressButtons && progressButtons()}
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -137,23 +141,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -1,12 +1,12 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WriteBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
import { WriteBlanksExercise } from "@/interfaces/exam";
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import { Fragment, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import { CommonProps } from ".";
import { toast } from "react-toastify";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
@@ -71,8 +71,9 @@ export default function WriteBlanksSolutions({
text,
onNext,
onBack,
disableProgressButtons = false
}: WriteBlanksExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
@@ -85,7 +86,7 @@ export default function WriteBlanksSolutions({
).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
const renderLines = (line: string) => {
@@ -104,27 +105,31 @@ export default function WriteBlanksSolutions({
);
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
{!disableProgressButtons && progressButtons()}
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -158,23 +163,7 @@ export default function WriteBlanksSolutions({
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -19,25 +19,27 @@ import TrueFalseSolution from "./TrueFalse";
import WriteBlanks from "./WriteBlanks";
import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), { ssr: false });
export interface CommonProps {
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
disableProgressButtons?: boolean,
}
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void,
disableProgressButtons?: boolean) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <FillBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "trueFalse":
return <TrueFalseSolution key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
return <TrueFalseSolution disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
return <MatchSentences disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice":
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
return <MultipleChoice disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <WriteBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking":

View File

@@ -326,11 +326,9 @@ export default function Finish({ user, scores, modules, information, solutions,
)}
</div>
<Link href={destination || "/"} className="w-full max-w-[200px] self-end">
<Button color="purple" className="w-full max-w-[200px] self-end">
Dashboard
</Button>
</Link>
<Button onClick={() => destination === "/exam" ? router.reload() : router.push(destination || "/")} color="purple" className="w-full max-w-[200px] self-end">
Dashboard
</Button>
</div>
)}
</>

View File

@@ -1,5 +1,5 @@
import { ListeningExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
import { useEffect, useState } from "react";
import { ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
import { Fragment, useEffect, useState } from "react";
import { renderExercise } from "@/components/Exercises";
import { renderSolution } from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle";
@@ -9,6 +9,9 @@ import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils";
import PartDivider from "./Navigation/SectionDivider";
import { Dialog, Transition } from "@headlessui/react";
import { capitalize } from "lodash";
import { mapBy } from "@/utils";
interface Props {
exam: ListeningExam;
@@ -17,17 +20,76 @@ interface Props {
onFinish: (userSolutions: UserSolution[]) => void;
}
function ScriptModal({ isOpen, script, onClose }: { isOpen: boolean; script: Script; onClose: () => void }) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<div className="mt-2 overflow-auto mb-28">
<p className="text-sm">
{typeof script === "string" && script.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
{typeof script === "object" && script.map((line, index) => (
<span key={index}>
<b>{line.name} ({capitalize(line.gender)})</b>: {line.text}
<br />
<br />
</span>
))}
</p>
</div>
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
Close
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
export default function Listening({ exam, showSolutions = false, preview = false, onFinish }: Props) {
const listeningBgColor = "bg-ielts-listening-light";
const [showTextModal, setShowTextModal] = useState(false);
const [timesListened, setTimesListened] = useState(0);
const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
@@ -99,75 +161,35 @@ export default function Listening({ exam, showSolutions = false, preview = false
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
}
if (storeQuestionIndex > 0) {
const exercise = getExercise();
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: storeQuestionIndex }]);
}
setStoreQuestionIndex(0);
if (solution)
setUserSolutions([
...userSolutions.filter((x) => x.exercise !== solution.exercise),
{ ...solution, module: "listening", exam: exam.id }
]);
};
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1);
return;
}
const previousExercise = (solution?: UserSolution) => { };
const nextPart = () => {
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1);
setTimesListened(0);
setExerciseIndex(showSolutions ? 0 : -1);
setExerciseIndex(0);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!hasExamEnded &&
!preview
) {
setShowBlankModal(true);
return;
if (!showSolutions && !hasExamEnded) {
const exercises = partIndex < exam.parts.length ? exam.parts[partIndex].exercises : []
const exerciseIDs = mapBy(exercises, 'id')
const hasMissing = userSolutions.filter(x => exerciseIDs.includes(x.exercise)).map(x => x.score.missing).some(x => x > 0)
if (hasMissing) return setShowBlankModal(true);
}
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
}
setStoreQuestionIndex(0);
setExerciseIndex(exerciseIndex - 1);
};
const getExercise = () => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
useEffect(() => {
if (partIndex > -1 && exerciseIndex > -1) {
const exercise = getExercise();
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex, partIndex]);
onFinish(userSolutions);
}
const calculateExerciseIndex = () => {
if (partIndex === -1) return 0;
@@ -186,6 +208,22 @@ export default function Listening({ exam, showSolutions = false, preview = false
);
};
const renderPartExercises = () => {
const exercises = partIndex > -1 ? exam.parts[partIndex].exercises : []
const formattedExercises = exercises.map(exercise => ({
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}))
return (
<div className="flex flex-col gap-4">
{formattedExercises.map(e => showSolutions
? renderSolution(e, nextExercise, previousExercise, undefined, true)
: renderExercise(e, exam.id, nextExercise, previousExercise, undefined, true))}
</div>
)
}
const renderAudioInstructionsPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
@@ -201,16 +239,28 @@ export default function Listening({ exam, showSolutions = false, preview = false
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
{exam?.parts[partIndex]?.audio?.source ? (
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-base">
{(() => {
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.";
})()}
</span>
<div className="w-full items-start flex justify-between">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-base">
{(() => {
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.";
})()}
</span>
</div>
{partIndex > -1 && !examState.assignment && !!exam.parts[partIndex].script && (
<Button
onClick={() => setShowTextModal(true)}
variant="outline"
color="gray"
className="w-full max-w-[200px]"
>
View Transcript
</Button>
)}
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer
@@ -231,6 +281,25 @@ export default function Listening({ exam, showSolutions = false, preview = false
</div>
);
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={previousExercise}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={nextPart}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<>
{showPartDivider ?
@@ -244,14 +313,19 @@ export default function Listening({ exam, showSolutions = false, preview = false
/> : (
<>
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
{partIndex > -1 && exam.parts[partIndex].script &&
<ScriptModal script={exam.parts[partIndex].script!} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
}
<div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle
exerciseIndex={calculateExerciseIndex()}
exerciseIndex={partIndex + 1}
minTimer={exam.minTimer}
module="listening"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
totalExercises={exam.parts.length}
disableTimer={showSolutions || preview}
indexLabel="Part"
/>
{/* Audio Player for the Instructions */}
{partIndex === -1 && renderAudioInstructionsPlayer()}
@@ -259,18 +333,14 @@ export default function Listening({ exam, showSolutions = false, preview = false
{partIndex > -1 && renderAudioPlayer()}
{/* Exercise renderer */}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{/* Solution renderer */}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex > -1 && partIndex > -1 && (
<>
{progressButtons()}
{renderPartExercises()}
{progressButtons()}
</>
)}
</div>
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
@@ -295,12 +365,12 @@ export default function Listening({ exam, showSolutions = false, preview = false
)}
{partIndex === -1 && exam.variant !== "partial" && (
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
<Button color="purple" onClick={() => nextPart()} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
<Button color="purple" onClick={() => nextPart()} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}

View File

@@ -90,6 +90,7 @@ export interface UserSolution {
exercise: string;
isDisabled?: boolean;
shuffleMaps?: ShuffleMap[];
isPractice?: boolean
}
export interface WritingExam extends ExamBase {
@@ -165,6 +166,7 @@ export interface WritingExercise extends Section {
}[];
topic?: string;
variant?: string;
isPractice?: boolean
}
export interface AIDetectionAttributes {
@@ -199,6 +201,7 @@ export interface SpeakingExercise extends Section {
evaluation?: SpeakingEvaluation;
}[];
topic?: string;
isPractice?: boolean
}
export interface InteractiveSpeakingExercise extends Section {
@@ -218,6 +221,7 @@ export interface InteractiveSpeakingExercise extends Section {
first_topic?: string;
second_topic?: string;
variant?: "initial" | "final";
isPractice?: boolean
}
export interface FillBlanksMCOption {
@@ -246,6 +250,7 @@ export interface FillBlanksExercise {
solution: string; // *EXAMPLE: "preserve"
}[];
variant?: string;
isPractice?: boolean
}
export interface TrueFalseExercise {
@@ -254,6 +259,7 @@ export interface TrueFalseExercise {
prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: TrueFalseQuestion[];
userSolutions: { id: string; solution: "true" | "false" | "not_given" }[];
isPractice?: boolean
}
export interface TrueFalseQuestion {
@@ -277,6 +283,7 @@ export interface WriteBlanksExercise {
solution: string;
}[];
variant?: string;
isPractice?: boolean
}
export interface MatchSentencesExercise {
@@ -288,6 +295,7 @@ export interface MatchSentencesExercise {
allowRepetition: boolean;
options: MatchSentenceExerciseOption[];
variant?: string;
isPractice?: boolean
}
export interface MatchSentenceExerciseSentence {
@@ -311,7 +319,8 @@ export interface MultipleChoiceExercise {
passage?: {
title: string;
content: string;
}
}
isPractice?: boolean
}
export interface MultipleChoiceQuestion {

View File

@@ -1,6 +1,6 @@
import {Module} from ".";
import {InstructorGender, ShuffleMap} from "./exam";
import {PermissionType} from "./permissions";
import { Module } from ".";
import { InstructorGender, ShuffleMap } from "./exam";
import { PermissionType } from "./permissions";
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser | MasterCorporateUser;
export type UserStatus = "active" | "disabled" | "paymentDue";
@@ -12,8 +12,8 @@ export interface BasicUser {
id: string;
isFirstLogin: boolean;
focus: "academic" | "general";
levels: {[key in Module]: number};
desiredLevels: {[key in Module]: number};
levels: { [key in Module]: number };
desiredLevels: { [key in Module]: number };
type: Type;
bio: string;
isVerified: boolean;
@@ -22,7 +22,7 @@ export interface BasicUser {
status: UserStatus;
permissions: PermissionType[];
lastLogin?: Date;
entities: {id: string; role: string}[];
entities: { id: string; role: string }[];
}
export interface StudentUser extends BasicUser {
@@ -109,13 +109,13 @@ export interface DemographicCorporateInformation {
export type Gender = "male" | "female" | "other";
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
{status: "student", label: "Student"},
{status: "employed", label: "Employed"},
{status: "unemployed", label: "Unemployed"},
{status: "self-employed", label: "Self-employed"},
{status: "retired", label: "Retired"},
{status: "other", label: "Other"},
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] = [
{ status: "student", label: "Student" },
{ status: "employed", label: "Employed" },
{ status: "unemployed", label: "Unemployed" },
{ status: "self-employed", label: "Self-employed" },
{ status: "retired", label: "Retired" },
{ status: "other", label: "Other" },
];
export interface Stat {
@@ -142,6 +142,7 @@ export interface Stat {
path: string;
version: string;
};
isPractice?: boolean
}
export interface Group {
@@ -174,4 +175,4 @@ export interface Code {
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
export type WithUser<T> = T extends {participants: string[]} ? Omit<T, "participants"> & {participants: User[]} : T;
export type WithUser<T> = T extends { participants: string[] } ? Omit<T, "participants"> & { participants: User[] } : T;

View File

@@ -11,7 +11,6 @@ import Reading from "@/exams/Reading";
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 useExamStore from "@/stores/examStore";
@@ -21,12 +20,7 @@ import axios from "axios";
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";
import useGradingSystem from "@/hooks/useGrading";
import { Assignment } from "@/interfaces/results";
import { mapBy } from "@/utils";
interface Props {
page: "exams" | "exercises";
@@ -214,7 +208,6 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
}, [setModuleIndex, showSolutions]);
useEffect(() => {
console.log(selectedModules)
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = exams[moduleIndex];
@@ -264,6 +257,7 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
isDisabled: solution.isDisabled,
shuffleMaps: solution.shuffleMaps,
...(assignment ? { assignment: assignment.id } : {}),
isPractice: solution.isPractice
}));
axios
@@ -422,7 +416,7 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
},
};
userSolutions.forEach((x) => {
userSolutions.filter(x => !x.isPractice).forEach((x) => {
const examModule =
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined);

View File

@@ -1,44 +1,45 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import {toast, ToastContainer} from "react-toastify";
import { User } from "@/interfaces/user";
import { toast, ToastContainer } from "react-toastify";
import axios from "axios";
import {FormEvent, useEffect, useState} from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import {Divider} from "primereact/divider";
import { Divider } from "primereact/divider";
import Button from "@/components/Low/Button";
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import { BsArrowRepeat, BsCheck } from "react-icons/bs";
import Link from "next/link";
import Input from "@/components/Low/Input";
import clsx from "clsx";
import {useRouter} from "next/router";
import { useRouter } from "next/router";
import EmailVerification from "./(auth)/EmailVerification";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { redirect } from "@/utils";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g);
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
const destination = !query.destination ? "/" : Buffer.from(query.destination as string, 'base64').toString()
const user = await requestUser(req, res)
if (user) return redirect(destination)
return {
props: {user: null, destination},
props: { user: null, destination },
};
}, sessionOptions);
export default function Login({ destination }: { destination: string }) {
export default function Login({ destination = "/" }: { destination?: string }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberPassword, setRememberPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const isOfficialExamLogin = useMemo(() => destination.startsWith("/official-exam"), [destination])
const {user, mutateUser} = useUser({
const { user, mutateUser } = useUser({
redirectTo: destination,
redirectIfFound: true,
});
@@ -56,10 +57,10 @@ export default function Login({ destination }: { destination: string }) {
}
axios
.post<{ok: boolean}>("/api/reset", {email})
.post<{ ok: boolean }>("/api/reset", { email })
.then((response) => {
if (response.data.ok) {
toast.success("You should receive an e-mail to reset your password!", {toastId: "forgot-success"});
toast.success("You should receive an e-mail to reset your password!", { toastId: "forgot-success" });
return;
}
@@ -79,7 +80,7 @@ export default function Login({ destination }: { destination: string }) {
setIsLoading(true);
axios
.post<User>("/api/login", {email, password})
.post<User>("/api/login", { email, password })
.then((response) => {
toast.success("You have been logged in!", {
toastId: "login-successful",
@@ -92,7 +93,7 @@ export default function Login({ destination }: { destination: string }) {
toastId: "wrong-credentials",
});
} else {
toast.error("Something went wrong!", {toastId: "server-error"});
toast.error("Something went wrong!", { toastId: "server-error" });
}
setIsLoading(false);
})
@@ -110,14 +111,25 @@ export default function Login({ destination }: { destination: string }) {
<main className="flex h-[100vh] w-full bg-white text-black">
<ToastContainer />
<section className="relative hidden h-full w-fit min-w-fit lg:flex">
{/* <div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" /> */}
<img src="/red-stock-photo.jpg" alt="People smiling looking at a tablet" className="aspect-auto h-full" />
{!isOfficialExamLogin && (
<img src="/red-stock-photo.jpg" alt="People smiling looking at a tablet" className="aspect-auto h-full" />
)}
{isOfficialExamLogin && (
<img src="/purple-stock-photo.png" alt="People smiling looking at a tablet" className="aspect-auto h-full" />
)}
</section>
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
<div className={clsx("flex flex-col items-center", !user && "mb-4")}>
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-56" />
<h1 className="text-2xl font-bold lg:text-4xl">Login to your account</h1>
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">with your registered Email Address</p>
{!isOfficialExamLogin && (
<>
<h1 className="text-2xl font-bold lg:text-4xl">Login to your account</h1>
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">with your registered Email Address</p>
</>
)}
{isOfficialExamLogin && (
<h1 className="text-2xl font-bold lg:text-4xl">Welcome to the Official Exams Portal</h1>
)}
</div>
<Divider className="max-w-xs lg:max-w-md" />
{!user && (

View File

@@ -1,27 +1,27 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat, User} from "@/interfaces/user";
import {useEffect, useMemo, useState} from "react";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Stat, User } from "@/interfaces/user";
import { useEffect, useMemo, useState } from "react";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import {groupByDate} from "@/utils/stats";
import { groupByDate } from "@/utils/stats";
import moment from "moment";
import useExamStore from "@/stores/examStore";
import {ToastContainer} from "react-toastify";
import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import clsx from "clsx";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {uuidv4} from "@firebase/util";
import {usePDFDownload} from "@/hooks/usePDFDownload";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { uuidv4 } from "@firebase/util";
import { usePDFDownload } from "@/hooks/usePDFDownload";
import useRecordStore from "@/stores/recordStore";
import StatsGridItem from "@/components/Medium/StatGridItem";
import RecordFilter from "@/components/Medium/RecordFilter";
import {useRouter} from "next/router";
import { useRouter } from "next/router";
import useTrainingContentStore from "@/stores/trainingContentStore";
import {Assignment} from "@/interfaces/results";
import {getEntitiesUsers, getUsers} from "@/utils/users.be";
import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be";
import { Assignment } from "@/interfaces/results";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import useGradingSystem from "@/hooks/useGrading";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -33,7 +33,7 @@ import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList";
import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
@@ -43,12 +43,10 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id')))
const gradingSystems = await Promise.all(entityIDs.map(getGradingSystemByEntity))
return {
props: serialize({user, users, assignments, entities, gradingSystems}),
props: serialize({ user, users, assignments, entities }),
};
}, sessionOptions);
@@ -58,13 +56,12 @@ interface Props {
user: User;
users: User[];
assignments: Assignment[];
gradingSystems: Grading[]
entities: EntityWithRoles[]
}
const MAX_TRAINING_EXAMS = 10;
export default function History({user, users, assignments, entities, gradingSystems}: Props) {
export default function History({ user, users, assignments, entities }: Props) {
const router = useRouter();
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
state.selectedUser,
@@ -75,8 +72,8 @@ export default function History({user, users, assignments, entities, gradingSyst
const [filter, setFilter] = useState<Filter>();
const {data: stats, isLoading: isStatsLoading} = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
const {gradingSystem} = useGradingSystem();
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
const { gradingSystem } = useGradingSystem();
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
@@ -113,12 +110,12 @@ export default function History({user, users, assignments, entities, gradingSyst
};
}, [router.events, setTraining]);
const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => {
if (filter && filter !== "assignments") {
const filterDate = moment()
.subtract({[filter as string]: 1})
.subtract({ [filter as string]: 1 })
.format("x");
const filteredStats: {[key: string]: Stat[]} = {};
const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
@@ -127,7 +124,7 @@ export default function History({user, users, assignments, entities, gradingSyst
}
if (filter && filter === "assignments") {
const filteredStats: {[key: string]: Stat[]} = {};
const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => {
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false))
@@ -140,21 +137,21 @@ export default function History({user, users, assignments, entities, gradingSyst
return stats;
};
const handleTrainingContentSubmission = () => {
if (groupedStats) {
const groupedStatsByDate = filterStatsByDate(groupedStats);
const allStats = Object.keys(groupedStatsByDate);
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => {
const timestamp = moduleAndTimestamp.split("-")[1];
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
accumulator[timestamp] = groupedStatsByDate[timestamp];
}
return accumulator;
}, {});
setTrainingStats(Object.values(selectedStats).flat());
router.push("/training");
}
};
const handleTrainingContentSubmission = () => {
if (groupedStats) {
const groupedStatsByDate = filterStatsByDate(groupedStats);
const allStats = Object.keys(groupedStatsByDate);
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => {
const timestamp = moduleAndTimestamp.split("-")[1];
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
accumulator[timestamp] = groupedStatsByDate[timestamp];
}
return accumulator;
}, {});
setTrainingStats(Object.values(selectedStats).flat());
router.push("/training");
}
};
const filteredStats = useMemo(() =>
Object.keys(filterStatsByDate(groupedStats))
@@ -203,7 +200,7 @@ const handleTrainingContentSubmission = () => {
<ToastContainer />
{user && (
<Layout user={user}>
<RecordFilter user={user} users={users} entities={entities} filterState={{filter: filter, setFilter: setFilter}}>
<RecordFilter user={user} users={users} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}>
{training && (
<div className="flex flex-row">
<div className="font-semibold text-2xl mr-4">