Merged in feature/ExamGenRework (pull request #122)

ENCOA-224, ENCOA-256: Added the import templates, Speaking didn't have the navbar yet, added multiple choice to reading since they've placed that in the import

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2024-12-04 09:16:53 +00:00
committed by Tiago Ribeiro
4 changed files with 292 additions and 26 deletions

View File

@@ -20,6 +20,8 @@ import {
FaQuestionCircle, FaQuestionCircle,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { ExerciseGen } from './generatedExercises'; import { ExerciseGen } from './generatedExercises';
import { MdRadioButtonChecked } from 'react-icons/md';
import { BsListCheck } from 'react-icons/bs';
const quantity = (quantity: number, tooltip?: string) => { const quantity = (quantity: number, tooltip?: string) => {
return { return {
@@ -39,6 +41,21 @@ const generate = () => {
const reading = (passage: number) => { const reading = (passage: number) => {
const readingExercises = [ const readingExercises = [
{
label: `Passage ${passage} - Multiple Choice`,
type: `reading_${passage}`,
icon: BsListCheck,
sectionId: passage,
extra: [
{
param: "name",
value: "multipleChoice"
},
quantity(5, "Quantity of Multiple Choice Questions"),
generate()
],
module: "reading"
},
{ {
label: `Passage ${passage} - Fill Blanks`, label: `Passage ${passage} - Fill Blanks`,
type: `reading_${passage}`, type: `reading_${passage}`,
@@ -110,7 +127,7 @@ const reading = (passage: number) => {
generate() generate()
], ],
module: "reading" module: "reading"
} },
]; ];
if (passage === 3) { if (passage === 3) {
@@ -153,7 +170,7 @@ const listening = (section: number) => {
module: "listening" module: "listening"
}, },
{ {
label: `Section ${section} - Write the Blanks: Questions`, label: `Section ${section} - Write Blanks: Questions`,
type: `listening_${section}`, type: `listening_${section}`,
icon: FaQuestionCircle, icon: FaQuestionCircle,
sectionId: section, sectionId: section,
@@ -187,7 +204,7 @@ const listening = (section: number) => {
if (section === 1 || section === 4) { if (section === 1 || section === 4) {
listeningExercises.push( listeningExercises.push(
{ {
label: `Section ${section} - Write the Blanks: Fill`, label: `Section ${section} - Write Blanks: Fill`,
type: `listening_${section}`, type: `listening_${section}`,
icon: FaEdit, icon: FaEdit,
sectionId: section, sectionId: section,
@@ -204,7 +221,7 @@ const listening = (section: number) => {
); );
listeningExercises.push( listeningExercises.push(
{ {
label: `Section ${section} - Write the Blanks: Form`, label: `Section ${section} - Write Blanks: Form`,
type: `listening_${section}`, type: `listening_${section}`,
sectionId: section, sectionId: section,
icon: FaWpforms, icon: FaWpforms,
@@ -239,7 +256,7 @@ const EXERCISES: ExerciseGen[] = [
module: "level" module: "level"
},*/ },*/
{ {
label: "Multiple Choice - Blank Space", label: "Multiple Choice: Blank Space",
type: "mcBlank", type: "mcBlank",
icon: FaEdit, icon: FaEdit,
extra: [ extra: [
@@ -253,7 +270,7 @@ const EXERCISES: ExerciseGen[] = [
module: "level" module: "level"
}, },
{ {
label: "Multiple Choice - Underlined", label: "Multiple Choice: Underlined",
type: "mcUnderline", type: "mcUnderline",
icon: FaUnderline, icon: FaUnderline,
extra: [ extra: [

View File

@@ -0,0 +1,213 @@
import Button from "@/components/Low/Button";
import { Module } from "@/interfaces";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import { capitalize } from "lodash";
import React, { Fragment, useCallback, useEffect, useState } from "react";
import { FaFileDownload } from "react-icons/fa";
import { HiOutlineDocumentText } from "react-icons/hi";
import { IoInformationCircleOutline } from "react-icons/io5";
interface Props {
module: Module;
state: { isOpen: boolean, type: "exam" | "solutions" };
setState: React.Dispatch<React.SetStateAction<{ isOpen: boolean, type: "exam" | "solutions" }>>;
}
const Templates: React.FC<Props> = ({ module, state, setState }) => {
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (state.isOpen) {
setMounted(true);
}
}, [state]);
useEffect(() => {
if (!state.isOpen && mounted) {
const timer = setTimeout(() => {
setMounted(false);
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}
}, [state, mounted]);
const blockMultipleClicksClose = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
setState({ isOpen: false, type: state.type });
const timer = setTimeout(() => {
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}, [isClosing, setState, state]);
if (!mounted && !state.isOpen) return null;
const moduleExercises = {
"reading": [
"Multiple Choice",
"Write Blanks",
"True False",
"Paragraph Match",
"Idea Match"
],
"listening": [
"Multiple Choice",
"True False",
"Write Blanks: Questions",
"Write Blanks: Fill",
"Write Blanks: Form",
],
"level": [
"Fill Blanks: Multiple Choice",
"Multiple Choice: Blank Space",
"Multiple Choice: Underline",
"Multiple Choice: Reading Passage"
],
"writing": [],
"speaking": [],
}
const handleTemplateDownload = () => {
const fileName = `${capitalize(module)}${state.type === "exam" ? "Exam" : "Solutions"}Template`;
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}.docx?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
const link = document.createElement('a');
link.href = url;
link.download = `${fileName}.docx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Transition
show={state.isOpen}
as={Fragment}
beforeEnter={() => setIsClosing(false)}
beforeLeave={() => setIsClosing(true)}
afterLeave={() => {
setIsClosing(false);
setMounted(false);
}}
>
<Dialog onClose={() => blockMultipleClicksClose()} className="relative z-50">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</TransitionChild>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className={`bg-ielts-${module}-light w-full max-w-6xl h-fit p-8 rounded-xl flex flex-col gap-4`}>
<DialogTitle className="flex font-bold text-xl justify-center text-gray-700"><span>{`${capitalize(module)} ${state.type === "exam" ? 'Exam' : 'Solutions'} Import`}</span></DialogTitle>
<div className="flex flex-col w-full mt-4 gap-6">
{state.type === "exam" ? (
<>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<HiOutlineDocumentText className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The {module} exam import accepts the following exercise types:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
{moduleExercises[module].map((item, index) => (
<li key={index} className="text-gray-700 list-disc">
{item}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The uploaded document must:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
be a Word .docx document.
</li>
<li className="text-gray-700 list-disc">
have clear part and exercise delineation (e.g. Part 1, ... , Part X, Question 1 - 10, ... , Question y - x).
</li>
{["reading", "level"].includes(module) && (
<li className="text-gray-700 list-disc">
a part must only contain a reading passage and it must be between the part delineator (e.g. Part 1) and the part exercises.
</li>
)}
<li className="text-gray-700 list-disc">
if solutions are going to be uploaded the exercise numbers/id&apos;s must match the ones in the solutions.
</li>
</ul>
</div>
</>
) :
<>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The uploaded document must:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
be a Word .docx document.
</li>
<li className="text-gray-700 list-disc">
match the exercise numbers/id&apos;s that are in the exam document.
</li>
</ul>
</div>
</>
}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-gray-600">
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling and formatting but it must adhere to the previous requirements${state.type === "exam" ? "and exercises of the same type should have the same formatting" : ""}.`}
</p>
</div>
<div className="w-full flex justify-between mt-4 gap-8">
<Button color="purple" onClick={() => blockMultipleClicksClose()} variant="outline" className="self-end w-full bg-white">
Close
</Button>
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
<div className="flex items-center gap-2">
<FaFileDownload size={24} />
Download Template
</div>
</Button>
</div>
</div>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</Transition>
);
}
export default Templates;

View File

@@ -8,6 +8,8 @@ import { toast } from 'react-toastify';
import useExamEditorStore from '@/stores/examEditor'; import useExamEditorStore from '@/stores/examEditor';
import { LevelPart, ListeningPart, ReadingPart } from '@/interfaces/exam'; import { LevelPart, ListeningPart, ReadingPart } from '@/interfaces/exam';
import { defaultSectionSettings } from '@/stores/examEditor/defaults'; import { defaultSectionSettings } from '@/stores/examEditor/defaults';
import Templates from './Templates';
import { IoInformationCircleOutline } from 'react-icons/io5';
const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: number) => void; }> = ({ module, setNumberOfLevelParts }) => { const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: number) => void; }> = ({ module, setNumberOfLevelParts }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
@@ -17,6 +19,7 @@ const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: nu
const [showUploaders, setShowUploaders] = useState(false); const [showUploaders, setShowUploaders] = useState(false);
const [examFile, setExamFile] = useState<File | null>(null); const [examFile, setExamFile] = useState<File | null>(null);
const [solutionsFile, setSolutionsFile] = useState<File | null>(null); const [solutionsFile, setSolutionsFile] = useState<File | null>(null);
const [templateState, setTemplateState] = useState<{ isOpen: boolean, type: "exam" | "solutions" }>({ isOpen: false, type: "exam" });
const handleExamChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleExamChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -78,7 +81,7 @@ const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: nu
if (module === "level") { if (module === "level") {
setNumberOfLevelParts(data.parts.length); setNumberOfLevelParts(data.parts.length);
} }
dispatch({ dispatch({
type: "UPDATE_MODULE", payload: { type: "UPDATE_MODULE", payload: {
updates: { updates: {
@@ -105,6 +108,7 @@ const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: nu
return ( return (
<> <>
<Templates module={module} state={templateState} setState={setTemplateState} />
{!showUploaders ? ( {!showUploaders ? (
<div <div
onClick={() => setShowUploaders(true)} onClick={() => setShowUploaders(true)}
@@ -146,7 +150,7 @@ const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: nu
<h3 className="font-semibold text-gray-700">Exam Document</h3> <h3 className="font-semibold text-gray-700">Exam Document</h3>
<p className="text-sm text-gray-500">Required</p> <p className="text-sm text-gray-500">Required</p>
</div> </div>
{examFile && ( {examFile ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FaCheckCircle className="w-6 h-6 text-green-500" /> <FaCheckCircle className="w-6 h-6 text-green-500" />
<button <button
@@ -159,6 +163,16 @@ const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: nu
<FaTimes className="w-4 h-4 text-green-600" /> <FaTimes className="w-4 h-4 text-green-600" />
</button> </button>
</div> </div>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setTemplateState({ isOpen: true, type: "exam" });
}}
className="p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
>
<IoInformationCircleOutline size={28} />
</button>
)} )}
</div> </div>
{examFile && ( {examFile && (
@@ -170,7 +184,7 @@ const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: nu
type="file" type="file"
ref={examInputRef} ref={examInputRef}
onChange={handleExamChange} onChange={handleExamChange}
accept=".doc,.docx" accept=".docx"
className="hidden" className="hidden"
/> />
</div> </div>
@@ -205,9 +219,20 @@ const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: nu
</button> </button>
</div> </div>
) : ( ) : (
<span className="text-xs text-gray-400 font-medium px-2 py-1 bg-gray-100 rounded"> <>
OPTIONAL <span className="text-xs text-gray-400 font-medium px-2 py-1 bg-gray-100 rounded">
</span> OPTIONAL
</span>
<button
onClick={(e) => {
e.stopPropagation();
setTemplateState({ isOpen: true, type: "solutions" });
}}
className="p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
>
<IoInformationCircleOutline size={28} />
</button>
</>
)} )}
</div> </div>
{solutionsFile && ( {solutionsFile && (
@@ -219,7 +244,7 @@ const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: nu
type="file" type="file"
ref={solutionsInputRef} ref={solutionsInputRef}
onChange={handleSolutionsChange} onChange={handleSolutionsChange}
accept=".doc,.docx" accept=".docx"
className="hidden" className="hidden"
/> />
</div> </div>

View File

@@ -12,6 +12,7 @@ import useExamTimer from "@/hooks/useExamTimer";
import useExamNavigation from "./Navigation/useExamNavigation"; import useExamNavigation from "./Navigation/useExamNavigation";
import ProgressButtons from "./components/ProgressButtons"; import ProgressButtons from "./components/ProgressButtons";
import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex"; import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex";
import SectionNavbar from "./Navigation/SectionNavbar";
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => { const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
@@ -36,7 +37,7 @@ const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = fal
const { const {
nextExercise, previousExercise, nextExercise, previousExercise,
showPartDivider, setShowPartDivider, showPartDivider, setShowPartDivider,
setSeenParts, setSeenParts, seenParts, setIsBetweenParts
} = useExamNavigation({ exam, module: "speaking", showSolutions, preview, disableBetweenParts: true }); } = useExamNavigation({ exam, module: "speaking", showSolutions, preview, disableBetweenParts: true });
useEffect(() => { useEffect(() => {
@@ -90,7 +91,7 @@ const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = fal
setSeenParts((prev) => new Set(prev).add(exerciseIndex)); setSeenParts((prev) => new Set(prev).add(exerciseIndex));
} }
const memoizedExerciseIndex = useMemo(() => const memoizedExerciseIndex = useMemo(() =>
calculateExerciseIndexSpeaking(exam, exerciseIndex, questionIndex) calculateExerciseIndexSpeaking(exam, exerciseIndex, questionIndex)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
, [exerciseIndex, questionIndex] , [exerciseIndex, questionIndex]
@@ -108,19 +109,29 @@ const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = fal
sectionIndex={exerciseIndex} sectionIndex={exerciseIndex}
onNext={handlePartDividerClick} onNext={handlePartDividerClick}
/> : ( /> : (
<div className="flex flex-col h-full w-full gap-8 items-center"> <>
<ModuleTitle {exam.exercises.length > 1 && <SectionNavbar
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={timer.current}
exerciseIndex={memoizedExerciseIndex}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} sectionLabel="Part"
disableTimer={showSolutions || preview} seenParts={seenParts}
setShowPartDivider={setShowPartDivider}
setSeenParts={setSeenParts}
preview={preview} preview={preview}
/> />}
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)} <div className="flex flex-col h-full w-full gap-8 items-center">
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)} <ModuleTitle
</div> label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={timer.current}
exerciseIndex={memoizedExerciseIndex}
module="speaking"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions || preview}
preview={preview}
/>
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
</div>
</>
)} )}
</> </>
); );