ENCOA-130: Add owners to exams
This commit is contained in:
@@ -12,6 +12,7 @@ interface ExamBase {
|
|||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
|
owners?: string[];
|
||||||
shuffle?: boolean;
|
shuffle?: boolean;
|
||||||
createdBy?: string; // option as it has been added later
|
createdBy?: string; // option as it has been added later
|
||||||
createdAt?: string; // option as it has been added later
|
createdAt?: string; // option as it has been added later
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useMemo} from "react";
|
import {useMemo, useState} from "react";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import useExams from "@/hooks/useExams";
|
import useExams from "@/hooks/useExams";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
@@ -11,11 +11,15 @@ import {countExercises} from "@/utils/moduleUtils";
|
|||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize, uniq} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {BsBan, BsBanFill, BsCheck, BsCircle, BsStop, BsTrash, BsUpload, BsX} from "react-icons/bs";
|
import {BsBan, BsBanFill, BsCheck, BsCircle, BsPencil, BsStop, BsTrash, BsUpload, BsX} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
|
||||||
const searchFields = [["module"], ["id"], ["createdBy"]];
|
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||||
|
|
||||||
@@ -29,9 +33,40 @@ const CLASSES: {[key in Module]: string} = {
|
|||||||
|
|
||||||
const columnHelper = createColumnHelper<Exam>();
|
const columnHelper = createColumnHelper<Exam>();
|
||||||
|
|
||||||
|
const ExamOwnerSelector = ({options, exam, onSave}: {options: User[]; exam: Exam; onSave: (owners: string[]) => void}) => {
|
||||||
|
const [owners, setOwners] = useState(exam.owners || []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-4 mt-4">
|
||||||
|
{options.map((c) => (
|
||||||
|
<Button
|
||||||
|
variant={owners.includes(c.id) ? "solid" : "outline"}
|
||||||
|
onClick={() => setOwners((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]))}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
key={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => onSave(owners)} className="w-full max-w-[200px] self-end">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExamList({user}: {user: User}) {
|
export default function ExamList({user}: {user: User}) {
|
||||||
|
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||||
|
|
||||||
const {exams, reload} = useExams();
|
const {exams, reload} = useExams();
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
const {groups} = useGroups({admin: user?.id, userType: user?.type});
|
||||||
|
|
||||||
|
const filteredCorporates = useMemo(() => {
|
||||||
|
const participantsAndAdmins = uniq(groups.flatMap((x) => [...x.participants, x.admin])).filter((x) => x !== user?.id);
|
||||||
|
return users.filter((x) => participantsAndAdmins.includes(x.id) && x.type === "corporate");
|
||||||
|
}, [users, groups, user]);
|
||||||
|
|
||||||
const parsedExams = useMemo(() => {
|
const parsedExams = useMemo(() => {
|
||||||
return exams.map((exam) => {
|
return exams.map((exam) => {
|
||||||
@@ -94,6 +129,29 @@ export default function ExamList({user}: {user: User}) {
|
|||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateExam = async (exam: Exam, body: object) => {
|
||||||
|
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.patch(`/api/exam/${exam.module}/${exam.id}`, body)
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload)
|
||||||
|
.finally(() => setSelectedExam(undefined));
|
||||||
|
};
|
||||||
|
|
||||||
const deleteExam = async (exam: Exam) => {
|
const deleteExam = async (exam: Exam) => {
|
||||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
||||||
|
|
||||||
@@ -166,12 +224,21 @@ export default function ExamList({user}: {user: User}) {
|
|||||||
cell: ({row}: {row: {original: Exam}}) => {
|
cell: ({row}: {row: {original: Exam}}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
||||||
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
<>
|
||||||
onClick={async () => await privatizeExam(row.original)}
|
<button
|
||||||
className="cursor-pointer tooltip">
|
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
||||||
{row.original.private ? <BsCircle /> : <BsBan />}
|
onClick={async () => await privatizeExam(row.original)}
|
||||||
</button>
|
className="cursor-pointer tooltip">
|
||||||
|
{row.original.private ? <BsCircle /> : <BsBan />}
|
||||||
|
</button>
|
||||||
|
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
|
||||||
|
<button data-tip="Edit owners" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
|
||||||
|
<BsPencil />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
data-tip="Load exam"
|
data-tip="Load exam"
|
||||||
className="cursor-pointer tooltip"
|
className="cursor-pointer tooltip"
|
||||||
@@ -198,6 +265,13 @@ export default function ExamList({user}: {user: User}) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full h-full">
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
|
<Modal isOpen={!!selectedExam} title={`Edit Exam Owners - ${selectedExam?.id}`} onClose={() => setSelectedExam(undefined)}>
|
||||||
|
{!!selectedExam ? (
|
||||||
|
<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, {owners})} />
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {capitalize, flatten, uniqBy} from "lodash";
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {sendEmail} from "@/email";
|
import {sendEmail} from "@/email";
|
||||||
import { release } from "os";
|
import {release} from "os";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ const generateExams = async (
|
|||||||
generateMultiple: Boolean,
|
generateMultiple: Boolean,
|
||||||
selectedModules: Module[],
|
selectedModules: Module[],
|
||||||
assignees: string[],
|
assignees: string[],
|
||||||
|
userId: string,
|
||||||
variant?: Variant,
|
variant?: Variant,
|
||||||
instructorGender?: InstructorGender,
|
instructorGender?: InstructorGender,
|
||||||
): Promise<ExamWithUser[]> => {
|
): Promise<ExamWithUser[]> => {
|
||||||
@@ -87,7 +88,7 @@ const generateExams = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||||
const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
|
const exams: Exam[] = await getExams(db, module, "false", userId, variant, instructorGender);
|
||||||
const exam = exams[getRandomIndex(exams)];
|
const exam = exams[getRandomIndex(exams)];
|
||||||
|
|
||||||
if (exam) {
|
if (exam) {
|
||||||
@@ -126,7 +127,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const exams: ExamWithUser[] = !!examIDs
|
const exams: ExamWithUser[] = !!examIDs
|
||||||
? examIDs.flatMap((e) => assignees.map((a) => ({...e, assignee: a})))
|
? examIDs.flatMap((e) => assignees.map((a) => ({...e, assignee: a})))
|
||||||
: await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
|
: await generateExams(generateMultiple, selectedModules, assignees, req.session.user!.id, variant, instructorGender);
|
||||||
|
|
||||||
if (exams.length === 0) {
|
if (exams.length === 0) {
|
||||||
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {sessionOptions} from "@/lib/session";
|
|||||||
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import {getExams} from "@/utils/exams.be";
|
import {getExams} from "@/utils/exams.be";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
import {getUserCorporate} from "@/utils/groups.be";
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
@@ -42,11 +43,16 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {module} = req.query as {module: string};
|
const {module} = req.query as {module: string};
|
||||||
|
const corporate = await getUserCorporate(req.session.user.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const exam = {
|
const exam = {
|
||||||
...req.body,
|
...req.body,
|
||||||
module: module,
|
module: module,
|
||||||
|
owners: [
|
||||||
|
...(["mastercorporate", "corporate"].includes(req.session.user.type) ? [req.session.user.id] : []),
|
||||||
|
...(!!corporate ? [corporate.id] : []),
|
||||||
|
],
|
||||||
createdBy: req.session.user.id,
|
createdBy: req.session.user.id,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {shuffle} from "lodash";
|
|||||||
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
|
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
|
||||||
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
import {getCorporateUser} from "@/resources/user";
|
||||||
|
import {getUserCorporate} from "./groups.be";
|
||||||
|
|
||||||
export const getExams = async (
|
export const getExams = async (
|
||||||
db: Firestore,
|
db: Firestore,
|
||||||
@@ -28,7 +30,8 @@ export const getExams = async (
|
|||||||
})),
|
})),
|
||||||
) as Exam[];
|
) as Exam[];
|
||||||
|
|
||||||
let exams: Exam[] = filterByVariant(allExams, variant);
|
let exams: Exam[] = await filterByOwners(allExams, userId);
|
||||||
|
exams = filterByVariant(exams, variant);
|
||||||
exams = filterByInstructorGender(exams, instructorGender);
|
exams = filterByInstructorGender(exams, instructorGender);
|
||||||
exams = await filterByDifficulty(db, exams, module, userId);
|
exams = await filterByDifficulty(db, exams, module, userId);
|
||||||
exams = await filterByPreference(db, exams, module, userId);
|
exams = await filterByPreference(db, exams, module, userId);
|
||||||
@@ -60,6 +63,20 @@ const filterByVariant = (exams: Exam[], variant?: Variant) => {
|
|||||||
return filtered.length > 0 ? filtered : exams;
|
return filtered.length > 0 ? filtered : exams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterByOwners = async (exams: Exam[], userID?: string) => {
|
||||||
|
if (!userID) return exams.filter((x) => !x.owners || x.owners.length === 0);
|
||||||
|
return Promise.all(
|
||||||
|
exams.filter(async (x) => {
|
||||||
|
if (!x.owners) return true;
|
||||||
|
if (x.owners.length === 0) return true;
|
||||||
|
if (x.owners.includes(userID)) return true;
|
||||||
|
|
||||||
|
const corporate = await getUserCorporate(userID);
|
||||||
|
return !corporate ? false : x.owners.includes(corporate.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
|
const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
|
||||||
if (!userID) return exams;
|
if (!userID) return exams;
|
||||||
const userRef = await getDoc(doc(db, "users", userID));
|
const userRef = await getDoc(doc(db, "users", userID));
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const updateExpiryDateOnGroup = async (participantID: string, corporateID
|
|||||||
|
|
||||||
export const getUserCorporate = async (id: string) => {
|
export const getUserCorporate = async (id: string) => {
|
||||||
const user = await getUser(id);
|
const user = await getUser(id);
|
||||||
|
if (["admin", "developer"].includes(user.type)) return undefined;
|
||||||
if (user.type === "mastercorporate") return user;
|
if (user.type === "mastercorporate") return user;
|
||||||
|
|
||||||
const groups = await getParticipantGroups(id);
|
const groups = await getParticipantGroups(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user