From 77ac15c2bb0334b1d4f3ced08d44b944aee30caf Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Fri, 6 Sep 2024 09:39:38 +0100 Subject: [PATCH] ENCOA-182, ENCOA-185, ENCOA-177, ENCOA-168, ENCOA-186, ENCOA-176, ENCOA-189, ENCOA-167 --- next.config.js | 2 +- .../DemographicInformationInput.tsx | 2 +- .../Exercises/FillBlanks/MCDropdown.tsx | 84 ++++++ src/components/Exercises/FillBlanks/index.tsx | 151 +++++----- .../Generation/fill.blanks.edit.tsx | 102 +++++-- src/components/Low/Input.tsx | 18 +- src/components/Medium/ModuleTitle.tsx | 4 +- src/exams/Level/TextComponent.tsx | 69 +++-- src/exams/Level/index.tsx | 89 ++++-- src/interfaces/exam.ts | 1 + src/pages/(generation)/LevelGeneration.tsx | 267 +++++++++++++++--- 11 files changed, 577 insertions(+), 212 deletions(-) create mode 100644 src/components/Exercises/FillBlanks/MCDropdown.tsx diff --git a/next.config.js b/next.config.js index cdba31d8..2203b000 100644 --- a/next.config.js +++ b/next.config.js @@ -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 [ diff --git a/src/components/DemographicInformationInput.tsx b/src/components/DemographicInformationInput.tsx index 1ba81b01..a042cf19 100644 --- a/src/components/DemographicInformationInput.tsx +++ b/src/components/DemographicInformationInput.tsx @@ -89,7 +89,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) { - setPhone(e)} placeholder="Enter phone number" required /> + setPhone(e)} value={phone} placeholder="Enter phone number" required /> {user.type === "student" && ( void; + selectedValue?: string; + className?: string; + width: number; + isOpen: boolean; + onToggle: (id: string) => void; +} + +const MCDropdown: React.FC = ({ + id, + options, + onSelect, + selectedValue, + className = "relative", + width, + isOpen, + onToggle, +}) => { + const contentRef = useRef(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 ( +
+ + +
+ {Object.entries(options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => ( +
{ + onSelect(value); + onToggle(id); + }} + className="p-4 hover:bg-mti-purple-ultralight cursor-pointer whitespace-nowrap" + > + {value} +
+ ))} +
+
+
+ ); +}; + +export default MCDropdown; \ No newline at end of file diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx index a2c0920d..db32c626 100644 --- a/src/components/Exercises/FillBlanks/index.tsx +++ b/src/components/Exercises/FillBlanks/index.tsx @@ -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 = ({ id, @@ -19,24 +20,19 @@ const FillBlanks: React.FC = ({ 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(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 = ({ 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 = ({ 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(null); + const renderLines = useCallback( (line: string) => { return ( -
+
{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" ? ( - <> - {/*{`(${id})`}*/} - - + 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)} + /> ) : ( 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} /> ); - })} -
+ }) + } +
); }, - [variant, words, setCurrentMCSelection, answers, currentMCSelection], + [variant, words, answers, openDropdownId], ); const memoizedLines = useMemo(() => { @@ -131,15 +139,15 @@ const FillBlanks: React.FC = ({

)); // 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 = ({ @@ -178,38 +186,7 @@ const FillBlanks: React.FC = ({ )} {memoizedLines} - {variant === "mc" && typeCheckWordsMC(words) ? ( - <> - {currentMCSelection && ( -
- {`${currentMCSelection.id} - Select the appropriate word.`} -
- {currentMCSelection.selection?.options && - Object.entries(currentMCSelection.selection.options) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, value]) => { - return ( -
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", - )}> - {key}. - {value} -
- ); - })} -
-
- )} - - ) : ( + {variant !== "mc" && (
Options
@@ -240,19 +217,19 @@ const FillBlanks: React.FC = ({
diff --git a/src/components/Generation/fill.blanks.edit.tsx b/src/components/Generation/fill.blanks.edit.tsx index 507f3e5d..e57cba3d 100644 --- a/src/components/Generation/fill.blanks.edit.tsx +++ b/src/components/Generation/fill.blanks.edit.tsx @@ -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 ( <> { } /> updateExercise({ - text: value, + text: exercise?.variant && exercise.variant === "mc" ? value : value, }) } /> -

Solutions

+

Solutions

{exercise.solutions.map((solution, index) => (
@@ -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)), }) } />
))}
-

Words

-
- {exercise.words.map((word, index) => ( -
- - updateExercise({ - words: exercise.words.map((sol, idx) => - index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol, - ), - }) - } - /> -
- ))} +

Words

+
+ {exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ? + ( +
+ {exercise.words.flatMap((mcOptions, wordIndex) => + <> + +
+ {Object.entries(mcOptions.options).map(([key, value], optionIndex) => ( +
+ + updateExercise({ + words: exercise.words.map((word, idx) => + idx === wordIndex + ? { + ...(word as FillBlanksMCOption), + options: { + ...(word as FillBlanksMCOption).options, + [key]: newValue + } + } + : word + ) + }) + } + /> +
+ ))} +
+ + )} +
+ ) + : + ( + exercise.words.map((word, index) => ( +
+ + updateExercise({ + words: exercise.words.map((sol, idx) => + index === idx ? (typeof word === "string" ? value : { ...word, word: value }) : sol, + ), + }) + } + /> +
+ )) + ) + }
); diff --git a/src/components/Low/Input.tsx b/src/components/Low/Input.tsx index f5e3cbc1..ae1b8710 100644 --- a/src/components/Low/Input.tsx +++ b/src/components/Low/Input.tsx @@ -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 ( +