Merged in approval-workflows (pull request #152)
Approval workflows Approved-by: Tiago Ribeiro
This commit is contained in:
@@ -159,7 +159,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
|||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
>
|
>
|
||||||
<FaFileUpload className="mr-2" size={18} />
|
<FaFileUpload className="mr-2" size={18} />
|
||||||
Submit Module as Exam
|
Submit module as exam for approval
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -167,11 +167,14 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
|||||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||||
"disabled:cursor-not-allowed disabled:text-gray-200"
|
"disabled:cursor-not-allowed disabled:text-gray-200"
|
||||||
)}
|
)}
|
||||||
onClick={() => submitModule(false)}
|
onClick={() => {
|
||||||
|
if (!confirm(`Are you sure you want to skip the approval process for this exam?`)) return;
|
||||||
|
submitModule(false);
|
||||||
|
}}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
>
|
>
|
||||||
<FaFileUpload className="mr-2" size={18} />
|
<FaFileUpload className="mr-2" size={18} />
|
||||||
Submit Module as Exam Without Approval Process
|
Submit module as exam and skip approval process
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -183,7 +186,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
|||||||
disabled={!canPreview}
|
disabled={!canPreview}
|
||||||
>
|
>
|
||||||
<FaEye className="mr-2" size={18} />
|
<FaEye className="mr-2" size={18} />
|
||||||
Preview Module
|
Preview module
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ const LevelSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}).filter(part => part.exercises.length > 0),
|
}).filter(part => part.exercises.length > 0),
|
||||||
requiresApproval: requiresApproval,
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: requiresApproval ? true : false, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
isDiagnostic: false,
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "level",
|
module: "level",
|
||||||
id: title,
|
id: title,
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
requiresApproval: requiresApproval,
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: requiresApproval ? true : false, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
isDiagnostic: false,
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "listening",
|
module: "listening",
|
||||||
id: title,
|
id: title,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const ReadingSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
requiresApproval: requiresApproval,
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: requiresApproval ? true : false, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
isDiagnostic: false,
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "reading",
|
module: "reading",
|
||||||
id: title,
|
id: title,
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
module: "speaking",
|
module: "speaking",
|
||||||
id: title,
|
id: title,
|
||||||
requiresApproval: requiresApproval,
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: requiresApproval ? true : false, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
instructorGender: "varied",
|
instructorGender: "varied",
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ const WritingSettings: React.FC = () => {
|
|||||||
module: "writing",
|
module: "writing",
|
||||||
id: title,
|
id: title,
|
||||||
requiresApproval: requiresApproval,
|
requiresApproval: requiresApproval,
|
||||||
isDiagnostic: requiresApproval ? true : false, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
access,
|
access,
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
throw new Error("Name already exists");
|
throw new Error("Name already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdmin(user)) {
|
if (exam.requiresApproval === true) {
|
||||||
exam.isDiagnostic = false;
|
exam.access = "confidential";
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.collection(module).updateOne(
|
await db.collection(module).updateOne(
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
|||||||
const handleApproveStep = () => {
|
const handleApproveStep = () => {
|
||||||
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
|
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
|
||||||
if (isLastStep) {
|
if (isLastStep) {
|
||||||
if (!confirm(`Are you sure you want to approve the last step? Doing so will approve the exam.`)) return;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedWorkflow: ApprovalWorkflow = {
|
const updatedWorkflow: ApprovalWorkflow = {
|
||||||
@@ -192,7 +192,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
|||||||
const examId = currentWorkflow.examId;
|
const examId = currentWorkflow.examId;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/exam/${examModule}/${examId}`, { isDiagnostic: false })
|
.patch(`/api/exam/${examModule}/${examId}`, { access: "private" })
|
||||||
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
|
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
if (reason.response.status === 404) {
|
if (reason.response.status === 404) {
|
||||||
@@ -557,7 +557,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
|||||||
<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-40">
|
||||||
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
|
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
|
||||||
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
|
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
|
||||||
<p key={index} className="text-sm text-gray-500 mb-2">
|
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
|
||||||
{change}
|
{change}
|
||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
import { Exam } from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { diff, Diff } from "deep-diff";
|
import { diff, Diff } from "deep-diff";
|
||||||
|
|
||||||
const EXCLUDED_FIELDS = new Set(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private"]);
|
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]);
|
||||||
|
|
||||||
|
const PATH_LABELS: Record<string, string> = {
|
||||||
|
access: "Access Type",
|
||||||
|
parts: "Parts",
|
||||||
|
exercises: "Exercises",
|
||||||
|
userSolutions: "User Solutions",
|
||||||
|
words: "Words",
|
||||||
|
options: "Options",
|
||||||
|
prompt: "Prompt",
|
||||||
|
text: "Text",
|
||||||
|
audio: "Audio",
|
||||||
|
script: "Script",
|
||||||
|
difficulty: "Difficulty",
|
||||||
|
shuffle: "Shuffle",
|
||||||
|
solutions: "Solutions",
|
||||||
|
variant: "Variant",
|
||||||
|
prefix: "Prefix",
|
||||||
|
suffix: "Suffix",
|
||||||
|
topic: "Topic",
|
||||||
|
allowRepetition: "Allow Repetition",
|
||||||
|
maxWords: "Max Words",
|
||||||
|
minTimer: "Timer",
|
||||||
|
};
|
||||||
|
|
||||||
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
|
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
|
||||||
const differences = diff(oldExam, newExam) || [];
|
const differences = diff(oldExam, newExam) || [];
|
||||||
@@ -9,34 +32,23 @@ export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDifference(change: Diff<any, any>): string | undefined {
|
function formatDifference(change: Diff<any, any>): string | undefined {
|
||||||
if (!change.path) {
|
if (!change.path) return;
|
||||||
|
|
||||||
|
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
|
const pathString = pathToHumanReadable(change.path);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert path array to something human-readable
|
|
||||||
const pathString = change.path.join(" \u2192 "); // e.g. "parts → 0 → exercises → 1 → prompt"
|
|
||||||
|
|
||||||
switch (change.kind) {
|
switch (change.kind) {
|
||||||
case "N":
|
case "N": // New property/element
|
||||||
// A new property/element was added
|
return `• Added ${pathString} with value: ${formatValue(change.rhs)}\n`;
|
||||||
return `\u{2022} Added \`${pathString}\` with value: ${formatValue(change.rhs)}`;
|
case "D": // Deleted property/element
|
||||||
|
return `• Removed ${pathString} which had value: ${formatValue(change.lhs)}\n`;
|
||||||
case "D":
|
case "E": // Edited property/element
|
||||||
// A property/element was deleted
|
return `• Changed ${pathString} from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}\n`;
|
||||||
return `\u{2022} Removed \`${pathString}\` which had value: ${formatValue(change.lhs)}`;
|
case "A": // Array change
|
||||||
|
|
||||||
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);
|
return formatArrayChange(change);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -44,12 +56,12 @@ function formatDifference(change: Diff<any, any>): string | undefined {
|
|||||||
|
|
||||||
function formatArrayChange(change: Diff<any, any>): string | undefined {
|
function formatArrayChange(change: Diff<any, any>): string | undefined {
|
||||||
if (!change.path) return;
|
if (!change.path) return;
|
||||||
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
|
|
||||||
|
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathString = change.path.join(" \u2192 ");
|
const pathString = pathToHumanReadable(change.path);
|
||||||
|
|
||||||
const arrayChange = (change as any).item;
|
const arrayChange = (change as any).item;
|
||||||
const idx = (change as any).index;
|
const idx = (change as any).index;
|
||||||
|
|
||||||
@@ -57,14 +69,13 @@ function formatArrayChange(change: Diff<any, any>): string | undefined {
|
|||||||
|
|
||||||
switch (arrayChange.kind) {
|
switch (arrayChange.kind) {
|
||||||
case "N":
|
case "N":
|
||||||
return `\u{2022} Added an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.rhs)}`;
|
return `• Added an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.rhs)}\n`;
|
||||||
case "D":
|
case "D":
|
||||||
return `\u{2022} Removed an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.lhs)}`;
|
return `• Removed an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.lhs)}\n`;
|
||||||
case "E":
|
case "E":
|
||||||
return `\u{2022} Edited an item at index [${idx}] in \`${pathString}\` from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}`;
|
return `• Edited an item at [#${idx + 1}] in ${pathString} from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}\n`;
|
||||||
case "A":
|
case "A":
|
||||||
// Nested array changes could happen theoretically; handle or ignore similarly
|
return `• Complex array change at [#${idx + 1}] in ${pathString}: ${JSON.stringify(arrayChange)}\n`;
|
||||||
return `\u{2022} Complex array change at index [${idx}] in \`${pathString}\`: ${JSON.stringify(arrayChange)}`;
|
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,12 +84,64 @@ function formatArrayChange(change: Diff<any, any>): string | undefined {
|
|||||||
function formatValue(value: any): string {
|
function formatValue(value: any): string {
|
||||||
if (value === null) return "null";
|
if (value === null) return "null";
|
||||||
if (value === undefined) return "undefined";
|
if (value === undefined) return "undefined";
|
||||||
|
|
||||||
if (typeof value === "object") {
|
if (typeof value === "object") {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value);
|
const sanitized = removeExcludedKeysDeep(value, EXCLUDED_KEYS);
|
||||||
|
|
||||||
|
const renamed = renameKeysDeep(sanitized, PATH_LABELS);
|
||||||
|
|
||||||
|
return JSON.stringify(renamed, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return JSON.stringify(value);
|
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(" → ");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user