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

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