Merged in feature/level-file-upload (pull request #92)

ENCOA-182, ENCOA-185, ENCOA-177, ENCOA-168, ENCOA-186, ENCOA-176, ENCOA-189, ENCOA-167

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2024-09-06 08:53:29 +00:00
committed by Tiago Ribeiro
11 changed files with 577 additions and 212 deletions

View File

@@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
const nextConfig = {
reactStrictMode: true,
reactStrictMode: false,
output: "standalone",
async headers() {
return [

View File

@@ -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

View 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;

View File

@@ -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>

View File

@@ -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>
</>
);

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;
}
@@ -49,7 +64,15 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
}
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>

View File

@@ -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()}

View File

@@ -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";

View File

@@ -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);