ENCOA-182, ENCOA-185, ENCOA-177, ENCOA-168, ENCOA-186, ENCOA-176, ENCOA-189, ENCOA-167
This commit is contained in:
@@ -89,7 +89,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
</div>
|
||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} value={phone} placeholder="Enter phone number" required />
|
||||
</div>
|
||||
{user.type === "student" && (
|
||||
<Input
|
||||
|
||||
84
src/components/Exercises/FillBlanks/MCDropdown.tsx
Normal file
84
src/components/Exercises/FillBlanks/MCDropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface MCDropdownProps {
|
||||
id: string;
|
||||
options: { [key: string]: string };
|
||||
onSelect: (value: string) => void;
|
||||
selectedValue?: string;
|
||||
className?: string;
|
||||
width: number;
|
||||
isOpen: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
const MCDropdown: React.FC<MCDropdownProps> = ({
|
||||
id,
|
||||
options,
|
||||
onSelect,
|
||||
selectedValue,
|
||||
className = "relative",
|
||||
width,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
setContentHeight(contentRef.current.scrollHeight);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const springProps = useSpring({
|
||||
height: isOpen ? contentHeight : 0,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
config: { tension: 300, friction: 30 }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
||||
<button
|
||||
onClick={() => onToggle(id)}
|
||||
className={
|
||||
clsx("rounded-full hover:text-white transition duration-300 ease-in-out px-5 py-2 text-center w-full flex items-center justify-between",
|
||||
selectedValue ? "bg-mti-purple text-white" : "bg-mti-purple-ultralight text-mti-purple-light"
|
||||
)}
|
||||
>
|
||||
<span className="truncate p-1">{selectedValue || 'Select an option'}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<animated.div
|
||||
style={{ ...springProps, width: `${width}px` }}
|
||||
className="absolute z-10 mt-1 overflow-hidden bg-white rounded-md shadow-lg"
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{Object.entries(options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => {
|
||||
onSelect(value);
|
||||
onToggle(id);
|
||||
}}
|
||||
className="p-4 hover:bg-mti-purple-ultralight cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCDropdown;
|
||||
@@ -1,11 +1,12 @@
|
||||
import {FillBlanksExercise, FillBlanksMCOption} from "@/interfaces/exam";
|
||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, useCallback, useEffect, useMemo, useState} from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, 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";
|
||||
import MCDropdown from "./MCDropdown";
|
||||
|
||||
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
id,
|
||||
@@ -19,24 +20,19 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
onNext,
|
||||
onBack,
|
||||
}) => {
|
||||
const {shuffles, exam, partIndex, questionIndex, exerciseIndex} = useExamStore((state) => state);
|
||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
||||
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||
|
||||
const [currentMCSelection, setCurrentMCSelection] = useState<{id: string; selection: FillBlanksMCOption}>();
|
||||
|
||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||
};
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const excludeWordMCType = (x: any) => {
|
||||
return typeof x === "string" ? x : (x as {letter: string; word: string});
|
||||
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -45,6 +41,19 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setOpenDropdownId(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = answers!.filter((x) => {
|
||||
@@ -71,56 +80,55 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
return false;
|
||||
}).length;
|
||||
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
return {total, correct, missing};
|
||||
return { total, correct, missing };
|
||||
};
|
||||
|
||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||
|
||||
const renderLines = useCallback(
|
||||
(line: string) => {
|
||||
return (
|
||||
<div className="text-base leading-5" key={v4()}>
|
||||
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
|
||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||
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",
|
||||
currentMCSelection?.id == id && "!bg-mti-purple !text-white !outline-none !ring-0",
|
||||
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
|
||||
!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" ? (
|
||||
<>
|
||||
{/*<span className="mr-2">{`(${id})`}</span>*/}
|
||||
<button
|
||||
className={styles}
|
||||
onClick={() => {
|
||||
setCurrentMCSelection({
|
||||
id: id,
|
||||
selection: words.find((x) => {
|
||||
if (typeof x !== "string" && "id" in x) {
|
||||
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
||||
}
|
||||
return false;
|
||||
}) as FillBlanksMCOption,
|
||||
});
|
||||
}}>
|
||||
{userSolution?.solution === undefined ? (
|
||||
<span className="text-transparent select-none">placeholder</span>
|
||||
) : (
|
||||
<span> {userSolution.solution} </span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
<MCDropdown
|
||||
id={id}
|
||||
options={currentSelection.options}
|
||||
onSelect={(value) => onSelection(id, value)}
|
||||
selectedValue={userSolution?.solution}
|
||||
className="inline-block py-2 px-1"
|
||||
width={220}
|
||||
isOpen={openDropdownId === id}
|
||||
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={styles}
|
||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
|
||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
||||
value={userSolution?.solution}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
})
|
||||
}
|
||||
</div >
|
||||
);
|
||||
},
|
||||
[variant, words, setCurrentMCSelection, answers, currentMCSelection],
|
||||
[variant, words, answers, openDropdownId],
|
||||
);
|
||||
|
||||
const memoizedLines = useMemo(() => {
|
||||
@@ -131,15 +139,15 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
</p>
|
||||
));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text, variant, renderLines, currentMCSelection]);
|
||||
}, [text, variant, renderLines]);
|
||||
|
||||
const onSelection = (questionID: string, value: string) => {
|
||||
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), {id: questionID, solution: value}]);
|
||||
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (variant === "mc") {
|
||||
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers]);
|
||||
@@ -150,19 +158,19 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps})}
|
||||
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}>
|
||||
Back
|
||||
Previous Page
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => {
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||
}}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -178,38 +186,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">{memoizedLines}</span>
|
||||
{variant === "mc" && typeCheckWordsMC(words) ? (
|
||||
<>
|
||||
{currentMCSelection && (
|
||||
<div className="bg-mti-gray-smoke rounded-xl flex flex-col gap-4 px-16 py-8">
|
||||
<span className="font-medium text-lg text-mti-purple-dark mb-4 px-2">{`${currentMCSelection.id} - Select the appropriate word.`}</span>
|
||||
<div className="flex gap-4 flex-wrap justify-between">
|
||||
{currentMCSelection.selection?.options &&
|
||||
Object.entries(currentMCSelection.selection.options)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => {
|
||||
return (
|
||||
<div
|
||||
key={v4()}
|
||||
onClick={() => onSelection(currentMCSelection.id, value)}
|
||||
className={clsx(
|
||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base",
|
||||
!!answers.find(
|
||||
(x) =>
|
||||
x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() &&
|
||||
x.id === currentMCSelection.id,
|
||||
) && "!bg-mti-purple-light !text-white",
|
||||
)}>
|
||||
<span className="font-semibold">{key}.</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
{variant !== "mc" && (
|
||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
||||
<span className="font-medium text-mti-purple-dark">Options</span>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
@@ -240,19 +217,19 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps})}
|
||||
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}>
|
||||
Back
|
||||
Previous Page
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => {
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||
}}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||
import React from "react";
|
||||
import Input from "@/components/Low/Input";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
exercise: FillBlanksExercise;
|
||||
@@ -8,11 +9,16 @@ interface Props {
|
||||
}
|
||||
|
||||
const FillBlanksEdit = (props: Props) => {
|
||||
const {exercise, updateExercise} = props;
|
||||
const { exercise, updateExercise } = props;
|
||||
|
||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||
label="Prompt"
|
||||
name="prompt"
|
||||
required
|
||||
@@ -24,18 +30,18 @@ const FillBlanksEdit = (props: Props) => {
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||
label="Text"
|
||||
name="text"
|
||||
required
|
||||
value={exercise.text}
|
||||
onChange={(value) =>
|
||||
updateExercise({
|
||||
text: value,
|
||||
text: exercise?.variant && exercise.variant === "mc" ? value : value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<h1>Solutions</h1>
|
||||
<h1 className="mt-4">Solutions</h1>
|
||||
<div className="w-full flex flex-wrap -mx-2">
|
||||
{exercise.solutions.map((solution, index) => (
|
||||
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||
@@ -47,33 +53,75 @@ const FillBlanksEdit = (props: Props) => {
|
||||
value={solution.solution}
|
||||
onChange={(value) =>
|
||||
updateExercise({
|
||||
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
|
||||
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h1>Words</h1>
|
||||
<div className="w-full flex flex-wrap -mx-2">
|
||||
{exercise.words.map((word, index) => (
|
||||
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||
<Input
|
||||
type="text"
|
||||
label={`Word ${index + 1}`}
|
||||
name="word"
|
||||
required
|
||||
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
||||
onChange={(value) =>
|
||||
updateExercise({
|
||||
words: exercise.words.map((sol, idx) =>
|
||||
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<h1 className="mt-4">Words</h1>
|
||||
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
|
||||
{exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
|
||||
(
|
||||
<div className="flex flex-col w-full">
|
||||
{exercise.words.flatMap((mcOptions, wordIndex) =>
|
||||
<>
|
||||
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
|
||||
<div className="flex flex-row">
|
||||
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
|
||||
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
|
||||
<Input
|
||||
type="text"
|
||||
label={`Option ${key}`}
|
||||
name="word"
|
||||
required
|
||||
value={value}
|
||||
onChange={(newValue) =>
|
||||
updateExercise({
|
||||
words: exercise.words.map((word, idx) =>
|
||||
idx === wordIndex
|
||||
? {
|
||||
...(word as FillBlanksMCOption),
|
||||
options: {
|
||||
...(word as FillBlanksMCOption).options,
|
||||
[key]: newValue
|
||||
}
|
||||
}
|
||||
: word
|
||||
)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
:
|
||||
(
|
||||
exercise.words.map((word, index) => (
|
||||
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||
<Input
|
||||
type="text"
|
||||
label={`Word ${index + 1}`}
|
||||
name="word"
|
||||
required
|
||||
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
||||
onChange={(value) =>
|
||||
updateExercise({
|
||||
words: exercise.words.map((sol, idx) =>
|
||||
index === idx ? (typeof word === "string" ? value : { ...word, word: value }) : sol,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
type: "email" | "text" | "password" | "tel" | "number";
|
||||
type: "email" | "text" | "password" | "tel" | "number" | "textarea";
|
||||
roundness?: "full" | "xl";
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
@@ -32,6 +32,20 @@ export default function Input({
|
||||
}: Props) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
if (type === "textarea") {
|
||||
return (
|
||||
<textarea
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl min-h-[200px]"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (type === "password") {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
|
||||
@@ -15,6 +15,7 @@ import React from "react";
|
||||
interface Props {
|
||||
minTimer: number;
|
||||
module: Module;
|
||||
examLabel?: string;
|
||||
label?: string;
|
||||
exerciseIndex: number;
|
||||
totalExercises: number;
|
||||
@@ -30,6 +31,7 @@ export default function ModuleTitle({
|
||||
minTimer,
|
||||
module,
|
||||
label,
|
||||
examLabel,
|
||||
exerciseIndex,
|
||||
totalExercises,
|
||||
disableTimer = false,
|
||||
@@ -156,7 +158,7 @@ export default function ModuleTitle({
|
||||
<div className="w-full flex justify-between">
|
||||
<span className="text-base font-semibold">
|
||||
{module === "level"
|
||||
? "Placement Test"
|
||||
? (examLabel ? examLabel : "Placement Test")
|
||||
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
|
||||
}
|
||||
</span>
|
||||
|
||||
@@ -3,16 +3,32 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
part: LevelPart,
|
||||
contextWord: string | undefined,
|
||||
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
|
||||
contextWords: { match: string, originalLine: string }[] | undefined,
|
||||
setContextWordLines: React.Dispatch<React.SetStateAction<number[] | undefined>>
|
||||
setTotalLines: React.Dispatch<React.SetStateAction<number>>
|
||||
}
|
||||
|
||||
const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine }) => {
|
||||
const TextComponent: React.FC<Props> = ({ part, contextWords, setContextWordLines, setTotalLines }) => {
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
|
||||
const [lineHeight, setLineHeight] = useState<number>(0);
|
||||
const [addBreaksTo, setAddBreaksTo] = useState<number[]>([]);
|
||||
|
||||
const getBoldTag = (context: string) => {
|
||||
const regex = /<b\s+class=['"]([^'"]+)['"]>(\d+)<\/b>/;
|
||||
const match = context.match(regex);
|
||||
if (match) {
|
||||
return {
|
||||
className: match[1],
|
||||
number: match[2],
|
||||
fullTag: match[0]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const bTag = getBoldTag(part.context!);
|
||||
|
||||
const calculateLineNumbers = () => {
|
||||
if (textRef.current) {
|
||||
const computedStyle = window.getComputedStyle(textRef.current);
|
||||
@@ -39,7 +55,6 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
||||
const lines = paragraphs.map((line, lineIndex) => {
|
||||
const paragraphWords = line.split(/(\s+)/);
|
||||
return paragraphWords.map((word, wordIndex) => {
|
||||
|
||||
if (lineIndex !== 0 && wordIndex == 0 && lineIndex < paragraphs.length) {
|
||||
betweenParagraphs[lineIndex - 1][1] = word;
|
||||
}
|
||||
@@ -47,9 +62,17 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
||||
if (wordIndex == paragraphWords.length - 1 && lineIndex < paragraphs.length) {
|
||||
betweenParagraphs[lineIndex][0] = word;
|
||||
}
|
||||
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = word;
|
||||
if (wordIndex === 0 && bTag) {
|
||||
const b = document.createElement('b');
|
||||
b.classList.add(bTag.className);
|
||||
b.textContent = `${lineIndex + 1}`;
|
||||
span.appendChild(b);
|
||||
span.appendChild(document.createTextNode(word.substring(1)));
|
||||
}else {
|
||||
span.appendChild(document.createTextNode(word));
|
||||
}
|
||||
return span;
|
||||
})
|
||||
}
|
||||
@@ -67,8 +90,11 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
||||
const processedLines: string[][] = [[]];
|
||||
let currentLine = 1;
|
||||
let currentLineTop: number | undefined;
|
||||
let contextWordLine: number | null = null;
|
||||
|
||||
let contextWordLines: number[] = [];
|
||||
if (contextWords) {
|
||||
contextWordLines = Array(contextWords.length).fill(-1);
|
||||
}
|
||||
const firstChild = offscreenElement.firstChild as HTMLElement;
|
||||
if (firstChild) {
|
||||
currentLineTop = firstChild.getBoundingClientRect().top;
|
||||
@@ -78,7 +104,7 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
||||
|
||||
let betweenIndex = 0;
|
||||
const addBreaksTo: number[] = [];
|
||||
spans.forEach((span, index)=> {
|
||||
spans.forEach((span, index) => {
|
||||
const rect = span.getBoundingClientRect();
|
||||
const top = rect.top;
|
||||
|
||||
@@ -98,17 +124,22 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
||||
}
|
||||
|
||||
processedLines[processedLines.length - 1].push(span.textContent?.trim() || '');
|
||||
|
||||
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
|
||||
contextWordLine = currentLine;
|
||||
if (contextWords && contextWordLines.some(element => element === -1)) {
|
||||
contextWords.forEach((w, index) => {
|
||||
if (span.textContent?.includes(w.match) && contextWordLines[index] == -1) {
|
||||
contextWordLines[index] = currentLine;
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
setAddBreaksTo(addBreaksTo);
|
||||
|
||||
setLineNumbers(processedLines.map((_, index) => index + 1));
|
||||
if (contextWordLine) {
|
||||
setContextWordLine(contextWordLine);
|
||||
setTotalLines(currentLine);
|
||||
|
||||
if (contextWordLines.length > 0) {
|
||||
setContextWordLines(contextWordLines);
|
||||
}
|
||||
|
||||
document.body.removeChild(offscreenElement);
|
||||
@@ -135,18 +166,18 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [part.context, contextWord]);
|
||||
}, [part.context, contextWords]);
|
||||
|
||||
return (
|
||||
<div className="flex mt-2">
|
||||
<div className="flex-shrink-0 w-8 pr-2">
|
||||
{lineNumbers.map(num => (
|
||||
<>
|
||||
<div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
|
||||
{num}
|
||||
</div>
|
||||
{/* Do not delete the space between the span or else the lines get messed up */}
|
||||
{addBreaksTo.includes(num) && <span className={`h-[${lineHeight}px] whitespace-pre-wrap`}> </span>}
|
||||
<div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
|
||||
{num}
|
||||
</div>
|
||||
{/* Do not delete the space between the span or else the lines get messed up */}
|
||||
{addBreaksTo.includes(num) && <span className={`h-[${lineHeight}px] whitespace-pre-wrap`}> </span>}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -85,8 +85,9 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
||||
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
|
||||
const [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | undefined>(undefined);
|
||||
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
|
||||
const [totalLines, setTotalLines] = useState<number>(0);
|
||||
|
||||
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
|
||||
|
||||
@@ -206,6 +207,14 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
|
||||
if (questionIndex == 0) {
|
||||
setPartIndex(partIndex - 1);
|
||||
if (!seenParts.has(partIndex - 1)) {
|
||||
setBgColor(levelBgColor);
|
||||
setShowPartDivider(true);
|
||||
setQuestionIndex(0);
|
||||
setSeenParts(prev => new Set(prev).add(partIndex - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
||||
const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex];
|
||||
setExerciseIndex(lastExerciseIndex);
|
||||
@@ -260,8 +269,9 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
{exam.parts[partIndex].context &&
|
||||
<TextComponent
|
||||
part={exam.parts[partIndex]}
|
||||
contextWord={contextWord}
|
||||
setContextWordLine={setContextWordLine}
|
||||
contextWords={contextWords}
|
||||
setContextWordLines={setContextWordLines}
|
||||
setTotalLines={setTotalLines}
|
||||
/>}
|
||||
</div>
|
||||
</>
|
||||
@@ -321,35 +331,59 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
|
||||
useEffect(() => {
|
||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||
|
||||
const findMatch = (index: number) => {
|
||||
if (currentExercise && currentExercise.type === "multipleChoice" && currentExercise!.questions[index]) {
|
||||
const match = currentExercise!.questions[index].prompt.match(regex);
|
||||
if (match) {
|
||||
return { match: match[1], originalLine: match[2] }
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// if the client for some whatever random reason decides
|
||||
// to add more questions update this
|
||||
const numberOfQuestions = 2;
|
||||
|
||||
if (exam.parts[partIndex].context) {
|
||||
const hits = Array.from({ length: numberOfQuestions }).reduce<{ match: string, originalLine: string }[]>((acc, _, i) => {
|
||||
const result = findMatch(questionIndex + i);
|
||||
if (!!result) {
|
||||
acc.push(result);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (hits.length > 0) {
|
||||
setContextWords(hits)
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentExercise, questionIndex, totalLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
exerciseIndex !== -1 && currentExercise &&
|
||||
currentExercise.type === "multipleChoice" &&
|
||||
currentExercise.questions[questionIndex] &&
|
||||
currentExercise.questions[questionIndex].prompt &&
|
||||
exam.parts[partIndex].context
|
||||
exam.parts[partIndex].context && contextWordLines
|
||||
) {
|
||||
const match = currentExercise.questions[questionIndex].prompt.match(regex);
|
||||
if (match) {
|
||||
const word = match[1];
|
||||
const originalLineNumber = match[2];
|
||||
|
||||
if (word !== contextWord) {
|
||||
setContextWord(word);
|
||||
}
|
||||
|
||||
const updatedPrompt = currentExercise.questions[questionIndex].prompt.replace(
|
||||
`in line ${originalLineNumber}`,
|
||||
`in line ${contextWordLine || originalLineNumber}`
|
||||
);
|
||||
|
||||
currentExercise.questions[questionIndex].prompt = updatedPrompt;
|
||||
if (contextWordLines.length > 0) {
|
||||
contextWordLines.forEach((n, i) => {
|
||||
if (contextWords && contextWords[i] && n !== -1) {
|
||||
const updatedPrompt = currentExercise!.questions[questionIndex + i].prompt.replace(
|
||||
`in line ${contextWords[i].originalLine}`,
|
||||
`in line ${n}`
|
||||
);
|
||||
currentExercise!.questions[questionIndex + i].prompt = updatedPrompt;
|
||||
}
|
||||
})
|
||||
setChangedPrompt(true);
|
||||
} else {
|
||||
setContextWord(undefined);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentExercise, questionIndex, contextWordLine]);
|
||||
}, [contextWordLines]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (continueAnyways) {
|
||||
@@ -419,7 +453,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
<Button color="purple" onClick={() => setShowSubmissionModal(false)} variant="outline" className="max-w-[200px] self-end w-full !text-xl">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="rose" onClick={() => { setShowSubmissionModal(false); setContinueAnyways(true)}} className="max-w-[200px] self-end w-full !text-xl">
|
||||
<Button color="rose" onClick={() => { setShowSubmissionModal(false); setContinueAnyways(true) }} className="max-w-[200px] self-end w-full !text-xl">
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
@@ -469,6 +503,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
</div>
|
||||
)}
|
||||
<ModuleTitle
|
||||
examLabel={exam.label}
|
||||
partLabel={partLabel()}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ExamBase {
|
||||
createdBy?: string; // option as it has been added later
|
||||
createdAt?: string; // option as it has been added later
|
||||
private?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
export interface ReadingExam extends ExamBase {
|
||||
module: "reading";
|
||||
|
||||
@@ -15,31 +15,38 @@ import {
|
||||
Exercise,
|
||||
} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
|
||||
import { capitalize, sample } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsArrowRepeat, BsCheck, BsPencilSquare, BsX } from "react-icons/bs";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
import { toast } from "react-toastify";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
interface Option {
|
||||
[key: string]: any;
|
||||
value: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||
const TYPES: {[key: string]: string} = {
|
||||
const TYPES: { [key: string]: string } = {
|
||||
multiple_choice_4: "Multiple Choice",
|
||||
multiple_choice_blank_space: "Multiple Choice - Blank Space",
|
||||
multiple_choice_underlined: "Multiple Choice - Underlined",
|
||||
blank_space_text: "Blank Space",
|
||||
reading_passage_utas: "Reading Passage",
|
||||
fill_blanks_mc: "Multiple Choice - Fill Blanks",
|
||||
};
|
||||
|
||||
type LevelSection = {type: string; quantity: number; topic?: string; part?: LevelPart};
|
||||
type LevelSection = { type: string; quantity: number; topic?: string; part?: LevelPart };
|
||||
|
||||
const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => {
|
||||
const QuestionDisplay = ({ question, onUpdate }: { question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [options, setOptions] = useState(question.options);
|
||||
const [answer, setAnswer] = useState(question.solution);
|
||||
@@ -70,7 +77,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
||||
<input
|
||||
defaultValue={option.text}
|
||||
className="w-60"
|
||||
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? {...x, text: e.target.value} : x)))}
|
||||
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? { ...x, text: e.target.value } : x)))}
|
||||
/>
|
||||
) : (
|
||||
<span>{option.text}</span>
|
||||
@@ -90,7 +97,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpdate({...question, options, solution: answer});
|
||||
onUpdate({ ...question, options, solution: answer });
|
||||
setIsEditing(false);
|
||||
}}
|
||||
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
|
||||
@@ -108,9 +115,16 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
||||
);
|
||||
};
|
||||
|
||||
const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (section: LevelSection) => void}) => {
|
||||
const TaskTab = ({ section, label, index, setSection }: { section: LevelSection; label: string, index: number, setSection: (section: LevelSection) => void }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [category, setCategory] = useState<string>("");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [customDescription, setCustomDescription] = useState<string>("");
|
||||
const [previousOption, setPreviousOption] = useState<Option>({ value: "None", label: "None" });
|
||||
const [descriptionOption, setDescriptionOption] = useState<Option>({ value: "None", label: "None" });
|
||||
const [updateIntro, setUpdateIntro] = useState<boolean>(false);
|
||||
|
||||
const onUpdate = (question: MultipleChoiceQuestion) => {
|
||||
if (!section) return;
|
||||
|
||||
@@ -124,6 +138,66 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
||||
setSection(updatedExam as any);
|
||||
};
|
||||
|
||||
const defaultPresets: any = {
|
||||
multiple_choice_4: "Welcome to {part} of the {label}. In this section, you'll be asked to select the correct word or group of words that best completes each sentence.\n\nFor each question, carefully read the sentence and click on the option (A, B, C, or D) that you believe is correct. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your previous answers, you can go back at any time by clicking \"Back\".",
|
||||
multiple_choice_blank_space: undefined,
|
||||
multiple_choice_underlined: "Welcome to {part} of the {label}. In this section, you'll be asked to identify the underlined word or group of words that is not correct in each sentence.\n\nFor each question, carefully review the sentence and click on the option (A, B, C, or D) that you believe contains the incorrect word or group of words. After making your selection, you can proceed to the next question by clicking \"Next\". If needed, you can go back to previous questions by clicking \"Back\".",
|
||||
blank_space_text: undefined,
|
||||
reading_passage_utas: "Welcome to {part} of the {label}. In this section, you will read a text and answer the questions that follow.\n\nCarefully read the provided text, then select the correct answer (A, B, C, or D) for each question. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your answers, you can go back at any time by clicking \"Back\".",
|
||||
fill_blanks_mc: "Welcome to {part} of the {label}. In this section, you will read a text and choose the correct word to fill in each blank space.\n\nFor each question, carefully read the text and click on the option (A, B, C, or D) that you believe best fits the context. Once you've made your choice, proceed to the next question by clicking \"Next\". If needed, you can go back to review or change your answers by clicking \"Back\"."
|
||||
};
|
||||
|
||||
const getDefaultPreset = () => {
|
||||
return defaultPresets[section.type] ? defaultPresets[section.type].replace('{part}', `Part ${index + 1}`).replace('{label}', label) :
|
||||
"No default preset is yet available for this type of exercise."
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (descriptionOption.value === "Default" && section?.type) {
|
||||
setDescription(getDefaultPreset())
|
||||
}
|
||||
if (descriptionOption.value === "Custom" && customDescription !== "") {
|
||||
setDescription(customDescription);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [descriptionOption, section?.type, label])
|
||||
|
||||
useEffect(() => {
|
||||
if (section?.type) {
|
||||
const defaultPreset = getDefaultPreset();
|
||||
if (descriptionOption.value === "Default" && previousOption.value === "Default" && description !== defaultPreset) {
|
||||
setDescriptionOption({ value: "Custom", label: "Custom" });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [descriptionOption, description, label])
|
||||
|
||||
useEffect(() => {
|
||||
setPreviousOption(descriptionOption);
|
||||
}, [descriptionOption])
|
||||
|
||||
useEffect(() => {
|
||||
if (section.part && ((descriptionOption.value === "Custom" || descriptionOption.value === "Default") && !section.part.intro)) {
|
||||
setUpdateIntro(true);
|
||||
}
|
||||
}, [section?.part, descriptionOption, category])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIntro && section.part) {
|
||||
setSection({
|
||||
...section,
|
||||
part: {
|
||||
...section.part!,
|
||||
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||
category: category === "" ? undefined : category
|
||||
}
|
||||
})
|
||||
setUpdateIntro(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updateIntro, section?.part])
|
||||
|
||||
const renderExercise = (exercise: Exercise) => {
|
||||
if (exercise.type === "multipleChoice")
|
||||
return (
|
||||
@@ -138,7 +212,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
||||
updateExercise={(data: any) =>
|
||||
setSection({
|
||||
...section,
|
||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
||||
part: {
|
||||
...section.part!,
|
||||
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||
category: category === "" ? undefined : category
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -158,7 +237,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
||||
updateExercise={(data: any) =>
|
||||
setSection({
|
||||
...section,
|
||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
||||
part: {
|
||||
...section.part!,
|
||||
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||
category: category === "" ? undefined : category
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -178,7 +262,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
||||
updateExercise={(data: any) =>
|
||||
setSection({
|
||||
...section,
|
||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
||||
part: {
|
||||
...section.part!,
|
||||
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||
category: category === "" ? undefined : category
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -188,30 +277,61 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-row w-full gap-4">
|
||||
<div className="flex flex-col gap-3 w-1/2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Description</label>
|
||||
<Select
|
||||
options={["None", "Default", "Custom"].map((descriptionOption) => ({ value: descriptionOption, label: descriptionOption }))}
|
||||
onChange={(o) => setDescriptionOption({ value: o!.value, label: o!.label })}
|
||||
value={descriptionOption}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-1/2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Category</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Category"
|
||||
name="category"
|
||||
onChange={(e) => setCategory(e)}
|
||||
roundness="full"
|
||||
defaultValue={category}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{descriptionOption.value !== "None" && (
|
||||
<Input
|
||||
type="textarea"
|
||||
placeholder="Part Description"
|
||||
name="category"
|
||||
onChange={(e) => { setDescription(e); setCustomDescription(e); }}
|
||||
roundness="full"
|
||||
value={descriptionOption.value === "Default" ? description : customDescription}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
|
||||
<Select
|
||||
options={Object.keys(TYPES).map((key) => ({value: key, label: TYPES[key]}))}
|
||||
onChange={(e) => setSection({...section, type: e!.value!})}
|
||||
value={{value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"]}}
|
||||
options={Object.keys(TYPES).map((key) => ({ value: key, label: TYPES[key] }))}
|
||||
onChange={(e) => setSection({ ...section, type: e!.value! })}
|
||||
value={{ value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Number of Questions</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">{section?.type && section.type === "fill_blanks_mc" ? "Number of Words" : "Number of Questions"}</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="Number of Questions"
|
||||
onChange={(v) => setSection({...section, quantity: parseInt(v)})}
|
||||
onChange={(v) => setSection({ ...section, quantity: parseInt(v) })}
|
||||
value={section?.quantity || 10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{section?.type === "reading_passage_utas" && (
|
||||
{section?.type === "reading_passage_utas" || section?.type === "fill_blanks_mc" && (
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
|
||||
<Input type="text" name="Topic" onChange={(v) => setSection({...section, topic: v})} value={section?.topic} />
|
||||
<Input type="text" name="Topic" onChange={(v) => setSection({ ...section, topic: v })} value={section?.topic} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -235,18 +355,19 @@ interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const LevelGeneration = ({id}: Props) => {
|
||||
const LevelGeneration = ({ id }: Props) => {
|
||||
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||
const [timer, setTimer] = useState(10);
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
const [numberOfParts, setNumberOfParts] = useState(1);
|
||||
const [parts, setParts] = useState<LevelSection[]>([{quantity: 10, type: "multiple_choice_4"}]);
|
||||
const [parts, setParts] = useState<LevelSection[]>([{ quantity: 10, type: "multiple_choice_4" }]);
|
||||
const [isPrivate, setPrivate] = useState<boolean>(false);
|
||||
const [label, setLabel] = useState<string>("Placement Test");
|
||||
|
||||
useEffect(() => {
|
||||
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : {quantity: 10, type: "multiple_choice_4"})));
|
||||
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : { quantity: 10, type: "multiple_choice_4" })));
|
||||
}, [numberOfParts]);
|
||||
|
||||
const router = useRouter();
|
||||
@@ -289,7 +410,7 @@ const LevelGeneration = ({id}: Props) => {
|
||||
let newParts = [...parts];
|
||||
|
||||
axios
|
||||
.post<{exercises: {[key: string]: any}}>("/api/exam/level/generate/level", {nr_exercises: numberOfParts, ...body})
|
||||
.post<{ exercises: { [key: string]: any } }>("/api/exam/level/generate/level", { nr_exercises: numberOfParts, ...body })
|
||||
.then((result) => {
|
||||
console.log(result.data);
|
||||
|
||||
@@ -304,6 +425,7 @@ const LevelGeneration = ({id}: Props) => {
|
||||
variant: "full",
|
||||
isDiagnostic: false,
|
||||
private: isPrivate,
|
||||
label: label,
|
||||
parts: parts
|
||||
.map((part, index) => {
|
||||
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
|
||||
@@ -317,23 +439,55 @@ const LevelGeneration = ({id}: Props) => {
|
||||
id: v4(),
|
||||
prompt:
|
||||
part.type === "multiple_choice_underlined"
|
||||
? "Select the wrong part of the sentence."
|
||||
: "Select the appropriate option.",
|
||||
questions: currentExercise.questions.map((x: any) => ({...x, variant: "text"})),
|
||||
? "Choose the underlined word or group of words that is not correct.\nFor each question, select your choice (A, B, C or D)."
|
||||
: "Choose the correct word or group of words that completes the sentences below.\nFor each question, select the correct letter (A, B, C or D).",
|
||||
questions: currentExercise.questions.map((x: any) => ({ ...x, variant: "text" })),
|
||||
type: "multipleChoice",
|
||||
userSolutions: [],
|
||||
};
|
||||
|
||||
const item = {
|
||||
exercises: [exercise],
|
||||
intro: parts[index].part?.intro,
|
||||
category: parts[index].part?.category
|
||||
};
|
||||
|
||||
newParts = newParts.map((p, i) =>
|
||||
i === index
|
||||
? {
|
||||
...p,
|
||||
part: item,
|
||||
}
|
||||
...p,
|
||||
part: item,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
if (part.type === "fill_blanks_mc") {
|
||||
const exercise: FillBlanksExercise = {
|
||||
id: v4(),
|
||||
prompt: "Read the text below and choose the correct word for each space.\nFor each question, select your choice (A, B, C or D). ",
|
||||
text: currentExercise.text,
|
||||
words: currentExercise.words,
|
||||
solutions: currentExercise.solutions,
|
||||
type: "fillBlanks",
|
||||
variant: "mc",
|
||||
userSolutions: [],
|
||||
};
|
||||
|
||||
const item = {
|
||||
exercises: [exercise],
|
||||
intro: parts[index].part?.intro,
|
||||
category: parts[index].part?.category
|
||||
};
|
||||
|
||||
newParts = newParts.map((p, i) =>
|
||||
i === index
|
||||
? {
|
||||
...p,
|
||||
part: item,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
|
||||
@@ -346,21 +500,23 @@ const LevelGeneration = ({id}: Props) => {
|
||||
prompt: "Complete the text below.",
|
||||
text: currentExercise.text,
|
||||
maxWords: 3,
|
||||
solutions: currentExercise.words.map((x: any) => ({id: x.id, solution: [x.text]})),
|
||||
solutions: currentExercise.words.map((x: any) => ({ id: x.id, solution: [x.text] })),
|
||||
type: "writeBlanks",
|
||||
userSolutions: [],
|
||||
};
|
||||
|
||||
const item = {
|
||||
exercises: [exercise],
|
||||
intro: parts[index].part?.intro,
|
||||
category: parts[index].part?.category
|
||||
};
|
||||
|
||||
newParts = newParts.map((p, i) =>
|
||||
i === index
|
||||
? {
|
||||
...p,
|
||||
part: item,
|
||||
}
|
||||
...p,
|
||||
part: item,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
|
||||
@@ -370,7 +526,7 @@ const LevelGeneration = ({id}: Props) => {
|
||||
const mcExercise: MultipleChoiceExercise = {
|
||||
id: v4(),
|
||||
prompt: "Select the appropriate option.",
|
||||
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({...x, variant: "text"})),
|
||||
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({ ...x, variant: "text" })),
|
||||
type: "multipleChoice",
|
||||
userSolutions: [],
|
||||
};
|
||||
@@ -391,14 +547,16 @@ const LevelGeneration = ({id}: Props) => {
|
||||
const item = {
|
||||
context: currentExercise.text.content,
|
||||
exercises: [mcExercise, wbExercise],
|
||||
intro: parts[index].part?.intro,
|
||||
category: parts[index].part?.category
|
||||
};
|
||||
|
||||
newParts = newParts.map((p, i) =>
|
||||
i === index
|
||||
? {
|
||||
...p,
|
||||
part: item,
|
||||
}
|
||||
...p,
|
||||
part: item,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
|
||||
@@ -435,7 +593,8 @@ const LevelGeneration = ({id}: Props) => {
|
||||
const exam = {
|
||||
...generatedExam,
|
||||
id,
|
||||
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
|
||||
label: label,
|
||||
parts: generatedExam.parts.map((p, i) => ({ ...p, exercises: parts[i].part!.exercises, category: parts[i].part?.category, intro: parts[i].part?.intro })),
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -466,7 +625,7 @@ const LevelGeneration = ({id}: Props) => {
|
||||
label: capitalize(x),
|
||||
}))}
|
||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
value={{ value: difficulty, label: capitalize(difficulty) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-1/3">
|
||||
@@ -484,12 +643,24 @@ const LevelGeneration = ({id}: Props) => {
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Label"
|
||||
name="label"
|
||||
onChange={(e) => setLabel(e)}
|
||||
roundness="xl"
|
||||
defaultValue={label}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
className={({selected}) =>
|
||||
className={({ selected }) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2",
|
||||
@@ -505,6 +676,8 @@ const LevelGeneration = ({id}: Props) => {
|
||||
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||
<TaskTab
|
||||
key={index}
|
||||
label={label}
|
||||
index={index}
|
||||
section={parts[index]}
|
||||
setSection={(part) => {
|
||||
console.log(part);
|
||||
|
||||
Reference in New Issue
Block a user