Merged in feature/level-file-upload (pull request #92)

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

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2024-09-06 08:53:29 +00:00
committed by Tiago Ribeiro
11 changed files with 577 additions and 212 deletions

View File

@@ -15,31 +15,38 @@ import {
Exercise,
} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound";
import {Tab} from "@headlessui/react";
import { getExamById } from "@/utils/exams";
import { playSound } from "@/utils/sound";
import { Tab } from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
import { capitalize, sample } from "lodash";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { BsArrowRepeat, BsCheck, BsPencilSquare, BsX } from "react-icons/bs";
import reactStringReplace from "react-string-replace";
import {toast} from "react-toastify";
import {v4} from "uuid";
import { toast } from "react-toastify";
import { v4 } from "uuid";
interface Option {
[key: string]: any;
value: string | null;
label: string;
}
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const TYPES: {[key: string]: string} = {
const TYPES: { [key: string]: string } = {
multiple_choice_4: "Multiple Choice",
multiple_choice_blank_space: "Multiple Choice - Blank Space",
multiple_choice_underlined: "Multiple Choice - Underlined",
blank_space_text: "Blank Space",
reading_passage_utas: "Reading Passage",
fill_blanks_mc: "Multiple Choice - Fill Blanks",
};
type LevelSection = {type: string; quantity: number; topic?: string; part?: LevelPart};
type LevelSection = { type: string; quantity: number; topic?: string; part?: LevelPart };
const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => {
const QuestionDisplay = ({ question, onUpdate }: { question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void }) => {
const [isEditing, setIsEditing] = useState(false);
const [options, setOptions] = useState(question.options);
const [answer, setAnswer] = useState(question.solution);
@@ -70,7 +77,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
<input
defaultValue={option.text}
className="w-60"
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? {...x, text: e.target.value} : x)))}
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? { ...x, text: e.target.value } : x)))}
/>
) : (
<span>{option.text}</span>
@@ -90,7 +97,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
<>
<button
onClick={() => {
onUpdate({...question, options, solution: answer});
onUpdate({ ...question, options, solution: answer });
setIsEditing(false);
}}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
@@ -108,9 +115,16 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
);
};
const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (section: LevelSection) => void}) => {
const TaskTab = ({ section, label, index, setSection }: { section: LevelSection; label: string, index: number, setSection: (section: LevelSection) => void }) => {
const [isLoading, setIsLoading] = useState(false);
const [category, setCategory] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [customDescription, setCustomDescription] = useState<string>("");
const [previousOption, setPreviousOption] = useState<Option>({ value: "None", label: "None" });
const [descriptionOption, setDescriptionOption] = useState<Option>({ value: "None", label: "None" });
const [updateIntro, setUpdateIntro] = useState<boolean>(false);
const onUpdate = (question: MultipleChoiceQuestion) => {
if (!section) return;
@@ -124,6 +138,66 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
setSection(updatedExam as any);
};
const defaultPresets: any = {
multiple_choice_4: "Welcome to {part} of the {label}. In this section, you'll be asked to select the correct word or group of words that best completes each sentence.\n\nFor each question, carefully read the sentence and click on the option (A, B, C, or D) that you believe is correct. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your previous answers, you can go back at any time by clicking \"Back\".",
multiple_choice_blank_space: undefined,
multiple_choice_underlined: "Welcome to {part} of the {label}. In this section, you'll be asked to identify the underlined word or group of words that is not correct in each sentence.\n\nFor each question, carefully review the sentence and click on the option (A, B, C, or D) that you believe contains the incorrect word or group of words. After making your selection, you can proceed to the next question by clicking \"Next\". If needed, you can go back to previous questions by clicking \"Back\".",
blank_space_text: undefined,
reading_passage_utas: "Welcome to {part} of the {label}. In this section, you will read a text and answer the questions that follow.\n\nCarefully read the provided text, then select the correct answer (A, B, C, or D) for each question. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your answers, you can go back at any time by clicking \"Back\".",
fill_blanks_mc: "Welcome to {part} of the {label}. In this section, you will read a text and choose the correct word to fill in each blank space.\n\nFor each question, carefully read the text and click on the option (A, B, C, or D) that you believe best fits the context. Once you've made your choice, proceed to the next question by clicking \"Next\". If needed, you can go back to review or change your answers by clicking \"Back\"."
};
const getDefaultPreset = () => {
return defaultPresets[section.type] ? defaultPresets[section.type].replace('{part}', `Part ${index + 1}`).replace('{label}', label) :
"No default preset is yet available for this type of exercise."
}
useEffect(() => {
if (descriptionOption.value === "Default" && section?.type) {
setDescription(getDefaultPreset())
}
if (descriptionOption.value === "Custom" && customDescription !== "") {
setDescription(customDescription);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [descriptionOption, section?.type, label])
useEffect(() => {
if (section?.type) {
const defaultPreset = getDefaultPreset();
if (descriptionOption.value === "Default" && previousOption.value === "Default" && description !== defaultPreset) {
setDescriptionOption({ value: "Custom", label: "Custom" });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [descriptionOption, description, label])
useEffect(() => {
setPreviousOption(descriptionOption);
}, [descriptionOption])
useEffect(() => {
if (section.part && ((descriptionOption.value === "Custom" || descriptionOption.value === "Default") && !section.part.intro)) {
setUpdateIntro(true);
}
}, [section?.part, descriptionOption, category])
useEffect(() => {
if (updateIntro && section.part) {
setSection({
...section,
part: {
...section.part!,
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
category: category === "" ? undefined : category
}
})
setUpdateIntro(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateIntro, section?.part])
const renderExercise = (exercise: Exercise) => {
if (exercise.type === "multipleChoice")
return (
@@ -138,7 +212,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
updateExercise={(data: any) =>
setSection({
...section,
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
part: {
...section.part!,
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
category: category === "" ? undefined : category
}
})
}
/>
@@ -158,7 +237,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
updateExercise={(data: any) =>
setSection({
...section,
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
part: {
...section.part!,
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
category: category === "" ? undefined : category
}
})
}
/>
@@ -178,7 +262,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
updateExercise={(data: any) =>
setSection({
...section,
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
part: {
...section.part!,
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
category: category === "" ? undefined : category
},
})
}
/>
@@ -188,30 +277,61 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
return (
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-8">
<div className="flex flex-row w-full gap-4">
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Description</label>
<Select
options={["None", "Default", "Custom"].map((descriptionOption) => ({ value: descriptionOption, label: descriptionOption }))}
onChange={(o) => setDescriptionOption({ value: o!.value, label: o!.label })}
value={descriptionOption}
/>
</div>
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Category</label>
<Input
type="text"
placeholder="Category"
name="category"
onChange={(e) => setCategory(e)}
roundness="full"
defaultValue={category}
/>
</div>
</div>
{descriptionOption.value !== "None" && (
<Input
type="textarea"
placeholder="Part Description"
name="category"
onChange={(e) => { setDescription(e); setCustomDescription(e); }}
roundness="full"
value={descriptionOption.value === "Default" ? description : customDescription}
/>
)}
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
<Select
options={Object.keys(TYPES).map((key) => ({value: key, label: TYPES[key]}))}
onChange={(e) => setSection({...section, type: e!.value!})}
value={{value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"]}}
options={Object.keys(TYPES).map((key) => ({ value: key, label: TYPES[key] }))}
onChange={(e) => setSection({ ...section, type: e!.value! })}
value={{ value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"] }}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Number of Questions</label>
<label className="font-normal text-base text-mti-gray-dim">{section?.type && section.type === "fill_blanks_mc" ? "Number of Words" : "Number of Questions"}</label>
<Input
type="number"
name="Number of Questions"
onChange={(v) => setSection({...section, quantity: parseInt(v)})}
onChange={(v) => setSection({ ...section, quantity: parseInt(v) })}
value={section?.quantity || 10}
/>
</div>
</div>
{section?.type === "reading_passage_utas" && (
{section?.type === "reading_passage_utas" || section?.type === "fill_blanks_mc" && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
<Input type="text" name="Topic" onChange={(v) => setSection({...section, topic: v})} value={section?.topic} />
<Input type="text" name="Topic" onChange={(v) => setSection({ ...section, topic: v })} value={section?.topic} />
</div>
)}
</div>
@@ -235,18 +355,19 @@ interface Props {
id: string;
}
const LevelGeneration = ({id}: Props) => {
const LevelGeneration = ({ id }: Props) => {
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>();
const [timer, setTimer] = useState(10);
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const [numberOfParts, setNumberOfParts] = useState(1);
const [parts, setParts] = useState<LevelSection[]>([{quantity: 10, type: "multiple_choice_4"}]);
const [parts, setParts] = useState<LevelSection[]>([{ quantity: 10, type: "multiple_choice_4" }]);
const [isPrivate, setPrivate] = useState<boolean>(false);
const [label, setLabel] = useState<string>("Placement Test");
useEffect(() => {
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : {quantity: 10, type: "multiple_choice_4"})));
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : { quantity: 10, type: "multiple_choice_4" })));
}, [numberOfParts]);
const router = useRouter();
@@ -289,7 +410,7 @@ const LevelGeneration = ({id}: Props) => {
let newParts = [...parts];
axios
.post<{exercises: {[key: string]: any}}>("/api/exam/level/generate/level", {nr_exercises: numberOfParts, ...body})
.post<{ exercises: { [key: string]: any } }>("/api/exam/level/generate/level", { nr_exercises: numberOfParts, ...body })
.then((result) => {
console.log(result.data);
@@ -304,6 +425,7 @@ const LevelGeneration = ({id}: Props) => {
variant: "full",
isDiagnostic: false,
private: isPrivate,
label: label,
parts: parts
.map((part, index) => {
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
@@ -317,23 +439,55 @@ const LevelGeneration = ({id}: Props) => {
id: v4(),
prompt:
part.type === "multiple_choice_underlined"
? "Select the wrong part of the sentence."
: "Select the appropriate option.",
questions: currentExercise.questions.map((x: any) => ({...x, variant: "text"})),
? "Choose the underlined word or group of words that is not correct.\nFor each question, select your choice (A, B, C or D)."
: "Choose the correct word or group of words that completes the sentences below.\nFor each question, select the correct letter (A, B, C or D).",
questions: currentExercise.questions.map((x: any) => ({ ...x, variant: "text" })),
type: "multipleChoice",
userSolutions: [],
};
const item = {
exercises: [exercise],
intro: parts[index].part?.intro,
category: parts[index].part?.category
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
...p,
part: item,
}
: p,
);
return item;
}
if (part.type === "fill_blanks_mc") {
const exercise: FillBlanksExercise = {
id: v4(),
prompt: "Read the text below and choose the correct word for each space.\nFor each question, select your choice (A, B, C or D). ",
text: currentExercise.text,
words: currentExercise.words,
solutions: currentExercise.solutions,
type: "fillBlanks",
variant: "mc",
userSolutions: [],
};
const item = {
exercises: [exercise],
intro: parts[index].part?.intro,
category: parts[index].part?.category
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
: p,
);
@@ -346,21 +500,23 @@ const LevelGeneration = ({id}: Props) => {
prompt: "Complete the text below.",
text: currentExercise.text,
maxWords: 3,
solutions: currentExercise.words.map((x: any) => ({id: x.id, solution: [x.text]})),
solutions: currentExercise.words.map((x: any) => ({ id: x.id, solution: [x.text] })),
type: "writeBlanks",
userSolutions: [],
};
const item = {
exercises: [exercise],
intro: parts[index].part?.intro,
category: parts[index].part?.category
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
...p,
part: item,
}
: p,
);
@@ -370,7 +526,7 @@ const LevelGeneration = ({id}: Props) => {
const mcExercise: MultipleChoiceExercise = {
id: v4(),
prompt: "Select the appropriate option.",
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({...x, variant: "text"})),
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({ ...x, variant: "text" })),
type: "multipleChoice",
userSolutions: [],
};
@@ -391,14 +547,16 @@ const LevelGeneration = ({id}: Props) => {
const item = {
context: currentExercise.text.content,
exercises: [mcExercise, wbExercise],
intro: parts[index].part?.intro,
category: parts[index].part?.category
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
...p,
part: item,
}
: p,
);
@@ -435,7 +593,8 @@ const LevelGeneration = ({id}: Props) => {
const exam = {
...generatedExam,
id,
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
label: label,
parts: generatedExam.parts.map((p, i) => ({ ...p, exercises: parts[i].part!.exercises, category: parts[i].part?.category, intro: parts[i].part?.intro })),
};
axios
@@ -466,7 +625,7 @@ const LevelGeneration = ({id}: Props) => {
label: capitalize(x),
}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
value={{ value: difficulty, label: capitalize(difficulty) }}
/>
</div>
<div className="flex flex-col gap-3 w-1/3">
@@ -484,12 +643,24 @@ const LevelGeneration = ({id}: Props) => {
</Checkbox>
</div>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
<Input
type="text"
placeholder="Label"
name="label"
onChange={(e) => setLabel(e)}
roundness="xl"
defaultValue={label}
required
/>
</div>
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
<Tab
key={index}
className={({selected}) =>
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) => (
<TaskTab
key={index}
label={label}
index={index}
section={parts[index]}
setSection={(part) => {
console.log(part);