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

This commit is contained in:
Carlos Mesquita
2024-09-06 09:39:38 +01:00
parent b6b5f3a9f1
commit 77ac15c2bb
11 changed files with 577 additions and 212 deletions

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>