Created a system to go directly to an assignment from a URL

This commit is contained in:
Tiago Ribeiro
2024-10-17 18:24:39 +01:00
parent a0a9402945
commit 4917583c67
10 changed files with 232 additions and 57 deletions

View File

@@ -11,16 +11,18 @@ interface Props {
className?: string; className?: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
hideSidebar?: boolean
bgColor?: string; bgColor?: string;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
} }
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Layout({user, children, className, bgColor="bg-white", hideSidebar, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}> <main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<ToastContainer /> <ToastContainer />
{!hideSidebar && (
<Navbar <Navbar
path={router.pathname} path={router.pathname}
user={user} user={user}
@@ -28,7 +30,9 @@ export default function Layout({user, children, className, bgColor="bg-white", n
focusMode={focusMode} focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter} onFocusLayerMouseEnter={onFocusLayerMouseEnter}
/> />
<div className="h-full w-full flex gap-2"> )}
<div className={clsx("h-full w-full flex gap-2")}>
{!hideSidebar && (
<Sidebar <Sidebar
path={router.pathname} path={router.pathname}
navDisabled={navDisabled} navDisabled={navDisabled}
@@ -37,10 +41,12 @@ export default function Layout({user, children, className, bgColor="bg-white", n
className="-md:hidden" className="-md:hidden"
user={user} user={user}
/> />
)}
<div <div
className={clsx( className={clsx(
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`, `w-full min-h-full ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
bgColor !== "bg-white" ? "justify-center" : "h-fit", bgColor !== "bg-white" ? "justify-center" : "h-fit",
hideSidebar ? "md:mx-8" : "md:mr-8",
className, className,
)}> )}>
{children} {children}

View File

@@ -25,13 +25,16 @@ import useSessions from "@/hooks/useSessions";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import clsx from "clsx"; import clsx from "clsx";
import useGradingSystem from "@/hooks/useGrading"; import useGradingSystem from "@/hooks/useGrading";
import { Assignment } from "@/interfaces/results";
import { mapBy } from "@/utils";
interface Props { interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
user: User; user: User;
hideSidebar?: boolean
} }
export default function ExamPage({page, user}: Props) { export default function ExamPage({page, user, hideSidebar = false}: Props) {
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
@@ -210,7 +213,7 @@ export default function ExamPage({page, user}: Props) {
}, [setModuleIndex, showSolutions]); }, [setModuleIndex, showSolutions]);
useEffect(() => { useEffect(() => {
(async () => { console.log(selectedModules)
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) { if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = exams[moduleIndex]; const nextExam = exams[moduleIndex];
@@ -218,7 +221,6 @@ export default function ExamPage({page, user}: Props) {
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0); if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
} }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, exams]); }, [selectedModules, moduleIndex, exams]);
@@ -520,6 +522,7 @@ export default function ExamPage({page, user}: Props) {
<Layout <Layout
user={user} user={user}
bgColor={bgColor} bgColor={bgColor}
hideSidebar={hideSidebar}
className="justify-between" className="justify-between"
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length} focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}> onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>

View File

@@ -52,7 +52,8 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params})
const entity = await getEntityWithRoles(assignment.entity || "") const entity = await getEntityWithRoles(assignment.entity || "")
if (!entity) return redirect("/assignments") if (!entity) return redirect("/assignments")
if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments") if (!doesEntityAllow(user, entity, 'view_assignments') && !["admin", "developer"].includes(user.type))
return redirect("/assignments")
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id)); const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id));

View File

@@ -119,15 +119,6 @@ export default function AssignmentsPage({assignments, corporateAssignments, enti
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/ <b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)} {activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)}
</span> </span>
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
<div key={x}>
<span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span>
<span>
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
</span>
</div>
))}
</div> </div>
</div> </div>

View File

@@ -9,11 +9,11 @@ import {Entity, EntityWithRoles} from "@/interfaces/entity";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import {mapBy, redirect, serialize} from "@/utils"; import {filterBy, mapBy, redirect, serialize} from "@/utils";
import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {getUserName} from "@/utils/users"; import {getUserName} from "@/utils/users";
import {getLinkedUsers} from "@/utils/users.be"; import {getEntitiesUsers, getLinkedUsers} from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
@@ -22,7 +22,7 @@ import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
import {useState} from "react"; import {useMemo, useState} from "react";
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs"; import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
@@ -34,12 +34,12 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/")
const linkedUsers = await getLinkedUsers(user.id, user.type); const users = await getEntitiesUsers(mapBy(user.entities, 'id'))
const entities = await getEntitiesWithRoles(mapBy(user.entities, "id")); const entities = await getEntitiesWithRoles(mapBy(user.entities, "id"));
const allowedEntities = findAllowedEntities(user, entities, "create_classroom") const allowedEntities = findAllowedEntities(user, entities, "create_classroom")
return { return {
props: serialize({user, entities: allowedEntities, users: linkedUsers.users.filter((x) => x.id !== user.id)}), props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -55,7 +55,9 @@ export default function Home({user, users, entities}: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id); const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
const {rows, renderSearch} = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users); const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users])
const {rows, renderSearch} = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], entityUsers);
const {items, renderMinimal} = usePagination<User>(rows, 16); const {items, renderMinimal} = usePagination<User>(rows, 16);
const router = useRouter(); const router = useRouter();

View File

@@ -6,15 +6,52 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ExamPage from "./(exam)/ExamPage"; import ExamPage from "./(exam)/ExamPage";
import Head from "next/head"; import Head from "next/head";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { redirect, serialize } from "@/utils"; import { filterBy, findBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be";
import { Assignment } from "@/interfaces/results";
import useExamStore from "@/stores/examStore";
import { useEffect } from "react";
import { Exam } from "@/interfaces/exam";
import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils";
import { uniqBy } from "lodash";
import { useRouter } from "next/router";
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions";
import moment from "moment";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") const destination = Buffer.from(req.url || "/").toString("base64")
if (!user) return redirect(`/login?destination=${destination}`)
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/")
const {assignment: assignmentID} = query as {assignment?: string}
if (assignmentID) {
const assignment = await getAssignment(assignmentID)
if (!assignment) return redirect("/exam")
if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type))
return redirect("/exam")
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID)
if (
filterBy(assignment.results, 'user', user.id).length > 0 ||
moment(assignment.startDate).isAfter(moment()) ||
moment(assignment.endDate).isBefore(moment())
)
return redirect("/exam")
return {
props: serialize({user, assignment, exams, session})
}
}
return { return {
props: serialize({user}), props: serialize({user}),
}; };
@@ -22,9 +59,55 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment
exams?: Exam[]
session?: Session
} }
export default function Page({user}: Props) { export default function Page({user, assignment, exams = [], session}: Props) {
const router = useRouter()
const state = useExamStore((state) => state)
useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !session) {
state.setUserSolutions([]);
state.setShowSolutions(false);
state.setAssignment(assignment);
state.setExams(exams.sort(sortByModule));
state.setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !!session) {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
return ( return (
<> <>
<Head> <Head>
@@ -36,7 +119,7 @@ export default function Page({user}: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exams" user={user} /> <ExamPage page="exams" user={user} hideSidebar={!!assignment} />
</> </>
); );
} }

View File

@@ -6,15 +6,51 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ExamPage from "./(exam)/ExamPage"; import ExamPage from "./(exam)/ExamPage";
import Head from "next/head"; import Head from "next/head";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { redirect, serialize } from "@/utils"; import { filterBy, findBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be";
import { Assignment } from "@/interfaces/results";
import useExamStore from "@/stores/examStore";
import { useEffect } from "react";
import { Exam } from "@/interfaces/exam";
import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils";
import { uniqBy } from "lodash";
import { useRouter } from "next/router";
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions";
import moment from "moment";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") const destination = Buffer.from(req.url || "/").toString("base64")
if (!user) return redirect(`/login?destination=${destination}`)
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/")
const {assignment: assignmentID} = query as {assignment?: string}
if (assignmentID) {
const assignment = await getAssignment(assignmentID)
if (!assignment) return redirect("/exercises")
if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises")
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID)
if (
filterBy(assignment.results, 'user', user.id) ||
moment(assignment.startDate).isBefore(moment()) ||
moment(assignment.endDate).isAfter(moment())
)
return redirect("/exercises")
return {
props: serialize({user, assignment, exams, session})
}
}
return { return {
props: serialize({user}), props: serialize({user}),
}; };
@@ -22,13 +58,59 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment
exams?: Exam[]
session?: Session
} }
export default function Page({user}: Props) { export default function Page({user, assignment, exams = [], session}: Props) {
const router = useRouter()
const state = useExamStore((state) => state)
useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !session) {
state.setUserSolutions([]);
state.setShowSolutions(false);
state.setAssignment(assignment);
state.setExams(exams.sort(sortByModule));
state.setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !!session) {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
return ( return (
<> <>
<Head> <Head>
<title>Exercises | EnCoach</title> <title>Exams | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
@@ -36,7 +118,7 @@ export default function Page({user}: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exercises" user={user} /> <ExamPage page="exams" user={user} hideSidebar={!!assignment} />
</> </>
); );
} }

View File

@@ -20,16 +20,17 @@ import { redirect } from "@/utils";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g);
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
const destination = !query.destination ? "/" : Buffer.from(query.destination as string, 'base64').toString()
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (user) return redirect("/") if (user) return redirect(destination)
return { return {
props: {user: null}, props: {user: null, destination},
}; };
}, sessionOptions); }, sessionOptions);
export default function Login() { export default function Login({ destination }: { destination: string }) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [rememberPassword, setRememberPassword] = useState(false); const [rememberPassword, setRememberPassword] = useState(false);
@@ -38,13 +39,13 @@ export default function Login() {
const router = useRouter(); const router = useRouter();
const {user, mutateUser} = useUser({ const {user, mutateUser} = useUser({
redirectTo: "/", redirectTo: destination,
redirectIfFound: true, redirectIfFound: true,
}); });
useEffect(() => { useEffect(() => {
if (user) router.push("/"); if (user) router.push(destination);
}, [router, user]); }, [router, user, destination]);
const forgotPassword = () => { const forgotPassword = () => {
if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) { if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) {

View File

@@ -8,6 +8,7 @@ import {getUserCorporate} from "./groups.be";
import {Db, ObjectId} from "mongodb"; import {Db, ObjectId} from "mongodb";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import {MODULE_ARRAY} from "./moduleUtils"; import {MODULE_ARRAY} from "./moduleUtils";
import { mapBy } from ".";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -37,7 +38,7 @@ export const getExamsByIds = async (ids: {module: Module; id: string}[]) => {
async (m) => async (m) =>
await db await db
.collection(m) .collection(m)
.find<Exam>({id: {$in: groupedByModule[m]}}) .find<Exam>({id: {$in: mapBy(groupedByModule[m], 'id')}})
.toArray(), .toArray(),
), ),
) )

View File

@@ -9,3 +9,8 @@ export const getSessionsByUser = async (id: string, limit?: number) =>
.find<Session>({user: id}) .find<Session>({user: id})
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();
export const getSessionByAssignment = async (assignmentID: string) =>
await db
.collection("sessions")
.findOne<Session>({"assignment.id": assignmentID})