implement logging of exam edits on workflow's current step
This commit is contained in:
@@ -37,6 +37,7 @@ export interface WorkflowStep {
|
||||
finalStep?: boolean,
|
||||
selected?: boolean,
|
||||
comments?: string,
|
||||
examChanges?: string[],
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import client from "@/lib/mongodb";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { mapBy } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/approval.workflows.be";
|
||||
import { generateExamDifferences } from "@/utils/exam.differences";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
@@ -108,8 +110,26 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
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 (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,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import { FaSpinner, FaWpforms } from "react-icons/fa6";
|
||||
import { FiSave } from "react-icons/fi";
|
||||
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
|
||||
import { IoDocumentTextOutline } from "react-icons/io5";
|
||||
import { MdOutlineDoubleArrow } from "react-icons/md";
|
||||
import { MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineDoubleArrow } from "react-icons/md";
|
||||
import { RiThumbUpLine } from "react-icons/ri";
|
||||
import { RxCrossCircled } from "react-icons/rx";
|
||||
import { TiEdit } from "react-icons/ti";
|
||||
@@ -89,6 +89,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
const [selectedStepIndex, setSelectedStepIndex] = useState<number>(currentStepIndex);
|
||||
const [selectedStep, setSelectedStep] = useState<WorkflowStep>(currentWorkflow.steps[selectedStepIndex]);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||||
const [isAccordionOpen, setIsAccordionOpen] = useState(false);
|
||||
const [comments, setComments] = useState<string>(selectedStep.comments || "");
|
||||
const [viewExamIsLoading, setViewExamIsLoading] = useState<boolean>(false);
|
||||
const [editExamIsLoading, setEditExamIsLoading] = useState<boolean>(false);
|
||||
@@ -517,11 +518,53 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
|
||||
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
|
||||
|
||||
{/* Accordion for Exam Changes */}
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer p-2 rounded-lg"
|
||||
onClick={() => setIsAccordionOpen((prev) => !prev)}
|
||||
>
|
||||
<h2 className="font-medium text-gray-500">
|
||||
Changes ({currentWorkflow.steps[selectedStepIndex].examChanges?.length || "0"})
|
||||
</h2>
|
||||
{isAccordionOpen ? (
|
||||
<MdKeyboardArrowUp size={24} />
|
||||
) : (
|
||||
<MdKeyboardArrowDown size={24} />
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isAccordionOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
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">
|
||||
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
|
||||
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
|
||||
<p key={index} className="text-sm text-gray-500 mb-2">
|
||||
{change}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
|
||||
|
||||
<textarea
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Input comments here"
|
||||
className="w-full h-64 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
|
||||
className="w-full h-40 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -34,6 +34,16 @@ export const getApprovalWorkflowByFormIntaker = async (entityId: string, formInt
|
||||
});
|
||||
};
|
||||
|
||||
export const getApprovalWorkflowsByExamId = async (examId: string) => {
|
||||
return await db
|
||||
.collection<ApprovalWorkflow>("active-workflows")
|
||||
.find({
|
||||
examId,
|
||||
status: { $in: ["pending"] }
|
||||
})
|
||||
.toArray();
|
||||
};
|
||||
|
||||
export const getFormIntakersByEntity = async (entityId: string) => {
|
||||
const results = await db
|
||||
.collection<ApprovalWorkflow>("configured-workflows")
|
||||
|
||||
84
src/utils/exam.differences.ts
Normal file
84
src/utils/exam.differences.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { diff, Diff } from "deep-diff";
|
||||
|
||||
const EXCLUDED_FIELDS = new Set(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private"]);
|
||||
|
||||
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
|
||||
const differences = diff(oldExam, newExam) || [];
|
||||
return differences.map((change) => formatDifference(change)).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
function formatDifference(change: Diff<any, any>): string | undefined {
|
||||
if (!change.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert path array to something human-readable
|
||||
const pathString = change.path.join(" \u2192 "); // e.g. "parts → 0 → exercises → 1 → prompt"
|
||||
|
||||
switch (change.kind) {
|
||||
case "N":
|
||||
// A new property/element was added
|
||||
return `\u{2022} Added \`${pathString}\` with value: ${formatValue(change.rhs)}`;
|
||||
|
||||
case "D":
|
||||
// A property/element was deleted
|
||||
return `\u{2022} Removed \`${pathString}\` which had value: ${formatValue(change.lhs)}`;
|
||||
|
||||
case "E":
|
||||
// A property/element was edited
|
||||
return `\u{2022} Changed \`${pathString}\` from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}`;
|
||||
|
||||
case "A":
|
||||
// An array change; change.item describes what happened at array index change.index
|
||||
return formatArrayChange(change);
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function formatArrayChange(change: Diff<any, any>): string | undefined {
|
||||
if (!change.path) return;
|
||||
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathString = change.path.join(" \u2192 ");
|
||||
|
||||
const arrayChange = (change as any).item;
|
||||
const idx = (change as any).index;
|
||||
|
||||
if (!arrayChange) return;
|
||||
|
||||
switch (arrayChange.kind) {
|
||||
case "N":
|
||||
return `\u{2022} Added an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.rhs)}`;
|
||||
case "D":
|
||||
return `\u{2022} Removed an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.lhs)}`;
|
||||
case "E":
|
||||
return `\u{2022} Edited an item at index [${idx}] in \`${pathString}\` from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}`;
|
||||
case "A":
|
||||
// Nested array changes could happen theoretically; handle or ignore similarly
|
||||
return `\u{2022} Complex array change at index [${idx}] in \`${pathString}\`: ${JSON.stringify(arrayChange)}`;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: any): string {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
Reference in New Issue
Block a user