Merged in approval-workflows (pull request #156)

Approval workflows

Approved-by: Tiago Ribeiro
This commit is contained in:
João Correia
2025-03-03 11:17:40 +00:00
committed by Tiago Ribeiro
17 changed files with 408 additions and 283 deletions

View File

@@ -13,6 +13,7 @@ import validateBlanks from "../validateBlanks";
import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert";
import PromptEdit from "../../Shared/PromptEdit";
import { uuidv4 } from "@firebase/util";
interface Word {
letter: string;
@@ -72,6 +73,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
...local,
text: blanksState.text,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
@@ -145,6 +147,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
@@ -189,6 +192,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
@@ -217,6 +221,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
@@ -234,6 +239,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))

View File

@@ -11,6 +11,7 @@ import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert";
import { MdEdit, MdEditOff } from "react-icons/md";
import MCOption from "./MCOption";
import { uuidv4 } from "@firebase/util";
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
@@ -69,6 +70,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
...local,
text: blanksState.text,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
@@ -139,6 +141,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
@@ -168,6 +171,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
@@ -217,6 +221,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
...prev,
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
@@ -234,6 +239,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
blanksMissingWords.forEach(blank => {
const newMCOption: FillBlanksMCOption = {
uuid: uuidv4(),
id: blank.id.toString(),
options: {
A: 'Option A',
@@ -249,6 +255,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
...prev,
words: newWords,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))

View File

@@ -18,6 +18,7 @@ import { toast } from 'react-toastify';
import { DragEndEvent } from '@dnd-kit/core';
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
@@ -98,6 +99,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
sentences: [
...local.sentences,
{
uuid: uuidv4(),
id: newId,
sentence: "",
solution: ""

View File

@@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
import { MdAdd } from "react-icons/md";
import Alert, { AlertItem } from "../../Shared/Alert";
import PromptEdit from "../../Shared/PromptEdit";
import { uuidv4 } from "@firebase/util";
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
@@ -57,6 +58,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
{
prompt: "",
solution: "",
uuid: uuidv4(),
id: newId,
options,
variant: "text"

View File

@@ -18,6 +18,7 @@ import SortableQuestion from '../../Shared/SortableQuestion';
import setEditingAlert from '../../Shared/setEditingAlert';
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
interface MultipleChoiceProps {
exercise: MultipleChoiceExercise;
@@ -120,6 +121,7 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
{
prompt: "",
solution: "",
uuid: uuidv4(),
id: newId,
options,
variant: "text"

View File

@@ -16,6 +16,7 @@ import setEditingAlert from '../Shared/setEditingAlert';
import { DragEndEvent } from '@dnd-kit/core';
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
@@ -50,6 +51,7 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
{
prompt: "",
solution: undefined,
uuid: uuidv4(),
id: newId
}
]

View File

@@ -22,6 +22,7 @@ import { validateEmptySolutions, validateQuestionText, validateWordCount } from
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
import { ParsedQuestion, parseText, reconstructText } from './parsing';
import PromptEdit from '../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
@@ -105,6 +106,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
const newId = (Math.max(...existingIds, 0) + 1).toString();
const newQuestion = {
uuid: uuidv4(),
id: newId,
questionText: "New question"
};
@@ -113,6 +115,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
const updatedText = reconstructText(updatedQuestions);
const updatedSolutions = [...local.solutions, {
uuid: uuidv4(),
id: newId,
solution: [""]
}];

View File

@@ -17,6 +17,7 @@ import { validateQuestions, validateEmptySolutions, validateWordCount } from "./
import Header from "../../Shared/Header";
import BlanksFormEditor from "./BlanksFormEditor";
import PromptEdit from "../Shared/PromptEdit";
import { uuidv4 } from "@firebase/util";
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
@@ -111,6 +112,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
const newLine = `New question with blank {{${newId}}}`;
const updatedQuestions = [...parsedQuestions, {
uuid: uuidv4(),
id: newId,
parts: parseLine(newLine),
editingPlaceholders: true
@@ -121,6 +123,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
.join('\\n') + '\\n';
const updatedSolutions = [...local.solutions, {
uuid: uuidv4(),
id: newId,
solution: [""]
}];

View File

@@ -241,6 +241,7 @@ export interface InteractiveSpeakingExercise extends Section {
}
export interface FillBlanksMCOption {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string;
options: {
A: string;
@@ -258,6 +259,7 @@ export interface FillBlanksExercise {
text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition?: boolean;
solutions: {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve"
}[];
@@ -281,6 +283,7 @@ export interface TrueFalseExercise {
}
export interface TrueFalseQuestion {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1"
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True"
@@ -293,6 +296,7 @@ export interface WriteBlanksExercise {
id: string;
text: string; // *EXAMPLE: "The Government plans to give ${{14}}"
solutions: {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "14"
solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive)
}[];
@@ -319,12 +323,14 @@ export interface MatchSentencesExercise {
}
export interface MatchSentenceExerciseSentence {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string;
sentence: string;
solution: string;
}
export interface MatchSentenceExerciseOption {
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string;
sentence: string;
}
@@ -346,6 +352,7 @@ export interface MultipleChoiceExercise {
export interface MultipleChoiceQuestion {
variant: "image" | "text";
uuid: string; // added later to fulfill the need for an immutable identifier.
id: string; // *EXAMPLE: "1"
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
solution: string; // *EXAMPLE: "A"

View File

@@ -68,14 +68,14 @@ export async function createApprovalWorkflowOnExamCreation(examAuthor: string, e
}
}
// prettier-ignore
if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
// commented because they asked for every exam to stay confidential
/* if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
await db.collection(examModule).updateOne(
{ id: examId },
{ $set: { id: examId, access: "private" }},
{ upsert: true }
);
}
} */
return {
successCount,

View File

@@ -23,5 +23,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const entityIdsArray = entityIdsString.split(",");
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
if (!["admin", "developer"].includes(user.type)) {
// filtering workflows that have user as assignee in at least one of the steps
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray, undefined, user.id));
} else {
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
}
}

View File

@@ -1,6 +1,6 @@
// 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 { Exam, ExamBase, InstructorGender, LevelExam, ListeningExam, ReadingExam, SpeakingExam, Variant } from "@/interfaces/exam";
import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
import client from "@/lib/mongodb";
import { sessionOptions } from "@/lib/session";
@@ -10,6 +10,7 @@ import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/a
import { generateExamDifferences } from "@/utils/exam.differences";
import { getExams } from "@/utils/exams.be";
import { isAdmin } from "@/utils/users";
import { uuidv4 } from "@firebase/util";
import { access } from "fs";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -18,140 +19,161 @@ const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await GET(req, res);
if (req.method === "POST") return await POST(req, res);
// Temporary: Adding UUID here but later move to backend.
function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase {
const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"];
res.status(404).json({ ok: false });
exam.parts = exam.parts.map((part) => {
const updatedExercises = part.exercises.map((exercise: any) => {
arraysToUpdate.forEach((arrayName) => {
if (exercise[arrayName] && Array.isArray(exercise[arrayName])) {
exercise[arrayName] = exercise[arrayName].map((item: any) => (item.uuid ? item : { ...item, uuid: uuidv4() }));
}
});
return exercise;
});
return { ...part, exercises: updatedExercises };
});
return exam;
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await GET(req, res);
if (req.method === "POST") return await POST(req, res);
res.status(404).json({ ok: false });
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { module, avoidRepeated, variant, instructorGender } = req.query as {
module: Module;
avoidRepeated: string;
variant?: Variant;
instructorGender?: InstructorGender;
};
const { module, avoidRepeated, variant, instructorGender } = req.query as {
module: Module;
avoidRepeated: string;
variant?: Variant;
instructorGender?: InstructorGender;
};
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
res.status(200).json(exams);
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
res.status(200).json(exams);
}
async function POST(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
const { module } = req.query as { module: string };
const { module } = req.query as { module: string };
const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
try {
const exam = {
access: "public", // default access is public
...req.body,
module: module,
entities,
createdBy: user.id,
createdAt: new Date().toISOString(),
};
try {
let exam = {
access: "public", // default access is public
...req.body,
module: module,
entities,
createdBy: user.id,
createdAt: new Date().toISOString(),
};
let responseStatus: number;
let responseMessage: string;
// Temporary: Adding UUID here but later move to backend.
exam = addUUIDs(exam);
await session.withTransaction(async () => {
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
let responseStatus: number;
let responseMessage: string;
// Check whether the id of the exam matches another exam with different
// owners, throw exception if there is, else allow editing
const existingExamOwners = docSnap?.owners ?? [];
const newExamOwners = exam.owners ?? [];
await session.withTransaction(async () => {
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
const ownersSet = new Set(existingExamOwners);
// Check whether the id of the exam matches another exam with different
// owners, throw exception if there is, else allow editing
const existingExamOwners = docSnap?.owners ?? [];
const newExamOwners = exam.owners ?? [];
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
throw new Error("Name already exists");
}
const ownersSet = new Set(existingExamOwners);
if (exam.requiresApproval === true) {
exam.access = "confidential";
}
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
throw new Error("Name already exists");
}
await db.collection(module).updateOne(
{ id: req.body.id },
{ $set: { id: req.body.id, ...exam } },
{
upsert: true,
session,
}
);
if (exam.requiresApproval === true) {
exam.access = "confidential";
}
// 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}"`;
await db.collection(module).updateOne(
{ id: req.body.id },
{ $set: { id: req.body.id, ...exam } },
{
upsert: true,
session,
}
);
// create workflow only if exam is being created for the first time
if (docSnap === null) {
try {
if (exam.requiresApproval === false) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to user request.`;
} else if (isAdmin(user)) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`;
} else {
const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
if (successCount === totalCount) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
} 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.`;
} else {
responseStatus = 207;
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
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
// 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}"`;
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);
// create workflow only if exam is being created for the first time
if (docSnap === null) {
try {
if (exam.requiresApproval === false) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to user request.`;
} else if (isAdmin(user)) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`;
} else {
const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
if (workflow.steps[currentStepIndex].examChanges === undefined) {
workflow.steps[currentStepIndex].examChanges = [...differences];
} else {
workflow.steps[currentStepIndex].examChanges!.push(...differences);
}
});
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
}
}
}
if (successCount === totalCount) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
} 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.`;
} else {
responseStatus = 207;
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
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
res.status(responseStatus).json({
message: responseMessage,
});
});
} catch (error) {
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message });
} finally {
session.endSession();
}
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);
if (workflow.steps[currentStepIndex].examChanges === undefined) {
workflow.steps[currentStepIndex].examChanges = [...differences];
} else {
workflow.steps[currentStepIndex].examChanges!.push(...differences);
}
});
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
}
}
}
res.status(responseStatus).json({
message: responseMessage,
});
});
} catch (error) {
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message });
} finally {
session.endSession();
}
}

View File

@@ -150,7 +150,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
const handleApproveStep = () => {
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
if (isLastStep) {
if (!confirm(`Are you sure you want to approve the last step? Doing so will change the access type of the exam from confidential to private.`)) return;
if (!confirm(`Are you sure you want to approve the last step and complete the approval process?`)) return;
}
const updatedWorkflow: ApprovalWorkflow = {
@@ -188,7 +188,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
if (isLastStep) {
setIsPanelOpen(false);
const examModule = currentWorkflow.modules[0];
/* const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
axios
@@ -207,7 +207,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
.finally(reload); */
} else {
handleStepClick(selectedStepIndex + 1, currentWorkflow.steps[selectedStepIndex + 1]);
}
@@ -386,7 +386,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
{/* Side panel */}
<AnimatePresence mode="wait">
<LayoutGroup key="sidePanel">
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
<section className={`absolute inset-y-0 right-0 h-full overflow-y-auto bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
{isPanelOpen && selectedStep && (
<motion.div
className="p-6"
@@ -551,12 +551,16 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
transition={{ duration: 0.3 }}
className="overflow-hidden mt-2"
>
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-40">
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-[300px]">
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
{change}
</p>
<>
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
<span className="text-mti-purple-light text-lg">{change.charAt(0)}</span>
{change.slice(1)}
</p>
<hr className="my-3 h-[3px] bg-mti-purple-light rounded-full w-full" />
</>
))
) : (
<p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p>
@@ -573,7 +577,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Input comments here"
className="w-full h-40 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
className="w-full h-[200px] p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
/>
<Button

View File

@@ -63,26 +63,26 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const [users, groups] = await Promise.all([
isAdmin(user)
? getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}
)
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
{},
0,
{},
{
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}),
}
)
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}),
isAdmin(user)
? getGroups()
: getGroupsByEntities(mapBy(allowedEntities, "id")),
@@ -143,6 +143,9 @@ export default function AssignmentsPage({
const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
const [showApprovedExams, setShowApprovedExams] = useState<boolean>(true);
const [showNonApprovedExams, setShowNonApprovedExams] = useState<boolean>(true);
const { exams } = useExams();
const router = useRouter();
@@ -326,7 +329,7 @@ export default function AssignmentsPage({
onClick={
(!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
@@ -501,37 +504,64 @@ export default function AssignmentsPage({
Random Exams
</Checkbox>
{!useRandomExams && (
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">
{capitalize(module)} Exam
</label>
<Select
value={{
value:
examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [
<>
<Checkbox
isChecked={showApprovedExams}
onChange={() => {
setShowApprovedExams((prev) => !prev)
}}
>
Show approved exams
</Checkbox>
<Checkbox
isChecked={showNonApprovedExams}
onChange={() => {
setShowNonApprovedExams((prev) => !prev)
}}
>
Show non-approved exams
</Checkbox>
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">
{capitalize(module)} Exam
</label>
<Select
isClearable
value={{
value:
examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [
...prev.filter((x) => x.module !== module),
{ id: value.value!, module },
])
: setExamIDs((prev) =>
: setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
}
options={exams
.filter((x) => !x.isDiagnostic && x.module === module)
.map((x) => ({ value: x.id, label: x.id }))}
/>
</div>
))}
</div>
}
options={exams
.filter((x) =>
!x.isDiagnostic &&
x.module === module &&
x.access !== "confidential" &&
(
(x.requiresApproval && showApprovedExams) ||
(!x.requiresApproval && showNonApprovedExams)
)
)
.map((x) => ({ value: x.id, label: x.id }))}
/>
</div>
))}
</div>
</>
)}
</div>
)}
@@ -568,7 +598,7 @@ export default function AssignmentsPage({
users
.filter((u) => g.participants.includes(u.id))
.every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
"!bg-mti-purple-light !text-white"
)}
>
{g.name}
@@ -653,7 +683,7 @@ export default function AssignmentsPage({
users
.filter((u) => g.participants.includes(u.id))
.every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
"!bg-mti-purple-light !text-white"
)}
>
{g.name}

View File

@@ -59,8 +59,8 @@ const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): Reo
let newIds = oldIds.map((_, index) => (startId + index).toString());
let newSolutions = exercise.solutions.map((solution, index) => ({
id: newIds[index],
solution: [...solution.solution]
...solution,
id: newIds[index]
}));
let newText = exercise.text;

View File

@@ -4,7 +4,7 @@ import { ObjectId } from "mongodb";
const db = client.db(process.env.MONGODB_DB);
export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[]) => {
export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[], assignee?: string) => {
const filters: any = {};
if (ids && ids.length > 0) {
@@ -15,7 +15,15 @@ export const getApprovalWorkflows = async (collection: string, entityIds?: strin
filters.entityId = { $in: entityIds };
}
return await db.collection<ApprovalWorkflow>(collection).find(filters).toArray();
if (assignee) {
filters["steps.assignees"] = assignee;
}
return await db
.collection<ApprovalWorkflow>(collection)
.find(filters)
.sort({ startDate: -1 })
.toArray();
};
export const getApprovalWorkflow = async (collection: string, id: string) => {
@@ -26,6 +34,7 @@ export const getApprovalWorkflowsByEntities = async (collection: string, ids: st
return await db
.collection<ApprovalWorkflow>(collection)
.find({ entityId: { $in: ids } })
.sort({ startDate: -1 })
.toArray();
};

View File

@@ -1,7 +1,6 @@
import { Exam } from "@/interfaces/exam";
import { diff, Diff } from "deep-diff";
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]);
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "uuid", "isDiagnostic", "owners", "entities", "createdAt", "createdBy", "access", "requiresApproval", "exerciseID", "questionID", "sectionId", "userSolutions"]);
const PATH_LABELS: Record<string, string> = {
access: "Access Type",
@@ -24,124 +23,146 @@ const PATH_LABELS: Record<string, string> = {
allowRepetition: "Allow Repetition",
maxWords: "Max Words",
minTimer: "Timer",
section: "Section",
module: "Module",
type: "Type",
intro: "Intro",
category: "Category",
context: "Context",
instructions: "Instructions",
name: "Name",
gender: "Gender",
voice: "Voice",
enableNavigation: "Enable Navigation",
limit: "Limit",
instructorGender: "Instructor Gender",
wordCounter: "Word Counter",
attachment: "Attachment",
first_title: "First Title",
second_title: "Second Title",
first_topic: "First Topic",
second_topic: "Second Topic",
questions: "Questions",
sentences: "Sentences",
sentence: "Sentence",
solution: "Solution",
passage: "Passage",
};
const ARRAY_ITEM_LABELS: Record<string, string> = {
exercises: "Exercise",
paths: "Path",
difficulties: "Difficulty",
solutions: "Solution",
options: "Option",
words: "Word",
questions: "Question",
userSolutions: "User Solution",
sentences: "Sentence",
parts: "Part",
};
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
const differences = diff(oldExam, newExam) || [];
return differences.map((change) => formatDifference(change)).filter(Boolean) as string[];
const differences: string[] = [];
compareObjects(oldExam, newExam, [], differences);
return differences;
}
function formatDifference(change: Diff<any, any>): string | undefined {
if (!change.path) return;
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
return;
}
const pathString = pathToHumanReadable(change.path);
switch (change.kind) {
case "N": // New property/element
return `• Added ${pathString} with value: ${formatValue(change.rhs)}\n`;
case "D": // Deleted property/element
return `• Removed ${pathString} which had value: ${formatValue(change.lhs)}\n`;
case "E": // Edited property/element
return `• Changed ${pathString} from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}\n`;
case "A": // Array change
return formatArrayChange(change);
default:
return;
}
function isObject(val: any): val is Record<string, any> {
return val !== null && typeof val === "object" && !Array.isArray(val);
}
function formatArrayChange(change: Diff<any, any>): string | undefined {
if (!change.path) return;
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
return;
}
const pathString = pathToHumanReadable(change.path);
const arrayChange = (change as any).item;
const idx = (change as any).index;
if (!arrayChange) return;
switch (arrayChange.kind) {
case "N":
return `• Added an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.rhs)}\n`;
case "D":
return `• Removed an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.lhs)}\n`;
case "E":
return `• Edited an item at [#${idx + 1}] in ${pathString} from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}\n`;
case "A":
return `• Complex array change at [#${idx + 1}] in ${pathString}: ${JSON.stringify(arrayChange)}\n`;
default:
return;
}
}
function formatValue(value: any): string {
if (value === null) return "null";
function formatPrimitive(value: any): string {
if (value === undefined) return "undefined";
if (typeof value === "object") {
try {
const sanitized = removeExcludedKeysDeep(value, EXCLUDED_KEYS);
const renamed = renameKeysDeep(sanitized, PATH_LABELS);
return JSON.stringify(renamed, null, 2);
} catch {
return String(value);
}
}
if (value === null) return "null";
return JSON.stringify(value);
}
function removeExcludedKeysDeep(obj: any, excludedKeys: Set<string>): any {
if (Array.isArray(obj)) {
return obj.map((item) => removeExcludedKeysDeep(item, excludedKeys));
} else if (obj && typeof obj === "object") {
const newObj: any = {};
for (const key of Object.keys(obj)) {
if (excludedKeys.has(key)) {
// Skip this key entirely
continue;
}
newObj[key] = removeExcludedKeysDeep(obj[key], excludedKeys);
}
return newObj;
}
return obj;
}
function renameKeysDeep(obj: any, renameMap: Record<string, string>): any {
if (Array.isArray(obj)) {
return obj.map((item) => renameKeysDeep(item, renameMap));
} else if (obj && typeof obj === "object") {
const newObj: any = {};
for (const key of Object.keys(obj)) {
const newKey = renameMap[key] ?? key; // Use friendly label if available
newObj[newKey] = renameKeysDeep(obj[key], renameMap);
}
return newObj;
}
return obj;
}
/**
* Convert an array of path segments into a user-friendly string.
* e.g. ["parts", 0, "exercises", 1, "prompt"]
* → "Parts → [#1] → Exercises → [#2] → Prompt"
*/
function pathToHumanReadable(pathSegments: Array<string | number>): string {
return pathSegments
.map((seg) => {
if (typeof seg === "number") {
return `[#${seg + 1}]`;
}
return PATH_LABELS[seg] ?? seg;
})
.join(" → ");
const mapped = pathSegments.map((seg) => {
if (typeof seg === "number") {
return `#${seg + 1}`;
}
return PATH_LABELS[seg] ?? seg;
});
let result = "";
for (let i = 0; i < mapped.length; i++) {
result += mapped[i];
if (mapped[i].startsWith("#") && i < mapped.length - 1) {
result += " - ";
} else if (i < mapped.length - 1) {
result += " ";
}
}
return result.trim();
}
function getArrayItemLabel(path: (string | number)[]): string {
if (path.length === 0) return "item";
const lastSegment = path[path.length - 1];
if (typeof lastSegment === "string" && ARRAY_ITEM_LABELS[lastSegment]) {
return ARRAY_ITEM_LABELS[lastSegment];
}
return "item";
}
function getIdentifier(item: any): string | number | undefined {
if (item?.uuid !== undefined) return item.uuid;
if (item?.id !== undefined) return item.id;
return undefined;
}
function compareObjects(oldObj: any, newObj: any, path: (string | number)[], differences: string[]) {
if (Array.isArray(oldObj) && Array.isArray(newObj)) {
// Check if array elements are objects with an identifier (uuid or id).
if (oldObj.length > 0 && typeof oldObj[0] === "object" && getIdentifier(oldObj[0]) !== undefined) {
// Process removed items
const newIds = new Set(newObj.map((item: any) => getIdentifier(item)));
for (let i = 0; i < oldObj.length; i++) {
const oldItem = oldObj[i];
const identifier = getIdentifier(oldItem);
if (identifier !== undefined && !newIds.has(identifier)) {
differences.push(`• Removed ${getArrayItemLabel(path)} #${i + 1} from ${pathToHumanReadable(path)}\n`);
}
}
const oldIndexMap = new Map(oldObj.map((item: any, index: number) => [getIdentifier(item), index]));
// Process items in the new array using their order.
for (let i = 0; i < newObj.length; i++) {
const newItem = newObj[i];
const identifier = getIdentifier(newItem);
if (identifier !== undefined) {
if (oldIndexMap.has(identifier)) {
const oldIndex = oldIndexMap.get(identifier)!;
const oldItem = oldObj[oldIndex];
compareObjects(oldItem, newItem, path.concat(`#${i + 1}`), differences);
} else {
differences.push(`• Added new ${getArrayItemLabel(path)} #${i + 1} at ${pathToHumanReadable(path)}\n`);
}
} else {
// Fallback: if item does not have an identifier, compare by index.
compareObjects(oldObj[i], newItem, path.concat(`#${i + 1}`), differences);
}
}
} else {
// For arrays that are not identifier-based, compare element by element.
const maxLength = Math.max(oldObj.length, newObj.length);
for (let i = 0; i < maxLength; i++) {
compareObjects(oldObj[i], newObj[i], path.concat(`#${i + 1}`), differences);
}
}
} else if (isObject(oldObj) && isObject(newObj)) {
// Compare objects by keys (ignoring excluded keys).
const keys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
for (const key of keys) {
if (EXCLUDED_KEYS.has(key)) continue;
compareObjects(oldObj[key], newObj[key], path.concat(key), differences);
}
} else {
if (oldObj !== newObj) {
differences.push(`• Changed ${pathToHumanReadable(path)} from:\n ${formatPrimitive(oldObj)}\n To:\n ${formatPrimitive(newObj)}\n`);
}
}
}