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 (
+
+
+
+ setLabel(e)}
+ roundness="xl"
+ defaultValue={label}
+ required
+ />
+
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
+ 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) => (
{
console.log(part);