ENCOA-107, ENCOA-115 Fixed completion percentage, brought back the line numbers, 'Level Exam' was replaced by 'Placement Test', Next/Back instructions with quotes, 'Submit' on last level question

This commit is contained in:
Carlos Mesquita
2024-08-27 17:08:33 +01:00
parent c464375414
commit 845bccbe2a
7 changed files with 128 additions and 120 deletions

View File

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

View File

@@ -55,7 +55,6 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false; if (!solution) return false;
const option = correctWords!.find((w: any) => { const option = correctWords!.find((w: any) => {
console.log(w);
if (typeof w === "string") { if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase(); return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) { } else if ('letter' in w) {
@@ -85,7 +84,8 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
const id = match.replaceAll(/[\{\}]/g, ""); const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id); const userSolution = answers.find((x) => x.id === id);
const styles = clsx( const styles = clsx(
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center", "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",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight", !userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight", userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
) )
@@ -122,7 +122,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
})} })}
</div> </div>
); );
}, [variant, words, setCurrentMCSelection, answers]); }, [variant, words, setCurrentMCSelection, answers, currentMCSelection]);
const memoizedLines = useMemo(() => { const memoizedLines = useMemo(() => {
return text.split("\\n").map((line, index) => ( return text.split("\\n").map((line, index) => (
@@ -131,7 +131,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
<br /> <br />
</p> </p>
)); ));
}, [text, variant, renderLines]); }, [text, variant, renderLines, currentMCSelection]);
const onSelection = (questionID: string, value: string) => { const onSelection = (questionID: string, value: string) => {
@@ -139,10 +139,9 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
} }
useEffect(() => { useEffect(() => {
//if (variant === "mc") { if (variant === "mc") {
console.log(answers);
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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]) }, [answers])

View File

@@ -76,6 +76,7 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
const { const {
questionIndex, questionIndex,
exerciseIndex,
exam, exam,
shuffles, shuffles,
hasExamEnded, hasExamEnded,
@@ -143,7 +144,7 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
} }
return isSolutionCorrect || false; return isSolutionCorrect || false;
}).length; }).length;
const missing = total - correct; const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
return { total, correct, missing }; return { total, correct, missing };
}; };
@@ -193,7 +194,12 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
</Button> </Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next {
exam && exam.module === "level" &&
partIndex === exam.parts.length - 1 &&
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
questionIndex === questions.length - 1
? "Submit" : "Next"}
</Button> </Button>
</div> </div>
</> </>

View File

@@ -12,6 +12,7 @@ import Button from "../Low/Button";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import Modal from "../Modal"; import Modal from "../Modal";
import React from "react";
interface Props { interface Props {
minTimer: number; minTimer: number;
@@ -126,25 +127,26 @@ export default function ModuleTitle({
</> </>
); );
}; };
return ( return (
<> <>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />} {showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
<div className="w-full"> <div className="w-full">
{partLabel && ( {partLabel && (
<div className="text-3xl space-y-4"> <div className="text-3xl space-y-4">
{partLabel.split("\n\n").map((line, index) => { {partLabel.split("\n\n").map((partInstructions, index) => {
if (index == 0) if (index === 0)
return ( return (
<p key={index} className="font-bold"> <p key={index} className="font-bold">
{line} {partInstructions}
</p> </p>
); );
else else
return ( return (
<p key={index} className="text-2xl font-semibold"> <div key={index} className="text-2xl font-semibold flex flex-col gap-2">
{line} {partInstructions.split("\\n").map((line, lineIndex) => (
</p> <span key={lineIndex}>{line}</span>
))}
</div>
); );
})} })}
</div> </div>
@@ -154,7 +156,10 @@ export default function ModuleTitle({
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">
<span className="text-base font-semibold"> <span className="text-base font-semibold">
{moduleLabels[module]} exam {label && `- ${label}`} {module === "level"
? "Placement Test"
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
}
</span> </span>
<span className="text-sm font-semibold self-end"> <span className="text-sm font-semibold self-end">
Question {exerciseIndex}/{totalExercises} Question {exerciseIndex}/{totalExercises}

View File

@@ -2,7 +2,6 @@
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam"; import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import { CommonProps } from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
@@ -101,7 +100,7 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
} }
}, },
).length; ).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; const missing = total - userSolutions.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
return { total, correct, missing }; return { total, correct, missing };
}; };

View File

@@ -1,5 +1,5 @@
import { LevelPart } from "@/interfaces/exam"; import { LevelPart } from "@/interfaces/exam";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
interface Props { interface Props {
part: LevelPart, part: LevelPart,
@@ -9,78 +9,65 @@ interface Props {
const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine }) => { const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine }) => {
const textRef = useRef<HTMLDivElement>(null); const textRef = useRef<HTMLDivElement>(null);
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
const [lineHeight, setLineHeight] = useState<number>(0);
const calculateLineNumbers = () => { const calculateLineNumbers = () => {
if (textRef.current) { if (textRef.current) {
const computedStyle = window.getComputedStyle(textRef.current); const computedStyle = window.getComputedStyle(textRef.current);
const lineHeightValue = parseFloat(computedStyle.lineHeight);
const containerWidth = textRef.current.clientWidth; const containerWidth = textRef.current.clientWidth;
setLineHeight(lineHeightValue);
const offscreenElement = document.createElement('div'); const offscreenElement = document.createElement('div');
offscreenElement.style.position = 'absolute'; offscreenElement.style.position = 'absolute';
offscreenElement.style.top = '-9999px'; offscreenElement.style.top = '-9999px';
offscreenElement.style.left = '-9999px'; offscreenElement.style.left = '-9999px';
offscreenElement.style.whiteSpace = 'pre-wrap';
offscreenElement.style.width = `${containerWidth}px`; offscreenElement.style.width = `${containerWidth}px`;
offscreenElement.style.font = computedStyle.font; offscreenElement.style.font = computedStyle.font;
offscreenElement.style.lineHeight = computedStyle.lineHeight; offscreenElement.style.lineHeight = computedStyle.lineHeight;
offscreenElement.style.whiteSpace = 'pre-wrap';
offscreenElement.style.wordWrap = 'break-word'; offscreenElement.style.wordWrap = 'break-word';
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign; offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
const paragraphs = part.context!.split('\n\n'); const textContent = textRef.current.textContent || '';
let currentLine = 1; textContent.split(/(\s+)/).forEach((word: string) => {
let contextWordLine: number | null = null;
const paragraphLineStarts: number[] = [];
paragraphs.forEach((paragraph, pIndex) => {
const p = document.createElement('p');
p.style.margin = '0';
p.style.padding = '0';
paragraph.split(/(\s+)/).forEach((word: string) => {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = word; span.textContent = word;
p.appendChild(span); offscreenElement.appendChild(span);
});
offscreenElement.appendChild(p);
if (pIndex < paragraphs.length - 1) {
const gap = document.createElement('div');
gap.style.height = '16px'; // gap-4
offscreenElement.appendChild(gap);
}
}); });
document.body.appendChild(offscreenElement); document.body.appendChild(offscreenElement);
const lines: string[][] = [[]];
let currentLine = 1;
let currentLineTop: number | undefined; let currentLineTop: number | undefined;
const elements = offscreenElement.querySelectorAll('p, div'); let contextWordLine: number | null = null;
elements.forEach((element) => { const firstChild = offscreenElement.firstChild as HTMLElement;
if (element.tagName === 'P') { if (firstChild) {
const spans = element.querySelectorAll<HTMLSpanElement>('span'); currentLineTop = firstChild.getBoundingClientRect().top;
paragraphLineStarts.push(currentLine); }
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span');
spans.forEach(span => { spans.forEach(span => {
const rect = span.getBoundingClientRect(); const rect = span.getBoundingClientRect();
const top = rect.top; const top = rect.top;
if (currentLineTop === undefined || top > currentLineTop) { if (currentLineTop !== undefined && top > currentLineTop) {
if (currentLineTop !== undefined) {
currentLine++; currentLine++;
}
currentLineTop = top; currentLineTop = top;
lines.push([]);
} }
lines[lines.length - 1].push(span.textContent?.trim() || '');
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) { if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
contextWordLine = currentLine; contextWordLine = currentLine;
} }
}); });
} else if (element.tagName === 'DIV') { // Gap setLineNumbers(lines.map((_, index) => index + 1));
currentLine++;
currentLineTop = undefined;
}
});
if (contextWordLine) { if (contextWordLine) {
setContextWordLine(contextWordLine); setContextWordLine(contextWordLine);
} }
@@ -131,10 +118,15 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
return ( return (
<div className="flex mt-2"> <div className="flex mt-2">
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4"> <div className="flex-shrink-0 w-8 pr-2">
{part.context!.split('\n\n').map((line, index) => { {lineNumbers.map(num => (
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p> <div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
})} {num}
</div>
))}
</div>
<div ref={textRef} className="h-fit whitespace-pre-wrap ml-2">
<div dangerouslySetInnerHTML={{ __html: part.context! }} />
</div> </div>
</div> </div>
); );

View File

@@ -8,7 +8,7 @@ import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, Multip
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import { use, useEffect, useState } from "react"; import { use, useEffect, useMemo, useState } from "react";
import TextComponent from "./TextComponent"; import TextComponent from "./TextComponent";
import PartDivider from "./PartDivider"; import PartDivider from "./PartDivider";
import Timer from "@/components/Medium/Timer"; import Timer from "@/components/Medium/Timer";
@@ -56,6 +56,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const [showQuestionsModal, setShowQuestionsModal] = useState(false); const [showQuestionsModal, setShowQuestionsModal] = useState(false);
const [continueAnyways, setContinueAnyways] = useState(false); const [continueAnyways, setContinueAnyways] = useState(false);
const [textRender, setTextRender] = useState(false); const [textRender, setTextRender] = useState(false);
const [changedPrompt, setChangedPrompt] = useState(false);
const [seenParts, setSeenParts] = useState<number[]>(showSolutions ? exam.parts.map((_, index) => index) : [0]); const [seenParts, setSeenParts] = useState<number[]>(showSolutions ? exam.parts.map((_, index) => index) : [0]);
@@ -66,8 +67,6 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } } onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
}); });
const [currentExercise, setCurrentExercise] = useState<Exercise>(exam.parts[0].exercises[0]); const [currentExercise, setCurrentExercise] = useState<Exercise>(exam.parts[0].exercises[0]);
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions); const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
@@ -120,37 +119,6 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
}, [partIndex, exerciseIndex, questionIndex]); }, [partIndex, exerciseIndex, questionIndex]);
useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
if (
exerciseIndex !== -1 && currentExercise &&
currentExercise.type === "multipleChoice" &&
currentExercise.questions[questionIndex] &&
currentExercise.questions[questionIndex].prompt &&
exam.parts[partIndex].context
) {
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;
} else {
setContextWord(undefined);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, questionIndex]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
@@ -341,6 +309,38 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
}); });
} }
useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
if (
exerciseIndex !== -1 && currentExercise &&
currentExercise.type === "multipleChoice" &&
currentExercise.questions[questionIndex] &&
currentExercise.questions[questionIndex].prompt &&
exam.parts[partIndex].context
) {
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;
setChangedPrompt(true);
} else {
setContextWord(undefined);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, questionIndex, contextWordLine]);
useEffect(() => { useEffect(() => {
if (continueAnyways) { if (continueAnyways) {
setContinueAnyways(false); setContinueAnyways(false);
@@ -375,6 +375,24 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
} }
const memoizedRender = useMemo(() => {
setChangedPrompt(false);
return (
<>
{textRender ?
renderText() :
<>
{exam.parts[partIndex].context && renderText()}
{(showSolutions || editing) ?
renderSolution(currentExercise, nextExercise, previousExercise)
:
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)
}
</>
}
</>)
}, [textRender, currentExercise, changedPrompt]);
return ( return (
<> <>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}> <div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
@@ -429,18 +447,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
"mb-20 w-full", "mb-20 w-full",
!!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4", !!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
)}> )}>
{memoizedRender}
{textRender ?
renderText() :
<>
{exam.parts[partIndex].context && renderText()}
{(showSolutions || editing) ?
renderSolution(currentExercise, nextExercise, previousExercise)
:
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)
}
</>
}
</div> </div>
{/*exerciseIndex === -1 && partIndex > 0 && ( {/*exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">