ENCOA-222 & ENCOA-223

ENCOA-222: Added an option for non-assignment exams to view the
transcript of a Listening audio;

ENCOA-223: Updated the Listening exam to show all of the
exercises/questions of each part on a single page;
This commit is contained in:
Tiago Ribeiro
2024-11-11 19:14:16 +00:00
parent 711a0743c2
commit 1787e3ed53
16 changed files with 661 additions and 607 deletions

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,74 +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
) {
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;
@@ -185,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">
@@ -200,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
@@ -230,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 ?
@@ -243,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}
indexLabel="Part"
/>
{/* Audio Player for the Instructions */}
{partIndex === -1 && renderAudioInstructionsPlayer()}
@@ -258,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" && (
@@ -294,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>
)}