diff --git a/src/components/Low/Checkbox.tsx b/src/components/Low/Checkbox.tsx index d5a7298d..fa90951f 100644 --- a/src/components/Low/Checkbox.tsx +++ b/src/components/Low/Checkbox.tsx @@ -11,14 +11,16 @@ interface Props { export default function Checkbox({isChecked, onChange, children, disabled}: Props) { return ( -
{ - if(disabled) return; - onChange(!isChecked); - }}> +
{ + if (disabled) return; + onChange(!isChecked); + }}>
diff --git a/src/components/Medium/StatGridItem.tsx b/src/components/Medium/StatGridItem.tsx index 7d0edfcd..3470b20c 100644 --- a/src/components/Medium/StatGridItem.tsx +++ b/src/components/Medium/StatGridItem.tsx @@ -4,17 +4,17 @@ import clsx from "clsx"; import {Stat, User} from "@/interfaces/user"; import {Module} from "@/interfaces"; import ai_usage from "@/utils/ai.detection"; -import { calculateBandScore } from "@/utils/score"; -import moment from 'moment'; -import { Assignment } from '@/interfaces/results'; -import { uuidv4 } from "@firebase/util"; -import { useRouter } from "next/router"; -import { uniqBy } from "lodash"; -import { sortByModule } from "@/utils/moduleUtils"; -import { convertToUserSolutions } from "@/utils/stats"; -import { getExamById } from "@/utils/exams"; -import { Exam, UserSolution } from '@/interfaces/exam'; -import ModuleBadge from '../ModuleBadge'; +import {calculateBandScore} from "@/utils/score"; +import moment from "moment"; +import {Assignment} from "@/interfaces/results"; +import {uuidv4} from "@firebase/util"; +import {useRouter} from "next/router"; +import {uniqBy} from "lodash"; +import {sortByModule} from "@/utils/moduleUtils"; +import {convertToUserSolutions} from "@/utils/stats"; +import {getExamById} from "@/utils/exams"; +import {Exam, UserSolution} from "@/interfaces/exam"; +import ModuleBadge from "../ModuleBadge"; const formatTimestamp = (timestamp: string | number) => { const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp; @@ -262,7 +262,7 @@ const StatsGridItem: React.FC = ({ key={uuidv4()} className={clsx( "flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden", - isDisabled && "grayscale tooltip", + (isDisabled || (!!assignment && !assignment.released)) && "grayscale tooltip", correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", correct / total < 0.3 && "hover:border-mti-rose", @@ -276,7 +276,7 @@ const StatsGridItem: React.FC = ({ ...(width !== undefined && {width}), ...(height !== undefined && {height}), }} - data-tip="This exam is still being evaluated..." + data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."} role="button"> {content}
diff --git a/src/dashboards/AssignmentCard.tsx b/src/dashboards/AssignmentCard.tsx index 6466a335..d3243656 100644 --- a/src/dashboards/AssignmentCard.tsx +++ b/src/dashboards/AssignmentCard.tsx @@ -62,6 +62,30 @@ export default function AssignmentCard({ return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length; }; + const uniqModules = uniqBy(exams, (x) => x.module); + + const shouldRenderPDF = () => { + if(released && allowDownload) { + // in order to be downloadable, the assignment has to be released + // the component should have the allowDownload prop + // and the assignment should not have the level module + return uniqModules.every(({ module }) => module !== 'level'); + } + + return false; + } + + const shouldRenderExcel = () => { + if(released && allowExcelDownload) { + // in order to be downloadable, the assignment has to be released + // the component should have the allowExcelDownload prop + // and the assignment should have the level module + return uniqModules.some(({ module }) => module === 'level'); + } + + return false; + } + return (

{name}

- {allowDownload && released && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} - {allowExcelDownload && released && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} + {shouldRenderPDF() && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} + {shouldRenderExcel() && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} {allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")} {allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")} {!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")} @@ -94,7 +118,7 @@ export default function AssignmentCard({ Assigner: {getUserName(users.find((x) => x.id === assigner))}
- {uniqBy(exams, (x) => x.module).map(({module}) => ( + {uniqModules.map(({module}) => (
{ + if (assignment) { + axios + .post(`/api/assignments/${assignment.id}/start`) + .then(() => { + toast.success(`The assignment "${assignment.name}" has been started successfully!`); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }); + } + }; + const formatTimestamp = (timestamp: string) => { const date = moment(parseInt(timestamp)); const formatter = "YYYY/MM/DD - HH:mm"; @@ -301,6 +315,11 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) { Delete )} + {assignment && (assignment.results.length === 0 || moment().isAfter(moment(assignment.startDate))) && ( + + )} diff --git a/src/exams/Finish.tsx b/src/exams/Finish.tsx index 7ce9396c..56ce9a07 100644 --- a/src/exams/Finish.tsx +++ b/src/exams/Finish.tsx @@ -11,6 +11,7 @@ import {useRouter} from "next/router"; import {Fragment, useEffect, useState} from "react"; import { BsArrowCounterclockwise, + BsBan, BsBook, BsClipboard, BsClipboardFill, @@ -27,6 +28,7 @@ import Modal from "@/components/Modal"; import {UserSolution} from "@/interfaces/exam"; import ai_usage from "@/utils/ai.detection"; import useGradingSystem from "@/hooks/useGrading"; +import {Assignment} from "@/interfaces/results"; interface Score { module: Module; @@ -45,10 +47,11 @@ interface Props { }; solutions: UserSolution[]; isLoading: boolean; + assignment?: Assignment; onViewResults: (moduleIndex?: number) => void; } -export default function Finish({user, scores, modules, information, solutions, isLoading, onViewResults}: Props) { +export default function Finish({user, scores, modules, information, solutions, isLoading, assignment, onViewResults}: Props) { const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedScore, setSelectedScore] = useState(scores.find((x) => x.module === modules[0])!); const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false); @@ -209,7 +212,18 @@ export default function Finish({user, scores, modules, information, solutions, i
)} - {!isLoading && ( + {assignment && !assignment.released && ( +
+ {/* */} + + + This exam has not yet been released by its assigner. +
+ You can check it later on your records page when it is released! +
+
+ )} + {!isLoading && false && (
{moduleResultText(selectedModule, bandScore)}
diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index fe919b0b..fa2a7384 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -1,4 +1,4 @@ -import { Module } from "."; +import {Module} from "."; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Variant = "full" | "partial"; @@ -15,6 +15,7 @@ interface ExamBase { shuffle?: boolean; createdBy?: string; // option as it has been added later createdAt?: string; // option as it has been added later + private?: boolean; } export interface ReadingExam extends ExamBase { module: "reading"; @@ -67,7 +68,7 @@ export interface UserSolution { }; exercise: string; isDisabled?: boolean; - shuffleMaps?: ShuffleMap[] + shuffleMaps?: ShuffleMap[]; } export interface WritingExam extends ExamBase { @@ -99,24 +100,19 @@ export type Exercise = export interface Evaluation { comment: string; overall: number; - task_response: { [key: string]: number | { grade: number; comment: string } }; - misspelled_pairs?: { correction: string | null; misspelled: string }[]; + task_response: {[key: string]: number | {grade: number; comment: string}}; + misspelled_pairs?: {correction: string | null; misspelled: string}[]; } - type InteractivePerfectAnswerKey = `perfect_answer_${number}`; type InteractiveTranscriptKey = `transcript_${number}`; type InteractiveFixedTextKey = `fixed_text_${number}`; -type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { answer: string } }; -type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string }; -type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string }; - -interface InteractiveSpeakingEvaluation extends Evaluation, - InteractivePerfectAnswerType, - InteractiveTranscriptType, - InteractiveFixedTextType { } +type InteractivePerfectAnswerType = {[key in InteractivePerfectAnswerKey]: {answer: string}}; +type InteractiveTranscriptType = {[key in InteractiveTranscriptKey]?: string}; +type InteractiveFixedTextType = {[key in InteractiveFixedTextKey]?: string}; +interface InteractiveSpeakingEvaluation extends Evaluation, InteractivePerfectAnswerType, InteractiveTranscriptType, InteractiveFixedTextType {} interface SpeakingEvaluation extends CommonEvaluation { perfect_answer_1?: string; @@ -189,10 +185,10 @@ export interface InteractiveSpeakingExercise { first_title?: string; second_title?: string; text: string; - prompts: { text: string; video_url: string }[]; + prompts: {text: string; video_url: string}[]; userSolutions: { id: string; - solution: { questionIndex: number; question: string; answer: string }[]; + solution: {questionIndex: number; question: string; answer: string}[]; evaluation?: InteractiveSpeakingEvaluation; }[]; topic?: string; @@ -208,14 +204,14 @@ export interface FillBlanksMCOption { B: string; C: string; D: string; - } + }; } export interface FillBlanksExercise { prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." type: "fillBlanks"; id: string; - words: (string | { letter: string; word: string } | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"] + words: (string | {letter: string; word: string} | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"] text: string; // *EXAMPLE: "They tried to {{1}} burning" allowRepetition?: boolean; solutions: { @@ -234,7 +230,7 @@ export interface TrueFalseExercise { id: string; prompt: string; // *EXAMPLE: "Select the appropriate option." questions: TrueFalseQuestion[]; - userSolutions: { id: string; solution: "true" | "false" | "not_given" }[]; + userSolutions: {id: string; solution: "true" | "false" | "not_given"}[]; } export interface TrueFalseQuestion { @@ -263,7 +259,7 @@ export interface MatchSentencesExercise { type: "matchSentences"; id: string; prompt: string; - userSolutions: { question: string; option: string }[]; + userSolutions: {question: string; option: string}[]; sentences: MatchSentenceExerciseSentence[]; allowRepetition: boolean; options: MatchSentenceExerciseOption[]; @@ -286,7 +282,7 @@ export interface MultipleChoiceExercise { id: string; prompt: string; // *EXAMPLE: "Select the appropriate option." questions: MultipleChoiceQuestion[]; - userSolutions: { question: string; option: string }[]; + userSolutions: {question: string; option: string}[]; } export interface MultipleChoiceQuestion { @@ -306,10 +302,10 @@ export interface ShuffleMap { questionID: string; map: { [key: string]: string; - } + }; } export interface Shuffles { exerciseID: string; - shuffles: ShuffleMap[] + shuffles: ShuffleMap[]; } diff --git a/src/interfaces/results.ts b/src/interfaces/results.ts index dcb04183..f14c0481 100644 --- a/src/interfaces/results.ts +++ b/src/interfaces/results.ts @@ -33,4 +33,4 @@ export interface Assignment { start?: boolean; } -export type AssignmentWithCorporateId = Assignment & { corporateId: string }; +export type AssignmentWithCorporateId = Assignment & {corporateId: string}; diff --git a/src/pages/(admin)/Lists/ExamList.tsx b/src/pages/(admin)/Lists/ExamList.tsx index 93ffb05c..2c990677 100644 --- a/src/pages/(admin)/Lists/ExamList.tsx +++ b/src/pages/(admin)/Lists/ExamList.tsx @@ -13,7 +13,7 @@ import axios from "axios"; import clsx from "clsx"; import {capitalize} from "lodash"; import {useRouter} from "next/router"; -import {BsCheck, BsTrash, BsUpload} from "react-icons/bs"; +import {BsBan, BsBanFill, BsCheck, BsCircle, BsStop, BsTrash, BsUpload, BsX} from "react-icons/bs"; import {toast} from "react-toastify"; import {useListSearch} from "@/hooks/useListSearch"; @@ -72,6 +72,28 @@ export default function ExamList({user}: {user: User}) { router.push("/exercises"); }; + const privatizeExam = async (exam: Exam) => { + if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return; + + axios + .patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private}) + .then(() => toast.success(`Updated the "${exam.id}" exam`)) + .catch((reason) => { + if (reason.response.status === 404) { + toast.error("Exam not found!"); + return; + } + + if (reason.response.status === 403) { + toast.error("You do not have permission to update this exam!"); + return; + } + + toast.error("Something went wrong, please try again later."); + }) + .finally(reload); + }; + const deleteExam = async (exam: Exam) => { if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return; @@ -119,6 +141,10 @@ export default function ExamList({user}: {user: User}) { header: "Timer", cell: (info) => <>{info.getValue()} minute(s), }), + columnHelper.accessor("private", { + header: "Private", + cell: (info) => {!info.getValue() ? : }, + }), columnHelper.accessor("createdAt", { header: "Created At", cell: (info) => { @@ -140,12 +166,18 @@ export default function ExamList({user}: {user: User}) { cell: ({row}: {row: {original: Exam}}) => { return (
-
await privatizeExam(row.original)} + className="cursor-pointer tooltip"> + {row.original.private ? : } + +
+ {PERMISSIONS.examManagement.delete.includes(user.type) && (
deleteExam(row.original)}> diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 7b0169e0..ad18ed05 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -459,6 +459,7 @@ export default function ExamPage({page}: Props) { user={user!} modules={selectedModules} solutions={userSolutions} + assignment={assignment} information={{ timeSpent, inactivity: totalInactivity, diff --git a/src/pages/(generation)/LevelGeneration.tsx b/src/pages/(generation)/LevelGeneration.tsx index 09b1615a..480ca61a 100644 --- a/src/pages/(generation)/LevelGeneration.tsx +++ b/src/pages/(generation)/LevelGeneration.tsx @@ -1,6 +1,7 @@ import FillBlanksEdit from "@/components/Generation/fill.blanks.edit"; import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit"; import WriteBlankEdits from "@/components/Generation/write.blanks.edit"; +import Checkbox from "@/components/Low/Checkbox"; import Input from "@/components/Low/Input"; import Select from "@/components/Low/Select"; import { @@ -234,7 +235,7 @@ interface Props { id: string; } -const LevelGeneration = ({ id } : Props) => { +const LevelGeneration = ({id}: Props) => { const [generatedExam, setGeneratedExam] = useState(); const [isLoading, setIsLoading] = useState(false); const [resultingExam, setResultingExam] = useState(); @@ -242,6 +243,7 @@ const LevelGeneration = ({ id } : Props) => { const [difficulty, setDifficulty] = useState(sample(DIFFICULTIES)!); const [numberOfParts, setNumberOfParts] = useState(1); const [parts, setParts] = useState([{quantity: 10, type: "multiple_choice_4"}]); + const [isPrivate, setPrivate] = useState(false); useEffect(() => { setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : {quantity: 10, type: "multiple_choice_4"}))); @@ -301,6 +303,7 @@ const LevelGeneration = ({ id } : Props) => { difficulty, variant: "full", isDiagnostic: false, + private: isPrivate, parts: parts .map((part, index) => { const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any; @@ -424,7 +427,7 @@ const LevelGeneration = ({ id } : Props) => { return; } - if(!id) { + if (!id) { toast.error("Please insert a title before submitting"); return; } @@ -456,8 +459,8 @@ const LevelGeneration = ({ id } : Props) => { return ( <> -
-
+
+
setNumberOfParts(parseInt(v))} value={numberOfParts} />
-
+
setTimer(parseInt(v))} value={timer} />
+
+
+ + Privacy (Only available for Assignments) + +
diff --git a/src/pages/(generation)/ListeningGeneration.tsx b/src/pages/(generation)/ListeningGeneration.tsx index 5388b183..ef17c4ca 100644 --- a/src/pages/(generation)/ListeningGeneration.tsx +++ b/src/pages/(generation)/ListeningGeneration.tsx @@ -16,6 +16,7 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {toast} from "react-toastify"; import WriteBlanksEdit from "@/components/Generation/write.blanks.edit"; import {generate} from "random-words"; +import Checkbox from "@/components/Low/Checkbox"; const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; @@ -232,7 +233,7 @@ interface Props { id: string; } -const ListeningGeneration = ({ id } : Props) => { +const ListeningGeneration = ({id}: Props) => { const [part1, setPart1] = useState(); const [part2, setPart2] = useState(); const [part3, setPart3] = useState(); @@ -241,6 +242,7 @@ const ListeningGeneration = ({ id } : Props) => { const [isLoading, setIsLoading] = useState(false); const [resultingExam, setResultingExam] = useState(); const [difficulty, setDifficulty] = useState(sample(DIFFICULTIES)!); + const [isPrivate, setPrivate] = useState(false); useEffect(() => { const part1Timer = part1 ? 5 : 0; @@ -262,11 +264,11 @@ const ListeningGeneration = ({ id } : Props) => { console.log({parts}); if (parts.length === 0) return toast.error("Please generate at least one section!"); - if(!id) { + if (!id) { toast.error("Please insert a title before submitting"); return; } - + setIsLoading(true); axios @@ -275,6 +277,7 @@ const ListeningGeneration = ({ id } : Props) => { parts, minTimer, difficulty, + private: isPrivate, }) .then((result) => { playSound("sent"); @@ -313,7 +316,7 @@ const ListeningGeneration = ({ id } : Props) => { return ( <> -
+
{ className="max-w-[300px]" />
-
+
{ className="max-w-[300px]" />
-
+
{ className="max-w-[300px]" />
-
+
{ className="max-w-[300px]" />
-
+