implement logging of exam edits on workflow's current step
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
"country-codes-list": "^1.6.11",
|
||||
"currency-symbol-map": "^5.1.0",
|
||||
"daisyui": "^3.1.5",
|
||||
"deep-diff": "^1.0.2",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.1.6",
|
||||
"exceljs": "^4.4.0",
|
||||
@@ -97,6 +98,7 @@
|
||||
"devDependencies": {
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/deep-diff": "^1.0.5",
|
||||
"@types/formidable": "^3.4.0",
|
||||
"@types/howler": "^2.2.11",
|
||||
"@types/lodash": "^4.14.191",
|
||||
|
||||
@@ -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,6 +110,24 @@ 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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
41
yarn.lock
41
yarn.lock
@@ -1683,6 +1683,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.4.tgz#cb7e85d9ad5ababfac2f27183e8ac8b576b2abb3"
|
||||
integrity sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==
|
||||
|
||||
"@types/deep-diff@^1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/deep-diff/-/deep-diff-1.0.5.tgz#95c08a57f097ffadd28bc98a45a8025f53c581e4"
|
||||
integrity sha512-PQyNSy1YMZU1hgZA5tTYfHPpUAo9Dorn1PZho2/budQLfqLu3JIP37JAavnwYpR1S2yFZTXa3hxaE4ifGW5jaA==
|
||||
|
||||
"@types/express-handlebars@^5":
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/express-handlebars/-/express-handlebars-5.3.1.tgz#30447330fa4b7d19bb953834c7c26077a906e25e"
|
||||
@@ -3035,6 +3040,11 @@ decamelize@^1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
|
||||
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
|
||||
|
||||
deep-diff@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26"
|
||||
integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==
|
||||
|
||||
deep-is@^0.1.3, deep-is@~0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||
@@ -6563,7 +6573,16 @@ streamsearch@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -6658,7 +6677,14 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -7295,7 +7321,7 @@ wordwrap@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@@ -7313,6 +7339,15 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
||||
Reference in New Issue
Block a user