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

This commit is contained in:
Carlos-Mesquita
2024-11-10 07:09:25 +00:00
17 changed files with 909 additions and 915 deletions

View File

@@ -73,7 +73,7 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
"h-5 w-5", "h-5 w-5",
`text-ielts-${currentModule}` `text-ielts-${currentModule}`
)} /> )} />
<span className="font-medium text-gray-900">Conversation</span> <span className="font-medium text-gray-900">{(sectionId === 1 || sectionId === 3) ? "Conversation" : "Monologue"}</span>
</div> </div>
} }
> >

View File

@@ -1,6 +1,7 @@
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
import { sortByModuleName } from "@/utils/moduleUtils"; import { sortByModuleName } from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
@@ -44,7 +45,16 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
<ModuleBadge className="scale-110 w-full" key={module} module={module} /> <ModuleBadge className="scale-110 w-full" key={module} module={module} />
))} ))}
</div> </div>
{!assignment.results.map((r) => r.user).includes(user.id) && ( {futureAssignmentFilter(assignment) && (
<Button
color="rose"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled
variant="outline">
Not yet started
</Button>
)}
{activeAssignmentFilter(assignment) && !assignment.results.map((r) => r.user).includes(user.id) && (
<> <>
<div <div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden" className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
@@ -86,9 +96,9 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
)} )}
{assignment.results.map((r) => r.user).includes(user.id) && ( {assignment.results.map((r) => r.user).includes(user.id) && (
<Button <Button
onClick={() => router.push("/record")}
color="green" color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled
variant="outline"> variant="outline">
Submitted Submitted
</Button> </Button>

View File

@@ -3,39 +3,30 @@ import Modal from "@/components/Modal";
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam"; import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import { useState } from "react"; import { useMemo, useState } from "react";
import { BsFillGrid3X3GapFill } from "react-icons/bs"; import { BsFillGrid3X3GapFill } from "react-icons/bs";
interface Props { interface Props {
exam: LevelExam
showSolutions: boolean; showSolutions: boolean;
runOnClick: ((index: number) => void) | undefined; runOnClick: ((index: number) => void) | undefined;
} }
const MCQuestionGrid: React.FC<Props> = ({showSolutions, runOnClick}) => { const MCQuestionGrid: React.FC<Props> = ({ exam, showSolutions, runOnClick }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { const {
userSolutions, userSolutions,
partIndex: sectionIndex, partIndex: sectionIndex,
exerciseIndex, exerciseIndex,
exam
} = useExamStore((state) => state); } = useExamStore((state) => state);
const isMultipleChoiceLevelExercise = () => { const currentExercise = useMemo(() => (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise, [exam, exerciseIndex, sectionIndex])
if (exam?.module === 'level' && typeof sectionIndex === "number" && sectionIndex > -1) { const userSolution = useMemo(() => userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!, [currentExercise.id, userSolutions])
const currentExercise = (exam as LevelExam).parts[sectionIndex].exercises[exerciseIndex]; const answeredQuestions = useMemo(() => new Set(userSolution.solutions.map(sol => sol.question.toString())), [userSolution.solutions])
return currentExercise && currentExercise.type === 'multipleChoice'; const exerciseOffset = useMemo(() => Number(currentExercise.questions[0].id), [currentExercise.questions])
} const lastExercise = useMemo(() => exerciseOffset + (currentExercise.questions.length - 1),
return false; [currentExercise.questions.length, exerciseOffset]);
};
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
const currentExercise = (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise;
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
const exerciseOffset = Number(currentExercise.questions[0].id);
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => { const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => { const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {

View File

@@ -1,11 +1,11 @@
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { moduleLabels } from "@/utils/moduleUtils"; import { moduleLabels } from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import { ReactNode, useState } from "react"; import { ReactNode, useMemo, useState } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs"; import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
import ProgressBar from "../../Low/ProgressBar"; import ProgressBar from "../../Low/ProgressBar";
import Timer from "../Timer"; import Timer from "../Timer";
import { Exercise } from "@/interfaces/exam"; import { Exercise, LevelExam } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import React from "react"; import React from "react";
import MCQuestionGrid from "./MCQuestionGrid"; import MCQuestionGrid from "./MCQuestionGrid";
@@ -38,9 +38,7 @@ export default function ModuleTitle({
showSolutions = false, showSolutions = false,
runOnClick = undefined runOnClick = undefined
}: Props) { }: Props) {
const { const { exam, partIndex, exerciseIndex: examExerciseIndex, userSolutions } = useExamStore((state) => state);
exam
} = useExamStore((state) => state);
const moduleIcon: { [key in Module]: ReactNode } = { const moduleIcon: { [key in Module]: ReactNode } = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />, reading: <BsBook className="text-ielts-reading w-6 h-6" />,
@@ -50,6 +48,14 @@ export default function ModuleTitle({
level: <BsClipboard className="text-ielts-level w-6 h-6" />, level: <BsClipboard className="text-ielts-level w-6 h-6" />,
}; };
const showGrid = useMemo(() =>
exam?.module === "level"
&& partIndex > -1
&& exam.parts[partIndex].exercises[examExerciseIndex].type === "multipleChoice"
&& !!userSolutions,
[exam, examExerciseIndex, partIndex, userSolutions]
)
return ( return (
<> <>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />} {showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
@@ -87,7 +93,7 @@ export default function ModuleTitle({
</div> </div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" /> <ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div> </div>
{exam?.module === "level" && <MCQuestionGrid showSolutions={showSolutions} runOnClick={runOnClick}/>} {showGrid && <MCQuestionGrid exam={exam as LevelExam} showSolutions={showSolutions} runOnClick={runOnClick} />}
</div> </div>
</div> </div>
</> </>

View File

@@ -65,7 +65,6 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
const [released, setReleased] = useState<boolean>(assignment?.released || false); const [released, setReleased] = useState<boolean>(assignment?.released || false);
const [autoStart, setAutostart] = useState<boolean>(assignment?.autoStart || false); const [autoStart, setAutostart] = useState<boolean>(assignment?.autoStart || false);
const [autoStartDate, setAutoStartDate] = useState<Date | null>(assignment ? moment(assignment.autoStartDate).toDate() : new Date());
const [useRandomExams, setUseRandomExams] = useState(true); const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]); const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
@@ -131,7 +130,6 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
instructorGender, instructorGender,
released, released,
autoStart, autoStart,
autoStartDate,
}) })
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -306,24 +304,6 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
onChange={(date) => setEndDate(date)} onChange={(date) => setEndDate(date)}
/> />
</div> </div>
{autoStart && (
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={autoStartDate}
showTimeSelect
onChange={(date) => setAutoStartDate(date)}
/>
</div>
)}
</div> </div>
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (

View File

@@ -49,9 +49,10 @@ interface Props {
isLoading: boolean; isLoading: boolean;
assignment?: Assignment; assignment?: Assignment;
onViewResults: (moduleIndex?: number) => void; onViewResults: (moduleIndex?: number) => void;
destination?: string
} }
export default function Finish({user, scores, modules, information, solutions, isLoading, assignment, onViewResults}: Props) { export default function Finish({ user, scores, modules, information, solutions, isLoading, assignment, onViewResults, destination }: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!); const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false); const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
@@ -60,6 +61,8 @@ export default function Finish({user, scores, modules, information, solutions, i
const exams = useExamStore((state) => state.exams); const exams = useExamStore((state) => state.exams);
const { gradingSystem } = useGradingSystem(); const { gradingSystem } = useGradingSystem();
const router = useRouter()
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]); useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
const moduleColors: { [key in Module]: { progress: string; inner: string } } = { const moduleColors: { [key in Module]: { progress: string; inner: string } } = {
@@ -286,10 +289,8 @@ export default function Finish({user, scores, modules, information, solutions, i
<div className="flex gap-8"> <div className="flex gap-8">
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={() => window.location.reload()} onClick={() => router.push(destination || "/exam")}
// disabled={user.type === "admin"} disabled={!!assignment}
// TODO: temporarily disabled
disabled
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"> className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="h-7 w-7 text-white" /> <BsArrowCounterclockwise className="h-7 w-7 text-white" />
</button> </button>
@@ -325,7 +326,7 @@ export default function Finish({user, scores, modules, information, solutions, i
)} )}
</div> </div>
<Link href="/" className="w-full max-w-[200px] self-end"> <Link href={destination || "/"} className="w-full max-w-[200px] self-end">
<Button color="purple" className="w-full max-w-[200px] self-end"> <Button color="purple" className="w-full max-w-[200px] self-end">
Dashboard Dashboard
</Button> </Button>

View File

@@ -18,6 +18,7 @@ import { Tab } from "@headlessui/react";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { typeCheckWordsMC } from "@/utils/type.check"; import { typeCheckWordsMC } from "@/utils/type.check";
import SectionNavbar from "../Navigation/SectionNavbar"; import SectionNavbar from "../Navigation/SectionNavbar";
import AudioPlayer from "@/components/Low/AudioPlayer";
interface Props { interface Props {
exam: LevelExam; exam: LevelExam;
@@ -54,6 +55,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
// In case client want to switch back // In case client want to switch back
const textRenderDisabled = true; const textRenderDisabled = true;
const [timesListened, setTimesListened] = useState(0);
const [showSubmissionModal, setShowSubmissionModal] = useState(false); const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [showQuestionsModal, setShowQuestionsModal] = useState(false); const [showQuestionsModal, setShowQuestionsModal] = useState(false);
const [continueAnyways, setContinueAnyways] = useState(false); const [continueAnyways, setContinueAnyways] = useState(false);
@@ -176,6 +178,8 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context && !textRenderDisabled) { if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context && !textRenderDisabled) {
setTextRender(true); setTextRender(true);
} }
setTimesListened(0);
setPartIndex(partIndex + 1); setPartIndex(partIndex + 1);
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);
@@ -256,6 +260,40 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
}, 0) + (questionIndex + 1); }, 0) + (questionIndex + 1);
}; };
const renderAudioPlayer = () => (
<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>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer
key={partIndex}
src={exam?.parts[partIndex]?.audio?.source ?? ''}
color="listening"
onEnd={() => setTimesListened((prev) => prev + 1)}
disabled={exam?.parts[partIndex]?.audio?.repeatableTimes != null &&
timesListened === exam.parts[partIndex]?.audio?.repeatableTimes}
disablePause
/>
</div>
</>
) : (
<span>This section will be displayed the audio once it has been generated.</span>
)}
</div>
);
const renderText = () => ( const renderText = () => (
<> <>
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}> <div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
@@ -274,7 +312,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
</h4> </h4>
)} )}
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{exam.parts[partIndex].context && {(exam.parts[partIndex].context || exam.parts[partIndex].text) &&
<TextComponent <TextComponent
part={exam.parts[partIndex]} part={exam.parts[partIndex]}
contextWords={contextWords} contextWords={contextWords}
@@ -436,13 +474,15 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
renderText() : renderText() :
<> <>
{exam.parts[partIndex]?.context && renderText()} {exam.parts[partIndex]?.context && renderText()}
{exam.parts[partIndex]?.audio && renderAudioPlayer()}
{(showSolutions) ? {(showSolutions) ?
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) : currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise) currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
} }
</> </>
} }
</>) </>
)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [textRender, currentExercise, changedPrompt]); }, [textRender, currentExercise, changedPrompt]);
@@ -483,7 +523,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }}
/> : ( /> : (
<> <>
{exam.parts[0].intro && exam.parts.length !== 1 && ( {exam.parts[0].intro && (
<SectionNavbar <SectionNavbar
module="level" module="level"
sections={exam.parts} sections={exam.parts}

View File

@@ -32,7 +32,6 @@ export interface Assignment {
// unless start is active, the assignment is not visible to the assignees // unless start is active, the assignment is not visible to the assignees
// start date now works as a limit time to start the exam // start date now works as a limit time to start the exam
start?: boolean; start?: boolean;
autoStartDate?: Date;
autoStart?: boolean; autoStart?: boolean;
entity?: string; entity?: string;
} }

View File

@@ -31,10 +31,11 @@ import { mapBy } from "@/utils";
interface Props { interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
user: User; user: User;
destination?: string
hideSidebar?: boolean hideSidebar?: boolean
} }
export default function ExamPage({page, user, hideSidebar = false}: Props) { export default function ExamPage({ page, user, destination = "/exam", hideSidebar = false }: Props) {
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
@@ -465,6 +466,7 @@ export default function ExamPage({page, user, hideSidebar = false}: Props) {
timeSpent, timeSpent,
inactivity: totalInactivity, inactivity: totalInactivity,
}} }}
destination={destination}
onViewResults={(index?: number) => { onViewResults={(index?: number) => {
if (exams[0].module === "level") { if (exams[0].module === "level") {
const levelExam = exams[0] as LevelExam; const levelExam = exams[0] as LevelExam;

View File

@@ -90,7 +90,6 @@ export default function AssignmentsPage({assignment, user, users, entities, grou
const [released, setReleased] = useState<boolean>(assignment.released || false); const [released, setReleased] = useState<boolean>(assignment.released || false);
const [autoStart, setAutostart] = useState<boolean>(assignment.autoStart || false); const [autoStart, setAutostart] = useState<boolean>(assignment.autoStart || false);
const [autoStartDate, setAutoStartDate] = useState<Date | null>(moment(assignment.autoStartDate).toDate());
const [useRandomExams, setUseRandomExams] = useState(true); const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]); const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
@@ -148,7 +147,6 @@ export default function AssignmentsPage({assignment, user, users, entities, grou
instructorGender, instructorGender,
released, released,
autoStart, autoStart,
autoStartDate,
}) })
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been updated successfully!`); toast.success(`The assignment "${name}" has been updated successfully!`);
@@ -355,24 +353,6 @@ export default function AssignmentsPage({assignment, user, users, entities, grou
onChange={(date) => setEndDate(date)} onChange={(date) => setEndDate(date)}
/> />
</div> </div>
{autoStart && (
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={autoStartDate}
showTimeSelect
onChange={(date) => setAutoStartDate(date)}
/>
</div>
)}
</div> </div>
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (

View File

@@ -88,7 +88,6 @@ export default function AssignmentsPage({user, users, groups, entities}: Props)
const [released, setReleased] = useState<boolean>(false); const [released, setReleased] = useState<boolean>(false);
const [autoStart, setAutostart] = useState<boolean>(false); const [autoStart, setAutostart] = useState<boolean>(false);
const [autoStartDate, setAutoStartDate] = useState<Date | null>(new Date());
const [useRandomExams, setUseRandomExams] = useState(true); const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]); const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
@@ -148,7 +147,6 @@ export default function AssignmentsPage({user, users, groups, entities}: Props)
instructorGender, instructorGender,
released, released,
autoStart, autoStart,
autoStartDate,
}) })
.then((result) => { .then((result) => {
toast.success(`The assignment "${name}" has been created successfully!`); toast.success(`The assignment "${name}" has been created successfully!`);
@@ -313,24 +311,6 @@ export default function AssignmentsPage({user, users, groups, entities}: Props)
onChange={(date) => setEndDate(date)} onChange={(date) => setEndDate(date)}
/> />
</div> </div>
{autoStart && (
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={autoStartDate}
showTimeSelect
onChange={(date) => setAutoStartDate(date)}
/>
</div>
)}
</div> </div>
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (

View File

@@ -20,36 +20,38 @@ import { useRouter } from "next/router";
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be"; import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import moment from "moment"; import moment from "moment";
import { activeAssignmentFilter } from "@/utils/assignments";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
const destination = Buffer.from(req.url || "/").toString("base64") const loginDestination = Buffer.from(req.url || "/").toString("base64")
if (!user) return redirect(`/login?destination=${destination}`) if (!user) return redirect(`/login?destination=${loginDestination}`)
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/")
const {assignment: assignmentID} = query as {assignment?: string} const { assignment: assignmentID, destination } = query as { assignment?: string, destination?: string }
const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined
if (assignmentID) { if (assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID)
if (!assignment) return redirect("/exam") if (!assignment) return redirect(destinationURL || "/exam")
if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type)) if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type))
return redirect("/exam") return redirect(destinationURL || "/exam")
if (filterBy(assignment.results, 'user', user.id).length > 0) if (filterBy(assignment.results, 'user', user.id).length > 0)
return redirect("/exam") return redirect(destinationURL || "/exam")
const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID) const session = await getSessionByAssignment(assignmentID)
return { return {
props: serialize({user, assignment, exams, session: session ?? undefined}) props: serialize({ user, assignment, exams, destinationURL, session: session ?? undefined })
} }
} }
return { return {
props: serialize({user}), props: serialize({ user, destinationURL }),
}; };
}, sessionOptions); }, sessionOptions);
@@ -58,16 +60,17 @@ interface Props {
assignment?: Assignment assignment?: Assignment
exams?: Exam[] exams?: Exam[]
session?: Session session?: Session
destinationURL?: string
} }
export default function Page({user, assignment, exams = [], session}: Props) { export default function Page({ user, assignment, exams = [], destinationURL = "/exam", session }: Props) {
const router = useRouter() const router = useRouter()
const state = useExamStore((state) => state) const state = useExamStore((state) => state)
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !session) { if (assignment && exams.length > 0 && !state.assignment && !session) {
if ((moment(assignment.startDate).isAfter(moment()) || moment(assignment.endDate).isBefore(moment())) && assignment.start) return if (!activeAssignmentFilter(assignment)) return
state.setUserSolutions([]); state.setUserSolutions([]);
state.setShowSolutions(false); state.setShowSolutions(false);
@@ -117,7 +120,7 @@ export default function Page({user, assignment, exams = [], session}: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exams" user={user} hideSidebar={!!assignment || !!state.assignment} /> <ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!state.assignment} />
</> </>
); );
} }

View File

@@ -10,31 +10,57 @@ import clsx from "clsx";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import { checkAccess } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types"; import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditor from "@/components/ExamEditor"; import ExamEditor from "@/components/ExamEditor";
import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit"; import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit";
import { redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { Module } from "@/interfaces";
import { getExam, getExams } from "@/utils/exams.be";
import { Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam";
import { useEffect } from "react"; import { useEffect } from "react";
import { Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users";
import axios from "axios"; import axios from "axios";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { type Permission = { [key in Module]: boolean }
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") if (!user) return redirect("/login")
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) if (shouldRedirectHome(user)) return redirect("/")
return redirect("/")
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const permissions: Permission = {
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0,
listening: findAllowedEntities(user, entities, `generate_listening`).length > 0,
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0,
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0,
level: findAllowedEntities(user, entities, `generate_level`).length > 0,
}
if (Object.keys(permissions).every(p => !permissions[p as Module])) return redirect("/")
const { id, module } = query as { id?: string, module?: Module }
if (!id || !module) return { props: serialize({ user, permissions }) };
if (!permissions[module]) return redirect("/generation")
const exam = await getExam(module, id)
if (!exam) return redirect("/generation")
return { return {
props: serialize({ user }), props: serialize({ user, exam, permissions }),
}; };
}, sessionOptions); }, sessionOptions);
export default function Generation({ user }: { user: User; }) { export default function Generation({ user, exam, permissions }: { user: User; exam?: Exam, permissions: Permission }) {
const { title, currentModule, modules, dispatch } = useExamEditorStore(); const { title, currentModule, modules, dispatch } = useExamEditorStore();
const updateRoot = (updates: Partial<ExamEditorStore>) => { const updateRoot = (updates: Partial<ExamEditorStore>) => {
@@ -97,6 +123,9 @@ export default function Generation({ user }: { user: User; }) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
if (exam) { }
}, [exam])
return ( return (
<> <>
@@ -130,7 +159,7 @@ export default function Generation({ user }: { user: User; }) {
value={currentModule} value={currentModule}
onChange={(currentModule) => updateRoot({ currentModule })} onChange={(currentModule) => updateRoot({ currentModule })}
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
{[...MODULE_ARRAY].map((x) => ( {[...MODULE_ARRAY].filter(m => permissions[m]).map((x) => (
<Radio value={x} key={x}> <Radio value={x} key={x}>
{({ checked }) => ( {({ checked }) => (
<span <span

View File

@@ -13,9 +13,9 @@ import {Assignment} from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {findBy, mapBy, redirect, serialize} from "@/utils"; import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import {activeAssignmentFilter} from "@/utils/assignments"; import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
import { getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
@@ -60,7 +60,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.flatMap((a) =>
a.exams.filter((e) => e.assignee === user.id).map((e) => ({module: e.module, id: e.id, key: `${e.module}_${e.id}`})), filterBy(a.exams, 'assignee', user.id).map((e) => ({ module: e.module, id: e.id, key: `${e.module}_${e.id}` })),
), ),
"key", "key",
); );
@@ -69,6 +69,8 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
return { props: serialize({ user, entities, assignments, exams, sessions }) }; return { props: serialize({ user, entities, assignments, exams, sessions }) };
}, sessionOptions); }, sessionOptions);
const destination = Buffer.from("/official-exam").toString("base64")
export default function OfficialExam({ user, entities, assignments, sessions, exams }: Props) { export default function OfficialExam({ user, entities, assignments, sessions, exams }: Props) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -93,7 +95,7 @@ export default function OfficialExam({user, entities, assignments, sessions, exa
state.setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module')); state.setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module'));
state.setAssignment(assignment); state.setAssignment(assignment);
router.push("/exam"); router.push(`/exam?assignment=${assignment.id}&destination=${destination}`);
} }
}; };
@@ -112,7 +114,7 @@ export default function OfficialExam({user, entities, assignments, sessions, exa
state.setShowSolutions(false); state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex); state.setQuestionIndex(session.questionIndex);
router.push("/exam"); router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`);
}; };
const logout = async () => { const logout = async () => {
@@ -121,7 +123,11 @@ export default function OfficialExam({user, entities, assignments, sessions, exa
}); });
}; };
const studentAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]); const studentAssignments = useMemo(() => [
...assignments.filter(activeAssignmentFilter), ...assignments.filter(futureAssignmentFilter)],
[assignments]
);
const assignmentSessions = useMemo(() => sessions.filter(s => mapBy(studentAssignments, 'id').includes(s.assignment?.id || "")), [sessions, studentAssignments]) const assignmentSessions = useMemo(() => sessions.filter(s => mapBy(studentAssignments, 'id').includes(s.assignment?.id || "")), [sessions, studentAssignments])
return ( return (

View File

@@ -30,7 +30,7 @@ export const getAssignment = async (id: string) => {
export const getAssignmentsByAssignee = async (id: string, filter?: { [key in keyof Partial<Assignment>]: any }) => { export const getAssignmentsByAssignee = async (id: string, filter?: { [key in keyof Partial<Assignment>]: any }) => {
return await db return await db
.collection("assignments") .collection("assignments")
.find<Assignment>({assignees: [id], ...(!filter ? {} : filter)}) .find<Assignment>({ assignees: id, ...(!filter ? {} : filter) })
.toArray(); .toArray();
}; };

View File

@@ -1,38 +1,20 @@
import moment from "moment"; import moment from "moment";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
// export const futureAssignmentFilter = (a: Assignment) => {
// if(a.archived) return false;
// if(a.start) return false;
// const currentDate = moment();
// const startDate = moment(a.startDate);
// if(currentDate.isAfter(startDate)) return false;
// if(a.autoStart && a.autoStartDate) {
// return moment(a.autoStartDate).isAfter(currentDate);
// }
// return false;
// }
export const futureAssignmentFilter = (a: Assignment) => { export const futureAssignmentFilter = (a: Assignment) => {
const currentDate = moment(); const currentDate = moment();
if(moment(a.endDate).isBefore(currentDate)) return false;
if (a.archived) return false; if (a.archived) return false;
if (moment(a.endDate).isBefore(currentDate)) return false;
if (a.autoStart && moment(a.startDate).isBefore(currentDate)) return false;
if(a.autoStart && a.autoStartDate && moment(a.autoStartDate).isBefore(currentDate)) return false; return !a.start;
if(!a.start) {
if(moment(a.startDate).isBefore(currentDate)) return false;
return true;
}
return false;
} }
export const pastAssignmentFilter = (a: Assignment) => { export const pastAssignmentFilter = (a: Assignment) => {
const currentDate = moment(); const currentDate = moment();
if(a.archived) {
return false; if (a.archived) return false;
}
return moment(a.endDate).isBefore(currentDate); return moment(a.endDate).isBefore(currentDate);
} }
@@ -41,33 +23,14 @@ export const archivedAssignmentFilter = (a: Assignment) => a.archived;
export const activeAssignmentFilter = (a: Assignment) => { export const activeAssignmentFilter = (a: Assignment) => {
const currentDate = moment(); const currentDate = moment();
if(moment(a.endDate).isBefore(currentDate)) return false; if (moment(a.endDate).isBefore(currentDate) || a.archived) return false;
if(a.archived) return false;
if (a.start) return true; if (a.start) return true;
if (a.autoStart) return currentDate.isAfter(moment(a.startDate));
if(a.autoStart && a.autoStartDate) { return false
return moment(a.autoStartDate).isBefore(currentDate);
}
// if(currentDate.isAfter(moment(a.startDate))) return true;
return false;
}; };
// export const unstartedAssignmentFilter = (a: Assignment) => {
// const currentDate = moment();
// if(moment(a.endDate).isBefore(currentDate)) return false;
// if(a.archived) return false;
// if(a.autoStart && a.autoStartDate && moment(a.autoStartDate).isBefore(currentDate)) return false;
// if(!a.start) {
// if(moment(a.startDate).isBefore(currentDate)) return false;
// return true;
// }
// return false;
// }
export const startHasExpiredAssignmentFilter = (a: Assignment) => { export const startHasExpiredAssignmentFilter = (a: Assignment) => {
const currentDate = moment(); const currentDate = moment();
if (a.archived) return false; if (a.archived) return false;

View File

@@ -30,6 +30,10 @@ export async function getSpecificExams(ids: string[]) {
return exams; return exams;
} }
export const getExam = async (module: Module, id: string) => {
return await db.collection(module).findOne<Exam>({ id }) ?? undefined
};
export const getExamsByIds = async (ids: { module: Module; id: string }[]) => { export const getExamsByIds = async (ids: { module: Module; id: string }[]) => {
const groupedByModule = groupBy(ids, "module"); const groupedByModule = groupBy(ids, "module");
const exams: Exam[] = ( const exams: Exam[] = (