diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 6ebb6176..7a4b155c 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -2,7 +2,7 @@ import morgan from "morgan"; import express, { ErrorRequestHandler } from "express"; import cors from "cors"; import { isUser } from "~/controllers/user"; -import { getAllCourses, getCourseByID, getCourses, getFilteredCourses } from "~/controllers/courses"; +import { getAllCourses, getCourseByID, getCourses, getFilteredCourses, getRequisites } from "~/controllers/courses"; import { getFCEs } from "~/controllers/fces"; import { getInstructors } from "~/controllers/instructors"; import { getGeneds } from "~/controllers/geneds"; @@ -21,6 +21,7 @@ app.route("/course/:courseID").get(getCourseByID); app.route("/courses").get(getCourses); app.route("/courses").post(isUser, getCourses); app.route("/courses/all").get(getAllCourses); +app.route("/courses/requisites/:courseID").get(getRequisites); app.route("/courses/search/").get(getFilteredCourses); app.route("/courses/search/").post(isUser, getFilteredCourses); diff --git a/apps/backend/src/controllers/courses.ts b/apps/backend/src/controllers/courses.ts index 7bd88b82..f1aa233b 100644 --- a/apps/backend/src/controllers/courses.ts +++ b/apps/backend/src/controllers/courses.ts @@ -6,6 +6,7 @@ import { SingleOrArray, singleToArray, standardizeID, + parsePrereqString, } from "~/util"; import { RequestHandler } from "express"; import db, { Prisma } from "@cmucourses/db"; @@ -272,3 +273,52 @@ export const getAllCourses: RequestHandler< res.json(allCoursesEntry.allCourses); } }; + + +export const getRequisites: RequestHandler = async (req, res, next) => { + try { + if (!req.params.courseID) { + return res.status(400).json({ error: 'courseID parameter is required' }); + } + + const courseID = standardizeID(req.params.courseID); + + const course = await db.courses.findUnique({ + where: { courseID }, + select: { + courseID: true, + prereqs: true, + prereqString: true, + }, + }); + + if (!course) { + return res.status(400).json({ error: 'Course not found' }); + } + + const parsedPrereqs = parsePrereqString(course.prereqString); + + const postreqs = await db.courses.findMany({ + where: { + prereqs: { + has: course.courseID, + }, + }, + select: { + courseID: true, + }, + }); + + const postreqIDs = postreqs.map(postreq => postreq.courseID); + + const courseRequisites = { + prereqs: course.prereqs, + prereqRelations: parsedPrereqs, + postreqs: postreqIDs + } + + res.json(courseRequisites); + } catch (e) { + next(e); + } +}; diff --git a/apps/backend/src/util.ts b/apps/backend/src/util.ts index b9adddf5..7075ec08 100644 --- a/apps/backend/src/util.ts +++ b/apps/backend/src/util.ts @@ -40,4 +40,10 @@ export type PrismaReturn any> = Awaited>; export type ElemType = - ArrayType extends readonly (infer ElementType)[] ? ElementType : never; \ No newline at end of file + ArrayType extends readonly (infer ElementType)[] ? ElementType : never; + +export function parsePrereqString(prereqString: string): string[][] { + const normalized = prereqString.replace(/\s+/g, "").replace(/[()]/g, ""); // Remove whitespace and parentheses + const andGroups = normalized.split("and"); // Split by AND groups + return andGroups.map((group) => group.split("or")); // Split each AND group into OR relationships +} \ No newline at end of file diff --git a/apps/frontend/src/app/api/course.ts b/apps/frontend/src/app/api/course.ts index 8196e5d5..4af39501 100644 --- a/apps/frontend/src/app/api/course.ts +++ b/apps/frontend/src/app/api/course.ts @@ -138,3 +138,29 @@ export const useFetchAllCourses = () => { staleTime: STALE_TIME, }); }; + +export type CourseRequisites = { + prereqs: string[]; + prereqRelations: string[][]; + postreqs: string[]; +}; + +export const fetchCourseRequisites = async (courseID: string): Promise => { + const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/courses/requisites/${courseID}`; + + const response = await axios.get(url, { + headers: { + "Content-Type": "application/json", + }, + }); + + return response.data; +}; + +export const useFetchCourseRequisites = (courseID: string) => { + return useQuery({ + queryKey: ['courseRequisites', courseID], + queryFn: () => fetchCourseRequisites(courseID), + staleTime: STALE_TIME, + }); +}; diff --git a/apps/frontend/src/app/types.ts b/apps/frontend/src/app/types.ts index f1b194f9..c86059ee 100644 --- a/apps/frontend/src/app/types.ts +++ b/apps/frontend/src/app/types.ts @@ -90,3 +90,9 @@ export interface Gened { fces: FCE[]; } +export interface TreeNode { + courseID: string; + prereqs?: TreeNode[]; + prereqRelations?: TreeNode[][]; + postreqs?: TreeNode[]; +} diff --git a/apps/frontend/src/components/Buttons.tsx b/apps/frontend/src/components/Buttons.tsx index d802b712..64709ea0 100644 --- a/apps/frontend/src/components/Buttons.tsx +++ b/apps/frontend/src/components/Buttons.tsx @@ -33,3 +33,18 @@ export const FlushedButton = ({ ); }; + +export const CourseIDButton = ({ + courseID, +}: { + courseID: string; +}) => { + return ( + + ) +} \ No newline at end of file diff --git a/apps/frontend/src/components/CourseDetail.tsx b/apps/frontend/src/components/CourseDetail.tsx index 1aeea172..78ee3a51 100644 --- a/apps/frontend/src/components/CourseDetail.tsx +++ b/apps/frontend/src/components/CourseDetail.tsx @@ -4,7 +4,8 @@ import CourseCard from "./CourseCard"; import { useFetchFCEInfoByCourse } from "~/app/api/fce"; import { SchedulesCard } from "./SchedulesCard"; import { FCECard } from "./FCECard"; -import { useFetchCourseInfo } from "~/app/api/course"; +import { useFetchCourseInfo, useFetchCourseRequisites } from "~/app/api/course"; +import ReqTreeCard from "./ReqTreeCard"; type Props = { courseID: string; @@ -12,19 +13,32 @@ type Props = { const CourseDetail = ({ courseID }: Props) => { const { data: { fces } = {} } = useFetchFCEInfoByCourse(courseID); - const { data: { schedules } = {} } = useFetchCourseInfo(courseID); + const { data: info } = useFetchCourseInfo(courseID); + const { data: requisites } = useFetchCourseRequisites(courseID); + + if (!info || !requisites) { + return
Loading...
; + } return (
{fces && } - {schedules && ( + {info.schedules && ( + )} + {info.prereqs && requisites.prereqRelations && requisites.postreqs && ( + )}
); }; -export default CourseDetail; +export default CourseDetail; \ No newline at end of file diff --git a/apps/frontend/src/components/PostReqCourses.tsx b/apps/frontend/src/components/PostReqCourses.tsx new file mode 100644 index 00000000..7a193744 --- /dev/null +++ b/apps/frontend/src/components/PostReqCourses.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { useFetchCourseRequisites } from "~/app/api/course"; +import { TreeNode } from "~/app/types"; +import { CourseIDButton } from "./Buttons"; + +interface Props { + courseID: string; +} + +export const PostReqCourses = ({ courseID }: Props) => { + const { isPending: isCourseInfoPending, data: requisites } = useFetchCourseRequisites(courseID); + + if (isCourseInfoPending || !requisites) { + return null; + } + + // Recursive function to render only the child branches + const renderTree = (nodes: TreeNode[]) => { + return ( +
+ {nodes.map((node) => ( +
+ {/* Half vertical line for the first postreq */} + {nodes && nodes.length > 1 && nodes.indexOf(node) === 0 && ( +
+
+
+
+ )} + + {/* Normal vertical Line connector */} + {nodes && nodes.length > 1 && nodes.indexOf(node) !== 0 && nodes.indexOf(node) !== nodes.length - 1 && ( +
+ )} + + {/* Half vertical line for the last prereq in the list */} + {nodes && nodes.length > 1 && nodes.indexOf(node) === nodes.length - 1 && ( +
+
+
+
+ )} + + {/* Line left to node */} + {nodes && nodes.length > 1 && ( +
+ )} + + {/* Course ID button */} + + + {/* Render child nodes recursively */} + {node.postreqs && renderTree(node.postreqs)} +
+ ))} +
+ ); + }; + + // Transform fetched data into a tree structure excluding the parent node + const childNodes: TreeNode[] = requisites.postreqs?.map((postreq: string) => ({ + courseID: postreq, + })) || []; + + return ( +
+ {childNodes.length > 0 ? ( + renderTree(childNodes) + ) : ( +
+ None +
+ )} +
+ ); +}; + +export default PostReqCourses; \ No newline at end of file diff --git a/apps/frontend/src/components/PreReqCourses.tsx b/apps/frontend/src/components/PreReqCourses.tsx new file mode 100644 index 00000000..58525b11 --- /dev/null +++ b/apps/frontend/src/components/PreReqCourses.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { useFetchCourseRequisites } from "~/app/api/course"; +import { TreeNode } from "~/app/types"; +import { CourseIDButton } from "./Buttons"; + +interface Props { + courseID: string; +} + +export const PreReqCourses = ({ courseID }: Props) => { + const { isPending: isCourseInfoPending, data: requisites } = useFetchCourseRequisites(courseID); + + if (isCourseInfoPending || !requisites) { + return null; + } + + // Recursive function to render only the child branches + const renderTree = (nodes: TreeNode[]) => { + return ( +
+ {nodes.map((node) => ( +
+ {/* Course ID button */} + + + {/* Line connector right to node */} + {nodes && nodes.length > 1 && ( +
+ )} + + {/* Half vertical line for the first prereq in the list */} + {nodes && nodes.length > 1 && nodes.indexOf(node) === 0 && ( +
+
+
+
+ )} + + {/* Normal vertical Line connector */} + {nodes && nodes.length > 1 && nodes.indexOf(node) !== 0 && nodes.indexOf(node) !== nodes.length - 1 && ( +
+ )} + + {/* Half vertical line for the last prereq in the list */} + {nodes && nodes.length > 1 && nodes.indexOf(node) === nodes.length - 1 && ( +
+
+
+
+ )} + + {/* Render child nodes recursively */} + {node.prereqs && renderTree(node.prereqs)} +
+ ))} +
+ ); + }; + + // Transform fetched data into a tree structure excluding the parent node + const childNodes: TreeNode[] = requisites.prereqs.map((prereq: string) => ({ + courseID: prereq, + })) || []; + + return ( +
+ {childNodes.length > 0 ? ( + renderTree(childNodes) + ) : ( +
+ None +
+ )} +
+ ); +}; + +export default PreReqCourses; \ No newline at end of file diff --git a/apps/frontend/src/components/ReqTreeCard.tsx b/apps/frontend/src/components/ReqTreeCard.tsx new file mode 100644 index 00000000..d80ddcf7 --- /dev/null +++ b/apps/frontend/src/components/ReqTreeCard.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Card } from "./Card"; +import ReqTreeDetail from "./ReqTreeDetail"; +import { TreeNode } from "~/app/types"; + +interface ReqTreeCardProps { + courseID: string; + prereqs: string[]; + prereqRelations: string[][]; + postreqs: string[]; +} + +const ReqTreeCard: React.FC = ({ courseID, prereqs, prereqRelations, postreqs }) => { + const hasNoRequisites = prereqs.length === 0 && postreqs.length === 0; + + const buildTree = (id: string, prereqList: string[], prereqRelationsList: string[][], postreqList: string[]): TreeNode => { + return { + courseID: id, + prereqs: prereqList.map((prereq) => ({ courseID: prereq })), + prereqRelations: prereqRelationsList.map((prereqSubList) => (prereqSubList.map((prereq) => ({ courseID: prereq })))), + postreqs: postreqList.map((postreq) => ({ courseID: postreq })), + }; + }; + + const tree = buildTree(courseID, prereqs, prereqRelations, postreqs); + + return ( + + Requisite Tree + {hasNoRequisites ? ( +
+ There are no prerequisites or postrequisites for this course. +
+ ) : ( + + )} + +
+ ); +}; + +export default ReqTreeCard; \ No newline at end of file diff --git a/apps/frontend/src/components/ReqTreeDetail.tsx b/apps/frontend/src/components/ReqTreeDetail.tsx new file mode 100644 index 00000000..c5d7c33e --- /dev/null +++ b/apps/frontend/src/components/ReqTreeDetail.tsx @@ -0,0 +1,201 @@ +import React, { useState } from "react"; +import PostReqCourses from "./PostReqCourses"; +import PreReqCourses from "./PreReqCourses"; +import { + ChevronLeftIcon, + ChevronRightIcon, +} from "@heroicons/react/20/solid"; +import { TreeNode } from "~/app/types"; +import { CourseIDButton } from "./Buttons"; + +interface ReqTreeProps { + root: TreeNode; +} + +const ReqTreeDetail: React.FC = ({ root }) => { + const [expandedPostReqIDs, setExpandedPostReqIDs] = useState([]); + const [expandedPreReqIDs, setExpandedPreReqIDs] = useState([]); + + const togglePostReqs = (courseID: string) => { + setExpandedPostReqIDs((prev) => + prev.includes(courseID) + ? prev.filter((id) => id !== courseID) + : [...prev, courseID] + ); + }; + + const togglePreReqs = (courseID: string) => { + setExpandedPreReqIDs((prev) => + prev.includes(courseID) + ? prev.filter((id) => id !== courseID) + : [...prev, courseID] + ); + }; + + return ( +
+ {/* Padding */} +
+ + {/* Prereqs on the left */} + {root.prereqs && root.prereqs.length > 0 && ( +
+ {root.prereqs.map((prereq) => ( +
+ + {/* Next level of prereqs */} + {expandedPreReqIDs.includes(prereq.courseID) && ( +
+ +
+
+ )} + + {/* Expansion button */} +
togglePreReqs(prereq.courseID)} + > + {expandedPreReqIDs.includes(prereq.courseID) ? ( +
+
Hide
+ +
+ ) : ( +
+ +
+ )} +
+ + {/* Line right to expansion button */} +
+ + {/* Course ID button */} + + + {/* Line connector right to node */} + {root.prereqs && root.prereqs.length > 1 && ( +
+ )} + + {/* Half vertical line for the first prereq in the list */} + {root.prereqs && root.prereqs.length > 1 && root.prereqs.indexOf(prereq) === 0 && ( +
+
+
+
+ )} + + {/* Normal vertical Line connector */} + {root.prereqs && root.prereqs.length > 1 && root.prereqs.indexOf(prereq) !== 0 && root.prereqs.indexOf(prereq) !== root.prereqs.length - 1 && ( +
+ )} + + {/* Half vertical line for the last prereq in the list */} + {root.prereqs && root.prereqs.length > 1 && root.prereqs.indexOf(prereq) === root.prereqs.length - 1 && ( +
+
+
+
+ )} + +
+ ))} +
+ )} + + {/* Main Course */} +
+ {/* Line left to node */} + {root.prereqs && root.prereqs.length > 0 && ( +
+ )} + + {/* Main node */} +
+ {root.courseID} +
+ + {/* Line right to node */} + {root.postreqs && root.postreqs.length > 0 && ( +
+ )} +
+ + {/* Postreqs on the right */} + {root.postreqs && root.postreqs.length > 0 && ( +
+ {root.postreqs.map((postreq) => ( +
+ {/* Half vertical line for the first postreq */} + {root.postreqs && root.postreqs.length > 1 && root.postreqs.indexOf(postreq) === 0 && ( +
+
+
+
+ )} + + {/* Normal vertical Line connector */} + {root.postreqs && root.postreqs.length > 1 && root.postreqs.indexOf(postreq) !== 0 && root.postreqs.indexOf(postreq) !== root.postreqs.length - 1 && ( +
+ )} + + {/* Half vertical line for the last postreq */} + {root.postreqs && root.postreqs.length > 1 && root.postreqs.indexOf(postreq) === root.postreqs.length - 1 && ( +
+
+
+
+ )} + + {/* Line left to node */} + {root.postreqs && root.postreqs.length > 1 && ( +
+ )} + + {/* Course ID button */} + + + {/* Line right to node */} +
+ + {/* Expansion button */} +
togglePostReqs(postreq.courseID)} + > + {expandedPostReqIDs.includes(postreq.courseID) ? ( +
+
Hide
+ +
+ ) : ( +
+ +
+ )} +
+ + {/* Next level of postreqs */} + {expandedPostReqIDs.includes(postreq.courseID) && ( +
+
+ +
+ )} + +
+ ))} +
+ )} + + {/* Padding */} +
+
+ ); +}; + +export default ReqTreeDetail; \ No newline at end of file