From cbe353c2c531a2942ddac8f781e5a43c4503468f Mon Sep 17 00:00:00 2001 From: Joao Correia Date: Sat, 8 Feb 2025 15:26:16 +0000 Subject: [PATCH 01/13] - start only one of the configured workflows (randomly at the moment) for the exam Author. - skip approval process for admins --- src/lib/createWorkflowsOnExamCreation.ts | 49 +++++++++++++++++++++++- src/pages/api/exam/[module]/index.ts | 34 +++++++++------- src/utils/approval.workflows.be.ts | 6 +-- 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/lib/createWorkflowsOnExamCreation.ts b/src/lib/createWorkflowsOnExamCreation.ts index 732a1f18..f72d9db7 100644 --- a/src/lib/createWorkflowsOnExamCreation.ts +++ b/src/lib/createWorkflowsOnExamCreation.ts @@ -1,7 +1,10 @@ import { Module } from "@/interfaces"; import { getApprovalWorkflowByFormIntaker, createApprovalWorkflow } from "@/utils/approval.workflows.be"; +import client from "@/lib/mongodb"; -export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) { +const db = client.db(process.env.MONGODB_DB); + +/* export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) { const results = await Promise.all( examEntities.map(async (entity) => { const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor); @@ -27,6 +30,50 @@ export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, const successCount = results.filter((r) => r.created).length; const totalCount = examEntities.length; + return { + successCount, + totalCount, + }; +} */ + +// TEMPORARY BEHAVIOUR! ONLY THE FIRST CONFIGURED WORKFLOW FOUND IS STARTED +export async function createApprovalWorkflowOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) { + let successCount = 0; + let totalCount = 0; + + for (const entity of examEntities) { + const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor); + + if (!configuredWorkflow) { + continue; + } + + totalCount = 1; // a workflow was found + + configuredWorkflow.modules.push(examModule as Module); + configuredWorkflow.name = examId; + configuredWorkflow.examId = examId; + configuredWorkflow.entityId = entity; + configuredWorkflow.startDate = Date.now(); + + try { + await createApprovalWorkflow("active-workflows", configuredWorkflow); + successCount = 1; + break; // Stop after the first success + } catch (error: any) { + break; + } + } + + // prettier-ignore + if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process + await db.collection(examModule).updateOne( + { id: examId }, + { $set: { id: examId, isDiagnostic: false }}, + { upsert: true } + ); + } + return { successCount, totalCount, diff --git a/src/pages/api/exam/[module]/index.ts b/src/pages/api/exam/[module]/index.ts index e78986e9..354fccf4 100644 --- a/src/pages/api/exam/[module]/index.ts +++ b/src/pages/api/exam/[module]/index.ts @@ -1,7 +1,7 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction import { Module } from "@/interfaces"; import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam"; -import { createApprovalWorkflowsOnExamCreation } from "@/lib/createWorkflowsOnExamCreation"; +import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation"; import client from "@/lib/mongodb"; import { sessionOptions } from "@/lib/session"; import { mapBy } from "@/utils"; @@ -48,7 +48,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { const { module } = req.query as { module: string }; const session = client.startSession(); - const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); // might need to change this with new approval workflows logic.. if an admin creates an exam no workflow is started because workflows must have entities configured. + const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); try { const exam = { @@ -76,6 +76,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { throw new Error("Name already exists"); } + if (isAdmin(user)) { + exam.isDiagnostic = false; + } + await db.collection(module).updateOne( { id: req.body.id }, { $set: { id: req.body.id, ...exam } }, @@ -88,37 +92,41 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { // if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response. responseStatus = 200; responseMessage = `Successfully updated exam with ID: "${exam.id}"`; - // TODO maybe find a way to start missing approval workflows in case they were only configured after exam creation. // create workflow only if exam is being created for the first time if (docSnap === null) { try { - const { successCount, totalCount } = await createApprovalWorkflowsOnExamCreation(exam.createdBy, exam.entities, exam.id, module); + const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module); - if (successCount === totalCount) { + if (isAdmin(user)) { responseStatus = 200; - responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow(s)`; + responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`; + } else if (successCount === totalCount) { + responseStatus = 200; + responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`; + /* responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow(s).`; */ } else if (successCount > 0) { responseStatus = 207; - responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities`; + responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities.`; } else { responseStatus = 207; - responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to find any configured Approval Workflow for the author.`; + responseMessage = `Successfully created exam with ID: "${exam.id}" but skipping approval process because no approval workflow was found configured for the exam author.`; } } catch (error) { console.error("Workflow creation error:", error); responseStatus = 207; responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`; } - } else { // if exam was updated, log the updates + } else { + // if exam was updated, log the updates const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id); - + if (approvalWorkflows) { const differences = generateExamDifferences(docSnap as Exam, exam as Exam); if (differences) { approvalWorkflows.forEach((workflow) => { - const currentStepIndex = workflow.steps.findIndex(step => !step.completed || step.rejected); - + const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected); + if (workflow.steps[currentStepIndex].examChanges === undefined) { workflow.steps[currentStepIndex].examChanges = [...differences]; } else { @@ -129,7 +137,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { } } } - + res.status(responseStatus).json({ message: responseMessage, }); diff --git a/src/utils/approval.workflows.be.ts b/src/utils/approval.workflows.be.ts index 8b610b54..1b32bfb8 100644 --- a/src/utils/approval.workflows.be.ts +++ b/src/utils/approval.workflows.be.ts @@ -37,9 +37,9 @@ export const getApprovalWorkflowByFormIntaker = async (entityId: string, formInt export const getApprovalWorkflowsByExamId = async (examId: string) => { return await db .collection("active-workflows") - .find({ - examId, - status: { $in: ["pending"] } + .find({ + examId, + status: { $in: ["pending"] }, }) .toArray(); }; From 3a3d3d014d9a57312c794afd62a940b43f437cfc Mon Sep 17 00:00:00 2001 From: Joao Correia Date: Sat, 8 Feb 2025 19:23:42 +0000 Subject: [PATCH 02/13] filter workflows user can see based on entities --- src/hooks/useApprovalWorkflows.tsx | 4 ++-- src/pages/api/approval-workflows/index.ts | 8 ++++++-- src/pages/approval-workflows/index.tsx | 13 +++++++------ src/utils/approval.workflows.be.ts | 17 ++++++++++++----- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/hooks/useApprovalWorkflows.tsx b/src/hooks/useApprovalWorkflows.tsx index 1b950c2f..7bfec920 100644 --- a/src/hooks/useApprovalWorkflows.tsx +++ b/src/hooks/useApprovalWorkflows.tsx @@ -2,7 +2,7 @@ import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; import axios from "axios"; import { useCallback, useEffect, useState } from "react"; -export default function useApprovalWorkflows() { +export default function useApprovalWorkflows(entitiesString?: string) { const [workflows, setWorkflows] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); @@ -10,7 +10,7 @@ export default function useApprovalWorkflows() { const getData = useCallback(() => { setIsLoading(true); axios - .get(`/api/approval-workflows`) + .get(`/api/approval-workflows`, {params: { entityIds: entitiesString }}) .then((response) => setWorkflows(response.data)) .catch((error) => { setIsError(true); diff --git a/src/pages/api/approval-workflows/index.ts b/src/pages/api/approval-workflows/index.ts index ff381a5e..a2cd2ea4 100644 --- a/src/pages/api/approval-workflows/index.ts +++ b/src/pages/api/approval-workflows/index.ts @@ -19,5 +19,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) { return res.status(403).json({ ok: false }); } - return res.status(200).json(await getApprovalWorkflows("active-workflows")); -} \ No newline at end of file + const entityIdsString = req.query.entityIds as string; + + const entityIdsArray = entityIdsString.split(","); + + return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray)); +} diff --git a/src/pages/approval-workflows/index.tsx b/src/pages/approval-workflows/index.tsx index a33d2967..58d4e54e 100644 --- a/src/pages/approval-workflows/index.tsx +++ b/src/pages/approval-workflows/index.tsx @@ -69,7 +69,11 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) return redirect("/"); - const workflows = await getApprovalWorkflows("active-workflows"); + const entityIDS = mapBy(user.entities, "id"); + const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); + const allowedEntities = findAllowedEntities(user, entities, "view_workflows"); + + const workflows = await getApprovalWorkflows("active-workflows", allowedEntities.map(entity => entity.id)); const allAssigneeIds: string[] = [ ...new Set( @@ -81,10 +85,6 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { ) ]; - const entityIDS = mapBy(user.entities, "id"); - const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); - const allowedEntities = findAllowedEntities(user, entities, "view_workflows"); - return { props: serialize({ user, @@ -103,7 +103,8 @@ interface Props { } export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: Props) { - const { workflows, reload } = useApprovalWorkflows(); + const entitiesString = userEntitiesWithLabel.map(entity => entity.id).join(","); + const { workflows, reload } = useApprovalWorkflows(entitiesString); const currentWorkflows = workflows || initialWorkflows; const [filteredWorkflows, setFilteredWorkflows] = useState([]); diff --git a/src/utils/approval.workflows.be.ts b/src/utils/approval.workflows.be.ts index 1b32bfb8..9998a06e 100644 --- a/src/utils/approval.workflows.be.ts +++ b/src/utils/approval.workflows.be.ts @@ -4,11 +4,18 @@ import { ObjectId } from "mongodb"; const db = client.db(process.env.MONGODB_DB); -export const getApprovalWorkflows = async (collection: string, ids?: string[]) => { - return await db - .collection(collection) - .find(ids ? { _id: { $in: ids.map((id) => new ObjectId(id)) } } : {}) - .toArray(); +export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[]) => { + const filters: any = {}; + + if (ids && ids.length > 0) { + filters.id = { $in: ids }; + } + + if (entityIds && entityIds.length > 0) { + filters.entityId = { $in: entityIds }; + } + + return await db.collection(collection).find(filters).toArray(); }; export const getApprovalWorkflow = async (collection: string, id: string) => { From b175d8797ee1c62bea28ee1a1a5683ea930ed124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Lima?= Date: Sun, 9 Feb 2025 04:28:34 +0000 Subject: [PATCH 03/13] added access variable to exams soo we can distinguish private, public and confidential exams and also bugfixes and improvements --- scripts/updatePrivateFieldExams.js | 51 ++ src/components/Diagnostic.tsx | 11 +- .../ExamEditor/SettingsEditor/level.tsx | 6 +- .../SettingsEditor/listening/index.tsx | 6 +- .../SettingsEditor/reading/index.tsx | 238 +++---- .../SettingsEditor/speaking/index.tsx | 6 +- .../SettingsEditor/writing/index.tsx | 6 +- src/components/ExamEditor/index.tsx | 493 ++++++++------- src/components/High/Table.tsx | 8 +- src/hooks/useExams.tsx | 28 +- src/hooks/usePagination.tsx | 185 ++++-- src/interfaces/exam.ts | 6 +- src/pages/(admin)/CorporateGradingSystem.tsx | 6 +- src/pages/(admin)/Lists/CodeList.tsx | 334 +++++----- src/pages/(admin)/Lists/ExamList.tsx | 597 +++++++++++------- src/pages/(admin)/Lists/UserList.tsx | 18 +- src/pages/(admin)/Lists/index.tsx | 1 - src/pages/api/code/entities.ts | 12 +- src/pages/api/code/index.ts | 1 + src/pages/api/exam/[module]/index.ts | 2 + src/pages/api/exam/index.ts | 40 +- src/pages/assignments/creator/[id].tsx | 1 - src/pages/classrooms/[id].tsx | 34 + src/pages/settings.tsx | 2 +- src/resources/entityPermissions.ts | 2 +- src/stores/examEditor/defaults.ts | 2 +- src/stores/examEditor/reducers/index.ts | 3 +- src/stores/examEditor/types.ts | 16 +- src/utils/exams.be.ts | 3 +- src/utils/moduleUtils.ts | 58 +- src/utils/permissions.ts | 1 - src/utils/users.ts | 52 +- 32 files changed, 1320 insertions(+), 909 deletions(-) create mode 100644 scripts/updatePrivateFieldExams.js diff --git a/scripts/updatePrivateFieldExams.js b/scripts/updatePrivateFieldExams.js new file mode 100644 index 00000000..0fba9d8f --- /dev/null +++ b/scripts/updatePrivateFieldExams.js @@ -0,0 +1,51 @@ +import dotenv from "dotenv"; +dotenv.config(); +import { MongoClient } from "mongodb"; +const uri = process.env.MONGODB_URI || ""; +const options = { + maxPoolSize: 10, +}; +const dbName = process.env.MONGODB_DB; // change this to prod db when needed +async function migrateData() { + const MODULE_ARRAY = ["reading", "listening", "writing", "speaking", "level"]; + const client = new MongoClient(uri, options); + + try { + await client.connect(); + console.log("Connected to MongoDB"); + if (!process.env.MONGODB_DB) { + throw new Error("Missing env var: MONGODB_DB"); + } + const db = client.db(dbName); + for (const string of MODULE_ARRAY) { + const collection = db.collection(string); + const result = await collection.updateMany( + { private: { $exists: false } }, + { $set: { access: "public" } } + ); + const result2 = await collection.updateMany( + { private: true }, + { $set: { access: "private" }, $unset: { private: "" } } + ); + const result1 = await collection.updateMany( + { private: { $exists: true } }, + { $set: { access: "public" } } + ); + console.log( + `Updated ${ + result.modifiedCount + result1.modifiedCount + } documents to "access: public" in ${string}` + ); + console.log( + `Updated ${result2.modifiedCount} documents to "access: private" and removed private var in ${string}` + ); + } + console.log("Migration completed successfully!"); + } catch (error) { + console.error("Migration failed:", error); + } finally { + await client.close(); + console.log("MongoDB connection closed."); + } +} +//migrateData(); // uncomment to run the migration diff --git a/src/components/Diagnostic.tsx b/src/components/Diagnostic.tsx index 1ca52b73..af022262 100644 --- a/src/components/Diagnostic.tsx +++ b/src/components/Diagnostic.tsx @@ -1,17 +1,12 @@ -import {infoButtonStyle} from "@/constants/buttonStyles"; -import {Module} from "@/interfaces"; import {User} from "@/interfaces/user"; import useExamStore from "@/stores/exam"; -import {getExam, getExamById} from "@/utils/exams"; +import {getExam} from "@/utils/exams"; import {MODULE_ARRAY} from "@/utils/moduleUtils"; -import {writingMarking} from "@/utils/score"; -import {Menu} from "@headlessui/react"; import axios from "axios"; import clsx from "clsx"; -import {capitalize} from "lodash"; import {useRouter} from "next/router"; -import {useEffect, useState} from "react"; -import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs"; +import { useState} from "react"; +import { BsQuestionSquare} from "react-icons/bs"; import {toast} from "react-toastify"; import Button from "./Low/Button"; import ModuleLevelSelector from "./Medium/ModuleLevelSelector"; diff --git a/src/components/ExamEditor/SettingsEditor/level.tsx b/src/components/ExamEditor/SettingsEditor/level.tsx index d192c629..02e97fd6 100644 --- a/src/components/ExamEditor/SettingsEditor/level.tsx +++ b/src/components/ExamEditor/SettingsEditor/level.tsx @@ -38,7 +38,7 @@ const LevelSettings: React.FC = () => { difficulty, sections, minTimer, - isPrivate, + access, } = useExamEditorStore(state => state.modules[currentModule]); const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( @@ -200,7 +200,7 @@ const LevelSettings: React.FC = () => { module: "level", id: title, difficulty, - private: isPrivate, + access, }; const result = await axios.post('/api/exam/level', exam); @@ -243,7 +243,7 @@ const LevelSettings: React.FC = () => { isDiagnostic: false, variant: undefined, difficulty, - private: isPrivate, + access, } as LevelExam); setExerciseIndex(0); setQuestionIndex(0); diff --git a/src/components/ExamEditor/SettingsEditor/listening/index.tsx b/src/components/ExamEditor/SettingsEditor/listening/index.tsx index 0e1e733e..c1a5fa7c 100644 --- a/src/components/ExamEditor/SettingsEditor/listening/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening/index.tsx @@ -27,7 +27,7 @@ const ListeningSettings: React.FC = () => { difficulty, sections, minTimer, - isPrivate, + access, instructionsState } = useExamEditorStore(state => state.modules[currentModule]); @@ -144,7 +144,7 @@ const ListeningSettings: React.FC = () => { id: title, variant: sections.length === 4 ? "full" : "partial", difficulty, - private: isPrivate, + access, instructions: instructionsURL }; @@ -191,7 +191,7 @@ const ListeningSettings: React.FC = () => { isDiagnostic: false, variant: sections.length === 4 ? "full" : "partial", difficulty, - private: isPrivate, + access, instructions: instructionsState.currentInstructionsURL } as ListeningExam); setExerciseIndex(0); diff --git a/src/components/ExamEditor/SettingsEditor/reading/index.tsx b/src/components/ExamEditor/SettingsEditor/reading/index.tsx index 45b00931..30424552 100644 --- a/src/components/ExamEditor/SettingsEditor/reading/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/reading/index.tsx @@ -15,135 +15,137 @@ import ReadingComponents from "./components"; import { getExamById } from "@/utils/exams"; const ReadingSettings: React.FC = () => { - const router = useRouter(); + const router = useRouter(); - const { - setExam, - setExerciseIndex, - setPartIndex, - setQuestionIndex, - setBgColor, - } = usePersistentExamStore(); + const { + setExam, + setExerciseIndex, + setPartIndex, + setQuestionIndex, + setBgColor, + } = usePersistentExamStore(); - const { currentModule, title } = useExamEditorStore(); - const { - focusedSection, - difficulty, - sections, - minTimer, - isPrivate, - type, - } = useExamEditorStore(state => state.modules[currentModule]); + const { currentModule, title } = useExamEditorStore(); + const { focusedSection, difficulty, sections, minTimer, access, type } = + useExamEditorStore((state) => state.modules[currentModule]); - const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( - currentModule, - focusedSection - ); + const { localSettings, updateLocalAndScheduleGlobal } = + useSettingsState(currentModule, focusedSection); - const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart; + const currentSection = sections.find( + (section) => section.sectionId == focusedSection + )?.state as ReadingPart; + const defaultPresets: Option[] = [ + { + label: "Preset: Reading Passage 1", + value: + "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas.", + }, + { + label: "Preset: Reading Passage 2", + value: + "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views.", + }, + { + label: "Preset: Reading Passage 3", + value: + "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas.", + }, + ]; - const defaultPresets: Option[] = [ - { - label: "Preset: Reading Passage 1", - value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas." - }, - { - label: "Preset: Reading Passage 2", - value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views." - }, - { - label: "Preset: Reading Passage 3", - value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas." - } - ]; + const canPreviewOrSubmit = sections.some( + (s) => + (s.state as ReadingPart).exercises && + (s.state as ReadingPart).exercises.length > 0 + ); - const canPreviewOrSubmit = sections.some( - (s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0 - ); - - const submitReading = () => { - if (title === "") { - toast.error("Enter a title for the exam!"); - return; - } - const exam: ReadingExam = { - parts: sections.map((s) => { - const exercise = s.state as ReadingPart; - return { - ...exercise, - intro: localSettings.currentIntro, - category: localSettings.category - }; - }), - isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. - minTimer, - module: "reading", - id: title, - variant: sections.length === 3 ? "full" : "partial", - difficulty, - private: isPrivate, - type: type! + const submitReading = () => { + if (title === "") { + toast.error("Enter a title for the exam!"); + return; + } + const exam: ReadingExam = { + parts: sections.map((s) => { + const exercise = s.state as ReadingPart; + return { + ...exercise, + intro: localSettings.currentIntro, + category: localSettings.category, }; + }), + isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. + minTimer, + module: "reading", + id: title, + variant: sections.length === 3 ? "full" : "partial", + difficulty, + access, + type: type!, + }; - axios.post(`/api/exam/reading`, exam) - .then((result) => { - playSound("sent"); - // Successfully submitted exam - if (result.status === 200) { - toast.success(result.data.message); - } else if (result.status === 207) { - toast.warning(result.data.message); - } - }) - .catch((error) => { - console.log(error); - toast.error(error.response.data.error || "Something went wrong while submitting, please try again later."); - }) - } + axios + .post(`/api/exam/reading`, exam) + .then((result) => { + playSound("sent"); + // Successfully submitted exam + if (result.status === 200) { + toast.success(result.data.message); + } else if (result.status === 207) { + toast.warning(result.data.message); + } + }) + .catch((error) => { + console.log(error); + toast.error( + error.response.data.error || + "Something went wrong while submitting, please try again later." + ); + }); + }; - const preview = () => { - setExam({ - parts: sections.map((s) => { - const exercises = s.state as ReadingPart; - return { - ...exercises, - intro: s.settings.currentIntro, - category: s.settings.category - }; - }), - minTimer, - module: "reading", - id: title, - isDiagnostic: false, - variant: undefined, - difficulty, - private: isPrivate, - type: type! - } as ReadingExam); - setExerciseIndex(0); - setQuestionIndex(0); - setPartIndex(0); - setBgColor("bg-white"); - openDetachedTab("popout?type=Exam&module=reading", router) - } + const preview = () => { + setExam({ + parts: sections.map((s) => { + const exercises = s.state as ReadingPart; + return { + ...exercises, + intro: s.settings.currentIntro, + category: s.settings.category, + }; + }), + minTimer, + module: "reading", + id: title, + isDiagnostic: false, + variant: undefined, + difficulty, + access: access, + type: type!, + } as ReadingExam); + setExerciseIndex(0); + setQuestionIndex(0); + setPartIndex(0); + setBgColor("bg-white"); + openDetachedTab("popout?type=Exam&module=reading", router); + }; - return ( - - - - ); + return ( + + + + ); }; export default ReadingSettings; diff --git a/src/components/ExamEditor/SettingsEditor/speaking/index.tsx b/src/components/ExamEditor/SettingsEditor/speaking/index.tsx index 513b30b1..297e864c 100644 --- a/src/components/ExamEditor/SettingsEditor/speaking/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/speaking/index.tsx @@ -30,7 +30,7 @@ const SpeakingSettings: React.FC = () => { } = usePersistentExamStore(); const { title, currentModule } = useExamEditorStore(); - const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule]) + const { focusedSection, difficulty, sections, minTimer, access } = useExamEditorStore((store) => store.modules[currentModule]) const section = sections.find((section) => section.sectionId == focusedSection)?.state; @@ -185,7 +185,7 @@ const SpeakingSettings: React.FC = () => { variant: undefined, difficulty, instructorGender: "varied", - private: isPrivate, + access, }; const result = await axios.post('/api/exam/speaking', exam); @@ -238,7 +238,7 @@ const SpeakingSettings: React.FC = () => { isDiagnostic: false, variant: undefined, difficulty, - private: isPrivate, + access, } as SpeakingExam); setExerciseIndex(0); setQuestionIndex(0); diff --git a/src/components/ExamEditor/SettingsEditor/writing/index.tsx b/src/components/ExamEditor/SettingsEditor/writing/index.tsx index 911d1817..8f077d2f 100644 --- a/src/components/ExamEditor/SettingsEditor/writing/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/writing/index.tsx @@ -23,7 +23,7 @@ const WritingSettings: React.FC = () => { const { minTimer, difficulty, - isPrivate, + access, sections, focusedSection, type, @@ -81,7 +81,7 @@ const WritingSettings: React.FC = () => { isDiagnostic: false, variant: undefined, difficulty, - private: isPrivate, + access, type: type! }); setExerciseIndex(0); @@ -134,7 +134,7 @@ const WritingSettings: React.FC = () => { isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. variant: undefined, difficulty, - private: isPrivate, + access, type: type! }; diff --git a/src/components/ExamEditor/index.tsx b/src/components/ExamEditor/index.tsx index 599f804b..c53af7a9 100644 --- a/src/components/ExamEditor/index.tsx +++ b/src/components/ExamEditor/index.tsx @@ -3,12 +3,12 @@ import SectionRenderer from "./SectionRenderer"; import Checkbox from "../Low/Checkbox"; import Input from "../Low/Input"; import Select from "../Low/Select"; -import {capitalize} from "lodash"; -import {Difficulty} from "@/interfaces/exam"; -import {useCallback, useEffect, useMemo, useState} from "react"; -import {toast} from "react-toastify"; -import {ModuleState, SectionState} from "@/stores/examEditor/types"; -import {Module} from "@/interfaces"; +import { capitalize } from "lodash"; +import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "react-toastify"; +import { ModuleState, SectionState } from "@/stores/examEditor/types"; +import { Module } from "@/interfaces"; import useExamEditorStore from "@/stores/examEditor"; import WritingSettings from "./SettingsEditor/writing"; import ReadingSettings from "./SettingsEditor/reading"; @@ -16,243 +16,286 @@ import LevelSettings from "./SettingsEditor/level"; import ListeningSettings from "./SettingsEditor/listening"; import SpeakingSettings from "./SettingsEditor/speaking"; import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch"; -import {defaultSectionSettings} from "@/stores/examEditor/defaults"; +import { defaultSectionSettings } from "@/stores/examEditor/defaults"; import Button from "../Low/Button"; import ResetModule from "./Standalone/ResetModule"; import ListeningInstructions from "./Standalone/ListeningInstructions"; -import {EntityWithRoles} from "@/interfaces/entity"; +import { EntityWithRoles } from "@/interfaces/entity"; const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"]; -const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: EntityWithRoles[]}> = ({ - levelParts = 0, - entitiesAllowEditPrivacy = [], -}) => { - const {currentModule, dispatch} = useExamEditorStore(); - const {sections, minTimer, expandedSections, examLabel, isPrivate, difficulty, sectionLabels, importModule} = useExamEditorStore( - (state) => state.modules[currentModule], - ); +const ExamEditor: React.FC<{ + levelParts?: number; + entitiesAllowEditPrivacy: EntityWithRoles[]; +}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { + sections, + minTimer, + expandedSections, + examLabel, + access, + difficulty, + sectionLabels, + importModule, + } = useExamEditorStore((state) => state.modules[currentModule]); - const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1); - const [isResetModuleOpen, setIsResetModuleOpen] = useState(false); + const [numberOfLevelParts, setNumberOfLevelParts] = useState( + levelParts !== 0 ? levelParts : 1 + ); + const [isResetModuleOpen, setIsResetModuleOpen] = useState(false); - // For exam edits - useEffect(() => { - if (levelParts !== 0) { - setNumberOfLevelParts(levelParts); - dispatch({ - type: "UPDATE_MODULE", - payload: { - updates: { - sectionLabels: Array.from({length: levelParts}).map((_, i) => ({ - id: i + 1, - label: `Part ${i + 1}`, - })), - }, - module: "level", - }, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [levelParts]); + // For exam edits + useEffect(() => { + if (levelParts !== 0) { + setNumberOfLevelParts(levelParts); + dispatch({ + type: "UPDATE_MODULE", + payload: { + updates: { + sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({ + id: i + 1, + label: `Part ${i + 1}`, + })), + }, + module: "level", + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelParts]); - useEffect(() => { - const currentSections = sections; - const currentLabels = sectionLabels; - let updatedSections: SectionState[]; - let updatedLabels: any; - if ((currentModule === "level" && currentSections.length !== currentLabels.length) || numberOfLevelParts !== currentSections.length) { - const newSections = [...currentSections]; - const newLabels = [...currentLabels]; - for (let i = currentLabels.length; i < numberOfLevelParts; i++) { - if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1)); - newLabels.push({ - id: i + 1, - label: `Part ${i + 1}`, - }); - } - updatedSections = newSections; - updatedLabels = newLabels; - } else if (numberOfLevelParts < currentSections.length) { - updatedSections = currentSections.slice(0, numberOfLevelParts); - updatedLabels = currentLabels.slice(0, numberOfLevelParts); - } else { - return; - } + useEffect(() => { + const currentSections = sections; + const currentLabels = sectionLabels; + let updatedSections: SectionState[]; + let updatedLabels: any; + if ( + (currentModule === "level" && + currentSections.length !== currentLabels.length) || + numberOfLevelParts !== currentSections.length + ) { + const newSections = [...currentSections]; + const newLabels = [...currentLabels]; + for (let i = currentLabels.length; i < numberOfLevelParts; i++) { + if (currentSections.length !== numberOfLevelParts) + newSections.push(defaultSectionSettings(currentModule, i + 1)); + newLabels.push({ + id: i + 1, + label: `Part ${i + 1}`, + }); + } + updatedSections = newSections; + updatedLabels = newLabels; + } else if (numberOfLevelParts < currentSections.length) { + updatedSections = currentSections.slice(0, numberOfLevelParts); + updatedLabels = currentLabels.slice(0, numberOfLevelParts); + } else { + return; + } - const updatedExpandedSections = expandedSections.filter((sectionId) => updatedSections.some((section) => section.sectionId === sectionId)); + const updatedExpandedSections = expandedSections.filter((sectionId) => + updatedSections.some((section) => section.sectionId === sectionId) + ); - dispatch({ - type: "UPDATE_MODULE", - payload: { - updates: { - sections: updatedSections, - sectionLabels: updatedLabels, - expandedSections: updatedExpandedSections, - }, - }, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [numberOfLevelParts]); + dispatch({ + type: "UPDATE_MODULE", + payload: { + updates: { + sections: updatedSections, + sectionLabels: updatedLabels, + expandedSections: updatedExpandedSections, + }, + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [numberOfLevelParts]); - const sectionIds = sections.map((section) => section.sectionId); + const sectionIds = sections.map((section) => section.sectionId); - const updateModule = useCallback( - (updates: Partial) => { - dispatch({type: "UPDATE_MODULE", payload: {updates}}); - }, - [dispatch], - ); + const updateModule = useCallback( + (updates: Partial) => { + dispatch({ type: "UPDATE_MODULE", payload: { updates } }); + }, + [dispatch] + ); - const toggleSection = (sectionId: number) => { - if (expandedSections.length === 1 && sectionIds.includes(sectionId)) { - toast.error("Include at least one section!"); - return; - } - dispatch({type: "TOGGLE_SECTION", payload: {sectionId}}); - }; + const toggleSection = (sectionId: number) => { + if (expandedSections.length === 1 && sectionIds.includes(sectionId)) { + toast.error("Include at least one section!"); + return; + } + dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } }); + }; - const ModuleSettings: Record = { - reading: ReadingSettings, - writing: WritingSettings, - speaking: SpeakingSettings, - listening: ListeningSettings, - level: LevelSettings, - }; + const ModuleSettings: Record = { + reading: ReadingSettings, + writing: WritingSettings, + speaking: SpeakingSettings, + listening: ListeningSettings, + level: LevelSettings, + }; - const Settings = ModuleSettings[currentModule]; - const showImport = importModule && ["reading", "listening", "level"].includes(currentModule); + const Settings = ModuleSettings[currentModule]; + const showImport = + importModule && ["reading", "listening", "level"].includes(currentModule); - const updateLevelParts = (parts: number) => { - setNumberOfLevelParts(parts); - }; + const updateLevelParts = (parts: number) => { + setNumberOfLevelParts(parts); + }; - return ( - <> - {showImport ? ( - - ) : ( - <> - {isResetModuleOpen && ( - - )} -
-
-
- - - updateModule({ - minTimer: parseInt(e) < 15 ? 15 : parseInt(e), - }) - } - value={minTimer} - className="max-w-[300px]" - /> -
-
- - setNumberOfLevelParts(parseInt(v))} - value={numberOfLevelParts} - /> -
- )} -
-
- updateModule({isPrivate: checked})} - disabled={entitiesAllowEditPrivacy.length === 0}> - Privacy (Only available for Assignments) - -
-
-
-
- - updateModule({examLabel: text})} - roundness="xl" - value={examLabel} - required - /> -
- {currentModule === "listening" && } - -
-
- -
- -
-
- - )} - - ); + return ( + <> + {showImport ? ( + + ) : ( + <> + {isResetModuleOpen && ( + + )} +
+
+
+ + + updateModule({ + minTimer: parseInt(e) < 15 ? 15 : parseInt(e), + }) + } + value={minTimer} + className="max-w-[300px]" + /> +
+
+ + setNumberOfLevelParts(parseInt(v))} + value={numberOfLevelParts} + /> +
+ )} +
+
+ updateModule({ examLabel: text })} + roundness="xl" + value={examLabel} + required + /> +
+ {currentModule === "listening" && } + +
+
+ +
+ +
+
+ + )} + + ); }; export default ExamEditor; diff --git a/src/components/High/Table.tsx b/src/components/High/Table.tsx index 2d588ffc..572e619a 100644 --- a/src/components/High/Table.tsx +++ b/src/components/High/Table.tsx @@ -149,10 +149,16 @@ export default function Table({ ))} - {isLoading && ( + {isLoading ? (
+ ) : ( + rows.length === 0 && ( +
+ No data found... +
+ ) )}
); diff --git a/src/hooks/useExams.tsx b/src/hooks/useExams.tsx index c9e001b5..befc474d 100644 --- a/src/hooks/useExams.tsx +++ b/src/hooks/useExams.tsx @@ -1,21 +1,21 @@ -import {Exam} from "@/interfaces/exam"; +import { Exam } from "@/interfaces/exam"; import axios from "axios"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; export default function useExams() { - const [exams, setExams] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isError, setIsError] = useState(false); + const [exams, setExams] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); - const getData = () => { - setIsLoading(true); - axios - .get("/api/exam") - .then((response) => setExams(response.data)) - .finally(() => setIsLoading(false)); - }; + const getData = () => { + setIsLoading(true); + axios + .get(`/api/exam`) + .then((response) => setExams(response.data)) + .finally(() => setIsLoading(false)); + }; - useEffect(getData, []); + useEffect(getData, []); - return {exams, isLoading, isError, reload: getData}; + return { exams, isLoading, isError, reload: getData }; } diff --git a/src/hooks/usePagination.tsx b/src/hooks/usePagination.tsx index 9075dbbe..3c307f33 100644 --- a/src/hooks/usePagination.tsx +++ b/src/hooks/usePagination.tsx @@ -1,60 +1,141 @@ import Button from "@/components/Low/Button"; -import {useMemo, useState} from "react"; -import {BiChevronLeft} from "react-icons/bi"; -import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs"; +import { useEffect, useMemo, useState } from "react"; +import { + BsChevronDoubleLeft, + BsChevronDoubleRight, + BsChevronLeft, + BsChevronRight, +} from "react-icons/bs"; +import Select from "../components/Low/Select"; export default function usePagination(list: T[], size = 25) { - const [page, setPage] = useState(0); + const [page, setPage] = useState(0); + const [itemsPerPage, setItemsPerPage] = useState(size); - const items = useMemo(() => list.slice(page * size, (page + 1) * size), [page, size, list]); + const items = useMemo( + () => list.slice(page * itemsPerPage, (page + 1) * itemsPerPage), + [list, page, itemsPerPage] + ); + useEffect(() => { + if (page * itemsPerPage >= list.length) setPage(0); + }, [items, itemsPerPage, list.length, page]); - const render = () => ( -
-
- -
-
- - {page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length} - - -
-
- ); + const itemsPerPageOptions = [25, 50, 100, 200]; - const renderMinimal = () => ( -
-
- - -
- - {page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length} - -
- - -
-
- ); + const render = () => ( +
+
+ +
+
+
+ setItemsPerPage(parseInt(value!.value ?? "25"))} + options={itemsPerPageOptions.map((size) => ({ + label: size.toString(), + value: size.toString(), + }))} + isClearable={false} + styles={{ + control: (styles) => ({ ...styles, width: "100px" }), + container: (styles) => ({ ...styles, width: "100px" }), + }} + /> + + {page * itemsPerPage + 1} -{" "} + {itemsPerPage * (page + 1) > list.length + ? list.length + : itemsPerPage * (page + 1)} + / {list.length} + +
+
+ + +
+
+ ); + + return { page, items, setPage, render, renderMinimal }; } diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 965ad45b..201ce888 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -1,4 +1,3 @@ -import instructions from "@/pages/api/exam/media/instructions"; import { Module } from "."; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; @@ -10,6 +9,9 @@ export type Difficulty = BasicDifficulty | CEFRLevels; // Left easy, medium and hard to support older exam versions export type BasicDifficulty = "easy" | "medium" | "hard"; export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2"; +export const ACCESSTYPE = ["public", "private", "confidential"] as const; +export type AccessType = typeof ACCESSTYPE[number]; + export interface ExamBase { @@ -24,7 +26,7 @@ export interface ExamBase { shuffle?: boolean; createdBy?: string; // option as it has been added later createdAt?: string; // option as it has been added later - private?: boolean; + access: AccessType; label?: string; } export interface ReadingExam extends ExamBase { diff --git a/src/pages/(admin)/CorporateGradingSystem.tsx b/src/pages/(admin)/CorporateGradingSystem.tsx index 9506108d..e4e9c829 100644 --- a/src/pages/(admin)/CorporateGradingSystem.tsx +++ b/src/pages/(admin)/CorporateGradingSystem.tsx @@ -265,7 +265,7 @@ export default function CorporateGradingSystem({ <>