added access variable to exams soo we can distinguish private, public and confidential exams and also bugfixes and improvements

This commit is contained in:
José Lima
2025-02-09 04:28:34 +00:00
parent f95bce6fa2
commit b175d8797e
32 changed files with 1320 additions and 909 deletions

View File

@@ -1,287 +1,394 @@
import {useMemo, useState} from "react";
import {PERMISSIONS} from "@/constants/userPermissions";
import { useMemo, useState } from "react";
import { PERMISSIONS } from "@/constants/userPermissions";
import useExams from "@/hooks/useExams";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces";
import {Exam} from "@/interfaces/exam";
import {User} from "@/interfaces/user";
import { Module } from "@/interfaces";
import { Exam } from "@/interfaces/exam";
import { User } from "@/interfaces/user";
import useExamStore from "@/stores/exam";
import {getExamById} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import { getExamById } from "@/utils/exams";
import { countExercises } from "@/utils/moduleUtils";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios";
import {capitalize, uniq} from "lodash";
import {useRouter} from "next/router";
import {BsBan, BsCheck, BsCircle, BsPencil, BsTrash, BsUpload, BsX} from "react-icons/bs";
import {toast} from "react-toastify";
import {useListSearch} from "@/hooks/useListSearch";
import { capitalize } from "lodash";
import { useRouter } from "next/router";
import { BsCheck, BsPencil, BsTrash, BsUpload, BsX } from "react-icons/bs";
import { toast } from "react-toastify";
import { useListSearch } from "@/hooks/useListSearch";
import Modal from "@/components/Modal";
import {checkAccess} from "@/utils/permissions";
import useGroups from "@/hooks/useGroups";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import Button from "@/components/Low/Button";
import {EntityWithRoles} from "@/interfaces/entity";
import {BiEdit} from "react-icons/bi";
import {findBy, mapBy} from "@/utils";
import {getUserName} from "@/utils/users";
import { EntityWithRoles } from "@/interfaces/entity";
import { BiEdit } from "react-icons/bi";
import { findBy, mapBy } from "@/utils";
const searchFields = [["module"], ["id"], ["createdBy"]];
const CLASSES: {[key in Module]: string} = {
reading: "text-ielts-reading",
listening: "text-ielts-listening",
speaking: "text-ielts-speaking",
writing: "text-ielts-writing",
level: "text-ielts-level",
const CLASSES: { [key in Module]: string } = {
reading: "text-ielts-reading",
listening: "text-ielts-listening",
speaking: "text-ielts-speaking",
writing: "text-ielts-writing",
level: "text-ielts-level",
};
const columnHelper = createColumnHelper<Exam>();
export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[]}) {
const [selectedExam, setSelectedExam] = useState<Exam>();
export default function ExamList({
user,
entities,
}: {
user: User;
entities: EntityWithRoles[];
}) {
const [selectedExam, setSelectedExam] = useState<Exam>();
const {exams, reload} = useExams();
const {users} = useUsers();
const canViewConfidentialEntities = useMemo(
() =>
mapBy(
findAllowedEntities(user, entities, "view_confidential_exams"),
"id"
),
[user, entities]
);
const filteredExams = useMemo(
() =>
exams.filter((e) => {
if (!e.private) return true;
return (e.entities || []).some((ent) => mapBy(user.entities, "id").includes(ent));
}),
[exams, user?.entities],
);
const { exams, reload, isLoading } = useExams();
const { users } = useUsers();
// Pass this permission filter to the backend later
const filteredExams = useMemo(
() =>
["admin", "developer"].includes(user.type)
? exams
: exams.filter((item) => {
if (
item.access === "confidential" &&
!canViewConfidentialEntities.find((x) =>
(item.entities ?? []).includes(x)
)
)
return false;
return true;
}),
[canViewConfidentialEntities, exams, user.type]
);
const parsedExams = useMemo(() => {
return filteredExams.map((exam) => {
if (exam.createdBy) {
const user = users.find((u) => u.id === exam.createdBy);
if (!user) return exam;
const parsedExams = useMemo(() => {
return filteredExams.map((exam) => {
if (exam.createdBy) {
const user = users.find((u) => u.id === exam.createdBy);
if (!user) return exam;
return {
...exam,
createdBy: user.type === "developer" ? "system" : user.name,
};
}
return {
...exam,
createdBy: user.type === "developer" ? "system" : user.name,
};
}
return exam;
});
}, [filteredExams, users]);
return exam;
});
}, [filteredExams, users]);
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(
searchFields,
parsedExams
);
const dispatch = useExamStore((state) => state.dispatch);
const dispatch = useExamStore((state) => state.dispatch);
const router = useRouter();
const router = useRouter();
const loadExam = async (module: Module, examId: string) => {
const exam = await getExamById(module, examId.trim());
if (!exam) {
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
toastId: "invalid-exam-id",
});
const loadExam = async (module: Module, examId: string) => {
const exam = await getExamById(module, examId.trim());
if (!exam) {
toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{
toastId: "invalid-exam-id",
}
);
return;
}
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}});
return;
}
dispatch({
type: "INIT_EXAM",
payload: { exams: [exam], modules: [module] },
});
router.push("/exam");
};
router.push("/exam");
};
const privatizeExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
/*
const privatizeExam = async (exam: Exam) => {
if (
!confirm(
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
exam.access
}?`
)
)
return;
axios
.patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private})
.then(() => toast.success(`Updated the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
axios
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
.then(() => toast.success(`Updated the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
*/
const deleteExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
const deleteExam = async (exam: Exam) => {
if (
!confirm(
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
)
)
return;
axios
.delete(`/api/exam/${exam.module}/${exam.id}`)
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
axios
.delete(`/api/exam/${exam.module}/${exam.id}`)
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const getTotalExercises = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
return countExercises(exam.parts.flatMap((x) => x.exercises));
}
const getTotalExercises = (exam: Exam) => {
if (
exam.module === "reading" ||
exam.module === "listening" ||
exam.module === "level"
) {
return countExercises((exam.parts ?? []).flatMap((x) => x.exercises));
}
return countExercises(exam.exercises);
};
return countExercises(exam.exercises);
};
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("module", {
header: "Module",
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
}),
columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("minTimer", {
header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>,
}),
columnHelper.accessor("private", {
header: "Private",
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>,
}),
columnHelper.accessor("createdAt", {
header: "Created At",
cell: (info) => {
const value = info.getValue();
if (value) {
return new Date(value).toLocaleDateString();
}
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("module", {
header: "Module",
cell: (info) => (
<span className={CLASSES[info.getValue()]}>
{capitalize(info.getValue())}
</span>
),
}),
columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("minTimer", {
header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>,
}),
columnHelper.accessor("access", {
header: "Access",
cell: (info) => <span>{capitalize(info.getValue())}</span>,
}),
columnHelper.accessor("createdAt", {
header: "Created At",
cell: (info) => {
const value = info.getValue();
if (value) {
return new Date(value).toLocaleDateString();
}
return null;
},
}),
columnHelper.accessor("createdBy", {
header: "Created By",
cell: (info) => (!info.getValue() ? "System" : findBy(users, "id", info.getValue())?.name || "N/A"),
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Exam}}) => {
return (
<div className="flex gap-4">
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
<>
<button
data-tip={row.original.private ? "Set as public" : "Set as private"}
onClick={async () => await privatizeExam(row.original)}
className="cursor-pointer tooltip">
{row.original.private ? <BsCircle /> : <BsBan />}
</button>
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
<button data-tip="Edit exam" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
<BsPencil />
</button>
)}
</>
)}
<button
data-tip="Load exam"
className="cursor-pointer tooltip"
onClick={async () => await loadExam(row.original.module, row.original.id)}>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{PERMISSIONS.examManagement.delete.includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
return null;
},
}),
columnHelper.accessor("createdBy", {
header: "Created By",
cell: (info) =>
!info.getValue()
? "System"
: findBy(users, "id", info.getValue())?.name || "N/A",
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Exam } }) => {
return (
<div className="flex gap-4">
{(row.original.owners?.includes(user.id) ||
checkAccess(user, ["admin", "developer"])) && (
<>
{checkAccess(user, [
"admin",
"developer",
"mastercorporate",
]) && (
<button
data-tip="Edit exam"
onClick={() => setSelectedExam(row.original)}
className="cursor-pointer tooltip"
>
<BsPencil />
</button>
)}
</>
)}
<button
data-tip="Load exam"
className="cursor-pointer tooltip"
onClick={async () =>
await loadExam(row.original.module, row.original.id)
}
>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{PERMISSIONS.examManagement.delete.includes(user.type) && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteExam(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({
data: filteredRows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const table = useReactTable({
data: filteredRows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const handleExamEdit = () => {
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
};
const handleExamEdit = () => {
router.push(
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
);
};
return (
<div className="flex flex-col gap-4 w-full h-full">
{renderSearch()}
<Modal isOpen={!!selectedExam} onClose={() => setSelectedExam(undefined)} maxWidth="max-w-xl">
{!!selectedExam ? (
<>
<div className="p-6">
<div className="mb-6">
<div className="flex items-center gap-2 mb-4">
<BiEdit className="w-5 h-5 text-gray-600" />
<span className="text-gray-600 font-medium">Ready to Edit</span>
</div>
return (
<div className="flex flex-col gap-4 w-full h-full">
{renderSearch()}
<Modal
isOpen={!!selectedExam}
onClose={() => setSelectedExam(undefined)}
maxWidth="max-w-xl"
>
{!!selectedExam ? (
<>
<div className="p-6">
<div className="mb-6">
<div className="flex items-center gap-2 mb-4">
<BiEdit className="w-5 h-5 text-gray-600" />
<span className="text-gray-600 font-medium">
Ready to Edit
</span>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-3">
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-3">
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
</div>
<p className="text-gray-500 text-sm">Click &apos;Next&apos; to proceed to the exam editor.</p>
</div>
<p className="text-gray-500 text-sm">
Click &apos;Next&apos; to proceed to the exam editor.
</p>
</div>
<div className="flex justify-between gap-4 mt-8">
<Button color="purple" variant="outline" onClick={() => setSelectedExam(undefined)} className="w-32">
Cancel
</Button>
<Button color="purple" onClick={handleExamEdit} className="w-32 text-white flex items-center justify-center gap-2">
Proceed
</Button>
</div>
</div>
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
</>
) : (
<div />
)}
</Modal>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
<div className="flex justify-between gap-4 mt-8">
<Button
color="purple"
variant="outline"
onClick={() => setSelectedExam(undefined)}
className="w-32"
>
Cancel
</Button>
<Button
color="purple"
onClick={handleExamEdit}
className="w-32 text-white flex items-center justify-center gap-2"
>
Proceed
</Button>
</div>
</div>
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
</>
) : (
<div />
)}
</Modal>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{isLoading ? (
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
) : (
filteredRows.length === 0 && (
<div className="w-full flex justify-center items-start">
<span className="text-xl text-gray-500">No data found...</span>
</div>
)
)}
</div>
);
}